feat: add file attachment support in Composer, MobiledocBuilder, and PostUploadWorker

- Add file card support to MobiledocBuilder with Ghost's native file card format
- Add file card tests to MobiledocBuilderTest
- Add file state fields to ComposerUiState (fileUri, fileName, fileSize, fileMimeType, uploadedFileUrl)
- Add addFile()/removeFile() methods to ComposerViewModel with 50MB size validation
- Add file picker button and FileAttachmentComposerCard in ComposerScreen
- Update PostUploadWorker to upload files via repository.uploadFile() and include in mobiledoc
This commit is contained in:
Paweł Orzech 2026-03-20 00:46:06 +01:00
parent 2f9b7dac09
commit 74dac1db6f
5 changed files with 339 additions and 10 deletions

View file

@ -50,10 +50,11 @@ object MobiledocBuilder {
}
/**
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first)
* and an optional link preview.
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
* an optional link preview, and an optional file attachment.
* Each image becomes an image card in the mobiledoc format.
* The bookmark card (link preview) is added after image cards.
* The file card is added last, after all other cards.
*/
fun build(
text: String,
@ -61,7 +62,10 @@ object MobiledocBuilder {
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?
imageAlt: String?,
fileUrl: String? = null,
fileName: String? = null,
fileSize: Long = 0
): String {
val escapedText = escapeForJson(text).replace("\n", "\\n")
@ -86,6 +90,14 @@ object MobiledocBuilder {
cardSections.add("[10,${cards.size - 1}]")
}
// Add file card if file is present
if (fileUrl != null) {
val escapedFileUrl = escapeForJson(fileUrl)
val escapedFileName = fileName?.let { escapeForJson(it) } ?: ""
cards.add("""["file",{"src":"$escapedFileUrl","fileName":"$escapedFileName","fileSize":$fileSize}]""")
cardSections.add("[10,${cards.size - 1}]")
}
val cardsJson = cards.joinToString(",")
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -110,8 +111,17 @@ fun ComposerScreen(
}
}
// File picker
val filePickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) {
viewModel.addFile(uri)
}
}
val canSubmit by remember {
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) }
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null) }
}
Scaffold(
@ -384,6 +394,12 @@ fun ComposerScreen(
OutlinedIconButton(onClick = { showLinkDialog = true }) {
Icon(Icons.Default.Link, "Add link")
}
OutlinedIconButton(
onClick = { filePickerLauncher.launch("application/*") },
enabled = state.fileUri == null
) {
Icon(Icons.Default.AttachFile, "Attach file")
}
}
// Image grid preview (multi-image) — 120dp thumbnails
@ -433,6 +449,23 @@ fun ComposerScreen(
}
}
// File attachment card
AnimatedVisibility(
visible = state.fileUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
FileAttachmentComposerCard(
fileName = state.fileName ?: "file",
fileSize = state.fileSize ?: 0,
fileMimeType = state.fileMimeType,
onRemove = viewModel::removeFile
)
}
}
// Link preview
AnimatedVisibility(
visible = state.isLoadingLink,
@ -1008,3 +1041,82 @@ 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.
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
}
}
/**
* File attachment card shown in the composer with file info and remove button.
*/
@Composable
fun FileAttachmentComposerCard(
fileName: String,
fileSize: Long,
fileMimeType: String?,
onRemove: () -> Unit
) {
val iconTint = fileTypeColor(fileMimeType)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = "File",
modifier = Modifier.size(32.dp),
tint = iconTint
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (fileSize > 0) {
Text(
text = formatFileSize(fileSize),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = "Remove file",
modifier = Modifier.size(18.dp)
)
}
}
}
}

View file

