Reapply "Support Android 14's non-linear font scaling for Material Text"
The tests are now fixed to don't rely on changing fontScale across the device
This reverts commit b72d11274aeb6e273668932ae3d755452f22ee73.
Bug: 332551882
Bug: 346299213
Change-Id: I8d4d77d7338c0c650b63f28696979ac331b084f7
diff --git a/wear/protolayout/protolayout-material-core/build.gradle b/wear/protolayout/protolayout-material-core/build.gradle
index dcf081a..ca1740a 100644
--- a/wear/protolayout/protolayout-material-core/build.gradle
+++ b/wear/protolayout/protolayout-material-core/build.gradle
@@ -26,6 +26,7 @@
plugins {
id("AndroidXPlugin")
id("com.android.library")
+ id("kotlin-android")
}
android {
@@ -55,6 +56,7 @@
testImplementation(libs.testRunner)
testImplementation(libs.testRules)
testImplementation(libs.truth)
+ testImplementation("androidx.core:core-ktx:1.13.1")
}
androidx {
diff --git a/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverter.java b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverter.java
new file mode 100644
index 0000000..98351ea
--- /dev/null
+++ b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverter.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.materialcore.fontscaling;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+import java.util.Arrays;
+
+/**
+ * A lookup table for non-linear font scaling. Converts font sizes given in "sp" dimensions to a
+ * "dp" dimension according to a non-linear curve.
+ *
+ * <p>This is meant to improve readability at larger font scales: larger fonts will scale up more
+ * slowly than smaller fonts, so we don't get ridiculously huge fonts that don't fit on the screen.
+ *
+ * <p>The thinking here is that large fonts are already big enough to read, but we still want to
+ * scale them slightly to preserve the visual hierarchy when compared to smaller fonts.
+ */
+// This is copied from
+// https://cs.android.com/android/_/android/platform/frameworks/base/+/2a4e99a798cc69944f64d54b81aee987fbea45d6:core/java/android/content/res/FontScaleConverter.java
+// TODO: b/342359552 - Use Android Platform api instead when it becomes public.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class FontScaleConverter {
+
+ final float[] mFromSpValues;
+ final float[] mToDpValues;
+
+ /**
+ * Creates a lookup table for the given conversions.
+ *
+ * <p>Any "sp" value not in the lookup table will be derived via linear interpolation.
+ *
+ * <p>The arrays must be sorted ascending and monotonically increasing.
+ *
+ * @param fromSp array of dimensions in SP
+ * @param toDp array of dimensions in DP that correspond to an SP value in fromSp
+ * @throws IllegalArgumentException if the array lengths don't match or are empty
+ */
+ FontScaleConverter(@NonNull float[] fromSp, @NonNull float[] toDp) {
+ if (fromSp.length != toDp.length || fromSp.length == 0) {
+ throw new IllegalArgumentException("Array lengths must match and be nonzero");
+ }
+
+ mFromSpValues = fromSp;
+ mToDpValues = toDp;
+ }
+
+ /** Convert a dimension in "dp" back to "sp" using the lookup table. */
+ public float convertDpToSp(float dp) {
+ return lookupAndInterpolate(dp, mToDpValues, mFromSpValues);
+ }
+
+ /** Convert a dimension in "sp" to "dp" using the lookup table. */
+ public float convertSpToDp(float sp) {
+ return lookupAndInterpolate(sp, mFromSpValues, mToDpValues);
+ }
+
+ private static float lookupAndInterpolate(
+ float sourceValue, float[] sourceValues, float[] targetValues) {
+ final float sourceValuePositive = Math.abs(sourceValue);
+ // TODO(b/247861374): find a match at a higher index?
+ final float sign = Math.signum(sourceValue);
+ // We search for exact matches only, even if it's just a little off. The interpolation will
+ // handle any non-exact matches.
+ final int index = Arrays.binarySearch(sourceValues, sourceValuePositive);
+ if (index >= 0) {
+ // exact match, return the matching dp
+ return sign * targetValues[index];
+ } else {
+ // must be a value in between index and index + 1: interpolate.
+ final int lowerIndex = -(index + 1) - 1;
+
+ final float startSp;
+ final float endSp;
+ final float startDp;
+ final float endDp;
+
+ if (lowerIndex >= sourceValues.length - 1) {
+ // It's past our lookup table. Determine the last elements' scaling factor and use.
+ startSp = sourceValues[sourceValues.length - 1];
+ startDp = targetValues[sourceValues.length - 1];
+
+ if (startSp == 0) {
+ return 0;
+ }
+
+ final float scalingFactor = startDp / startSp;
+ return sourceValue * scalingFactor;
+ } else if (lowerIndex == -1) {
+ // It's smaller than the smallest value in our table. Interpolate from 0.
+ startSp = 0;
+ startDp = 0;
+ endSp = sourceValues[0];
+ endDp = targetValues[0];
+ } else {
+ startSp = sourceValues[lowerIndex];
+ endSp = sourceValues[lowerIndex + 1];
+ startDp = targetValues[lowerIndex];
+ endDp = targetValues[lowerIndex + 1];
+ }
+
+ return sign
+ * MathUtils.constrainedMap(startDp, endDp, startSp, endSp, sourceValuePositive);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null) {
+ return false;
+ }
+ if (!(o instanceof FontScaleConverter)) {
+ return false;
+ }
+ FontScaleConverter that = (FontScaleConverter) o;
+ return Arrays.equals(mFromSpValues, that.mFromSpValues)
+ && Arrays.equals(mToDpValues, that.mToDpValues);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Arrays.hashCode(mFromSpValues);
+ result = 31 * result + Arrays.hashCode(mToDpValues);
+ return result;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "FontScaleConverter{"
+ + "fromSpValues="
+ + Arrays.toString(mFromSpValues)
+ + ", toDpValues="
+ + Arrays.toString(mToDpValues)
+ + '}';
+ }
+}
diff --git a/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactory.java b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactory.java
new file mode 100644
index 0000000..5b8b3f7
--- /dev/null
+++ b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactory.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.materialcore.fontscaling;
+
+import android.content.res.Configuration;
+import android.util.SparseArray;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+
+/** Stores lookup tables for creating {@link FontScaleConverter}s at various scales. */
+// This is copied from
+// https://cs.android.com/android/_/android/platform/frameworks/base/+/2a4e99a798cc69944f64d54b81aee987fbea45d6:core/java/android/content/res/FontScaleConverterFactory.java
+// TODO: b/342359552 - Use Android Platform api instead when it becomes public.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class FontScaleConverterFactory {
+ private static final float SCALE_KEY_MULTIPLIER = 100f;
+
+ @VisibleForTesting
+ static final SparseArray<FontScaleConverter> LOOKUP_TABLES = new SparseArray<>();
+
+ @SuppressWarnings("NonFinalStaticField")
+ private static float sMinScaleBeforeCurvesApplied = 1.05f;
+
+ static {
+ // These were generated by frameworks/base/tools/fonts/font-scaling-array-generator.js and
+ // manually tweaked for optimum readability.
+ put(
+ /* scaleKey= */ 1.15f,
+ new FontScaleConverter(
+ /* fromSp= */ new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */ new float[] {
+ 9.2f, 11.5f, 13.8f, 16.4f, 19.8f, 21.8f, 25.2f, 30f, 100
+ }));
+
+ put(
+ /* scaleKey= */ 1.3f,
+ new FontScaleConverter(
+ /* fromSp= */ new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */ new float[] {
+ 10.4f, 13f, 15.6f, 18.8f, 21.6f, 23.6f, 26.4f, 30f, 100
+ }));
+
+ put(
+ /* scaleKey= */ 1.5f,
+ new FontScaleConverter(
+ /* fromSp= */ new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */ new float[] {12f, 15f, 18f, 22f, 24f, 26f, 28f, 30f, 100}));
+
+ put(
+ /* scaleKey= */ 1.8f,
+ new FontScaleConverter(
+ /* fromSp= */ new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */ new float[] {
+ 14.4f, 18f, 21.6f, 24.4f, 27.6f, 30.8f, 32.8f, 34.8f, 100
+ }));
+
+ put(
+ /* scaleKey= */ 2f,
+ new FontScaleConverter(
+ /* fromSp= */ new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */ new float[] {16f, 20f, 24f, 26f, 30f, 34f, 36f, 38f, 100}));
+
+ sMinScaleBeforeCurvesApplied = getScaleFromKey(LOOKUP_TABLES.keyAt(0)) - 0.02f;
+ if (sMinScaleBeforeCurvesApplied <= 1.0f) {
+ throw new IllegalStateException(
+ "You should only apply non-linear scaling to font scales > 1");
+ }
+ }
+
+ private FontScaleConverterFactory() {}
+
+ /**
+ * Returns true if non-linear font scaling curves would be in effect for the given scale, false
+ * if the scaling would follow a linear curve or for no scaling.
+ *
+ * <p>Example usage: <code>
+ * isNonLinearFontScalingActive(getResources().getConfiguration().fontScale)</code>
+ */
+ public static boolean isNonLinearFontScalingActive(float fontScale) {
+ return fontScale >= sMinScaleBeforeCurvesApplied;
+ }
+
+ /**
+ * Finds a matching FontScaleConverter for the given fontScale factor.
+ *
+ * @param fontScale the scale factor, usually from {@link Configuration#fontScale}.
+ * @return a converter for the given scale, or null if non-linear scaling should not be used.
+ */
+ @Nullable
+ public static FontScaleConverter forScale(float fontScale) {
+ if (!isNonLinearFontScalingActive(fontScale)) {
+ return null;
+ }
+
+ FontScaleConverter lookupTable = get(fontScale);
+ if (lookupTable != null) {
+ return lookupTable;
+ }
+
+ // Didn't find an exact match: interpolate between two existing tables
+ final int index = LOOKUP_TABLES.indexOfKey(getKey(fontScale));
+ if (index >= 0) {
+ // This should never happen, should have been covered by get() above.
+ return LOOKUP_TABLES.valueAt(index);
+ }
+ // Didn't find an exact match: interpolate between two existing tables
+ final int lowerIndex = -(index + 1) - 1;
+ final int higherIndex = lowerIndex + 1;
+ if (lowerIndex < 0 || higherIndex >= LOOKUP_TABLES.size()) {
+ // We have gone beyond our bounds and have nothing to interpolate between. Just give
+ // them a straight linear table instead.
+ // This works because when FontScaleConverter encounters a size beyond its bounds, it
+ // calculates a linear fontScale factor using the ratio of the last element pair.
+ return new FontScaleConverter(new float[] {1f}, new float[] {fontScale});
+ } else {
+ float startScale = getScaleFromKey(LOOKUP_TABLES.keyAt(lowerIndex));
+ float endScale = getScaleFromKey(LOOKUP_TABLES.keyAt(higherIndex));
+ float interpolationPoint =
+ MathUtils.constrainedMap(
+ /* rangeMin= */ 0f,
+ /* rangeMax= */ 1f,
+ startScale,
+ endScale,
+ fontScale);
+ return createInterpolatedTableBetween(
+ LOOKUP_TABLES.valueAt(lowerIndex),
+ LOOKUP_TABLES.valueAt(higherIndex),
+ interpolationPoint);
+ }
+ }
+
+ @NonNull
+ private static FontScaleConverter createInterpolatedTableBetween(
+ FontScaleConverter start, FontScaleConverter end, float interpolationPoint) {
+ float[] commonSpSizes = new float[] {8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100f};
+ float[] dpInterpolated = new float[commonSpSizes.length];
+
+ for (int i = 0; i < commonSpSizes.length; i++) {
+ float sp = commonSpSizes[i];
+ float startDp = start.convertSpToDp(sp);
+ float endDp = end.convertSpToDp(sp);
+ dpInterpolated[i] = MathUtils.lerp(startDp, endDp, interpolationPoint);
+ }
+
+ return new FontScaleConverter(commonSpSizes, dpInterpolated);
+ }
+
+ private static int getKey(float fontScale) {
+ return (int) (fontScale * SCALE_KEY_MULTIPLIER);
+ }
+
+ private static float getScaleFromKey(int key) {
+ return (float) key / SCALE_KEY_MULTIPLIER;
+ }
+
+ private static void put(float scaleKey, @NonNull FontScaleConverter fontScaleConverter) {
+ LOOKUP_TABLES.put(getKey(scaleKey), fontScaleConverter);
+ }
+
+ @Nullable
+ private static FontScaleConverter get(float scaleKey) {
+ return LOOKUP_TABLES.get(getKey(scaleKey));
+ }
+}
diff --git a/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/MathUtils.java b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/MathUtils.java
new file mode 100644
index 0000000..5773d03
--- /dev/null
+++ b/wear/protolayout/protolayout-material-core/src/main/java/androidx/wear/protolayout/materialcore/fontscaling/MathUtils.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.wear.protolayout.materialcore.fontscaling;
+
+import static java.lang.Math.min;
+
+/** A class that contains utility methods related to numbers. */
+final class MathUtils {
+ private MathUtils() {}
+
+ /**
+ * Returns the linear interpolation of {@code amount} between {@code start} and {@code stop}.
+ */
+ static float lerp(float start, float stop, float amount) {
+ return start + (stop - start) * amount;
+ }
+
+ /**
+ * Returns the interpolation scalar (s) that satisfies the equation: {@code value = }{@link
+ * #lerp}{@code (a, b, s)}
+ *
+ * <p>If {@code a == b}, then this function will return 0.
+ */
+ static float lerpInv(float a, float b, float value) {
+ return a != b ? ((value - a) / (b - a)) : 0.0f;
+ }
+
+ /** Returns the single argument constrained between [0.0, 1.0]. */
+ static float saturate(float value) {
+ return value < 0.0f ? 0.0f : min(1.0f, value);
+ }
+
+ /** Returns the saturated (constrained between [0, 1]) result of {@link #lerpInv}. */
+ static float lerpInvSat(float a, float b, float value) {
+ return saturate(lerpInv(a, b, value));
+ }
+
+ /**
+ * Calculates a value in [rangeMin, rangeMax] that maps value in [valueMin, valueMax] to
+ * returnVal in [rangeMin, rangeMax].
+ *
+ * <p>Always returns a constrained value in the range [rangeMin, rangeMax], even if value is
+ * outside [valueMin, valueMax].
+ *
+ * <p>Eg: constrainedMap(0f, 100f, 0f, 1f, 0.5f) = 50f constrainedMap(20f, 200f, 10f, 20f, 20f)
+ * = 200f constrainedMap(20f, 200f, 10f, 20f, 50f) = 200f constrainedMap(10f, 50f, 10f, 20f, 5f)
+ * = 10f
+ *
+ * @param rangeMin minimum of the range that should be returned.
+ * @param rangeMax maximum of the range that should be returned.
+ * @param valueMin minimum of range to map {@code value} to.
+ * @param valueMax maximum of range to map {@code value} to.
+ * @param value to map to the range [{@code valueMin}, {@code valueMax}]. Note, can be outside
+ * this range, resulting in a clamped value.
+ * @return the mapped value, constrained to [{@code rangeMin}, {@code rangeMax}.
+ */
+ static float constrainedMap(
+ float rangeMin, float rangeMax, float valueMin, float valueMax, float value) {
+ return lerp(rangeMin, rangeMax, lerpInvSat(valueMin, valueMax, value));
+ }
+}
diff --git a/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactoryTest.kt b/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactoryTest.kt
new file mode 100644
index 0000000..841825b
--- /dev/null
+++ b/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterFactoryTest.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.materialcore.fontscaling
+
+import androidx.core.util.forEach
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlin.random.Random.Default.nextFloat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FontScaleConverterFactoryTest {
+
+ @Test
+ fun scale200IsTwiceAtSmallSizes() {
+ val table = FontScaleConverterFactory.forScale(2F)!!
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(2f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(16f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(20f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(10f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @LargeTest
+ @Test
+ fun missingLookupTablePastEnd_returnsLinear() {
+ val table = FontScaleConverterFactory.forScale(3F)!!
+ generateSequenceOfFractions(-10000f..10000f, step = 0.01f).map {
+ assertThat(table.convertSpToDp(it)).isWithin(CONVERSION_TOLERANCE).of(it * 3f)
+ }
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(3f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(24f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(30f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(15f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ assertThat(table.convertSpToDp(50F)).isWithin(CONVERSION_TOLERANCE).of(150f)
+ assertThat(table.convertSpToDp(100F)).isWithin(CONVERSION_TOLERANCE).of(300f)
+ }
+
+ @SmallTest
+ fun missingLookupTable110_returnsInterpolated() {
+ val table = FontScaleConverterFactory.forScale(1.1F)!!
+
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1.1f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(8f * 1.1f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(11f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(5f * 1.1f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ assertThat(table.convertSpToDp(50F)).isLessThan(50f * 1.1f)
+ assertThat(table.convertSpToDp(100F)).isLessThan(100f * 1.1f)
+ }
+
+ @Test
+ fun missingLookupTable199_returnsInterpolated() {
+ val table = FontScaleConverterFactory.forScale(1.9999F)!!
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(2f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(16f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(20f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(10f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @Test
+ fun missingLookupTable160_returnsInterpolated() {
+ val table = FontScaleConverterFactory.forScale(1.6F)!!
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1f * 1.6F)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(8f * 1.6F)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(10f * 1.6F)
+ assertThat(table.convertSpToDp(20F)).isLessThan(20f * 1.6F)
+ assertThat(table.convertSpToDp(100F)).isLessThan(100f * 1.6F)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(5f * 1.6F)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @SmallTest
+ fun missingLookupTableNegativeReturnsNull() {
+ assertThat(FontScaleConverterFactory.forScale(-1F)).isNull()
+ }
+
+ @SmallTest
+ fun unnecessaryFontScalesReturnsNull() {
+ assertThat(FontScaleConverterFactory.forScale(0F)).isNull()
+ assertThat(FontScaleConverterFactory.forScale(1F)).isNull()
+ assertThat(FontScaleConverterFactory.forScale(0.85F)).isNull()
+ }
+
+ @SmallTest
+ fun tablesMatchAndAreMonotonicallyIncreasing() {
+ FontScaleConverterFactory.LOOKUP_TABLES.forEach { _, lookupTable ->
+ assertThat(lookupTable.mToDpValues).hasLength(lookupTable.mFromSpValues.size)
+ assertThat(lookupTable.mToDpValues).isNotEmpty()
+
+ assertThat(lookupTable.mFromSpValues.asList()).isInStrictOrder()
+ assertThat(lookupTable.mToDpValues.asList()).isInStrictOrder()
+
+ assertThat(lookupTable.mFromSpValues.asList()).containsNoDuplicates()
+ assertThat(lookupTable.mToDpValues.asList()).containsNoDuplicates()
+ }
+ }
+
+ @SmallTest
+ fun testIsNonLinearFontScalingActive() {
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(0f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(-1f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(0.85f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.02f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.10f)).isFalse()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.15f)).isTrue()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.1499999f)).isTrue()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.5f)).isTrue()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(2f)).isTrue()
+ assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(3f)).isTrue()
+ }
+
+ @LargeTest
+ @Test
+ fun allFeasibleScalesAndConversionsDoNotCrash() {
+ generateSequenceOfFractions(-10f..10f, step = 0.1f)
+ .fuzzFractions()
+ .mapNotNull { FontScaleConverterFactory.forScale(it) }
+ .flatMap { table ->
+ generateSequenceOfFractions(-2000f..2000f, step = 0.1f).fuzzFractions().map {
+ Pair(table, it)
+ }
+ }
+ .forEach { (table, sp) ->
+ try {
+ // Truth is slow because it creates a bunch of
+ // objects. Don't use it unless we need to.
+ if (!table.convertSpToDp(sp).isFinite()) {
+ assertWithMessage("convertSpToDp(%s) on table: %s", sp, table)
+ .that(table.convertSpToDp(sp))
+ .isFinite()
+ }
+ } catch (e: Exception) {
+ throw AssertionError("Exception during convertSpToDp($sp) on table: $table", e)
+ }
+ }
+ }
+
+ @Test
+ fun testGenerateSequenceOfFractions() {
+ val fractions = generateSequenceOfFractions(-1000f..1000f, step = 0.1f).toList()
+ fractions.forEach {
+ assertThat(it).isAtLeast(-1000f)
+ assertThat(it).isAtMost(1000f)
+ }
+
+ assertThat(fractions).isInStrictOrder()
+ assertThat(fractions).hasSize(1000 * 2 * 10 + 1) // Don't forget the 0 in the middle!
+
+ assertThat(fractions).contains(100f)
+ assertThat(fractions).contains(500.1f)
+ assertThat(fractions).contains(500.2f)
+ assertThat(fractions).contains(0.2f)
+ assertThat(fractions).contains(0f)
+ assertThat(fractions).contains(-10f)
+ assertThat(fractions).contains(-10f)
+ assertThat(fractions).contains(-10.3f)
+
+ assertThat(fractions).doesNotContain(-10.31f)
+ assertThat(fractions).doesNotContain(0.35f)
+ assertThat(fractions).doesNotContain(0.31f)
+ assertThat(fractions).doesNotContain(-.35f)
+ }
+
+ @Test
+ fun testFuzzFractions() {
+ val numFuzzedFractions = 6
+ val fractions =
+ generateSequenceOfFractions(-1000f..1000f, step = 0.1f).fuzzFractions().toList()
+ fractions.forEach {
+ assertThat(it).isAtLeast(-1000f)
+ assertThat(it).isLessThan(1001f)
+ }
+
+ val numGeneratedFractions = 1000 * 2 * 10 + 1 // Don't forget the 0 in the middle!
+ assertThat(fractions).hasSize(numGeneratedFractions * numFuzzedFractions)
+
+ assertThat(fractions).contains(100f)
+ assertThat(fractions).contains(500.1f)
+ assertThat(fractions).contains(500.2f)
+ assertThat(fractions).contains(0.2f)
+ assertThat(fractions).contains(0f)
+ assertThat(fractions).contains(-10f)
+ assertThat(fractions).contains(-10f)
+ assertThat(fractions).contains(-10.3f)
+ }
+
+ companion object {
+ private const val CONVERSION_TOLERANCE = 0.05f
+ }
+}
+
+fun generateSequenceOfFractions(
+ range: ClosedFloatingPointRange<Float>,
+ step: Float
+): Sequence<Float> {
+ val multiplier = 1f / step
+ val start = floor(range.start * multiplier).toInt()
+ val endInclusive = ceil(range.endInclusive * multiplier).toInt()
+ return generateSequence(start) { it + 1 }
+ .takeWhile { it <= endInclusive }
+ .map { it.toFloat() / multiplier }
+}
+
+private fun Sequence<Float>.fuzzFractions(): Sequence<Float> {
+ return flatMap { i ->
+ listOf(i, i + 0.01f, i + 0.054f, i + 0.099f, i + nextFloat(), i + nextFloat())
+ }
+}
diff --git a/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterTest.kt b/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterTest.kt
new file mode 100644
index 0000000..7dc342a
--- /dev/null
+++ b/wear/protolayout/protolayout-material-core/src/test/java/androidx/wear/protolayout/materialcore/fontscaling/FontScaleConverterTest.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.materialcore.fontscaling
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FontScaleConverterTest {
+
+ @Test
+ fun straightInterpolation() {
+ val table = createTable(8f to 8f, 10f to 10f, 20f to 20f)
+ verifyConversionBothWays(table, 1f, 1F)
+ verifyConversionBothWays(table, 8f, 8F)
+ verifyConversionBothWays(table, 10f, 10F)
+ verifyConversionBothWays(table, 30f, 30F)
+ verifyConversionBothWays(table, 20f, 20F)
+ verifyConversionBothWays(table, 5f, 5F)
+ verifyConversionBothWays(table, 0f, 0F)
+ }
+
+ @Test
+ fun interpolate200Percent() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ verifyConversionBothWays(table, 2f, 1F)
+ verifyConversionBothWays(table, 16f, 8F)
+ verifyConversionBothWays(table, 20f, 10F)
+ verifyConversionBothWays(table, 60f, 30F)
+ verifyConversionBothWays(table, 40f, 20F)
+ verifyConversionBothWays(table, 10f, 5F)
+ verifyConversionBothWays(table, 0f, 0F)
+ }
+
+ @Test
+ fun interpolate150Percent() {
+ val table = createTable(2f to 3f, 10f to 15f, 20f to 30f, 100f to 150f)
+ verifyConversionBothWays(table, 3f, 2F)
+ verifyConversionBothWays(table, 1.5f, 1F)
+ verifyConversionBothWays(table, 12f, 8F)
+ verifyConversionBothWays(table, 15f, 10F)
+ verifyConversionBothWays(table, 30f, 20F)
+ verifyConversionBothWays(table, 75f, 50F)
+ verifyConversionBothWays(table, 7.5f, 5F)
+ verifyConversionBothWays(table, 0f, 0F)
+ }
+
+ @Test
+ fun pastEndsUsesLastScalingFactor() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ verifyConversionBothWays(table, 200f, 100F)
+ verifyConversionBothWays(table, 62f, 31F)
+ verifyConversionBothWays(table, 2000f, 1000F)
+ verifyConversionBothWays(table, 4000f, 2000F)
+ verifyConversionBothWays(table, 20000f, 10000F)
+ }
+
+ @Test
+ fun negativeSpIsNegativeDp() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ verifyConversionBothWays(table, -2f, -1F)
+ verifyConversionBothWays(table, -16f, -8F)
+ verifyConversionBothWays(table, -20f, -10F)
+ verifyConversionBothWays(table, -60f, -30F)
+ verifyConversionBothWays(table, -40f, -20F)
+ verifyConversionBothWays(table, -10f, -5F)
+ verifyConversionBothWays(table, 0f, -0F)
+ }
+
+ private fun createTable(vararg pairs: Pair<Float, Float>) =
+ FontScaleConverter(
+ pairs.map { it.first }.toFloatArray(),
+ pairs.map { it.second }.toFloatArray()
+ )
+
+ private fun verifyConversionBothWays(
+ table: FontScaleConverter,
+ expectedDp: Float,
+ spToConvert: Float
+ ) {
+ assertWithMessage("convertSpToDp")
+ .that(table.convertSpToDp(spToConvert))
+ .isWithin(CONVERSION_TOLERANCE)
+ .of(expectedDp)
+
+ assertWithMessage("inverse: convertDpToSp")
+ .that(table.convertDpToSp(expectedDp))
+ .isWithin(CONVERSION_TOLERANCE)
+ .of(spToConvert)
+ }
+
+ companion object {
+ private const val CONVERSION_TOLERANCE = 0.05f
+ }
+}
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
index 0299154..7dd4feb 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
@@ -67,21 +67,10 @@
@Parameterized.Parameters(name = "{0}")
public static Collection<Object[]> data() {
- Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
float scale = displayMetrics.density;
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
-
DeviceParameters deviceParameters =
new DeviceParameters.Builder()
.setScreenWidthDp(pxToDp(SCREEN_WIDTH, scale))
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
index 61734b9..ef36a3d 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
@@ -16,11 +16,12 @@
package androidx.wear.protolayout.material;
-import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static androidx.wear.protolayout.material.RunnerUtils.SCREEN_HEIGHT;
import static androidx.wear.protolayout.material.RunnerUtils.SCREEN_WIDTH;
import static androidx.wear.protolayout.material.RunnerUtils.convertToTestParameters;
+import static androidx.wear.protolayout.material.RunnerUtils.getFontScale;
import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
+import static androidx.wear.protolayout.material.RunnerUtils.setFontScale;
import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
import static androidx.wear.protolayout.material.TestCasesGenerator.XXXL_SCALE_SUFFIX;
import static androidx.wear.protolayout.material.TestCasesGenerator.generateTestCases;
@@ -38,6 +39,8 @@
import androidx.wear.protolayout.DeviceParametersBuilders.DeviceParameters;
import androidx.wear.protolayout.material.RunnerUtils.TestCase;
+import org.junit.After;
+import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -50,15 +53,10 @@
@RunWith(Parameterized.class)
@LargeTest
public class MaterialGoldenXLTest {
- /* We set DisplayMetrics in the data() method for creating test cases. However, when running all
- tests together, first all parametrization (data()) methods are called, and then individual
- tests, causing that actual DisplayMetrics will be different. So we need to restore it before
- each test. */
- private static final DisplayMetrics DISPLAY_METRICS_FOR_TEST = new DisplayMetrics();
- private static final DisplayMetrics OLD_DISPLAY_METRICS = new DisplayMetrics();
-
private static final float FONT_SCALE_XXXL = 1.24f;
+ private static float originalFontScale;
+
private final TestCase mTestCase;
private final String mExpected;
@@ -76,27 +74,18 @@
return (int) ((px - 0.5f) / scale);
}
- @SuppressWarnings("deprecation")
@Parameterized.Parameters(name = "{0}")
- public static Collection<Object[]> data() {
+ public static Collection<Object[]> data() throws Exception {
+ // These "parameters" methods are called before any parameterized test (from any class)
+ // executes. We set and later reset the font here to have the correct context during test
+ // generation. We later set and reset the font for the actual test in BeforeClass/AfterClass
+ // methods.
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
- DisplayMetrics currentDisplayMetrics = new DisplayMetrics();
+ originalFontScale =
+ getFontScale(InstrumentationRegistry.getInstrumentation().getTargetContext());
+ setFontScale(context, FONT_SCALE_XXXL);
+
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
- currentDisplayMetrics.setTo(displayMetrics);
- displayMetrics.scaledDensity *= FONT_SCALE_XXXL;
-
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
-
- DISPLAY_METRICS_FOR_TEST.setTo(displayMetrics);
float scale = displayMetrics.density;
DeviceParameters deviceParameters =
@@ -104,6 +93,7 @@
.setScreenWidthDp(pxToDp(SCREEN_WIDTH, scale))
.setScreenHeightDp(pxToDp(SCREEN_HEIGHT, scale))
.setScreenDensity(displayMetrics.density)
+ .setFontScale(context.getResources().getConfiguration().fontScale)
// Not important for components.
.setScreenShape(DeviceParametersBuilders.SCREEN_SHAPE_RECT)
.build();
@@ -126,31 +116,22 @@
/* isForLtr= */ true));
// Restore state before this method, so other test have correct context.
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(currentDisplayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(currentDisplayMetrics);
-
+ setFontScale(context, originalFontScale);
waitForNotificationToDisappears();
return testCaseList;
}
- @Parameterized.BeforeParam
- public static void restoreBefore() {
- OLD_DISPLAY_METRICS.setTo(getApplicationContext().getResources().getDisplayMetrics());
- getApplicationContext().getResources().getDisplayMetrics().setTo(DISPLAY_METRICS_FOR_TEST);
+ @Before
+ public void setUp() {
+ setFontScale(
+ InstrumentationRegistry.getInstrumentation().getTargetContext(), FONT_SCALE_XXXL);
}
- @Parameterized.AfterParam
- public static void restoreAfter() {
- getApplicationContext().getResources().getDisplayMetrics().setTo(OLD_DISPLAY_METRICS);
+ @After
+ public void tearDown() {
+ setFontScale(
+ InstrumentationRegistry.getInstrumentation().getTargetContext(), originalFontScale);
}
@Test
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/RunnerUtils.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/RunnerUtils.java
index 0fe42d3..dd901e5 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/RunnerUtils.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/RunnerUtils.java
@@ -17,8 +17,9 @@
package androidx.wear.protolayout.material;
import android.annotation.SuppressLint;
-import android.app.Activity;
+import android.content.Context;
import android.content.Intent;
+import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.util.DisplayMetrics;
import android.util.Log;
@@ -70,53 +71,39 @@
startIntent.putExtra("layout", layoutPayload);
startIntent.putExtra(GoldenTestActivity.USE_RTL_DIRECTION, isRtlDirection);
- ActivityScenario<GoldenTestActivity> scenario = ActivityScenario.launch(startIntent);
- InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ try (ActivityScenario<GoldenTestActivity> scenario = ActivityScenario.launch(startIntent)) {
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
- try {
- // Wait 1s after launching the activity. This allows for the old white layout in the
- // bootstrap activity to fully go away before proceeding.
- Thread.sleep(1000);
- } catch (Exception ex) {
- if (ex instanceof InterruptedException) {
- Thread.currentThread().interrupt();
+ try {
+ // Wait 1s after launching the activity. This allows for the old white layout in the
+ // bootstrap activity to fully go away before proceeding.
+ Thread.sleep(100);
+ } catch (Exception ex) {
+ if (ex instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ Log.e("MaterialGoldenTest", "Error sleeping", ex);
}
- Log.e("MaterialGoldenTest", "Error sleeping", ex);
- }
- DisplayMetrics displayMetrics =
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics();
+ DisplayMetrics displayMetrics =
+ InstrumentationRegistry.getInstrumentation()
+ .getTargetContext()
+ .getResources()
+ .getDisplayMetrics();
- // RTL will put the View on the right side.
- int screenWidthStart = isRtlDirection ? displayMetrics.widthPixels - SCREEN_WIDTH : 0;
+ // RTL will put the View on the right side.
+ int screenWidthStart = isRtlDirection ? displayMetrics.widthPixels - SCREEN_WIDTH : 0;
- Bitmap bitmap =
- Bitmap.createBitmap(
- InstrumentationRegistry.getInstrumentation()
- .getUiAutomation()
- .takeScreenshot(),
- screenWidthStart,
- 0,
- SCREEN_WIDTH,
- SCREEN_HEIGHT);
- rule.assertBitmapAgainstGolden(bitmap, expected, new MSSIMMatcher());
-
- // There's a weird bug (related to b/159805732) where, when calling .close() on
- // ActivityScenario or calling finish() and immediately exiting the test, the test can hang
- // on a white screen for 45s. Closing the activity here and waiting for 1s seems to fix
- // this.
- scenario.onActivity(Activity::finish);
-
- try {
- Thread.sleep(1000);
- } catch (Exception ex) {
- if (ex instanceof InterruptedException) {
- Thread.currentThread().interrupt();
- }
- Log.e("MaterialGoldenTest", "Error sleeping", ex);
+ Bitmap bitmap =
+ Bitmap.createBitmap(
+ InstrumentationRegistry.getInstrumentation()
+ .getUiAutomation()
+ .takeScreenshot(),
+ screenWidthStart,
+ 0,
+ SCREEN_WIDTH,
+ SCREEN_HEIGHT);
+ rule.assertBitmapAgainstGolden(bitmap, expected, new MSSIMMatcher());
}
}
@@ -153,4 +140,17 @@
this.isForLtr = isForLtr;
}
}
+
+ public static float getFontScale(Context context) {
+ return context.getResources().getConfiguration().fontScale;
+ }
+
+ @SuppressWarnings("deprecation")
+ public static void setFontScale(Context context, float fontScale) {
+ Configuration newConfiguration =
+ new Configuration(context.getResources().getConfiguration());
+ newConfiguration.fontScale = fontScale;
+ context.getResources()
+ .updateConfiguration(newConfiguration, context.getResources().getDisplayMetrics());
+ }
}
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
index c613323..9685084e 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
@@ -65,21 +65,10 @@
@Parameterized.Parameters(name = "{0}")
public static Collection<Object[]> data() {
- Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
float scale = displayMetrics.density;
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
-
DeviceParameters deviceParameters =
new DeviceParameters.Builder()
.setScreenWidthDp(pxToDp(SCREEN_WIDTH, scale))
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
index 3c49e92..dc82303 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
@@ -16,11 +16,12 @@
package androidx.wear.protolayout.material.layouts;
-import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static androidx.wear.protolayout.material.RunnerUtils.SCREEN_HEIGHT;
import static androidx.wear.protolayout.material.RunnerUtils.SCREEN_WIDTH;
import static androidx.wear.protolayout.material.RunnerUtils.convertToTestParameters;
+import static androidx.wear.protolayout.material.RunnerUtils.getFontScale;
import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
+import static androidx.wear.protolayout.material.RunnerUtils.setFontScale;
import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
import static androidx.wear.protolayout.material.layouts.TestCasesGenerator.XXXL_SCALE_SUFFIX;
import static androidx.wear.protolayout.material.layouts.TestCasesGenerator.generateTestCases;
@@ -36,6 +37,8 @@
import androidx.wear.protolayout.DeviceParametersBuilders.DeviceParameters;
import androidx.wear.protolayout.material.RunnerUtils.TestCase;
+import org.junit.After;
+import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -47,15 +50,10 @@
@RunWith(Parameterized.class)
@LargeTest
public class LayoutsGoldenXLTest {
- /* We set DisplayMetrics in the data() method for creating test cases. However, when running all
- tests together, first all parametrization (data()) methods are called, and then individual
- tests, causing that actual DisplayMetrics will be different. So we need to restore it before
- each test. */
- private static final DisplayMetrics DISPLAY_METRICS_FOR_TEST = new DisplayMetrics();
- private static final DisplayMetrics OLD_DISPLAY_METRICS = new DisplayMetrics();
-
private static final float FONT_SCALE_XXXL = 1.24f;
+ private static float originalFontScale;
+
private final TestCase mTestCase;
private final String mExpected;
@@ -73,27 +71,18 @@
return (int) ((px - 0.5f) / scale);
}
- @SuppressWarnings("deprecation")
@Parameterized.Parameters(name = "{0}")
- public static Collection<Object[]> data() {
+ public static Collection<Object[]> data() throws Exception {
+ // These "parameters" methods are called before any parameterized test (from any class)
+ // executes. We set and later reset the font here to have the correct context during test
+ // generation. We later set and reset the font for the actual test in BeforeClass/AfterClass
+ // methods.
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
- DisplayMetrics currentDisplayMetrics = new DisplayMetrics();
+ originalFontScale =
+ getFontScale(InstrumentationRegistry.getInstrumentation().getTargetContext());
+ setFontScale(context, FONT_SCALE_XXXL);
+
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
- currentDisplayMetrics.setTo(displayMetrics);
- displayMetrics.scaledDensity *= FONT_SCALE_XXXL;
-
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(displayMetrics);
-
- DISPLAY_METRICS_FOR_TEST.setTo(displayMetrics);
float scale = displayMetrics.density;
DeviceParameters deviceParameters =
@@ -101,6 +90,7 @@
.setScreenWidthDp(pxToDp(SCREEN_WIDTH, scale))
.setScreenHeightDp(pxToDp(SCREEN_HEIGHT, scale))
.setScreenDensity(displayMetrics.density)
+ .setFontScale(context.getResources().getConfiguration().fontScale)
// TODO(b/231543947): Add test cases for round screen.
.setScreenShape(DeviceParametersBuilders.SCREEN_SHAPE_RECT)
.build();
@@ -111,40 +101,23 @@
/* isForRtl= */ true,
/* isForLtr= */ true);
- // Restore state before this method, so other test have correct context. This is needed here
- // too, besides in restoreBefore and restoreAfter as the test cases builder uses the context
- // to apply font scaling, so we need that display metrics passed in. However, after
- // generating cases we need to restore the state as other data() methods in this package can
- // work correctly with the default state, as when the tests are run, first all data() static
- // methods are called, and then parameterized test cases.
- InstrumentationRegistry.getInstrumentation()
- .getContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(currentDisplayMetrics);
- InstrumentationRegistry.getInstrumentation()
- .getTargetContext()
- .getResources()
- .getDisplayMetrics()
- .setTo(currentDisplayMetrics);
+ // Restore state before this method, so other test have correct context.
+ setFontScale(context, originalFontScale);
waitForNotificationToDisappears();
return testCaseList;
}
- @Parameterized.BeforeParam
- public static void restoreBefore() {
- // Set the state as it was in data() method when we generated test cases. This was
- // overridden by other static data() methods, so we need to restore it.
- OLD_DISPLAY_METRICS.setTo(getApplicationContext().getResources().getDisplayMetrics());
- getApplicationContext().getResources().getDisplayMetrics().setTo(DISPLAY_METRICS_FOR_TEST);
+ @Before
+ public void setUp() {
+ setFontScale(
+ InstrumentationRegistry.getInstrumentation().getTargetContext(), FONT_SCALE_XXXL);
}
- @Parameterized.AfterParam
- public static void restoreAfter() {
- // Restore the state to default, so the other tests and emulator have the correct starter
- // state.
- getApplicationContext().getResources().getDisplayMetrics().setTo(OLD_DISPLAY_METRICS);
+ @After
+ public void tearDown() {
+ setFontScale(
+ InstrumentationRegistry.getInstrumentation().getTargetContext(), originalFontScale);
}
@Test
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/test/GoldenTestActivity.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/test/GoldenTestActivity.java
index 10c457c..8717539d 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/test/GoldenTestActivity.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/test/GoldenTestActivity.java
@@ -45,12 +45,14 @@
import com.google.common.util.concurrent.MoreExecutors;
import java.util.Locale;
+import java.util.concurrent.ExecutionException;
@SuppressWarnings("deprecation")
public class GoldenTestActivity extends Activity {
/** Extra to be put in the intent if test should use RTL direction on parent View. */
public static final String USE_RTL_DIRECTION = "using_rtl";
+
private static final String ICON_ID = "icon";
private static final String ICON_ID_SMALL = "icon_small";
private static final String AVATAR = "avatar_image";
@@ -81,7 +83,15 @@
.setIsViewFullyVisible(true)
.build());
- instance.renderAndAttach(checkNotNull(layout).toProto(), resources.toProto(), root);
+ try {
+ instance.renderAndAttach(checkNotNull(layout).toProto(), resources.toProto(), root)
+ .get();
+ } catch (ExecutionException e) {
+ throw new RuntimeException(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
View firstChild = root.getChildAt(0);
@@ -136,7 +146,7 @@
Locale.setDefault(locale);
Configuration config = new Configuration();
config.setLocale(locale);
- context.getResources().updateConfiguration(
- config, context.getResources().getDisplayMetrics());
+ context.getResources()
+ .updateConfiguration(config, context.getResources().getDisplayMetrics());
}
}
diff --git a/wear/protolayout/protolayout-material/src/main/java/androidx/wear/protolayout/material/Typography.java b/wear/protolayout/protolayout-material/src/main/java/androidx/wear/protolayout/material/Typography.java
index 9e1613e..da9219f 100644
--- a/wear/protolayout/protolayout-material/src/main/java/androidx/wear/protolayout/material/Typography.java
+++ b/wear/protolayout/protolayout-material/src/main/java/androidx/wear/protolayout/material/Typography.java
@@ -16,6 +16,9 @@
package androidx.wear.protolayout.material;
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+
import static androidx.annotation.Dimension.DP;
import static androidx.annotation.Dimension.SP;
import static androidx.wear.protolayout.DimensionBuilders.sp;
@@ -28,7 +31,6 @@
import android.annotation.SuppressLint;
import android.content.Context;
-import android.util.DisplayMetrics;
import androidx.annotation.Dimension;
import androidx.annotation.IntDef;
@@ -40,6 +42,8 @@
import androidx.wear.protolayout.LayoutElementBuilders.FontStyle;
import androidx.wear.protolayout.LayoutElementBuilders.FontVariant;
import androidx.wear.protolayout.LayoutElementBuilders.FontWeight;
+import androidx.wear.protolayout.materialcore.fontscaling.FontScaleConverter;
+import androidx.wear.protolayout.materialcore.fontscaling.FontScaleConverterFactory;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -120,6 +124,9 @@
TYPOGRAPHY_TO_LINE_HEIGHT_SP.put(TYPOGRAPHY_CAPTION2, 16f);
TYPOGRAPHY_TO_LINE_HEIGHT_SP.put(TYPOGRAPHY_CAPTION3, 14f);
}
+
+ private Typography() {}
+
/**
* Returns the {@link FontStyle.Builder} for the given FontStyle code with the recommended size,
* weight and letter spacing. Font will be scalable.
@@ -130,8 +137,6 @@
return getFontStyleBuilder(fontStyleCode, context, true);
}
- private Typography() {}
-
/**
* Returns the {@link FontStyle.Builder} for the given Typography code with the recommended
* size, weight and letter spacing, with the option to make this font not scalable.
@@ -183,17 +188,29 @@
return sp(checkNotNull(TYPOGRAPHY_TO_LINE_HEIGHT_SP.get(typography)).intValue());
}
- @NonNull
- @SuppressLint("ResourceType")
- @SuppressWarnings("deprecation") // scaledDensity, b/335215227
- // This is a helper function to make the font not scalable. It should interpret in value as DP
- // and convert it to SP which is needed to be passed in as a font size. However, we will pass an
- // SP object to it, because the default style is defined in it, but for the case when the font
- // size on device in 1, so the DP is equal to SP.
- private static SpProp dpToSp(@NonNull Context context, @Dimension(unit = DP) float valueDp) {
- DisplayMetrics metrics = context.getResources().getDisplayMetrics();
- float scaledSp = (valueDp / metrics.scaledDensity) * metrics.density;
- return sp(scaledSp);
+ /**
+ * This is a helper function to make the font not scalable. It should interpret in value as DP
+ * and convert it to SP which is needed to be passed in as a font size. However, we will pass an
+ * SP object to it, because the default style is defined in it, but for the case when the font
+ * size on device is 1, so the DP is equal to SP.
+ */
+ @Dimension(unit = SP)
+ private static float dpToSp(float fontScale, @Dimension(unit = DP) float valueDp) {
+ FontScaleConverter converter =
+ (SDK_INT >= UPSIDE_DOWN_CAKE)
+ ? FontScaleConverterFactory.forScale(fontScale)
+ : null;
+
+ if (converter == null) {
+ return dpToSpLinear(fontScale, valueDp);
+ }
+
+ return converter.convertDpToSp(valueDp);
+ }
+
+ @Dimension(unit = SP)
+ private static float dpToSpLinear(float fontScale, @Dimension(unit = DP) float valueDp) {
+ return valueDp / fontScale;
}
// The @Dimension(unit = SP) on sp() is seemingly being ignored, so lint complains that we're
@@ -206,8 +223,9 @@
float letterSpacing,
boolean isScalable,
@NonNull Context context) {
+ float fontScale = context.getResources().getConfiguration().fontScale;
return new FontStyle.Builder()
- .setSize(isScalable ? DimensionBuilders.sp(size) : dpToSp(context, size))
+ .setSize(DimensionBuilders.sp(isScalable ? size : dpToSp(fontScale, size)))
.setLetterSpacing(DimensionBuilders.em(letterSpacing))
.setVariant(variant)
.setWeight(weight);