diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 4d75fb856d..05eaf88ffb 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -202,6 +202,7 @@ + @@ -210,11 +211,14 @@ Needs FeatureFlags.ENABLE_RESPONSIVE_WORKSPACE enabled --> - + + + @@ -278,6 +282,11 @@ + + + + + diff --git a/src/com/android/launcher3/DeviceProfile.java b/src/com/android/launcher3/DeviceProfile.java index 353d006fab..7ece9a4145 100644 --- a/src/com/android/launcher3/DeviceProfile.java +++ b/src/com/android/launcher3/DeviceProfile.java @@ -56,8 +56,10 @@ import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.responsive.AllAppsSpecs; import com.android.launcher3.responsive.CalculatedAllAppsSpec; import com.android.launcher3.responsive.CalculatedFolderSpec; +import com.android.launcher3.responsive.CalculatedHotseatSpec; import com.android.launcher3.responsive.CalculatedWorkspaceSpec; import com.android.launcher3.responsive.FolderSpecs; +import com.android.launcher3.responsive.HotseatSpecs; import com.android.launcher3.responsive.WorkspaceSpecs; import com.android.launcher3.uioverrides.ApiWrapper; import com.android.launcher3.util.DisplayController; @@ -121,6 +123,7 @@ public class DeviceProfile { private CalculatedAllAppsSpec mAllAppsResponsiveHeightSpec; private CalculatedFolderSpec mResponsiveFolderWidthSpec; private CalculatedFolderSpec mResponsiveFolderHeightSpec; + private CalculatedHotseatSpec mResponsiveHotseatSpec; /** * The maximum amount of left/right workspace padding as a percentage of the screen width. @@ -316,7 +319,8 @@ public class DeviceProfile { // TODO(b/241386436): shouldn't change any launcher behaviour mIsResponsiveGrid = inv.workspaceSpecsId != INVALID_RESOURCE_HANDLE && inv.allAppsSpecsId != INVALID_RESOURCE_HANDLE - && inv.folderSpecsId != INVALID_RESOURCE_HANDLE; + && inv.folderSpecsId != INVALID_RESOURCE_HANDLE + && inv.hotseatSpecsId != INVALID_RESOURCE_HANDLE; mIsScalableGrid = inv.isScalable && !isVerticalBarLayout() && !isMultiWindowMode; // Determine device posture. @@ -495,7 +499,17 @@ public class DeviceProfile { int hotseatBarBottomSpace = pxFromDp(inv.hotseatBarBottomSpace[mTypeIndex], mMetrics); int minQsbMargin = res.getDimensionPixelSize(R.dimen.min_qsb_margin); - hotseatQsbSpace = pxFromDp(inv.hotseatQsbSpace[mTypeIndex], mMetrics); + + if (mIsResponsiveGrid) { + HotseatSpecs hotseatSpecs = + HotseatSpecs.create(new ResourceHelper(context, + isTwoPanels ? inv.hotseatSpecsTwoPanelId : inv.hotseatSpecsId)); + mResponsiveHotseatSpec = hotseatSpecs.getCalculatedHeightSpec(heightPx); + hotseatQsbSpace = mResponsiveHotseatSpec.getHotseatQsbSpace(); + } else { + hotseatQsbSpace = pxFromDp(inv.hotseatQsbSpace[mTypeIndex], mMetrics); + } + // Have a little space between the inset and the QSB if (mInsets.bottom + minQsbMargin > hotseatBarBottomSpace) { int availableSpace = hotseatQsbSpace - (mInsets.bottom - hotseatBarBottomSpace); @@ -1985,6 +1999,7 @@ public class DeviceProfile { + mAllAppsResponsiveWidthSpec.toString()); writer.println(prefix + "\tmResponsiveFolderHeightSpec:" + mResponsiveFolderHeightSpec); writer.println(prefix + "\tmResponsiveFolderWidthSpec:" + mResponsiveFolderWidthSpec); + writer.println(prefix + "\tmResponsiveHotseatSpec:" + mResponsiveHotseatSpec); } } diff --git a/src/com/android/launcher3/InvariantDeviceProfile.java b/src/com/android/launcher3/InvariantDeviceProfile.java index a4d77bd64e..be14844fbc 100644 --- a/src/com/android/launcher3/InvariantDeviceProfile.java +++ b/src/com/android/launcher3/InvariantDeviceProfile.java @@ -190,6 +190,8 @@ public class InvariantDeviceProfile { public int folderSpecsId = INVALID_RESOURCE_HANDLE; @XmlRes public int folderSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; + public int hotseatSpecsId = INVALID_RESOURCE_HANDLE; + public int hotseatSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; public String dbFile; public int defaultLayoutId; @@ -369,6 +371,8 @@ public class InvariantDeviceProfile { allAppsSpecsTwoPanelId = closestProfile.mAllAppsSpecsTwoPanelId; folderSpecsId = closestProfile.mFolderSpecsId; folderSpecsTwoPanelId = closestProfile.mFolderSpecsTwoPanelId; + hotseatSpecsId = closestProfile.mHotseatSpecsId; + hotseatSpecsTwoPanelId = closestProfile.mHotseatSpecsTwoPanelId; this.deviceType = deviceType; inlineNavButtonsEndSpacing = closestProfile.inlineNavButtonsEndSpacing; @@ -820,6 +824,8 @@ public class InvariantDeviceProfile { private final int mAllAppsSpecsTwoPanelId; private final int mFolderSpecsId; private final int mFolderSpecsTwoPanelId; + private final int mHotseatSpecsId; + private final int mHotseatSpecsTwoPanelId; public GridOption(Context context, AttributeSet attrs) { TypedArray a = context.obtainStyledAttributes( @@ -897,6 +903,11 @@ public class InvariantDeviceProfile { mFolderSpecsTwoPanelId = a.getResourceId( R.styleable.GridDisplayOption_folderSpecsTwoPanelId, INVALID_RESOURCE_HANDLE); + mHotseatSpecsId = a.getResourceId( + R.styleable.GridDisplayOption_hotseatSpecsId, INVALID_RESOURCE_HANDLE); + mHotseatSpecsTwoPanelId = a.getResourceId( + R.styleable.GridDisplayOption_hotseatSpecsTwoPanelId, + INVALID_RESOURCE_HANDLE); } else { mWorkspaceSpecsId = INVALID_RESOURCE_HANDLE; mWorkspaceSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; @@ -904,6 +915,8 @@ public class InvariantDeviceProfile { mAllAppsSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; mFolderSpecsId = INVALID_RESOURCE_HANDLE; mFolderSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; + mHotseatSpecsId = INVALID_RESOURCE_HANDLE; + mHotseatSpecsTwoPanelId = INVALID_RESOURCE_HANDLE; } int inlineForRotation = a.getInt(R.styleable.GridDisplayOption_inlineQsb, diff --git a/src/com/android/launcher3/responsive/HotseatSpecs.kt b/src/com/android/launcher3/responsive/HotseatSpecs.kt new file mode 100644 index 0000000000..482508d085 --- /dev/null +++ b/src/com/android/launcher3/responsive/HotseatSpecs.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2023 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 com.android.launcher3.responsive + +import android.content.res.TypedArray +import android.util.Log +import com.android.launcher3.R +import com.android.launcher3.util.ResourceHelper + +class HotseatSpecs(val specs: List) { + + fun getCalculatedHeightSpec(availableHeight: Int): CalculatedHotseatSpec { + val spec = specs.firstOrNull { availableHeight <= it.maxAvailableSize } + check(spec != null) { "No available height spec found within $availableHeight." } + return CalculatedHotseatSpec(availableHeight, spec) + } + + companion object { + private const val XML_HOTSEAT_SPEC = "hotseatSpec" + + @JvmStatic + fun create(resourceHelper: ResourceHelper): HotseatSpecs { + val parser = ResponsiveSpecsParser(resourceHelper) + val specs = parser.parseXML(XML_HOTSEAT_SPEC, ::HotseatSpec) + return HotseatSpecs(specs.filter { it.specType == ResponsiveSpec.SpecType.HEIGHT }) + } + } +} + +data class HotseatSpec( + val maxAvailableSize: Int, + val specType: ResponsiveSpec.SpecType, + val hotseatQsbSpace: SizeSpec +) { + + init { + check(isValid()) { "Invalid HotseatSpec found." } + } + + constructor( + attrs: TypedArray, + specs: Map + ) : this( + maxAvailableSize = + attrs.getDimensionPixelSize(R.styleable.ResponsiveSpec_maxAvailableSize, 0), + specType = + ResponsiveSpec.SpecType.values()[ + attrs.getInt( + R.styleable.ResponsiveSpec_specType, + ResponsiveSpec.SpecType.HEIGHT.ordinal + )], + hotseatQsbSpace = specs.getOrError(SizeSpec.XmlTags.HOTSEAT_QSB_SPACE) + ) + + fun isValid(): Boolean { + if (maxAvailableSize <= 0) { + Log.e(LOG_TAG, "${this::class.simpleName}#isValid - maxAvailableSize <= 0") + return false + } + + // All specs need to be individually valid + if (!allSpecsAreValid()) { + Log.e(LOG_TAG, "${this::class.simpleName}#isValid - !allSpecsAreValid()") + return false + } + + return true + } + + private fun allSpecsAreValid(): Boolean { + return hotseatQsbSpace.isValid() && hotseatQsbSpace.onlyFixedSize() + } + + companion object { + private const val LOG_TAG = "HotseatSpec" + } +} + +class CalculatedHotseatSpec(val availableSpace: Int, val spec: HotseatSpec) { + + var hotseatQsbSpace: Int = 0 + private set + + init { + hotseatQsbSpace = spec.hotseatQsbSpace.getCalculatedValue(availableSpace) + } + + override fun hashCode(): Int { + var result = availableSpace.hashCode() + result = 31 * result + hotseatQsbSpace.hashCode() + result = 31 * result + spec.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + return other is CalculatedHotseatSpec && + availableSpace == other.availableSpace && + hotseatQsbSpace == other.hotseatQsbSpace && + spec == other.spec + } + + override fun toString(): String { + return "${this::class.simpleName}(" + + "availableSpace=$availableSpace, hotseatQsbSpace=$hotseatQsbSpace, " + + "${spec::class.simpleName}.maxAvailableSize=${spec.maxAvailableSize}" + + ")" + } +} diff --git a/src/com/android/launcher3/responsive/SizeSpec.kt b/src/com/android/launcher3/responsive/SizeSpec.kt index d3868f0c20..c868c9f7be 100644 --- a/src/com/android/launcher3/responsive/SizeSpec.kt +++ b/src/com/android/launcher3/responsive/SizeSpec.kt @@ -107,11 +107,20 @@ data class SizeSpec( return true } + fun onlyFixedSize(): Boolean { + if (ofAvailableSpace > 0 || ofRemainderSpace > 0 || matchWorkspace) { + Log.e(TAG, "SizeSpec#onlyFixedSize - only fixed size allowed for this tag") + return false + } + return true + } + object XmlTags { const val START_PADDING = "startPadding" const val END_PADDING = "endPadding" const val GUTTER = "gutter" const val CELL_SIZE = "cellSize" + const val HOTSEAT_QSB_SPACE = "hotseatQsbSpace" } companion object { diff --git a/tests/res/xml/invalid_hotseat_file_case_1.xml b/tests/res/xml/invalid_hotseat_file_case_1.xml new file mode 100644 index 0000000000..fcbc5ead8d --- /dev/null +++ b/tests/res/xml/invalid_hotseat_file_case_1.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/res/xml/valid_hotseat_file.xml b/tests/res/xml/valid_hotseat_file.xml new file mode 100644 index 0000000000..c7f52e82c2 --- /dev/null +++ b/tests/res/xml/valid_hotseat_file.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt index 694893aa01..dd79ca8b02 100644 --- a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt +++ b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt @@ -22,6 +22,7 @@ import android.graphics.Rect import android.util.DisplayMetrics import android.view.Surface import androidx.test.core.app.ApplicationProvider +import com.android.launcher3.testing.shared.ResourceUtils import com.android.launcher3.util.DisplayController import com.android.launcher3.util.NavigationMode import com.android.launcher3.util.WindowBounds @@ -320,4 +321,12 @@ abstract class AbstractDeviceProfileTest { private fun writeToDevice(context: Context, fileName: String, content: String) { File(context.getDir("dumpTests", Context.MODE_PRIVATE), fileName).writeText(content) } + + protected fun Float.dpToPx(): Float { + return ResourceUtils.pxFromDp(this, context!!.resources.displayMetrics).toFloat() + } + + protected fun Int.dpToPx(): Int { + return ResourceUtils.pxFromDp(this.toFloat(), context!!.resources.displayMetrics) + } } diff --git a/tests/src/com/android/launcher3/responsive/CalculatedFolderSpecsTest.kt b/tests/src/com/android/launcher3/responsive/CalculatedFolderSpecsTest.kt index 863cf76185..f2a269a607 100644 --- a/tests/src/com/android/launcher3/responsive/CalculatedFolderSpecsTest.kt +++ b/tests/src/com/android/launcher3/responsive/CalculatedFolderSpecsTest.kt @@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.android.launcher3.AbstractDeviceProfileTest -import com.android.launcher3.testing.shared.ResourceUtils import com.android.launcher3.tests.R import com.android.launcher3.util.TestResourceHelper import com.google.common.truth.Truth.assertThat @@ -118,8 +117,4 @@ class CalculatedFolderSpecsTest : AbstractDeviceProfileTest() { assertThat(cellSizePx).isEqualTo(calculatedWorkspace.cellSizePx) } } - - private fun Int.dpToPx(): Int { - return ResourceUtils.pxFromDp(this.toFloat(), context!!.resources.displayMetrics) - } } diff --git a/tests/src/com/android/launcher3/responsive/CalculatedHotseatSpecTest.kt b/tests/src/com/android/launcher3/responsive/CalculatedHotseatSpecTest.kt new file mode 100644 index 0000000000..0ecf7bae5a --- /dev/null +++ b/tests/src/com/android/launcher3/responsive/CalculatedHotseatSpecTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 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 com.android.launcher3.responsive + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.AbstractDeviceProfileTest +import com.android.launcher3.tests.R as TestR +import com.android.launcher3.util.TestResourceHelper +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class CalculatedHotseatSpecTest : AbstractDeviceProfileTest() { + override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context + + /** + * This test tests: + * - (height spec) gets the correct breakpoint from the XML - skips the first breakpoint + */ + @Test + fun normalPhone_returnsSecondBreakpointSpec() { + val deviceSpec = deviceSpecs["phone"]!! + initializeVarsForPhone(deviceSpec) + + // Hotseat uses the whole device height + val availableHeight = deviceSpec.naturalSize.second + + val hotseatSpecs = + HotseatSpecs.create(TestResourceHelper(context!!, TestR.xml.valid_hotseat_file)) + val heightSpec = hotseatSpecs.getCalculatedHeightSpec(availableHeight) + + assertThat(heightSpec.availableSpace).isEqualTo(availableHeight) + assertThat(heightSpec.hotseatQsbSpace).isEqualTo(95) + } + + /** + * This test tests: + * - (height spec) gets the correct breakpoint from the XML - use the first breakpoint + */ + @Test + fun smallPhone_returnsFirstBreakpointSpec() { + val deviceSpec = deviceSpecs["phone"]!! + deviceSpec.densityDpi = 540 // larger display size + initializeVarsForPhone(deviceSpec) + + // Hotseat uses the whole device height + val availableHeight = deviceSpec.naturalSize.second + + val hotseatSpecs = + HotseatSpecs.create(TestResourceHelper(context!!, TestR.xml.valid_hotseat_file)) + val heightSpec = hotseatSpecs.getCalculatedHeightSpec(availableHeight) + + assertThat(heightSpec.availableSpace).isEqualTo(availableHeight) + assertThat(heightSpec.hotseatQsbSpace).isEqualTo(81) + } +} diff --git a/tests/src/com/android/launcher3/responsive/FolderSpecsTest.kt b/tests/src/com/android/launcher3/responsive/FolderSpecsTest.kt index e21af57cd5..4b05949e35 100644 --- a/tests/src/com/android/launcher3/responsive/FolderSpecsTest.kt +++ b/tests/src/com/android/launcher3/responsive/FolderSpecsTest.kt @@ -22,7 +22,6 @@ import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.android.launcher3.AbstractDeviceProfileTest import com.android.launcher3.responsive.ResponsiveSpec.SpecType -import com.android.launcher3.testing.shared.ResourceUtils import com.android.launcher3.tests.R import com.android.launcher3.util.TestResourceHelper import com.google.common.truth.Truth.assertThat @@ -249,12 +248,4 @@ class FolderSpecsTest : AbstractDeviceProfileTest() { val folderSpecs = FolderSpecs.create(resourceHelper) folderSpecs.getCalculatedHeightSpec(cells, availableSpace, calculatedWorkspaceSpec) } - - private fun Float.dpToPx(): Float { - return ResourceUtils.pxFromDp(this, context!!.resources.displayMetrics).toFloat() - } - - private fun Int.dpToPx(): Int { - return ResourceUtils.pxFromDp(this.toFloat(), context!!.resources.displayMetrics) - } } diff --git a/tests/src/com/android/launcher3/responsive/HotseatSpecsTest.kt b/tests/src/com/android/launcher3/responsive/HotseatSpecsTest.kt new file mode 100644 index 0000000000..c764e47526 --- /dev/null +++ b/tests/src/com/android/launcher3/responsive/HotseatSpecsTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2023 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 com.android.launcher3.responsive + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.AbstractDeviceProfileTest +import com.android.launcher3.tests.R as TestR +import com.android.launcher3.util.TestResourceHelper +import com.android.systemui.util.dpToPx +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class HotseatSpecsTest : AbstractDeviceProfileTest() { + override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context + + @Before + fun setup() { + initializeVarsForPhone(deviceSpecs["phone"]!!) + } + + @Test + fun parseValidFile() { + val hotseatSpecs = + HotseatSpecs.create(TestResourceHelper(context!!, TestR.xml.valid_hotseat_file)) + assertThat(hotseatSpecs.specs.size).isEqualTo(2) + + val expectedSpecs = + listOf( + HotseatSpec( + maxAvailableSize = 847.dpToPx(), + specType = ResponsiveSpec.SpecType.HEIGHT, + hotseatQsbSpace = SizeSpec(24f.dpToPx()) + ), + HotseatSpec( + maxAvailableSize = 9999.dpToPx(), + specType = ResponsiveSpec.SpecType.HEIGHT, + hotseatQsbSpace = SizeSpec(36f.dpToPx()) + ), + ) + + assertThat(hotseatSpecs.specs.size).isEqualTo(expectedSpecs.size) + assertThat(hotseatSpecs.specs[0]).isEqualTo(expectedSpecs[0]) + assertThat(hotseatSpecs.specs[1]).isEqualTo(expectedSpecs[1]) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_spaceIsNotFixedSize_throwsError() { + HotseatSpecs.create(TestResourceHelper(context!!, TestR.xml.invalid_hotseat_file_case_1)) + } +} diff --git a/tests/src/com/android/launcher3/responsive/SizeSpecTest.kt b/tests/src/com/android/launcher3/responsive/SizeSpecTest.kt index 088cae1485..8ca07c6d96 100644 --- a/tests/src/com/android/launcher3/responsive/SizeSpecTest.kt +++ b/tests/src/com/android/launcher3/responsive/SizeSpecTest.kt @@ -139,4 +139,20 @@ class SizeSpecTest : AbstractDeviceProfileTest() { assertThat(instance.isValid()).isEqualTo(false) } } + + @Test + fun onlyFixedSize() { + assertThat(SizeSpec(fixedSize = 16f).onlyFixedSize()).isEqualTo(true) + + val combinations = + listOf( + SizeSpec(0f, 1.1f, 0f, false), + SizeSpec(0f, 0f, 1.1f, false), + SizeSpec(0f, 0f, 0f, true) + ) + + for (instance in combinations) { + assertThat(instance.onlyFixedSize()).isEqualTo(false) + } + } }