@ -156,6 +156,57 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
}
}
/**
* Attach a file to the post. Reads filename and size from ContentResolver.
* Validates type and size (max 50 MB).
*/
fun addFile(uri: Uri) {
val contentResolver = appContext.contentResolver
var name: String? = null
var size: Long? = null
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (nameIndex >= 0) name = cursor.getString(nameIndex)
if (sizeIndex >= 0) size = cursor.getLong(sizeIndex)
}
}
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
// Validate size: max 50 MB
val maxSize = 50L * 1024 * 1024
if (size != null && size!! > maxSize) {
_uiState.update { it.copy(error = "File too large. Maximum file size is 50 MB.") }
return
}
_uiState.update {
it.copy(
fileUri = uri,
fileName = name ?: "file",
fileSize = size,
fileMimeType = mimeType,
uploadedFileUrl = null,
error = null
)
}
}
fun removeFile() {
_uiState.update {
it.copy(
fileUri = null,
fileName = null,
fileSize = null,
fileMimeType = null,
uploadedFileUrl = null
)
}
}
fun fetchLinkPreview(url: String) {
if (url.isBlank()) return
viewModelScope.launch {
@ -234,7 +285,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
val state = _uiState.value
if (state.text.isBlank() && state.imageUris.isEmpty()) return
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, error = null) }
@ -265,7 +316,9 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus,
fileUri = state.fileUri?.toString(),
fileName = state.fileName
)
repository.saveLocalPost(localPost)
@ -290,12 +343,28 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
)
}
// Upload file if attached
var uploadedFileUrl = state.uploadedFileUrl
if (state.fileUri != null && uploadedFileUrl == null) {
val fileResult = repository.uploadFile(state.fileUri)
fileResult.fold(
onSuccess = { url -> uploadedFileUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, error = "File upload failed: ${e.message}") }
return@launch
}
)
}
val featureImage = uploadedImageUrls.firstOrNull()
val mobiledoc = MobiledocBuilder.build(
state.text, uploadedImageUrls,
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
altText
altText,
fileUrl = uploadedFileUrl,
fileName = state.fileName,
fileSize = state.fileSize ?: 0
)
val ghostTags = allTags.map { GhostTag(name = it) }
@ -342,7 +411,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = offlineQueueStatus
queueStatus = offlineQueueStatus,
fileUri = state.fileUri?.toString(),
uploadedFileUrl = uploadedFileUrl,
fileName = state.fileName
)
repository.saveLocalPost(localPost)
PostUploadWorker.enqueue(appContext)
@ -382,7 +454,13 @@ data class ComposerUiState(
val isEditing: Boolean = false,
val error: String? = null,
val isPreviewMode: Boolean = false,
val previewHtml: String = ""
val previewHtml: String = "",
// File attachment
val fileUri: Uri? = null,
val fileName: String? = null,
val fileSize: Long? = null,
val fileMimeType: String? = null,
val uploadedFileUrl: String? = null
) {
/**
* Backwards compatibility: returns the first image URI or null.

View file

@ -66,9 +66,24 @@ class PostUploadWorker(
featureImage = allImageUrls.first()
}
// Upload file if needed
var uploadedFileUrl = post.uploadedFileUrl
if (uploadedFileUrl == null && post.fileUri != null) {
val fileResult = repository.uploadFile(Uri.parse(post.fileUri))
if (fileResult.isFailure) {
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
allSuccess = false
continue
}
uploadedFileUrl = fileResult.getOrNull()
}
val mobiledoc = MobiledocBuilder.build(
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
post.imageAlt
post.imageAlt,
fileUrl = uploadedFileUrl,
fileName = post.fileName,
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
)
// Parse tags from JSON stored in LocalPost

View file

@ -597,4 +597,116 @@ class MobiledocBuilderTest {
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
assertEquals("", secondCard.get("alt").asString)
}
// --- File card tests ---
@Test
fun `build with file card produces valid JSON`() {
val result = MobiledocBuilder.build(
"Post text", emptyList(), null, null, null, null,
fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf",
fileSize = 102400
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
}
@Test
fun `build with file card includes file type`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
fileUrl = "https://example.com/files/doc.pdf",
fileName = "doc.pdf",
fileSize = 5000
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
assertEquals(1, cards.size())
val card = cards.get(0).asJsonArray
assertEquals("file", card.get(0).asString)
}
@Test
fun `build with file card includes src fileName and fileSize`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf",
fileSize = 204800
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
val cardData = cards.get(0).asJsonArray.get(1).asJsonObject
assertEquals("https://example.com/files/report.pdf", cardData.get("src").asString)
assertEquals("report.pdf", cardData.get("fileName").asString)
assertEquals(204800, cardData.get("fileSize").asLong)
}
@Test
fun `build with file card has correct section count`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 1000
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
assertEquals("Should have text section and file card section", 2, sections.size())
}
@Test
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",
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 1024
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
assertEquals(3, cards.size())
// Image first, bookmark second, file last
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString)
assertEquals("file", cards.get(2).asJsonArray.get(0).asString)
}
@Test
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,
fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf",
fileSize = 500
)
val json = JsonParser.parseString(result).asJsonObject
// 1 text + 2 image + 1 bookmark + 1 file = 5
assertEquals(5, json.getAsJsonArray("sections").size())
}
@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 json = JsonParser.parseString(result).asJsonObject
assertTrue(json.getAsJsonArray("cards").isEmpty)
}
@Test
fun `build with file card escapes fileName`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
fileUrl = "https://example.com/file.pdf",
fileName = "my \"special\" file.pdf",
fileSize = 100
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull("Should produce valid JSON with escaped file name", json)
}
}