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
+
+
+ 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