SpannableString looses spans when used multiple times

https://developer.android.com/reference/android/text/TextUtils#concat(java.lang.CharSequence...):

If there are paragraph spans in the source CharSequences that satisfy paragraph boundary requirements in the sources but would no longer satisfy them in the concatenated CharSequence, they may get extended in the resulting CharSequence or not retained.

While this isn't super clear (at least to me) I infer that paragraph boundaries are extended if they should be extended. They should be extended if they are INCLUSIVE boundaries meaning if you insert at the beginning you should use Spannable.SPAN_INCLUSIVE_EXCLUSIVE or Spannable.SPAN_INCLUSIVE_INCLUSIVE, if you append at the end, you should use Spannable.SPAN_EXCLUSIVE_INCLUSIVE or Spannable.SPAN_INCLUSIVE_INCLUSIVE.

So use Spannable.SPAN_INCLUSIVE_INCLUSIVE instead of Spannable.SPAN_INCLUSIVE_EXCLUSIVE (I tested it and it works).

With SPAN_EXCLUSIVE_INCLUSIVE:

enter image description here

With SPAN_INCLUSIVE_INCLUSIVE:

enter image description here

After some digging I found TextUtils is using SpannableStringBuilder.append which is using SpannableStringBuilder.replace (https://developer.android.com/reference/android/text/SpannableStringBuilder#replace(int,%20int,%20java.lang.CharSequence,%20int,%20int)) under the hood and the replace is considered an insert at the end. With SPAN_EXCLUSIVE_INCLUSIVE it would not expand the formatting while it would with SPAN_INCLUSIVE_INCLUSIVE:

If the source contains a span with Spanned#SPAN_PARAGRAPH flag, and it does not satisfy the paragraph boundary constraint, it is not retained.

I also checked the resulting span and printed the start/end of the StyleSpan. The first line with SPAN_EXCLUSIVE_INCLUSIVE the last line with SPAN_INCLUSIVE_INCLUSIVE:

01-14 12:44:11.218  5017  5017 E test    : span start/end: 0/5
01-14 12:44:22.575  5079  5079 E test    : span start/end: 0/10

If you append text to a Spannable it doesn't matter whether that text is just plain text or another Spannable. Spans in the original texts will either expand or not depending on the span's flags (Spannable.SPAN_xyz_INCLUSIVE vs Spannable.SPAN_xyz_EXCLUSIVE). That's why the bold formatting will either expand to the whole string with TextUtils.concat("A", s, "B", s, "C") (using Spannable.SPAN_xyz_INCLUSIVE) or not expand at all (using Spannable.SPAN_xyz_EXCLUSIVE).

Your attempt to append an already formatted Spannable won't work because a span can be used just once.

That's why this:

    val s = SpannableStringBuilder("hellohello")
    val span = StyleSpan(Typeface.BOLD)
    s.setSpan(span, 0, 5, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
    s.setSpan(span, 5, 10, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)

will result in hellohello, while this:

    val s = SpannableStringBuilder("hellohello")
    val span1 = StyleSpan(Typeface.BOLD)
    val span2 = StyleSpan(Typeface.BOLD)
    s.setSpan(span1, 0, 5, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
    s.setSpan(span2, 5, 10, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)

will result in hellohello

This answers your question:

Why aren't both "hello" in bold?

Unfortunately your question isn't very specific on what you're actually trying to achieve. If it's simply to have a Spannable with AhelloBhelloC, then you now know how. If your goal is to have a generic solution that allows to append arbitrary Spannable with arbitrary spans and keep those spans (CharacterStyle and ParagraphStyle) then you'll have to find a way to clone arbitrary Spanned texts with arbitrary spans (note if s is a SpannableStringBuilder then s.subSequence(0, s.length) copies spans but doesn't clone them). So here's how I'd do it:

private fun Spannable.clone(): Spannable {
    val clone = SpannableStringBuilder(toString())
    for (span in getSpans(0, length, Any::class.java)) {
        if (span is CharacterStyle || span is ParagraphStyle) {
            val st = getSpanStart(span).coerceAtLeast(0)
            val en = getSpanEnd(span).coerceAtMost(length)
            val fl = getSpanFlags(span)
            val clonedSpan = cloneSpan(span)
            clone.setSpan(clonedSpan, st, en, fl)
        }
    }
    return clone
}

private fun cloneSpan(span: Any): Any {
    return when (span) {
        is StyleSpan -> StyleSpan(span.style)
        // more clone code to be written...
        else -> span
    }
}

Then you can do:

val s  = SpannableString("hello")
s.setSpan(StyleSpan(Typeface.BOLD), 0, s.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = TextUtils.concat(s, " not bold ", s.clone())