Status Update
Comments
ch...@google.com <ch...@google.com> #2
<transition xmlns:android="
<item android:drawable="@drawable/test_drawable_blue"/>
<item android:drawable="@drawable/test_drawable_green"/>
</transition>
where blue/green drawables look like this:
<shape
xmlns:android="
android:shape="rectangle">
<size
android:width="@dimen/drawable_large_size"
android:height="@dimen/drawable_small_size" />
<solid
android:color="@color/test_blue" />
</shape>
Then added this test:
@Test
public void testMutateTransitionDrawable() {
Drawable drawable = ResourcesCompat.getDrawable(mResources,
R.drawable.test_transition_drawable, null);
assertTrue(drawable instanceof TransitionDrawable);
Drawable mutated = drawable.mutate();
assertTrue(drawable instanceof TransitionDrawable);
assertTrue(mutated instanceof TransitionDrawable);
}
It passes on the device. Going to also try on other earlier devices a bit later in the day once they are charged.
at...@monzo.com <at...@monzo.com> #3
ch...@google.com <ch...@google.com> #4
he...@ataulm.com <he...@ataulm.com> #5
Then in AppCompat's version (the public API), delegate all the public functions so that the code isn't duplicated?
at...@monzo.com <at...@monzo.com> #6
I had a go at fixing this by modifying ColorStateListInflaterCompat
only. Instead of using getColor
:
final int baseColor = a.getColor(R.styleable.ColorStateListItem_android_color,
Color.MAGENTA);
it checks whether it's a resource ID, and then calls back up to createFromXml
:
final int resourceId = a.getResourceId(R.styleable.ColorStateListItem_android_color, -1);
final int baseColor;
if (resourceId != -1) {
baseColor = createFromXml(r, r.getXml(resourceId), theme).getDefaultColor();
} else {
baseColor = a.getColor(R.styleable.ColorStateListItem_android_color, Color.MAGENTA);
}
This seems to work well enough for my use case, where I'm referencing a single-state CSL within a CSL, since it uses .getDefaultColor()
.
If the 2nd CSL had multiple states, I'm not sure what the expected behaviour should be, since colorList
is an int array, and I assume that these are expected to be color ints, rather than a list of color ints OR resource IDs.
Is it ok to try and submit a patch for this?
at...@monzo.com <at...@monzo.com> #7
*also need to check that it's not a colorInt:
if (resourceId != -1 && !isColorInt(r, resourceId)) {
ch...@google.com <ch...@google.com> #8
Sure, thanks in advance for the submission!
The logic sgtm, I'd just add a fallback to getColor()
on an exception. Something like:
if (resourceId != -1 && !isColorInt(r, resourceId)) {
try {
baseColor = createFromXml(r, r.getXml(resourceId), theme).getDefaultColor();
} catch (...) {
baseColor = a.getColor(R.styleable.ColorStateListItem_android_color, Color.MAGENTA);
}
} else {
// ....
}
he...@ataulm.com <he...@ataulm.com> #9
ki...@google.com <ki...@google.com> #10
Chris - how about we add another method to ColorStateListInflaterCompat
. Something like inflateColorStateList(Context, int, (Context, int)->Color)
that would allow ColorStateListInflaterCompat
from core
to call "back" to AppCompatResources
from appcompat-resources
?
Alan - what's the expectation of arbitrary "nesting" of color state lists? Specifically:
<!-- res/values/themes.xml -->
<resources>
<style name="Theme.MyApp">
<item name="colorPrimary">@color/blue</item>
</style>
</resources>
<!-- res/color/primary_60.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.60" android:color="?attr/colorPrimary" />
</selector>
<!-- res/color/text_tertiary.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/primary_60" android:state_enabled="true" />
<item android:alpha="0.60" android:color="@color/primary_60" />
</selector>
In here, the disabled state of text_tertiary
references another selector that then references a theme attribute. But what if that other selector (primary_60
) had multiple entries for different states? Would that reference resolve to the default color?
he...@ataulm.com <he...@ataulm.com> #11
I started a
I'd move the cache to ContextCompat, then hook up the cache in ContextCompat.getColorStateList(). You can then proxy AppCompatResources.getColorStateList() to ContextCompat.getColorStateList()
maybe ResourcesCompat instead. But we should also make sure that ContextCompat always calls through to ResourcesCompat
I haven't added another function to ColorStateListInflaterCompat
with the callback, which would help solve the cache issue when called from AppCompatResources
, ContextCompat
and ResourcesCompat
.
There's one other usage of ColorStateListInflaterCompat
from TypedArrayUtils
which doesn't have a CSL cache anyway.
he...@ataulm.com <he...@ataulm.com> #12
*disregard the "one other usage" - I had unloaded a bunch of modules.
ch...@google.com <ch...@google.com> #13
how about we add another method to ColorStateListInflaterCompat?
Makes sense to me, then AppCompat can extend and cache.
Alan - what's the expectation of arbitrary "nesting" of color state lists?
I say we keep to the simple case, which is what the framework implicitly does: squash down to the default color.
al...@google.com <al...@google.com> #14
Alan - what's the expectation of arbitrary "nesting" of color state lists?
...
But what if that other selector (primary_60) had multiple entries for different states? Would that reference resolve to the default color?
I say we keep to the simple case, which is what the framework implicitly does: squash down to the default color.
Agreed, I think adding nesting to CsL would be going a bit too far.
ki...@google.com <ki...@google.com> #15
ch...@google.com <ch...@google.com> #16
LGTM to me overall
at...@monzo.com <at...@monzo.com> #17
I had a look into adding a callback so that CSLInflaterCompat could hook back into ResourcesCompat, if it needed to inflate another CSL, which has a cache of inflated CSLs.
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP_PREFIX)
interface ColorStateListCache {
@Nullable
ColorStateList getCachedColorStateList(Resources resources, @ColorRes int resId);
}
Since ResourcesCompat
is the only one with a CSL cache, we could pass a nullable cache to createFromXml
:
@NonNull
public static ColorStateList createFromXml(
@NonNull Resources r,
@NonNull XmlPullParser parser,
@Nullable Resources.Theme theme,
@Nullable ColorStateListCache cache
) throws XmlPullParserException, IOException {
final AttributeSet attrs = Xml.asAttributeSet(parser);
// ...
and createFromXmlInner
:
@NonNull
public static ColorStateList createFromXmlInner(
@NonNull Resources r,
@NonNull XmlPullParser parser,
@NonNull AttributeSet attrs,
@Nullable Resources.Theme theme,
@Nullable ColorStateListCache cache
) throws XmlPullParserException, IOException {
final String name = parser.getName();
// ...
Although these functions are public, this class is restricted to the library group, so only a few changes are required (and no public API changes):
ComplexColorCompat.inflate()
usescreateFromXmlInner
. This would pass anull
cache.TypedArrayUtils.getNamedColorStateList()
usescreateFromXml()
. This would pass anull
cache too.ResourcesCompat
usescreateFromXml()
. This passes a non-null cache:
@Nullable
private static ColorStateList inflateColorStateList(Resources resources, int resId,
@Nullable Theme theme) {
if (isColorInt(resources, resId)) {
// The resource is a color int, we can't handle it so return null
return null;
}
final XmlPullParser xml = resources.getXml(resId);
try {
return ColorStateListInflaterCompat.createFromXml(resources, xml, theme, cache);
} catch (Exception e) {
Log.e(TAG, "Failed to inflate ColorStateList, leaving it to the framework", e);
}
return null;
}
private static final ColorStateListCache cache = new ColorStateListCache() {
@Nullable
@Override
public ColorStateList getCachedColorStateList(Resources resources, int resId) {
return ResourcesCompat.getCachedColorStateList(resources, resId);
}
};
What do you think?
at...@monzo.com <at...@monzo.com> #18
*The logic in the private inflate
function gets pretty gnarly in Java though:
int resourceId = a.getResourceId(R.styleable.ColorStateListItem_android_color, -1);
int baseColor;
if (resourceId != -1 && !isColorInt(r, resourceId)) {
try {
if (cache != null) {
ColorStateList cachedCsl = cache.getCachedColorStateList(r, resourceId);
if (cachedCsl != null) {
baseColor = cachedCsl.getDefaultColor();
} else {
baseColor = createFromXml(r, r.getXml(resourceId),
theme).getDefaultColor();
}
} else {
baseColor = createFromXml(r, r.getXml(resourceId), theme).getDefaultColor();
}
} catch (Exception e) {
baseColor = a.getColor(R.styleable.ColorStateListItem_android_color,
Color.MAGENTA);
}
} else {
baseColor = a.getColor(R.styleable.ColorStateListItem_android_color, Color.MAGENTA);
}
ch...@google.com <ch...@google.com> #19
Hmmm, I say we keep to the simple solution which we spoke about in #8.
ki...@google.com <ki...@google.com> #20
The contributor CL has been merged and will be available in the next alphas of core and appcompat
ap...@google.com <ap...@google.com> #21
Branch: androidx-master-dev
commit e232f52b6f340b10ac5bed52d065068ac78e7b06
Author: Ataul Munim <hello@ataulm.com>
Date: Fri May 29 21:41:42 2020
Add CSL value support to ColorStateListInflaterCompat
Bug: 155579892
Test: ColorStateListInflaterCompatTest
Change-Id: I61efc84945a4ac1633b085b8fe1b83aca1a6cd4b
M appcompat/appcompat-resources/src/main/java/androidx/appcompat/content/res/AppCompatResources.java
M core/core/src/androidTest/java/androidx/core/content/res/ColorStateListInflaterCompatTest.java
A core/core/src/androidTest/res/color/color_state_list_secondary_text.xml
M core/core/src/androidTest/res/values/styles.xml
M core/core/src/main/java/androidx/core/content/ContextCompat.java
M core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java
M core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java
ki...@google.com <ki...@google.com>
ki...@google.com <ki...@google.com> #22
Detected a regression that this CL introduced.
al...@google.com <al...@google.com> #23
P1 to block stable.
Looks like the CSL cache is keying resolved CSLs on Resources
without accounting for the Theme
against which they was resolved. It was always broken, we just never noticed.
Global cache keyed on Resources
:
private static final WeakHashMap<Resources, SparseArray<ColorStateListCacheEntry>>
sColorStateCaches = new WeakHashMap<>(0);
Resolved ColorStateList
and no mention of Theme
:
private static class ColorStateListCacheEntry {
final ColorStateList mValue;
final Configuration mConfiguration;
ColorStateListCacheEntry(@NonNull ColorStateList value,
@NonNull Configuration configuration) {
mValue = value;
mConfiguration = configuration;
}
}
ki...@google.com <ki...@google.com> #24
From Alan:
The base cache class is Resources.ThemeKey
. The old implementation in appcompat-resources
never ran into this issue since it never supported nested CSLs. However, now you can run into a cached CSL that was resolved against a Theme
, and then get the same CSL when you were asking for one that matches Resources
with no Theme
. So keying on Configuration
alone is not enough for caching.
A potential quick fix is to disable caching altogether (same as in framework). Or alternatively add something to the ColorStateListCacheEntry
that would respect the Theme
set on Resources
al...@google.com <al...@google.com> #25
Note that the platform's ResourcesImpl
keys the cache on Resources
, Theme
, and asset cookie:
final long key = (((long) value.assetCookie) << 32) | value.data;
final ConfigurationBoundResourceCache<ComplexColor> cache = mComplexColorCache;
ComplexColor complexColor = cache.getInstance(key, wrapper, theme);
Theme
doesn't implement hashCode
and its comparable ThemeKey
isn't public API, but you'd still get some benefits from keying off the Object
implementation of hashCode
.
al...@google.com <al...@google.com>
he...@ataulm.com <he...@ataulm.com> #26
What's the preferred approach? I can pick this up.
he...@ataulm.com <he...@ataulm.com> #27
I didn't understand when the Resources
might be different - is it this?
ResourcesCompat.getColorStateList(
context.resources,
R.color.my_csl,
ContextThemeWrapper(context, R.style.ThemeOverlay_EverythingDifferent).theme
)
If adding Theme
to ColorStateListCacheEntry
isn't good enough because the lack of a legit hashcode implementation means potential collisions, could we instead keep Resources
as the key in the global cache, but use theme?.resources ?: res
instead?
he...@ataulm.com <he...@ataulm.com> #28
The old implementation in appcompat-resources never ran into this issue since it never supported nested CSLs. However, now you can run into a cached CSL that was resolved against a Theme, and then get the same CSL when you were asking for one that matches Resources with no Theme.
This is the part I didn't understand. The nested CSL isn't resolved from the cache, it's always inflated, so I didn't follow why this is a problem now, but wasn't before.
he...@ataulm.com <he...@ataulm.com> #29
@Alan, is this still blocking?
al...@google.com <al...@google.com> #30
could we instead keep
Resources
as the key in the global cache, but usetheme?.resources ?: res
instead?
No, because many Theme
s will reference the same Resources
object. Theme
is a non-static inner class of Resources
and contains a reference to the Resources
object from which it was created.
@Alan, is this still blocking?
Not blocking, per se. We had to revert the CL. Lowering to P2 and considering this a FR now.
he...@ataulm.com <he...@ataulm.com> #32
I'm able to reproduce the bug and I understand what you mean now. It's a very common case, e.g. when using theme overlays:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Purple"
android:textColor="@color/csl_pointing_to_color_primary_which_is_purple" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Green"
android:textColor="@color/csl_pointing_to_color_primary_which_is_purple"
android:theme="@style/ThemeOverlay.GreenPrimary" />
We'd expect green text for the second one, but @color/csl_pointing_to_color_primary_which_is_purple
is cached, so it'll be purple. :+1:
I added a key that takes Theme
into account:
private static class ColorStateListCacheKey {
final Resources mResources;
@Nullable final Theme mTheme;
ColorStateListCacheKey(@NonNull Resources resources, @Nullable Theme theme) {
mResources = resources;
mTheme = theme;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ColorStateListCacheKey that = (ColorStateListCacheKey) o;
return mResources.equals(that.mResources) &&
Objects.equals(mTheme, that.mTheme);
}
@Override
public int hashCode() {
return Objects.hash(mResources, mTheme);
}
}
I don't think there would be any false positive matches anymore (the cache bug), but there could still be some false negatives (unnecessary inflation, duplicate values in cache). As you said this is what ResourcesImpl
does (*), is this a sufficient solution?
(*) I didn't understand the value of including the asset cookie in the key. Is that applicable for this case?
he...@ataulm.com <he...@ataulm.com> #33
Bump, I've got time to work on this this month. If I submit a CL with the above, reckon someone could take a look?
ki...@google.com <ki...@google.com> #34
Sure, if you have a CL with test covering the new paths, we'll be happy to get it in and see if it breaks any of the app tests that we have.
he...@ataulm.com <he...@ataulm.com> #35
CL here
I didn't add any new tests - ResourcesCompatTest
already covered the bug as I understood it, where R.color.complex_themed_selector
is loaded twice with distinct themes. I extended the coverage down to the minSdkVersion of the library (but could only test it on API 15).
ki...@google.com <ki...@google.com>
ap...@google.com <ap...@google.com> #36
Branch: androidx-main
commit 63f5fe8e647c9b5b704a33c34201dbc3bab946ef
Author: Ataul Munim <hello@ataulm.com>
Date: Sun Jul 26 10:05:33 2020
Add CSL value support to ColorStateListInflaterCompat
Bug: 155579892
Test: ColorStateListInflaterCompatTest verifies nested CSLs are loaded,
ResourcesCompatTest extends getThemedCsl coverage to all API versions
(tested down to API 15)
Change-Id: I2e4098d45173a443dda97a23923c02755e83acfb
M appcompat/appcompat-resources/src/main/java/androidx/appcompat/content/res/AppCompatResources.java
M core/core/src/androidTest/java/androidx/core/content/res/ColorStateListInflaterCompatTest.java
M core/core/src/androidTest/java/androidx/core/content/res/ResourcesCompatTest.java
A core/core/src/androidTest/res/color/color_state_list_secondary_text.xml
M core/core/src/androidTest/res/values/styles.xml
M core/core/src/main/java/androidx/core/content/ContextCompat.java
M core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java
M core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java
Description
Issue
Steps to reproduce
colorPrimary
has a value@color/blue
(a plain hex color).@color/primary_60
is a single value CSL used to apply 60% alpha@color/text_tertiary
is a CSL with enabled/disabled states. It references@color/primary_60
for both states, applying a further 60% alpha for the disabled state.(This is a contrived use case, since we could define
@color/text_tertiary
in terms of?attr/colorPrimary
rather than@color/primary_60
.)The bug is that
AppCompatResources.getColorStateList
behaviour doesn't match the framework's resolution of CSLs.The same behavior is exhibited if using
android:textColor
or manually loading the@color/text_tertiary
withAppCompatResources
and usingsetTextColor(ColorStateList)
(AppCompatTextView
usesAppCompatResources
for this attribute under the hood).Screenshots
Attached screenshot comparing API 22 and API 29.