summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/java/android/content/res/CompatibilityInfo.java3
-rw-r--r--core/java/android/content/res/FontScaleConverter.java148
-rw-r--r--core/java/android/content/res/FontScaleConverterFactory.java119
-rw-r--r--core/java/android/content/res/ResourcesImpl.java2
-rwxr-xr-xcore/java/android/util/DisplayMetrics.java12
-rw-r--r--core/java/android/util/TypedValue.java9
-rw-r--r--core/tests/coretests/Android.bp1
-rw-r--r--core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt78
-rw-r--r--core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt95
-rw-r--r--core/tests/coretests/src/android/util/TypedValueTest.kt26
-rw-r--r--tools/fonts/font-scaling-array-generator.js195
11 files changed, 682 insertions, 6 deletions
diff --git a/core/java/android/content/res/CompatibilityInfo.java b/core/java/android/content/res/CompatibilityInfo.java
index 6ce22422643e..ce6e1c7c676f 100644
--- a/core/java/android/content/res/CompatibilityInfo.java
+++ b/core/java/android/content/res/CompatibilityInfo.java
@@ -563,6 +563,9 @@ public class CompatibilityInfo implements Parcelable {
if (applyToSize) {
inoutDm.widthPixels = (int) (inoutDm.widthPixels * invertedRatio + 0.5f);
inoutDm.heightPixels = (int) (inoutDm.heightPixels * invertedRatio + 0.5f);
+
+ float fontScale = inoutDm.scaledDensity / inoutDm.density;
+ inoutDm.fontScaleConverter = FontScaleConverterFactory.forScale(fontScale);
}
}
diff --git a/core/java/android/content/res/FontScaleConverter.java b/core/java/android/content/res/FontScaleConverter.java
new file mode 100644
index 000000000000..c7fdb16682e3
--- /dev/null
+++ b/core/java/android/content/res/FontScaleConverter.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2022 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 android.content.res;
+
+import android.annotation.NonNull;
+import android.util.MathUtils;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+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.
+ *
+ * @hide
+ */
+public class FontScaleConverter {
+ /**
+ * How close the given SP should be to a canonical SP in the array before they are considered
+ * the same for lookup purposes.
+ */
+ private static final float THRESHOLD_FOR_MATCHING_SP = 0.02f;
+
+ @VisibleForTesting
+ final float[] mFromSpValues;
+ @VisibleForTesting
+ 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
+ * @hide
+ */
+ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+ public 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 "sp" to "dp" using the lookup table.
+ *
+ * @hide
+ */
+ public float convertSpToDp(float sp) {
+ final float spPositive = Math.abs(sp);
+ // TODO(b/247861374): find a match at a higher index?
+ final int spRounded = Math.round(spPositive);
+ final float sign = Math.signum(sp);
+ final int index = Arrays.binarySearch(mFromSpValues, spRounded);
+ if (index >= 0 && Math.abs(spRounded - spPositive) < THRESHOLD_FOR_MATCHING_SP) {
+ // exact match, return the matching dp
+ return sign * mToDpValues[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 >= mFromSpValues.length - 1) {
+ // It's past our lookup table. Determine the last elements' scaling factor and use.
+ startSp = mFromSpValues[mFromSpValues.length - 1];
+ startDp = mToDpValues[mFromSpValues.length - 1];
+
+ if (startSp == 0) return 0;
+
+ final float scalingFactor = startDp / startSp;
+ return sp * scalingFactor;
+ } else if (lowerIndex == -1) {
+ // It's smaller than the smallest value in our table. Interpolate from 0.
+ startSp = 0;
+ startDp = 0;
+ endSp = mFromSpValues[0];
+ endDp = mToDpValues[0];
+ } else {
+ startSp = mFromSpValues[lowerIndex];
+ endSp = mFromSpValues[lowerIndex + 1];
+ startDp = mToDpValues[lowerIndex];
+ endDp = mToDpValues[lowerIndex + 1];
+ }
+
+ return sign * MathUtils.constrainedMap(startDp, endDp, startSp, endSp, spPositive);
+ }
+ }
+
+ @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;
+ }
+
+ @Override
+ public String toString() {
+ return "FontScaleConverter{"
+ + "fromSpValues="
+ + Arrays.toString(mFromSpValues)
+ + ", toDpValues="
+ + Arrays.toString(mToDpValues)
+ + '}';
+ }
+}
diff --git a/core/java/android/content/res/FontScaleConverterFactory.java b/core/java/android/content/res/FontScaleConverterFactory.java
new file mode 100644
index 000000000000..c77a372f1939
--- /dev/null
+++ b/core/java/android/content/res/FontScaleConverterFactory.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2022 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 android.content.res;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * Stores lookup tables for creating {@link FontScaleConverter}s at various scales.
+ *
+ * @hide
+ */
+public class FontScaleConverterFactory {
+ private static final float SCALE_KEY_MULTIPLIER = 100f;
+
+ @VisibleForTesting
+ static final SparseArray<FontScaleConverter> LOOKUP_TABLES = new SparseArray<>();
+
+ 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.1f, 20.7f, 23f, 27.6f, 34.5f, 115})
+ );
+
+ 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.2f, 23.4f, 26f, 31.2f, 39f, 130})
+ );
+
+ put(
+ /* scaleKey= */ 1.5f,
+ new FontScaleConverter(
+ /* fromSp= */
+ new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */
+ new float[] { 12f, 15f, 18f, 21f, 27f, 30f, 36f, 45f, 150})
+ );
+
+ 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, 25.2f, 32.4f, 36f, 43.2f, 54f, 180})
+ );
+
+ put(
+ /* scaleKey= */ 2f,
+ new FontScaleConverter(
+ /* fromSp= */
+ new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100},
+ /* toDp= */
+ new float[] { 16f, 20f, 24f, 28f, 36f, 40f, 48f, 60f, 200})
+ );
+
+ }
+
+ private FontScaleConverterFactory() {}
+
+ /**
+ * 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.
+ *
+ * @hide
+ */
+ @Nullable
+ public static FontScaleConverter forScale(float fontScale) {
+ if (fontScale <= 1) {
+ // We don't need non-linear curves for shrinking text or for 100%.
+ // Also, fontScale==0 should not have a curve either
+ return null;
+ }
+
+ FontScaleConverter lookupTable = get(fontScale);
+ // TODO(b/247861716): interpolate between two tables when null
+
+ return lookupTable;
+ }
+
+ private static void put(float scaleKey, @NonNull FontScaleConverter fontScaleConverter) {
+ LOOKUP_TABLES.put((int) (scaleKey * SCALE_KEY_MULTIPLIER), fontScaleConverter);
+ }
+
+ @Nullable
+ private static FontScaleConverter get(float scaleKey) {
+ return LOOKUP_TABLES.get((int) (scaleKey * SCALE_KEY_MULTIPLIER));
+ }
+}
diff --git a/core/java/android/content/res/ResourcesImpl.java b/core/java/android/content/res/ResourcesImpl.java
index ff072916292b..50809ac13f4d 100644
--- a/core/java/android/content/res/ResourcesImpl.java
+++ b/core/java/android/content/res/ResourcesImpl.java
@@ -430,6 +430,8 @@ public class ResourcesImpl {
// Protect against an unset fontScale.
mMetrics.scaledDensity = mMetrics.density *
(mConfiguration.fontScale != 0 ? mConfiguration.fontScale : 1.0f);
+ mMetrics.fontScaleConverter =
+ FontScaleConverterFactory.forScale(mConfiguration.fontScale);
final int width, height;
if (mMetrics.widthPixels >= mMetrics.heightPixels) {
diff --git a/core/java/android/util/DisplayMetrics.java b/core/java/android/util/DisplayMetrics.java
index 0a3e6b1cff38..9ce7c93c799c 100755
--- a/core/java/android/util/DisplayMetrics.java
+++ b/core/java/android/util/DisplayMetrics.java
@@ -18,6 +18,7 @@ package android.util;
import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
+import android.content.res.FontScaleConverter;
import android.os.SystemProperties;
/**
@@ -265,6 +266,15 @@ public class DisplayMetrics {
* increments at runtime based on a user preference for the font size.
*/
public float scaledDensity;
+
+ /**
+ * If non-null, this will be used to calculate font sizes instead of {@link #scaledDensity}.
+ *
+ * @hide
+ */
+ @Nullable
+ public FontScaleConverter fontScaleConverter;
+
/**
* The exact physical pixels per inch of the screen in the X dimension.
*/
@@ -342,6 +352,7 @@ public class DisplayMetrics {
noncompatScaledDensity = o.noncompatScaledDensity;
noncompatXdpi = o.noncompatXdpi;
noncompatYdpi = o.noncompatYdpi;
+ fontScaleConverter = o.fontScaleConverter;
}
public void setToDefaults() {
@@ -359,6 +370,7 @@ public class DisplayMetrics {
noncompatScaledDensity = scaledDensity;
noncompatXdpi = xdpi;
noncompatYdpi = ydpi;
+ fontScaleConverter = null;
}
@Override
diff --git a/core/java/android/util/TypedValue.java b/core/java/android/util/TypedValue.java
index 19de396c4a4a..de204a60a59c 100644
--- a/core/java/android/util/TypedValue.java
+++ b/core/java/android/util/TypedValue.java
@@ -408,7 +408,14 @@ public class TypedValue {
case COMPLEX_UNIT_DIP:
return value * metrics.density;
case COMPLEX_UNIT_SP:
- return value * metrics.scaledDensity;
+ if (metrics.fontScaleConverter != null) {
+ return applyDimension(
+ COMPLEX_UNIT_DIP,
+ metrics.fontScaleConverter.convertSpToDp(value),
+ metrics);
+ } else {
+ return value * metrics.scaledDensity;
+ }
case COMPLEX_UNIT_PT:
return value * metrics.xdpi * (1.0f/72);
case COMPLEX_UNIT_IN:
diff --git a/core/tests/coretests/Android.bp b/core/tests/coretests/Android.bp
index a767f834c765..cffc702b42f7 100644
--- a/core/tests/coretests/Android.bp
+++ b/core/tests/coretests/Android.bp
@@ -43,6 +43,7 @@ android_test {
"mockwebserver",
"guava",
"androidx.core_core",
+ "androidx.core_core-ktx",
"androidx.test.espresso.core",
"androidx.test.ext.junit",
"androidx.test.runner",
diff --git a/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt b/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt
new file mode 100644
index 000000000000..cfca0375bb96
--- /dev/null
+++ b/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022 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 android.content.res
+
+import androidx.core.util.forEach
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+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)
+ }
+
+ @SmallTest
+ fun missingLookupTableReturnsNull() {
+ assertThat(FontScaleConverterFactory.forScale(3F)).isNull()
+ }
+
+ @SmallTest
+ fun missingLookupTable105ReturnsNull() {
+ assertThat(FontScaleConverterFactory.forScale(1.05F)).isNull()
+ }
+
+ @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()
+ }
+ }
+
+ companion object {
+ private const val CONVERSION_TOLERANCE = 0.05f
+ }
+}
diff --git a/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt b/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt
new file mode 100644
index 000000000000..e405c55a53e3
--- /dev/null
+++ b/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2022 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 android.content.res
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+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)
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(8f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(10f)
+ assertThat(table.convertSpToDp(30F)).isWithin(CONVERSION_TOLERANCE).of(30f)
+ assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(20f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(5f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @Test
+ fun interpolate200Percent() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ 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(30F)).isWithin(CONVERSION_TOLERANCE).of(60f)
+ assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(40f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(10f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @Test
+ fun interpolate150Percent() {
+ val table = createTable(2f to 3f, 10f to 15f, 20f to 30f, 100f to 150f)
+ assertThat(table.convertSpToDp(2F)).isWithin(CONVERSION_TOLERANCE).of(3f)
+ assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1.5f)
+ assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(12f)
+ assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(15f)
+ assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(30f)
+ assertThat(table.convertSpToDp(50F)).isWithin(CONVERSION_TOLERANCE).of(75f)
+ assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(7.5f)
+ assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ @Test
+ fun pastEndsUsesLastScalingFactor() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ assertThat(table.convertSpToDp(100F)).isWithin(CONVERSION_TOLERANCE).of(200f)
+ assertThat(table.convertSpToDp(31F)).isWithin(CONVERSION_TOLERANCE).of(62f)
+ assertThat(table.convertSpToDp(1000F)).isWithin(CONVERSION_TOLERANCE).of(2000f)
+ assertThat(table.convertSpToDp(2000F)).isWithin(CONVERSION_TOLERANCE).of(4000f)
+ assertThat(table.convertSpToDp(10000F)).isWithin(CONVERSION_TOLERANCE).of(20000f)
+ }
+
+ @Test
+ fun negativeSpIsNegativeDp() {
+ val table = createTable(8f to 16f, 10f to 20f, 30f to 60f)
+ 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(-30F)).isWithin(CONVERSION_TOLERANCE).of(-60f)
+ assertThat(table.convertSpToDp(-20F)).isWithin(CONVERSION_TOLERANCE).of(-40f)
+ assertThat(table.convertSpToDp(-5F)).isWithin(CONVERSION_TOLERANCE).of(-10f)
+ assertThat(table.convertSpToDp(-0F)).isWithin(CONVERSION_TOLERANCE).of(0f)
+ }
+
+ private fun createTable(vararg pairs: Pair<Float, Float>) =
+ FontScaleConverter(
+ pairs.map { it.first }.toFloatArray(),
+ pairs.map { it.second }.toFloatArray()
+ )
+
+ companion object {
+ private const val CONVERSION_TOLERANCE = 0.05f
+ }
+}
diff --git a/core/tests/coretests/src/android/util/TypedValueTest.kt b/core/tests/coretests/src/android/util/TypedValueTest.kt
index 7a05d970de33..7d98a7d1faa1 100644
--- a/core/tests/coretests/src/android/util/TypedValueTest.kt
+++ b/core/tests/coretests/src/android/util/TypedValueTest.kt
@@ -16,16 +16,17 @@
package android.util
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SmallTest
-import androidx.test.runner.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.roundToInt
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
-import kotlin.math.abs
-import kotlin.math.min
-import kotlin.math.roundToInt
@RunWith(AndroidJUnit4::class)
class TypedValueTest {
@@ -152,4 +153,19 @@ class TypedValueTest {
val widthPx = TypedValue.complexToDimensionPixelSize(widthDimen, metrics)
assertEquals(widthFloat.roundToInt(), widthPx)
}
-} \ No newline at end of file
+
+ @SmallTest
+ @Test
+ fun testNonLinearFontScaling_nullLookupFallsBackToScaledDensity() {
+ val metrics: DisplayMetrics = mock(DisplayMetrics::class.java)
+ val fontScale = 2f
+ metrics.density = 1f
+ metrics.scaledDensity = fontScale * metrics.density
+ metrics.fontScaleConverter = null
+
+ assertThat(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 10f, metrics))
+ .isEqualTo(20f)
+ assertThat(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 50f, metrics))
+ .isEqualTo(100f)
+ }
+}
diff --git a/tools/fonts/font-scaling-array-generator.js b/tools/fonts/font-scaling-array-generator.js
new file mode 100644
index 000000000000..9754697bfb51
--- /dev/null
+++ b/tools/fonts/font-scaling-array-generator.js
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2022 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.
+ */
+
+/**
+ Generates arrays for non-linear font scaling, to be pasted into
+ frameworks/base/core/java/android/content/res/FontScaleConverterFactory.java
+
+ To use:
+ `node font-scaling-array-generator.js`
+ or just open a browser, open DevTools, and paste into the Console.
+*/
+
+/**
+ * Modify this to match your packages/apps/Settings/res/arrays.xml#entryvalues_font_size
+ * array so that all possible scales are generated.
+ */
+const scales = [1.15, 1.30, 1.5, 1.8, 2];
+
+const commonSpSizes = [8, 10, 12, 14, 18, 20, 24, 30, 100];
+
+/**
+ * Enum for GENERATION_STYLE which determines how to generate the arrays.
+ */
+const GenerationStyle = {
+ /**
+ * Interpolates between hand-tweaked curves. This is the best option and
+ * shouldn't require any additional tweaking.
+ */
+ CUSTOM_TWEAKED: 'CUSTOM_TWEAKED',
+
+ /**
+ * Uses a curve equation that is mostly correct, but will need manual tweaking
+ * at some scales.
+ */
+ CURVE: 'CURVE',
+
+ /**
+ * Uses straight linear multiplication. Good starting point for manual
+ * tweaking.
+ */
+ LINEAR: 'LINEAR'
+}
+
+/**
+ * Determines how arrays are generated. Must be one of the GenerationStyle
+ * values.
+ */
+const GENERATION_STYLE = GenerationStyle.CUSTOM_TWEAKED;
+
+// These are hand-tweaked curves from which we will derive the other
+// interstitial curves using linear interpolation, in the case of using
+// GenerationStyle.CUSTOM_TWEAKED.
+const interpolationTargets = {
+ 1.0: commonSpSizes,
+ 1.5: [12, 15, 18, 22, 24, 26, 28, 30, 100],
+ 2.0: [16, 20, 24, 26, 30, 34, 36, 38, 100]
+};
+
+/**
+ * Interpolate a value with specified extrema, to a new value between new
+ * extrema.
+ *
+ * @param value the current value
+ * @param inputMin minimum the input value can reach
+ * @param inputMax maximum the input value can reach
+ * @param outputMin minimum the output value can reach
+ * @param outputMax maximum the output value can reach
+ */
+function map(value, inputMin, inputMax, outputMin, outputMax) {
+ return outputMin + (outputMax - outputMin) * ((value - inputMin) / (inputMax - inputMin));
+}
+
+/***
+ * Interpolate between values a and b.
+ */
+function lerp(a, b, fraction) {
+ return (a * (1.0 - fraction)) + (b * fraction);
+}
+
+function generateRatios(scale) {
+ // Find the best two arrays to interpolate between.
+ let startTarget, endTarget;
+ let startTargetScale, endTargetScale;
+ const targetScales = Object.keys(interpolationTargets).sort();
+ for (let i = 0; i < targetScales.length - 1; i++) {
+ const targetScaleKey = targetScales[i];
+ const targetScale = parseFloat(targetScaleKey, 10);
+ const startTargetScaleKey = targetScaleKey;
+ const endTargetScaleKey = targetScales[i + 1];
+
+ if (scale < parseFloat(startTargetScaleKey, 10)) {
+ break;
+ }
+
+ startTargetScale = parseFloat(startTargetScaleKey, 10);
+ endTargetScale = parseFloat(endTargetScaleKey, 10);
+ startTarget = interpolationTargets[startTargetScaleKey];
+ endTarget = interpolationTargets[endTargetScaleKey];
+ }
+ const interpolationProgress = map(scale, startTargetScale, endTargetScale, 0, 1);
+
+ return commonSpSizes.map((sp, i) => {
+ const originalSizeDp = sp;
+ let newSizeDp;
+ switch (GENERATION_STYLE) {
+ case GenerationStyle.CUSTOM_TWEAKED:
+ newSizeDp = lerp(startTarget[i], endTarget[i], interpolationProgress);
+ break;
+ case GenerationStyle.CURVE: {
+ let coeff1;
+ let coeff2;
+ if (scale < 1) {
+ // \left(1.22^{-\left(x+5\right)}+0.5\right)\cdot x
+ coeff1 = -5;
+ coeff2 = scale;
+ } else {
+ // (1.22^{-\left(x-10\right)}+1\right)\cdot x
+ coeff1 = map(scale, 1, 2, 2, 8);
+ coeff2 = 1;
+ }
+ newSizeDp = ((Math.pow(1.22, (-(originalSizeDp - coeff1))) + coeff2) * originalSizeDp);
+ break;
+ }
+ case GenerationStyle.LINEAR:
+ newSizeDp = originalSizeDp * scale;
+ break;
+ default:
+ throw new Error('Invalid GENERATION_STYLE');
+ }
+ return {
+ fromSp: sp,
+ toDp: newSizeDp
+ }
+ });
+}
+
+const scaleArrays =
+ scales
+ .map(scale => {
+ const scaleString = (scale * 100).toFixed(0);
+ return {
+ scale,
+ name: `font_size_original_sp_to_scaled_dp_${scaleString}_percent`
+ }
+ })
+ .map(scaleArray => {
+ const items = generateRatios(scaleArray.scale);
+
+ return {
+ ...scaleArray,
+ items
+ }
+ });
+
+function formatDigit(d) {
+ const twoSignificantDigits = Math.round(d * 100) / 100;
+ return String(twoSignificantDigits).padStart(4, ' ');
+}
+
+console.log(
+ '' +
+ scaleArrays.reduce(
+ (previousScaleArray, currentScaleArray) => {
+ const itemsFromSp = currentScaleArray.items.map(d => d.fromSp)
+ .map(formatDigit)
+ .join('f, ');
+ const itemsToDp = currentScaleArray.items.map(d => d.toDp)
+ .map(formatDigit)
+ .join('f, ');
+
+ return previousScaleArray + `
+ put(
+ /* scaleKey= */ ${currentScaleArray.scale}f,
+ new FontScaleConverter(
+ /* fromSp= */
+ new float[] {${itemsFromSp}},
+ /* toDp= */
+ new float[] {${itemsToDp}})
+ );
+ `;
+ },
+ ''));