TWiStErRob

at it's worst

Color support for XML string resources in Android

I challenge you to find anything more inconsistent than this!

So you read somewhere that Android supports <font> tags to color string resources, when used with Resources#getText()? Well, yeah, except…

… there’s no uniform way across SDK platform versions to represent “this text should be red” with just a String resource.

Following is a summary and excerpt of the code handling XML font colors in Android. I tried to make it easier to digest than just raw code. The codes did not change between the listed versions unless otherwise noted. The sources come from

  • android.content.res.StringBlock: parses tags and attributes
  • com.android.internal.util.XmlUtils: parses numbers as colors
  • android.graphics.Color: color handling

(They’re all available in android-sdk/sources folder, but to see them in IDEA you have use the Navigate > File action instead of Navigate > Class because they’re @hide.)

API Level 10, 14—17

Attribute names
fgcolor
(via XmlUtils.convertValueToUnsignedInt -> parseLong)
Base prefixes
0 (octal), 0x (hexadecimal), # (hexadecimal)
Number formats
Bd*, B-d*
(B is the base prefix above, d* is any resonable number of digits in that base)
Color range
full ARGB
Exceptions
  • null: no effect (guarded in StringBlock)
  • empty string: crash
  • @system_color_res: crash NumberFormatException
  • named color: crash NumberFormatException
public static final int
convertValueToUnsignedInt(String value, int defaultValue)
{
    if (null == value)
        return defaultValue;

    return parseUnsignedIntAttribute(value);
}

public static final int
parseUnsignedIntAttribute(CharSequence charSeq)
{
    String  value = charSeq.toString();

    long    bits;
    int     index = 0;
    int     len = value.length();
    int     base = 10;

    if ('0' == value.charAt(index)) {
        //  Quick check for zero by itself
        if (index == (len - 1))
            return 0;

        char    c = value.charAt(index + 1);

        if ('x' == c || 'X' == c) {     //  check for hex
            index += 2;
            base = 16;
        } else {                        //  check for octal
            index++;
            base = 8;
        }
    } else if ('#' == value.charAt(index)) {
        index++;
        base = 16;
    }

    return (int) Long.parseLong(value.substring(index), base);
}

API Level 18—22

Attribute names
fgcolor, color
(via getColor -> Color.getHtmlColor -> XmlUtils.convertValueToInt -> parseInt)
Base prefixes
0 (octal), 0x (hexadecimal), # (hexadecimal)
Number formats
Bd*, -Bd*, B-d*, -B+d*, -B-d*
(B is the base prefix above, d* is any resonable number of digits in that base)
Color range
partial ARGB (alpha <= 0x7f)
Exceptions
  • null: no effect (guarded in StringBlock)
  • empty string: 0xff000000 (= BLACK)
  • @system_color_res: works
  • named color: works
    (except it may be problematic on API Level 18: the explicit Locale.ROOT is missing from toLowerCase. For example, this causes color="LIME" to be interpreted as lıme in tr-TR (Turkish) locale; notice there is no dot on the i!)
  • invalid named color: -1 (= 0xFFFFFFFF = WHITE)
  • invalid format: -1 (= 0xFFFFFFFF = WHITE)
  • numeric color above 0x7fffffff: -1 (= 0xFFFFFFFF = WHITE)
private static CharacterStyle getColor(String color, boolean foreground) {
    int c = 0xff000000;

    if (!TextUtils.isEmpty(color)) {
        if (color.startsWith("@")) {
            Resources res = Resources.getSystem();
            String name = color.substring(1);
            int colorRes = res.getIdentifier(name, "color", "android");
            if (colorRes != 0) {
                ColorStateList colors = res.getColorStateList(colorRes);
                if (foreground) {
                    return new TextAppearanceSpan(null, 0, 0, colors, null);
                } else {
                    c = colors.getDefaultColor();
                }
            }
        } else {
            c = Color.getHtmlColor(color);
        }
    }

    if (foreground) {
        return new ForegroundColorSpan(c);
    } else {
        return new BackgroundColorSpan(c);
    }
}

public static int getHtmlColor(String color) {
    Integer i = sColorNameMap.get(color.toLowerCase(Locale.ROOT));
    if (i != null) {
        return i;
    } else {
        try {
            return XmlUtils.convertValueToInt(color, -1);
        } catch (NumberFormatException nfe) {
            return -1;
        }
    }
}

