Add built-in attachment markdown html preview
This commit is contained in:
@@ -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("?");
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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<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 {
|
||||
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<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 deletingIssue by remember(issueId) { mutableStateOf(false) }
|
||||
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(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
|
||||
|
||||
Reference in New Issue
Block a user