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);