refactor: simplify code after /simplify review

- Parallelize StatsViewModel fetches (posts, members, tags via async/await)
- Collapse MobiledocBuilder overloads into single function with defaults
- Extract shared FileTypeColor composable from duplicated color mappings
- Remove redundant state in FeedViewModel and FeedScreen
- Unify formatFileSize usage, remove inline duplication
- Fix minor issues in MediaPlayers, PostUploadWorker, Pages, Settings
This commit is contained in:
Paweł Orzech 2026-03-20 09:05:22 +01:00
parent 29927a7638
commit da8a90470d
No known key found for this signature in database
13 changed files with 305 additions and 376 deletions

View file

@ -9,81 +9,7 @@ import com.swoosh.microblog.data.model.LinkPreview
object MobiledocBuilder {
fun build(text: String, linkPreview: LinkPreview?): String {
return build(text, linkPreview?.url, linkPreview?.title, linkPreview?.description)
}
fun build(
text: String,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?
): String {
return build(text, emptyList(), linkUrl, linkTitle, linkDescription, null)
}
/**
* Build with a single image URL and optional alt text (HEAD's 6-param overload).
*/
fun build(
text: String,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageUrl: String?,
imageAlt: String?
): String {
val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList()
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt)
}
/**
* Build with multiple image URLs but no alt text (multi-image branch's 5-param overload).
*/
fun build(
text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?
): String {
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, null)
}
/**
* Build with multiple images, alt text, and optional video/audio.
* Delegates to the full implementation.
*/
fun build(
text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?
): String {
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt, null, null)
}
/**
* Build with images, video, audio (no file).
* Delegates to the full implementation.
*/
fun build(
text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?,
videoUrl: String?,
audioUrl: String?
): String {
return build(
text = text, imageUrls = imageUrls,
linkUrl = linkUrl, linkTitle = linkTitle, linkDescription = linkDescription,
imageAlt = imageAlt, videoUrl = videoUrl, audioUrl = audioUrl,
fileUrl = null, fileName = null, fileSize = 0
)
return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description)
}
/**
@ -94,11 +20,11 @@ object MobiledocBuilder {
*/
fun build(
text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?,
imageUrls: List<String> = emptyList(),
linkUrl: String? = null,
linkTitle: String? = null,
linkDescription: String? = null,
imageAlt: String? = null,
videoUrl: String? = null,
audioUrl: String? = null,
fileUrl: String? = null,

View file

@ -16,3 +16,10 @@ object UrlNormalizer {
return normalized
}
}
/**
* Strips the URL scheme (http/https) and trailing slash for display purposes.
* e.g., "https://example.com/" -> "example.com"
*/
fun String.toDisplayUrl(): String =
removePrefix("https://").removePrefix("http://").removeSuffix("/")

View file

@ -0,0 +1,46 @@
package com.swoosh.microblog.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Returns an appropriate icon tint color for a file based on MIME type or file name.
* Accepts either a MIME type string, a file name, or both (MIME type takes priority).
*/
@Composable
fun fileTypeColor(mimeType: String? = null, fileName: String? = null): Color {
// Try MIME type first
if (mimeType != null) {
val color = colorForMimeType(mimeType)
if (color != null) return color
}
// Fall back to file extension
if (fileName != null) {
val color = colorForFileName(fileName)
if (color != null) return color
}
return MaterialTheme.colorScheme.onSurfaceVariant
}
private fun colorForMimeType(mimeType: String): Color? = when {
mimeType.contains("pdf") -> Color(0xFFD32F2F)
mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0)
mimeType.contains("text") -> Color(0xFF757575)
mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32)
mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100)
else -> null
}
private fun colorForFileName(fileName: String): Color? {
val lower = fileName.lowercase()
return when {
lower.endsWith(".pdf") -> Color(0xFFD32F2F)
lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0)
lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575)
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32)
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100)
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A)
else -> null
}
}

View file

