From af1ea96832b2e7f819eace9c9def48b6deb3786c Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Sat, 20 Jun 2026 03:12:18 +0800 Subject: [PATCH] Add built-in attachment markdown html preview --- .../main/java/ai/multica/app/ApiClient.java | 36 ++ .../ai/multica/app/AttachmentPreview.java | 80 +++++ .../ai/multica/app/ComposePilotActivity.kt | 308 +++++++++++++++++- 3 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/ai/multica/app/AttachmentPreview.java diff --git a/app/src/main/java/ai/multica/app/ApiClient.java b/app/src/main/java/ai/multica/app/ApiClient.java index 94d715c..e882754 100644 --- a/app/src/main/java/ai/multica/app/ApiClient.java +++ b/app/src/main/java/ai/multica/app/ApiClient.java @@ -521,6 +521,31 @@ final class ApiClient { return new Models.Attachment(json); } + String downloadAttachmentText(String url) throws Exception { + if (url == null || url.trim().isEmpty()) return ""; + HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8"); + conn.setRequestProperty("X-Client-Platform", "android"); + conn.setRequestProperty("X-Client-Version", "debug"); + String token = authStore.token(); + if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + String cookieHeader = authStore.cloudFrontCookieHeader(); + if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) { + conn.setRequestProperty("Cookie", cookieHeader); + } + int code = conn.getResponseCode(); + saveResponseCookies(conn); + InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String text = readTextPreservingLines(stream); + if (code < 200 || code >= 300) throw new ApiException(code, text); + return text; + } + void deleteAttachment(String workspaceId, String attachmentId) throws Exception { requestObject("DELETE", "/api/attachments/" + encPath(attachmentId), query(null, "workspace_id", workspaceId), null); @@ -1506,6 +1531,17 @@ final class ApiClient { return sb.toString(); } + private static String readTextPreservingLines(InputStream stream) throws Exception { + if (stream == null) return ""; + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[4096]; + try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + int count; + while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count); + } + return sb.toString(); + } + private static String queryString(JSONObject query) throws Exception { if (query == null || query.length() == 0) return ""; StringBuilder sb = new StringBuilder("?"); diff --git a/app/src/main/java/ai/multica/app/AttachmentPreview.java b/app/src/main/java/ai/multica/app/AttachmentPreview.java new file mode 100644 index 0000000..58111b1 --- /dev/null +++ b/app/src/main/java/ai/multica/app/AttachmentPreview.java @@ -0,0 +1,80 @@ +package ai.multica.app; + +import java.net.URI; + +final class AttachmentPreview { + static final String BASE_URL = "https://attachment-preview.local/"; + + enum Kind { + MARKDOWN, + HTML, + XML, + DOWNLOAD + } + + private AttachmentPreview() {} + + static Kind kindFor(String contentType, String filename) { + String type = mediaType(contentType); + if (isMarkdownType(type)) return Kind.MARKDOWN; + if (isHtmlType(type)) return Kind.HTML; + if (isXmlType(type)) return Kind.XML; + + String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT); + if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN; + if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML; + if (name.endsWith(".xml")) return Kind.XML; + return Kind.DOWNLOAD; + } + + static boolean isLocalPreviewUrl(String url) { + if (url == null || url.trim().isEmpty()) return false; + try { + URI uri = URI.create(url.trim()); + String scheme = uri.getScheme(); + String host = uri.getHost(); + return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + static String xmlPreviewDocument(String xml) { + return "" + + "" + + "" + + "
"
+                + escapeHtml(xml == null ? "" : xml)
+                + "
"; + } + + private static String mediaType(String contentType) { + if (contentType == null) return ""; + int semicolon = contentType.indexOf(';'); + String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType; + return value.trim().toLowerCase(java.util.Locale.ROOT); + } + + private static boolean isMarkdownType(String type) { + return "text/markdown".equals(type) + || "text/x-markdown".equals(type) + || "application/markdown".equals(type) + || "application/x-markdown".equals(type); + } + + private static boolean isHtmlType(String type) { + return "text/html".equals(type) || "application/xhtml+xml".equals(type); + } + + private static boolean isXmlType(String type) { + return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml"); + } + + private static String escapeHtml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index 451a016..cebb6b0 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -1,6 +1,7 @@ package ai.multica.app import android.app.Activity +import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -12,6 +13,11 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.OpenableColumns +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient import android.widget.LinearLayout import androidx.activity.compose.BackHandler import androidx.activity.ComponentActivity @@ -288,6 +294,22 @@ class ComposePilotActivity : ComponentActivity() { } return } + if (intent.getBooleanExtra("preview_attachment_markdown", false)) { + setContent { + MulticaTheme { + PilotAttachmentMarkdownPreviewFixture() + } + } + return + } + if (intent.getBooleanExtra("preview_attachment_html", false)) { + setContent { + MulticaTheme { + PilotAttachmentHtmlPreviewFixture() + } + } + return + } if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) { val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false) val previewUser = Models.User(JSONObject() @@ -9360,6 +9382,266 @@ private fun IssueAttachmentsWebPanel( } } +@Composable +private fun PilotAttachmentPreview( + api: ApiClient, + attachment: Models.Attachment, + zh: Boolean, + onBack: () -> Unit, +) { + val context = LocalContext.current + val kind = remember(attachment.contentType, attachment.filename) { + AttachmentPreview.kindFor(attachment.contentType, attachment.filename) + } + val target = remember(attachment.downloadUrl, attachment.url) { + absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }) + } + var state by remember(attachment.id) { mutableStateOf?>(null) } + var refresh by remember(attachment.id) { mutableIntStateOf(0) } + + BackHandler(onBack = onBack) + LaunchedEffect(attachment.id, target, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { api.downloadAttachmentText(target) } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding() + .semantics { contentDescription = "Attachment Preview Page ${attachment.id}" }, + ) { + PilotPageHeader( + title = attachment.filename.ifBlank { if (zh) "附件预览" else "Attachment preview" }, + leading = { + MulticaIconPillButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = if (zh) "返回" else "Back", + onClick = onBack, + tone = MulticaButtonTone.Ghost, + modifier = Modifier.size(36.dp), + ) + }, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = listOf(attachment.contentType, formatBytes(attachment.sizeBytes)) + .filter { it.isNotBlank() } + .joinToString(" · ") + .ifBlank { if (zh) "内置预览" else "Built-in preview" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when { + target.isBlank() -> MulticaErrorState( + message = if (zh) "附件没有可预览链接" else "Attachment has no preview URL", + onRetry = onBack, + fullScreen = false, + ) + state == null -> Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + CupertinoActivityIndicator(modifier = Modifier.size(24.dp)) + } + state?.isFailure == true -> MulticaErrorState( + message = "${if (zh) "附件加载失败" else "Attachment load failed"}\n${state?.exceptionOrNull()?.message.orEmpty()}", + onRetry = { refresh++ }, + fullScreen = false, + ) + else -> { + val body = state?.getOrNull().orEmpty() + when (kind) { + AttachmentPreview.Kind.MARKDOWN -> AttachmentMarkdownPreview(body) + AttachmentPreview.Kind.HTML -> AttachmentHtmlPreview(body, context) + AttachmentPreview.Kind.XML -> AttachmentHtmlPreview(AttachmentPreview.xmlPreviewDocument(body), context) + AttachmentPreview.Kind.DOWNLOAD -> MulticaErrorState( + message = if (zh) "该附件类型不支持内置预览" else "This attachment type does not support built-in preview", + onRetry = onBack, + fullScreen = false, + ) + } + } + } + } + } +} + +@Composable +private fun AttachmentMarkdownPreview(markdown: String) { + SelectionContainer { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 0, 0, 24) + } + }, + update = { view -> + view.removeAllViews() + MarkdownRenderer.render( + view.context, + view, + markdown, + MulticaColors.Text.toArgb(), + MulticaColors.Muted.toArgb(), + MulticaColors.Border.toArgb(), + ) + }, + ) + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun ColumnScope.AttachmentHtmlPreview(html: String, context: Context) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + factory = { viewContext -> + WebView(viewContext).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = false + settings.databaseEnabled = false + settings.cacheMode = WebSettings.LOAD_NO_CACHE + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.allowFileAccessFromFileURLs = false + settings.allowUniversalAccessFromFileURLs = false + clearCache(true) + clearHistory() + webViewClient = AttachmentPreviewWebViewClient(context) + } + }, + update = { webView -> + webView.loadDataWithBaseURL(AttachmentPreview.BASE_URL, html, "text/html", "UTF-8", null) + }, + onRelease = { webView -> + webView.stopLoading() + webView.clearHistory() + webView.clearCache(true) + webView.destroy() + }, + ) +} + +private class AttachmentPreviewWebViewClient( + private val context: Context, +) : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + return handleNavigation(url) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return handleNavigation(request.url?.toString().orEmpty()) + } + + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + val url = request.url?.toString().orEmpty() + return if (AttachmentPreview.isLocalPreviewUrl(url)) null else WebResourceResponse("text/plain", "UTF-8", null) + } + + private fun handleNavigation(url: String): Boolean { + if (AttachmentPreview.isLocalPreviewUrl(url)) return false + openExternalUrl(context, url) + return true + } +} + +@Composable +private fun PilotAttachmentMarkdownPreviewFixture() { + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PilotPageHeader(title = "Markdown Attachment Preview") + AttachmentMarkdownPreview( + """ + # Markdown Attachment + + This attachment renders **inside Android** with the existing Markdown renderer. + + | Feature | Status | + | --- | --- | + | Tables | Ready | + | Links | [Multica](https://app.multica.ai) | + """.trimIndent(), + ) + } +} + +@Composable +private fun PilotAttachmentHtmlPreviewFixture() { + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PilotPageHeader(title = "HTML Attachment Preview") + AttachmentHtmlPreview( + """ + + + + + + + + +

HTML Attachment

+ + + Jump to anchor + External link +
First tab content
+
Second tab content
+

Local anchor target

+ + + + """.trimIndent(), + LocalContext.current, + ) + } +} + private fun shouldCollapseIssueComment(content: String): Boolean { if (content.contains("```")) return false if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false @@ -9419,6 +9701,7 @@ private fun PilotIssueDetail( var uploadingAttachment by remember(issueId) { mutableStateOf(false) } var deletingAttachmentId by remember(issueId) { mutableStateOf(null) } var attachmentMessage by remember(issueId) { mutableStateOf(null) } + var previewAttachment by remember(issueId) { mutableStateOf(null) } var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) } var deletingIssue by remember(issueId) { mutableStateOf(false) } var issueMutationMessage by remember(issueId) { mutableStateOf(null) } @@ -9455,6 +9738,16 @@ private fun PilotIssueDetail( ) return } + val activePreviewAttachment = previewAttachment + if (activePreviewAttachment != null) { + PilotAttachmentPreview( + api = api, + attachment = activePreviewAttachment, + zh = zh, + onBack = { previewAttachment = null }, + ) + return + } LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) { state = null @@ -9701,11 +9994,15 @@ private fun PilotIssueDetail( } fun openAttachment(attachment: Models.Attachment) { - val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) } + val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }) if (target.isBlank()) { attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL" return } + if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) { + previewAttachment = attachment + return + } val result = runCatching { context.startActivity( Intent(Intent.ACTION_VIEW, Uri.parse(target)) @@ -12052,6 +12349,15 @@ private fun absoluteMarkdownImageUrl(url: String): String { } } +private fun absoluteAttachmentUrl(url: String): String { + val value = clean(url) + return if (value.startsWith("/")) { + BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value + } else { + value + } +} + private fun openExternalUrl(context: Context, url: String) { val target = clean(url) if (target.isBlank()) return