diff --git a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java new file mode 100644 index 0000000..e2c33e4 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java @@ -0,0 +1,256 @@ +package com.hyzq.boss; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.BackgroundColorSpan; +import android.text.style.BulletSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.LeadingMarginSpan; +import android.text.style.QuoteSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class BossMarkdown { + private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$"); + private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$"); + private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$"); + private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");; + + private BossMarkdown() {} + + public static CharSequence render(Context context, String markdown, boolean outgoing) { + if (TextUtils.isEmpty(markdown) || TextUtils.isEmpty(markdown.trim())) { + return "(空消息)"; + } + Palette palette = Palette.resolve(context, outgoing); + SpannableStringBuilder builder = new SpannableStringBuilder(); + String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); + String[] lines = normalized.split("\n", -1); + boolean inCodeFence = false; + List codeLines = new ArrayList<>(); + + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("```")) { + if (inCodeFence) { + appendCodeBlock(builder, joinCodeLines(codeLines), palette); + codeLines.clear(); + } + inCodeFence = !inCodeFence; + continue; + } + if (inCodeFence) { + codeLines.add(line); + continue; + } + if (trimmed.isEmpty()) { + appendBlankLine(builder); + continue; + } + + Matcher headingMatcher = HEADING_PATTERN.matcher(line); + if (headingMatcher.matches()) { + int level = headingMatcher.group(1).length(); + appendHeading(builder, headingMatcher.group(2), level, palette); + continue; + } + + Matcher bulletMatcher = BULLET_PATTERN.matcher(line); + if (bulletMatcher.matches()) { + appendBullet(builder, bulletMatcher.group(1), palette); + continue; + } + + Matcher orderedMatcher = ORDERED_PATTERN.matcher(line); + if (orderedMatcher.matches()) { + appendOrdered(builder, orderedMatcher.group(1), orderedMatcher.group(2), palette); + continue; + } + + if (trimmed.startsWith(">")) { + appendQuote(builder, trimmed.substring(1).trim(), palette); + continue; + } + + appendParagraph(builder, line, palette); + } + + if (inCodeFence && !codeLines.isEmpty()) { + appendCodeBlock(builder, joinCodeLines(codeLines), palette); + } + + trimTrailingNewline(builder); + return builder; + } + + private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) { + ensureBlockSeparation(builder, true); + int start = builder.length(); + appendInlineStyled(builder, text, palette); + builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + float size = level == 1 ? 1.18f : level == 2 ? 1.1f : 1.04f; + builder.setSpan(new RelativeSizeSpan(size), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + } + + private static void appendParagraph(SpannableStringBuilder builder, String text, Palette palette) { + ensureBlockSeparation(builder, false); + appendInlineStyled(builder, text, palette); + builder.append('\n'); + } + + private static void appendBullet(SpannableStringBuilder builder, String text, Palette palette) { + ensureBlockSeparation(builder, false); + int start = builder.length(); + builder.append("• "); + appendInlineStyled(builder, text, palette); + builder.setSpan(new BulletSpan(BossUi.dp(palette.context, 6), palette.quoteColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + } + + private static void appendOrdered(SpannableStringBuilder builder, String number, String text, Palette palette) { + ensureBlockSeparation(builder, false); + int start = builder.length(); + builder.append(number).append(". "); + appendInlineStyled(builder, text, palette); + builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + } + + private static void appendQuote(SpannableStringBuilder builder, String text, Palette palette) { + ensureBlockSeparation(builder, false); + int start = builder.length(); + appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette); + builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)), + start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + } + + private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) { + if (TextUtils.isEmpty(text)) { + return; + } + ensureBlockSeparation(builder, true); + int start = builder.length(); + builder.append(text); + int end = builder.length(); + builder.setSpan(new TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 10), BossUi.dp(palette.context, 10)), + start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + } + + private static void appendInlineStyled(SpannableStringBuilder builder, String text, Palette palette) { + Matcher matcher = INLINE_TOKEN_PATTERN.matcher(text); + int cursor = 0; + while (matcher.find()) { + if (matcher.start() > cursor) { + builder.append(text, cursor, matcher.start()); + } + if (matcher.group(2) != null) { + int start = builder.length(); + builder.append(matcher.group(2)); + builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } else if (matcher.group(4) != null) { + int start = builder.length(); + builder.append(matcher.group(4)); + builder.setSpan(new TypefaceSpan("monospace"), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + cursor = matcher.end(); + } + if (cursor < text.length()) { + builder.append(text.substring(cursor)); + } + } + + private static void ensureBlockSeparation(SpannableStringBuilder builder, boolean relaxed) { + if (builder.length() == 0) { + return; + } + if (builder.charAt(builder.length() - 1) != '\n') { + builder.append('\n'); + return; + } + if (!relaxed) { + return; + } + if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') { + builder.append('\n'); + } + } + + private static void appendBlankLine(SpannableStringBuilder builder) { + if (builder.length() == 0) { + return; + } + if (builder.charAt(builder.length() - 1) != '\n') { + builder.append('\n'); + } + if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') { + builder.append('\n'); + } + } + + private static void trimTrailingNewline(SpannableStringBuilder builder) { + while (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') { + builder.delete(builder.length() - 1, builder.length()); + } + } + + private static String joinCodeLines(List codeLines) { + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < codeLines.size(); index += 1) { + if (index > 0) { + builder.append('\n'); + } + builder.append(codeLines.get(index)); + } + return builder.toString(); + } + + private static final class Palette { + final Context context; + final int quoteColor; + final int codeBackgroundColor; + final int codeTextColor; + + private Palette(Context context, int quoteColor, int codeBackgroundColor, int codeTextColor) { + this.context = context; + this.quoteColor = quoteColor; + this.codeBackgroundColor = codeBackgroundColor; + this.codeTextColor = codeTextColor; + } + + static Palette resolve(Context context, boolean outgoing) { + if (outgoing) { + return new Palette( + context, + Color.parseColor("#B7E6C6"), + Color.parseColor("#33FFFFFF"), + context.getColor(R.color.boss_surface) + ); + } + return new Palette( + context, + Color.parseColor("#91A39A"), + Color.parseColor("#FFF0F1F3"), + context.getColor(R.color.boss_text_primary) + ); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index f30c421..6cd791a 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -9,6 +9,7 @@ import android.graphics.Paint; import android.graphics.RectF; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; +import android.text.Layout; import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; @@ -850,11 +851,13 @@ public final class BossUi { } TextView bodyView = new TextView(context); - bodyView.setText(TextUtils.isEmpty(body) ? "(空消息)" : body); + bodyView.setText(BossMarkdown.render(context, body, outgoing)); bodyView.setTextSize(15); - bodyView.setLineSpacing(0f, 1.2f); + bodyView.setLineSpacing(0f, 1.34f); bodyView.setTextColor(context.getColor(outgoing ? R.color.boss_surface : R.color.boss_text_primary)); bodyView.setMaxWidth(maxBubbleWidth); + bodyView.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY); + bodyView.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL); bubble.addView(bodyView); wrapper.addView(bubble); diff --git a/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java b/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java new file mode 100644 index 0000000..26a5a51 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java @@ -0,0 +1,63 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.BulletSpan; +import android.text.style.QuoteSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossMarkdownTest { + @Test + public void render_formatsCommonMarkdownPatternsForChatReading() { + Context context = RuntimeEnvironment.getApplication(); + CharSequence rendered = BossMarkdown.render( + context, + "# 标题\n\n" + + "- 第一项\n" + + "1. 第二项\n\n" + + "> 引用内容\n\n" + + "普通段落里有 **重点** 和 `代码`\n\n" + + "```js\nconst ok = true;\n```", + false + ); + + assertTrue(rendered instanceof Spanned); + Spanned spanned = (Spanned) rendered; + + assertTrue(spanned.toString().contains("标题")); + assertTrue(spanned.toString().contains("• 第一项")); + assertTrue(spanned.toString().contains("1. 第二项")); + assertTrue(spanned.toString().contains("引用内容")); + assertTrue(spanned.toString().contains("重点")); + assertTrue(spanned.toString().contains("代码")); + assertTrue(spanned.toString().contains("const ok = true;")); + + assertTrue(spanned.getSpans(0, spanned.length(), StyleSpan.class).length > 0); + assertTrue(spanned.getSpans(0, spanned.length(), BulletSpan.class).length > 0); + assertTrue(spanned.getSpans(0, spanned.length(), QuoteSpan.class).length > 0); + assertTrue(spanned.getSpans(0, spanned.length(), TypefaceSpan.class).length > 0); + assertTrue(spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class).length > 0); + } + + @Test + public void render_returnsReadablePlaceholderForEmptyBody() { + Context context = RuntimeEnvironment.getApplication(); + + CharSequence rendered = BossMarkdown.render(context, "", false); + + assertEquals("(空消息)", rendered.toString()); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/BossUiMessageBubbleTest.java b/android/app/src/test/java/com/hyzq/boss/BossUiMessageBubbleTest.java new file mode 100644 index 0000000..f8851ba --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossUiMessageBubbleTest.java @@ -0,0 +1,41 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.text.Spanned; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossUiMessageBubbleTest { + @Test + public void buildMessageBubble_rendersMarkdownInsteadOfRawPlainText() { + Context context = RuntimeEnvironment.getApplication(); + + LinearLayout wrapper = BossUi.buildMessageBubble( + context, + "主 Agent", + "# 标题\n- 条目\n\n带 **重点** 和 `代码`", + "10:26", + false, + null + ); + + LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1); + TextView bodyView = (TextView) bubble.getChildAt(0); + + assertTrue(bodyView.getText() instanceof Spanned); + assertTrue(bodyView.getText().toString().contains("标题")); + assertTrue(bodyView.getText().toString().contains("• 条目")); + assertTrue(bodyView.getText().toString().contains("重点")); + assertTrue(bodyView.getText().toString().contains("代码")); + } +}