public static final int
convertValueToInt(CharSequence charSeq, int defaultValue)
{
    if (null == charSeq)
        return defaultValue;

    String nm = charSeq.toString();

    // XXX This code is copied from Integer.decode() so we don't
    // have to instantiate an Integer!

    int value;
    int sign = 1;
    int index = 0;
    int len = nm.length();
    int base = 10;

    if ('-' == nm.charAt(0)) {
        sign = -1;
        index++;
    }

    if ('0' == nm.charAt(index)) {
        //  Quick check for a zero by itself
        if (index == (len - 1))
            return 0;

        char    c = nm.charAt(index + 1);

        if ('x' == c || 'X' == c) {
            index += 2;
            base = 16;
        } else {
            index++;
            base = 8;
        }
    }
    else if ('#' == nm.charAt(index))
    {
        index++;
        base = 16;
    }

    return Integer.parseInt(nm.substring(index), base) * sign;
}

API Level 23—28

Attribute names
fgcolor, color
(via getColor -> Color.parseColor -> parseLong)
Base prefixes
# (hexadecimal)
Number formats
#xxxxxx, #xxxxxxxx
(# is literally the hashmark, x is hexadecimal digit, strictly 6 or 8 digits)
Color range
full ARGB
Exceptions
  • null: no effect (guarded in StringBlock)
  • empty string: 0xff000000 (= BLACK)
  • @system_color_res: works
  • named color: works
  • invalid named color: BLACK (= 0xFF000000)
  • invalid format: BLACK (= 0xFF000000)
private static CharacterStyle getColor(String color, boolean foreground) {
    int c = 0xff000000;

    if (!TextUtils.isEmpty(color)) {
        if (color.startsWith("@")) {
            Resources res = Resources.getSystem();
            String name = color.substring(1);
            int colorRes = res.getIdentifier(name, "color", "android");
            if (colorRes != 0) {
                ColorStateList colors = res.getColorStateList(colorRes, null);
                if (foreground) {
                    return new TextAppearanceSpan(null, 0, 0, colors, null);
                } else {
                    c = colors.getDefaultColor();
                }
            }
        } else {
            try {
                c = Color.parseColor(color);
            } catch (IllegalArgumentException e) {
                c = Color.BLACK;
            }
        }
    }

    if (foreground) {
        return new ForegroundColorSpan(c);
    } else {
        return new BackgroundColorSpan(c);
    }
}

public static int parseColor(String colorString) {
    if (colorString.charAt(0) == '#') {
        // Use a long to avoid rollovers on #ffXXXXXX
        long color = Long.parseLong(colorString.substring(1), 16);
        if (colorString.length() == 7) {
            // Set the alpha value
            color |= 0x00000000ff000000;
        } else if (colorString.length() != 9) {
            throw new IllegalArgumentException("Unknown color");
        }
        return (int)color;
    } else {
        Integer color = sColorNameMap.get(colorString.toLowerCase(Locale.ROOT));
        if (color != null) {
            return color;
        }
    }
    throw new IllegalArgumentException("Unknown color");
}

Special cases

  • HTC One M8 4.4.2 where named colors crash with NumberFormatException: Invalid long: "cyan" (sample size is 1, and I’m not sure if it’s a custom ROM or not)

Solution?

Forget colors! But seriously, after days of investigation I found no built-in way of doing it. For full support we have to write code that builds/fixes the ForegroundColorSpans’ alpha values or our own parser. At first I thought it’ll work with color="@res_name", but that format only supports color resources from the framework’s resources, no custom ones.

Here are some partial solutions that does’t require (much) code. Most of the hacks I tried were based on the fact that color is parsed after fgcolor in the code of StringBlock and CharSequence’s spans are created in the order that allows color to win on newer platforms. If you don’t like multiple attributes, you could also nest <font> tags, then the outermost wins.

Screenshots of the above techniques

<font fgcolor="#FFFF0000" color="red">
If you want to use a named color you’re in luck! Your designers won’t be happy, but you CAN actually do this. The above solutions work on all 3 variants of code: the old platforms understand fgcolor=ARGB correctly, the new platforms support named colors. Note: this will probably crash on special cases.
<font fgcolor="#FFFF0000" color="-#00010000">
If you don’t care about new platforms… (Who does that?!), then you can use this to support anything below API 23. Old platforms understand fgcolor=ARGB correctly, and the not-so-old ones support the negative hack. Note: on new platforms the color will be black, so be careful if you have a dark theme.
<font fgcolor="#7FFF0000">
If you want only alpha < 128 you’re in luck, because all platforms uniformly support fgcolor=ARGB in that range.
Html.fromHtml("<font color=\"#FF0000\">text</font>")
If you only want opaque colors and willing to write some minor code, you can use HTML (string can be stored as <![CDATA[ in an XML string resource). But this only supports RGB colors, no alpha channel. Works on all platforms.
Please share if you find more minimal-code workarounds, I’ll add it here!

References

Go to top