Add built-in attachment markdown html preview

This commit is contained in:
Xisheng-Zhao
2026-06-20 03:12:18 +08:00
parent 604a08ebc4
commit af1ea96832
3 changed files with 423 additions and 1 deletions
@@ -521,6 +521,31 @@ final class ApiClient {
return new Models.Attachment(json); 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 { void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId), requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
query(null, "workspace_id", workspaceId), null); query(null, "workspace_id", workspaceId), null);
@@ -1506,6 +1531,17 @@ final class ApiClient {
return sb.toString(); 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 { private static String queryString(JSONObject query) throws Exception {
if (query == null || query.length() == 0) return ""; if (query == null || query.length() == 0) return "";
StringBuilder sb = new StringBuilder("?"); StringBuilder sb = new StringBuilder("?");
@@ -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 "<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
+ "</head><body><pre>"
+ escapeHtml(xml == null ? "" : xml)
+ "</pre></body></html>";
}
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -1,6 +1,7 @@
package ai.multica.app package ai.multica.app
import android.app.Activity import android.app.Activity
import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -12,6 +13,11 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns 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 android.widget.LinearLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -288,6 +294,22 @@ class ComposePilotActivity : ComponentActivity() {
} }
return 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)) { if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false) val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
val previewUser = Models.User(JSONObject() 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<Result<String>?>(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(
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font: 16px -apple-system, BlinkMacSystemFont, sans-serif; padding: 16px; color: #111; }
button { border: 1px solid #d1d5db; border-radius: 999px; padding: 10px 14px; background: white; }
button.active { background: #0A58FF; color: white; }
.panel { display: none; margin-top: 16px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 16px; }
.panel.active { display: block; }
</style>
</head>
<body>
<h1>HTML Attachment</h1>
<button id="tab-one" class="active" onclick="showTab('one')">Tab One</button>
<button id="tab-two" onclick="showTab('two')">Tab Two</button>
<a href="#local-anchor" id="anchor-link">Jump to anchor</a>
<a href="https://example.com/" id="external-link">External link</a>
<section id="one" class="panel active">First tab content</section>
<section id="two" class="panel">Second tab content</section>
<p id="local-anchor">Local anchor target</p>
<script>
function showTab(id) {
document.querySelectorAll('button').forEach(function(button) { button.classList.remove('active'); });
document.querySelectorAll('.panel').forEach(function(panel) { panel.classList.remove('active'); });
document.getElementById('tab-' + id).classList.add('active');
document.getElementById(id).classList.add('active');
}
</script>
</body>
</html>
""".trimIndent(),
LocalContext.current,
)
}
}
private fun shouldCollapseIssueComment(content: String): Boolean { private fun shouldCollapseIssueComment(content: String): Boolean {
if (content.contains("```")) return false if (content.contains("```")) return false
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) 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 uploadingAttachment by remember(issueId) { mutableStateOf(false) }
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) } var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) } var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) } var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
var deletingIssue by remember(issueId) { mutableStateOf(false) } var deletingIssue by remember(issueId) { mutableStateOf(false) }
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) } var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
@@ -9455,6 +9738,16 @@ private fun PilotIssueDetail(
) )
return return
} }
val activePreviewAttachment = previewAttachment
if (activePreviewAttachment != null) {
PilotAttachmentPreview(
api = api,
attachment = activePreviewAttachment,
zh = zh,
onBack = { previewAttachment = null },
)
return
}
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) { LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
state = null state = null
@@ -9701,11 +9994,15 @@ private fun PilotIssueDetail(
} }
fun openAttachment(attachment: Models.Attachment) { 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()) { if (target.isBlank()) {
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL" attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
return return
} }
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
previewAttachment = attachment
return
}
val result = runCatching { val result = runCatching {
context.startActivity( context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(target)) 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) { private fun openExternalUrl(context: Context, url: String) {
val target = clean(url) val target = clean(url)
if (target.isBlank()) return if (target.isBlank()) return