@ -24,7 +24,8 @@ import androidx.media3.ui.PlayerView
/**
* Video player composable using ExoPlayer.
* Shows a play button overlay; tap to play/pause. No autoplay.
* Shows a play button overlay; tap to play/pause.
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
*/
@Composable
fun VideoPlayer(
@ -35,11 +36,11 @@ fun VideoPlayer(
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var showOverlay by remember { mutableStateOf(true) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false
}
}
@ -72,6 +73,10 @@ fun VideoPlayer(
exoPlayer.pause()
showOverlay = true
} else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play()
showOverlay = false
}
@ -114,6 +119,7 @@ fun VideoPlayer(
/**
* Compact audio player with play/pause button, progress slider, and duration text.
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
*/
@Composable
fun AudioPlayer(
@ -124,11 +130,11 @@ fun AudioPlayer(
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false
}
}
@ -190,6 +196,10 @@ fun AudioPlayer(
if (isPlaying) {
exoPlayer.pause()
} else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play()
}
},

View file

@ -1429,12 +1429,7 @@ fun MediaPreviewCard(
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (cursor.moveToFirst() && sizeIndex >= 0) {
val bytes = cursor.getLong(sizeIndex)
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
formatFileSize(cursor.getLong(sizeIndex))
} else null
}
} catch (_: Exception) {
@ -1610,21 +1605,6 @@ fun TagsSection(
}
}
/**
* Returns an appropriate icon tint color based on file MIME type.
*/
@Composable
private fun fileTypeColor(mimeType: String?): Color {
return when {
mimeType == null -> MaterialTheme.colorScheme.onSurfaceVariant
mimeType.contains("pdf") -> Color(0xFFD32F2F) // red for PDF
mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0) // blue for DOC
mimeType.contains("text") -> Color(0xFF757575) // gray for TXT
mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32) // green for spreadsheets
mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100) // orange for presentations
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
/**
* Formats file size in human-readable units.
@ -1647,7 +1627,7 @@ fun FileAttachmentComposerCard(
fileMimeType: String?,
onRemove: () -> Unit
) {
val iconTint = fileTypeColor(fileMimeType)
val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(mimeType = fileMimeType)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(

View file

@ -82,6 +82,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.toDisplayUrl
import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost
@ -245,10 +246,7 @@ fun FeedScreen(
)
if (activeAccount != null) {
Text(
text = activeAccount!!.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
text = activeAccount!!.blogUrl.toDisplayUrl(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@ -264,7 +262,7 @@ fun FeedScreen(
}
}
if (accounts.size > 1 || accounts.isNotEmpty()) {
if (accounts.size > 1) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Switch account",
@ -1178,10 +1176,7 @@ fun AccountListItem(
},
supportingContent = {
Text(
account.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
account.blogUrl.toDisplayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -1443,11 +1438,6 @@ fun PostCardContent(
var expanded by remember { mutableStateOf(false) }
var showContextMenu by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val displayText = if (expanded || post.textContent.length <= 280) {
post.textContent
} else {
post.textContent.take(280) + "..."
}
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
@ -1506,7 +1496,8 @@ fun PostCardContent(
// Content -- the star of the show
if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText(
text = displayText,
text = if (expanded || post.textContent.length <= 280) post.textContent
else post.textContent.take(280) + "...",
query = highlightQuery,
maxLines = if (expanded) Int.MAX_VALUE else 8
)
@ -2259,22 +2250,6 @@ fun StatusBadge(post: FeedPost) {
)
}
/**
* Returns an appropriate icon tint color based on file extension.
*/
@Composable
private fun fileTypeColorFromName(fileName: String): Color {
val lower = fileName.lowercase()
return when {
lower.endsWith(".pdf") -> Color(0xFFD32F2F) // red
lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0) // blue
lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575) // gray
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32) // green
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100) // orange
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A) // purple
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
/**
* Card displaying a file attachment in Feed/Detail screens.
@ -2286,7 +2261,7 @@ fun FileAttachmentCard(
fileName: String
) {
val context = LocalContext.current
val iconTint = fileTypeColorFromName(fileName)
val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(fileName = fileName)
OutlinedCard(
onClick = {

View file

@ -37,6 +37,7 @@ data class SnackbarEvent(
class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val gson = Gson()
private val accountManager = AccountManager(application)
private var repository = PostRepository(application)
private var tagRepository = TagRepository(application)
@ -523,20 +524,62 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(posts = sorted) }
}
/**
* Parsed mobiledoc card data. Single-parse result for all card types.
*/
private data class MobiledocCards(
val imageUrls: List<String> = emptyList(),
val videoUrl: String? = null,
val audioUrl: String? = null,
val fileUrl: String? = null,
val fileName: String? = null
)
/**
* Parses mobiledoc JSON once and extracts all card data (images, video, audio, file).
*/
private fun parseMobiledocCards(mobiledoc: String?): MobiledocCards {
if (mobiledoc == null) return MobiledocCards()
return try {
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
val cards = json.getAsJsonArray("cards") ?: return MobiledocCards()
val imageUrls = mutableListOf<String>()
var videoUrl: String? = null
var audioUrl: String? = null
var fileUrl: String? = null
var fileName: String? = null
for (card in cards) {
val cardArray = card.asJsonArray
if (cardArray.size() < 2) continue
val cardData = cardArray[1].asJsonObject
when (cardArray[0].asString) {
"image" -> cardData.get("src")?.asString?.let { imageUrls.add(it) }
"video" -> if (videoUrl == null) videoUrl = cardData.get("src")?.asString
"audio" -> if (audioUrl == null) audioUrl = cardData.get("src")?.asString
"file" -> if (fileUrl == null) {
fileUrl = cardData.get("src")?.asString
fileName = cardData.get("fileName")?.asString ?: "file"
}
}
}
MobiledocCards(imageUrls, videoUrl, audioUrl, fileUrl, fileName)
} catch (e: Exception) {
MobiledocCards()
}
}
private fun GhostPost.toFeedPost(): FeedPost {
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
val mobiledocCards = parseMobiledocCards(mobiledoc)
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
val allImages = mutableListOf<String>()
if (feature_image != null) {
allImages.add(feature_image)
}
for (url in imageUrls) {
for (url in mobiledocCards.imageUrls) {
if (url !in allImages) {
allImages.add(url)
}
}
val fileData = extractFileCardFromMobiledoc(mobiledoc)
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
val isEmailOnly = status == "sent" || email_only == true
return FeedPost(
ghostId = id,
@ -548,8 +591,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImages.firstOrNull(),
imageAlt = feature_image_alt,
imageUrls = allImages,
videoUrl = videoUrl,
audioUrl = audioUrl,
videoUrl = mobiledocCards.videoUrl,
audioUrl = mobiledocCards.audioUrl,
linkUrl = null,
linkTitle = null,
linkDescription = null,
@ -561,97 +604,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
createdAt = created_at,
updatedAt = updated_at,
isLocal = false,
fileUrl = fileData?.first,
fileName = fileData?.second,
fileUrl = mobiledocCards.fileUrl,
fileName = mobiledocCards.fileName,
emailOnly = isEmailOnly
)
}
/**
* Extracts image URLs from Ghost mobiledoc JSON.
* Image cards have the format: ["image", {"src": "url"}]
*/
private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List<String> {
if (mobiledoc == null) return emptyList()
return try {
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
val cards = json.getAsJsonArray("cards") ?: return emptyList()
cards.mapNotNull { card ->
val cardArray = card.asJsonArray
if (cardArray.size() >= 2 && cardArray[0].asString == "image") {
val cardData = cardArray[1].asJsonObject
cardData.get("src")?.asString
} else null
}
} catch (e: Exception) {
emptyList()
}
}
/**
* Extracts file card data from Ghost mobiledoc JSON.
* File cards have the format: ["file", {"src": "url", "fileName": "name"}]
* Returns a Pair of (fileUrl, fileName) or null if no file card.
*/
private fun extractFileCardFromMobiledoc(mobiledoc: String?): Pair<String, String>? {
if (mobiledoc == null) return null
return try {
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
val cards = json.getAsJsonArray("cards") ?: return null
for (card in cards) {
val cardArray = card.asJsonArray
if (cardArray.size() >= 2 && cardArray[0].asString == "file") {
val cardData = cardArray[1].asJsonObject
val src = cardData.get("src")?.asString ?: continue
val name = cardData.get("fileName")?.asString ?: "file"
return Pair(src, name)
}
}
null
} catch (e: Exception) {
null
}
}
/**
* Extracts video and audio URLs from Ghost mobiledoc JSON.
* Video cards: ["video", {"src": "url"}]
* Audio cards: ["audio", {"src": "url"}]
* Returns a Pair of (videoUrl, audioUrl), either may be null.
*/
private fun extractMediaUrlsFromMobiledoc(mobiledoc: String?): Pair<String?, String?> {
if (mobiledoc == null) return null to null
return try {
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
val cards = json.getAsJsonArray("cards") ?: return null to null
var videoUrl: String? = null
var audioUrl: String? = null
for (card in cards) {
val cardArray = card.asJsonArray
if (cardArray.size() >= 2) {
when (cardArray[0].asString) {
"video" -> {
if (videoUrl == null) {
videoUrl = cardArray[1].asJsonObject.get("src")?.asString
}
}
"audio" -> {
if (audioUrl == null) {
audioUrl = cardArray[1].asJsonObject.get("src")?.asString
}
}
}
}
}
videoUrl to audioUrl
} catch (e: Exception) {
null to null
}
}
private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
gson.fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}

View file

@ -257,7 +257,7 @@ private fun PageEditorScreen(
if (isNew) {
onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus)
} else {
val mobiledoc = MobiledocBuilder.build(content, null, null, null)
val mobiledoc = MobiledocBuilder.build(content)
val updatedPage = GhostPage(
title = title,
mobiledoc = mobiledoc,

View file

@ -40,7 +40,7 @@ class PagesViewModel(application: Application) : AndroidViewModel(application) {
fun savePage(title: String, content: String, slug: String?, status: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val mobiledoc = MobiledocBuilder.build(content, null, null, null)
val mobiledoc = MobiledocBuilder.build(content)
val page = GhostPage(
title = title,
mobiledoc = mobiledoc,

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.toDisplayUrl
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient
@ -166,10 +167,7 @@ fun SettingsScreen(
val siteUrl = siteData.url
if (!siteUrl.isNullOrBlank()) {
Text(
text = siteUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
text = siteUrl.toDisplayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -330,10 +328,7 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodyLarge
)
Text(
text = activeAccount.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
text = activeAccount.blogUrl.toDisplayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View file

@ -10,6 +10,7 @@ import com.swoosh.microblog.data.repository.MemberRepository
import com.swoosh.microblog.data.repository.MemberStats
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -34,10 +35,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoading = true) }
try {
// Get local posts
// Launch posts, members, and tags fetches in parallel
val postsDeferred = async {
val localPosts = repository.getAllLocalPostsList()
// Get remote posts
val remotePosts = mutableListOf<FeedPost>()
var page = 1
var hasMore = true
@ -71,18 +71,15 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
hasMore = false
}
)
// Safety limit
if (page > 20) break
}
// Remove remote duplicates that exist locally
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
Pair(localPosts, uniqueRemotePosts)
}
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
// Fetch member stats (non-fatal if it fails)
val memberStats = try {
val membersDeferred = async {
try {
val membersResult = memberRepository.fetchAllMembers()
membersResult.getOrNull()?.let { members ->
memberRepository.getMemberStats(members)
@ -90,15 +87,23 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {
null
}
}
// Fetch tag stats
val tagStats = try {
val tagsDeferred = async {
try {
tagRepository.fetchTags().getOrNull()
?.sortedByDescending { it.count?.posts ?: 0 }
?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
val (localPosts, uniqueRemotePosts) = postsDeferred.await()
val memberStats = membersDeferred.await()
val tagStats = tagsDeferred.await()
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
// Count posts without any tags
val totalPosts = localPosts.size + uniqueRemotePosts.size

View file

@ -18,6 +18,8 @@ class PostUploadWorker(
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
private val gson = Gson()
override suspend fun doWork(): Result {
val repository = PostRepository(applicationContext)
val queuedPosts = repository.getQueuedPosts()
@ -118,7 +120,7 @@ class PostUploadWorker(
// Parse tags from JSON stored in LocalPost
val tagNames: List<String> = try {
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
gson.fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}

View file

@ -199,7 +199,7 @@ class MobiledocBuilderTest {
@Test
fun `build with separate params and no link produces same as null preview`() {
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
val resultB = MobiledocBuilder.build("Hello", null, null, null)
val resultB = MobiledocBuilder.build("Hello")
assertEquals(resultA, resultB)
}
@ -207,9 +207,9 @@ class MobiledocBuilderTest {
fun `build with separate params includes link data`() {
val result = MobiledocBuilder.build(
"Text",
"https://test.com",
"Test Title",
"Test Desc"
linkUrl = "https://test.com",
linkTitle = "Test Title",
linkDescription = "Test Desc"
)
assertTrue(result.contains("https://test.com"))
assertTrue(result.contains("Test Title"))
@ -219,7 +219,7 @@ class MobiledocBuilderTest {
@Test
fun `build with separate params handles null title and description`() {
val result = MobiledocBuilder.build("Text", "https://test.com", null, null)
val result = MobiledocBuilder.build("Text", linkUrl = "https://test.com")
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
assertTrue(result.contains("bookmark"))
@ -257,8 +257,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card produces valid JSON`() {
val result = MobiledocBuilder.build(
"Post text", null, null, null,
"https://example.com/photo.jpg", "A sunset"
"Post text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "A sunset"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -267,8 +268,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card includes image type`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "Alt text"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt text"
)
assertTrue("Should contain image card type", result.contains("\"image\""))
}
@ -276,8 +278,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card includes src`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "Alt text"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt text"
)
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
}
@ -285,8 +288,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card includes alt text`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "A beautiful sunset"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "A beautiful sunset"
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -300,8 +304,8 @@ class MobiledocBuilderTest {
@Test
fun `build with image card and null alt uses empty string`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", null
"Text",
imageUrls = listOf("https://example.com/photo.jpg")
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -313,8 +317,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card includes caption field`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "Alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
)
val json = JsonParser.parseString(result).asJsonObject
val card = json.getAsJsonArray("cards").get(0).asJsonArray
@ -325,8 +330,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card has card section`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "Alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -336,8 +342,12 @@ class MobiledocBuilderTest {
@Test
fun `build with image and link has both cards`() {
val result = MobiledocBuilder.build(
"Text", "https://link.com", "Link Title", "Link Desc",
"https://example.com/photo.jpg", "Image alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Link Title",
linkDescription = "Link Desc",
imageAlt = "Image alt"
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -350,8 +360,12 @@ class MobiledocBuilderTest {
@Test
fun `build with image and link has three sections`() {
val result = MobiledocBuilder.build(
"Text", "https://link.com", "Title", "Desc",
"https://example.com/photo.jpg", "Alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Title",
linkDescription = "Desc",
imageAlt = "Alt"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -361,8 +375,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card escapes alt text`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "He said \"hello\""
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "He said \"hello\""
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull("Should produce valid JSON with escaped alt text", json)
@ -370,10 +385,7 @@ class MobiledocBuilderTest {
@Test
fun `build without image produces no image card`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
null, null
)
val result = MobiledocBuilder.build("Text")
val json = JsonParser.parseString(result).asJsonObject
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
}
@ -381,8 +393,9 @@ class MobiledocBuilderTest {
@Test
fun `build with image card section references correct card index`() {
val result = MobiledocBuilder.build(
"Text", null, null, null,
"https://example.com/photo.jpg", "Alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -394,8 +407,11 @@ class MobiledocBuilderTest {
@Test
fun `build with image and link card sections reference correct indices`() {
val result = MobiledocBuilder.build(
"Text", "https://link.com", "Title", null,
"https://example.com/photo.jpg", "Alt"
"Text",
imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Title",
imageAlt = "Alt"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -414,7 +430,7 @@ class MobiledocBuilderTest {
@Test
fun `build with single image produces valid JSON`() {
val result = MobiledocBuilder.build(
"Hello", listOf("https://example.com/img.jpg"), null, null, null
"Hello", listOf("https://example.com/img.jpg")
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -423,7 +439,7 @@ class MobiledocBuilderTest {
@Test
fun `build with single image has one image card`() {
val result = MobiledocBuilder.build(
"Hello", listOf("https://example.com/img.jpg"), null, null, null
"Hello", listOf("https://example.com/img.jpg")
)
val json = JsonParser.parseString(result).asJsonObject
assertEquals(1, json.getAsJsonArray("cards").size())
@ -436,7 +452,7 @@ class MobiledocBuilderTest {
@Test
fun `build with single image has two sections`() {
val result = MobiledocBuilder.build(
"Hello", listOf("https://example.com/img.jpg"), null, null, null
"Hello", listOf("https://example.com/img.jpg")
)
val json = JsonParser.parseString(result).asJsonObject
assertEquals(2, json.getAsJsonArray("sections").size())
@ -449,7 +465,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg",
"https://example.com/img3.jpg"
)
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
}
@ -461,7 +477,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg",
"https://example.com/img3.jpg"
)
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
assertEquals(3, json.getAsJsonArray("cards").size())
}
@ -472,7 +488,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg",
"https://example.com/img2.jpg"
)
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
// 1 text section + 2 card sections
assertEquals(3, json.getAsJsonArray("sections").size())
@ -484,7 +500,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg",
"https://example.com/img2.jpg"
)
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
for (i in 0 until cards.size()) {
@ -500,7 +516,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg",
"https://example.com/img3.jpg"
)
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString)
@ -512,7 +528,7 @@ class MobiledocBuilderTest {
fun `build with images and link has both image and bookmark cards`() {
val images = listOf("https://example.com/img1.jpg")
val result = MobiledocBuilder.build(
"Hello", images, "https://example.com", "Title", "Desc"
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -528,7 +544,7 @@ class MobiledocBuilderTest {
fun `build with images and link has correct number of sections`() {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build(
"Hello", images, "https://example.com", "Title", "Desc"
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
)
val json = JsonParser.parseString(result).asJsonObject
// 1 text section + 2 image card sections + 1 bookmark card section
@ -538,7 +554,7 @@ class MobiledocBuilderTest {
@Test
fun `build with images card sections reference correct card indices`() {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -556,7 +572,7 @@ class MobiledocBuilderTest {
@Test
fun `build with empty image list produces no image cards`() {
val result = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
val result = MobiledocBuilder.build("Hello", emptyList())
val json = JsonParser.parseString(result).asJsonObject
assertTrue(json.getAsJsonArray("cards").isEmpty)
assertEquals(1, json.getAsJsonArray("sections").size())
@ -565,14 +581,14 @@ class MobiledocBuilderTest {
@Test
fun `build with empty image list matches no-image build`() {
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
val resultB = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
val resultB = MobiledocBuilder.build("Hello", emptyList())
assertEquals(resultA, resultB)
}
@Test
fun `build with image URL containing special chars produces valid JSON`() {
val images = listOf("https://example.com/img?id=1&name=\"test\"")
val result = MobiledocBuilder.build("Hello", images, null, null, null)
val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
}
@ -585,7 +601,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg",
"https://example.com/img2.jpg"
)
val result = MobiledocBuilder.build("Text", images, null, null, null, "First image alt")
val result = MobiledocBuilder.build("Text", images, imageAlt = "First image alt")
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -603,8 +619,8 @@ class MobiledocBuilderTest {
@Test
fun `build with video only produces valid JSON with video card`() {
val result = MobiledocBuilder.build(
"Check this video", emptyList(), null, null, null, null,
"https://example.com/video.mp4", null
"Check this video",
videoUrl = "https://example.com/video.mp4"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -621,8 +637,8 @@ class MobiledocBuilderTest {
@Test
fun `build with video has correct sections`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"https://example.com/video.mp4", null
"Text",
videoUrl = "https://example.com/video.mp4"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -638,8 +654,8 @@ class MobiledocBuilderTest {
@Test
fun `build with audio only produces valid JSON with audio card`() {
val result = MobiledocBuilder.build(
"Listen to this", emptyList(), null, null, null, null,
null, "https://example.com/audio.mp3"
"Listen to this",
audioUrl = "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -655,8 +671,8 @@ class MobiledocBuilderTest {
@Test
fun `build with audio has correct sections`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
null, "https://example.com/audio.mp3"
"Text",
audioUrl = "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
@ -670,10 +686,12 @@ class MobiledocBuilderTest {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build(
"Full post", images,
"https://link.com", "Link Title", "Link Desc",
"Alt text",
"https://example.com/video.mp4",
"https://example.com/audio.mp3"
linkUrl = "https://link.com",
linkTitle = "Link Title",
linkDescription = "Link Desc",
imageAlt = "Alt text",
videoUrl = "https://example.com/video.mp4",
audioUrl = "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -704,9 +722,9 @@ class MobiledocBuilderTest {
@Test
fun `build with video and audio but no images produces correct cards`() {
val result = MobiledocBuilder.build(
"Media post", emptyList(), null, null, null, null,
"https://example.com/video.mp4",
"https://example.com/audio.mp3"
"Media post",
videoUrl = "https://example.com/video.mp4",
audioUrl = "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
@ -718,8 +736,8 @@ class MobiledocBuilderTest {
@Test
fun `build with video URL containing special chars produces valid JSON`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"https://example.com/video?id=1&name=\"test\"", null
"Text",
videoUrl = "https://example.com/video?id=1&name=\"test\""
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
@ -730,7 +748,7 @@ class MobiledocBuilderTest {
@Test
fun `build with file card produces valid JSON`() {
val result = MobiledocBuilder.build(
"Post text", emptyList(), null, null, null, null,
"Post text",
fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf",
fileSize = 102400
@ -742,7 +760,7 @@ class MobiledocBuilderTest {
@Test
fun `build with file card includes file type`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"Text",
fileUrl = "https://example.com/files/doc.pdf",
fileName = "doc.pdf",
fileSize = 5000
@ -757,7 +775,7 @@ class MobiledocBuilderTest {
@Test
fun `build with file card includes src fileName and fileSize`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"Text",
fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf",
fileSize = 204800
@ -773,7 +791,7 @@ class MobiledocBuilderTest {
@Test
fun `build with file card has correct section count`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"Text",
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 1000
@ -787,7 +805,11 @@ class MobiledocBuilderTest {
fun `build with file card comes after image and bookmark cards`() {
val images = listOf("https://example.com/img.jpg")
val result = MobiledocBuilder.build(
"Text", images, "https://link.com", "Link Title", "Desc", "Alt",
"Text", images,
linkUrl = "https://link.com",
linkTitle = "Link Title",
linkDescription = "Desc",
imageAlt = "Alt",
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 1024
@ -805,7 +827,10 @@ class MobiledocBuilderTest {
fun `build with file card and all attachments has correct section count`() {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build(
"Text", images, "https://link.com", "Title", "Desc", null,
"Text", images,
linkUrl = "https://link.com",
linkTitle = "Title",
linkDescription = "Desc",
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 500
@ -817,10 +842,7 @@ class MobiledocBuilderTest {
@Test
fun `build without file produces no file card`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
fileUrl = null, fileName = null, fileSize = 0
)
val result = MobiledocBuilder.build("Text")
val json = JsonParser.parseString(result).asJsonObject
assertTrue(json.getAsJsonArray("cards").isEmpty)
}
@ -828,7 +850,7 @@ class MobiledocBuilderTest {
@Test
fun `build with file card escapes fileName`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"Text",
fileUrl = "https://example.com/file.pdf",
fileName = "my \"special\" file.pdf",
fileSize = 100