diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 0ffe37bfe4..bf0c4a3d4e 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -265,6 +265,11 @@ + + + + + diff --git a/src/com/android/launcher3/responsive/AllAppsSpecs.kt b/src/com/android/launcher3/responsive/AllAppsSpecs.kt new file mode 100644 index 0000000000..85e383e3d6 --- /dev/null +++ b/src/com/android/launcher3/responsive/AllAppsSpecs.kt @@ -0,0 +1,292 @@ +/* + * 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.XmlResourceParser +import android.util.AttributeSet +import android.util.Log +import android.util.Xml +import com.android.launcher3.R +import com.android.launcher3.util.ResourceHelper +import com.android.launcher3.workspace.CalculatedWorkspaceSpec +import java.io.IOException +import kotlin.math.roundToInt +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException + +private const val LOG_TAG = "AllAppsSpecs" + +class AllAppsSpecs(resourceHelper: ResourceHelper) { + object XmlTags { + const val ALL_APPS_SPECS = "allAppsSpecs" + + const val ALL_APPS_SPEC = "allAppsSpec" + const val START_PADDING = "startPadding" + const val END_PADDING = "endPadding" + const val GUTTER = "gutter" + const val CELL_SIZE = "cellSize" + } + + val allAppsHeightSpecList = mutableListOf() + val allAppsWidthSpecList = mutableListOf() + + // TODO(b/286538013) Remove this init after a more generic or reusable parser is created + init { + var parser: XmlResourceParser? = null + try { + parser = resourceHelper.getXml() + val depth = parser.depth + var type: Int + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > depth) && type != XmlPullParser.END_DOCUMENT + ) { + if (type == XmlPullParser.START_TAG && XmlTags.ALL_APPS_SPECS == parser.name) { + val displayDepth = parser.depth + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > displayDepth) && type != XmlPullParser.END_DOCUMENT + ) { + if ( + type == XmlPullParser.START_TAG && XmlTags.ALL_APPS_SPEC == parser.name + ) { + val attrs = + resourceHelper.obtainStyledAttributes( + Xml.asAttributeSet(parser), + R.styleable.AllAppsSpec + ) + val maxAvailableSize = + attrs.getDimensionPixelSize( + R.styleable.AllAppsSpec_maxAvailableSize, + 0 + ) + val specType = + AllAppsSpec.SpecType.values()[ + attrs.getInt( + R.styleable.AllAppsSpec_specType, + AllAppsSpec.SpecType.HEIGHT.ordinal + )] + attrs.recycle() + + var startPadding: SizeSpec? = null + var endPadding: SizeSpec? = null + var gutter: SizeSpec? = null + var cellSize: SizeSpec? = null + + val limitDepth = parser.depth + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > limitDepth) && type != XmlPullParser.END_DOCUMENT + ) { + val attr: AttributeSet = Xml.asAttributeSet(parser) + if (type == XmlPullParser.START_TAG) { + when (parser.name) { + XmlTags.START_PADDING -> { + startPadding = SizeSpec.create(resourceHelper, attr) + } + XmlTags.END_PADDING -> { + endPadding = SizeSpec.create(resourceHelper, attr) + } + XmlTags.GUTTER -> { + gutter = SizeSpec.create(resourceHelper, attr) + } + XmlTags.CELL_SIZE -> { + cellSize = SizeSpec.create(resourceHelper, attr) + } + } + } + } + + if ( + startPadding == null || + endPadding == null || + gutter == null || + cellSize == null + ) { + throw IllegalStateException( + "All attributes in AllAppsSpec must be defined" + ) + } + + val allAppsSpec = + AllAppsSpec( + maxAvailableSize, + specType, + startPadding, + endPadding, + gutter, + cellSize + ) + if (allAppsSpec.isValid()) { + if (allAppsSpec.specType == AllAppsSpec.SpecType.HEIGHT) + allAppsHeightSpecList.add(allAppsSpec) + else allAppsWidthSpecList.add(allAppsSpec) + } else { + throw IllegalStateException("Invalid AllAppsSpec found.") + } + } + } + + if (allAppsWidthSpecList.isEmpty() || allAppsHeightSpecList.isEmpty()) { + throw IllegalStateException( + "AllAppsSpecs is incomplete - " + + "height list size = ${allAppsHeightSpecList.size}; " + + "width list size = ${allAppsWidthSpecList.size}." + ) + } + } + } + } catch (e: Exception) { + when (e) { + is IOException, + is XmlPullParserException -> { + throw RuntimeException("Failure parsing all apps specs file.", e) + } + else -> throw e + } + } finally { + parser?.close() + } + } + + /** + * Returns the CalculatedAllAppsSpec for width, based on the available width, the AllAppsSpecs + * and the CalculatedWorkspaceSpec. + */ + fun getCalculatedWidthSpec( + columns: Int, + availableWidth: Int, + calculatedWorkspaceSpec: CalculatedWorkspaceSpec + ): CalculatedAllAppsSpec { + val widthSpec = allAppsWidthSpecList.first { availableWidth <= it.maxAvailableSize } + + return CalculatedAllAppsSpec(availableWidth, columns, widthSpec, calculatedWorkspaceSpec) + } + + /** + * Returns the CalculatedAllAppsSpec for height, based on the available height, the AllAppsSpecs + * and the CalculatedWorkspaceSpec. + */ + fun getCalculatedHeightSpec( + rows: Int, + availableHeight: Int, + calculatedWorkspaceSpec: CalculatedWorkspaceSpec + ): CalculatedAllAppsSpec { + val heightSpec = allAppsHeightSpecList.first { availableHeight <= it.maxAvailableSize } + + return CalculatedAllAppsSpec(availableHeight, rows, heightSpec, calculatedWorkspaceSpec) + } +} + +class CalculatedAllAppsSpec( + val availableSpace: Int, + val cells: Int, + private val allAppsSpec: AllAppsSpec, + calculatedWorkspaceSpec: CalculatedWorkspaceSpec +) { + var startPaddingPx: Int = 0 + private set + var endPaddingPx: Int = 0 + private set + var gutterPx: Int = 0 + private set + var cellSizePx: Int = 0 + private set + init { + // Copy values from workspace + if (allAppsSpec.startPadding.matchWorkspace) + startPaddingPx = calculatedWorkspaceSpec.startPaddingPx + if (allAppsSpec.endPadding.matchWorkspace) + endPaddingPx = calculatedWorkspaceSpec.endPaddingPx + if (allAppsSpec.gutter.matchWorkspace) gutterPx = calculatedWorkspaceSpec.gutterPx + if (allAppsSpec.cellSize.matchWorkspace) cellSizePx = calculatedWorkspaceSpec.cellSizePx + + // Calculate all fixed size first + if (allAppsSpec.startPadding.fixedSize > 0) + startPaddingPx = allAppsSpec.startPadding.fixedSize.roundToInt() + if (allAppsSpec.endPadding.fixedSize > 0) + endPaddingPx = allAppsSpec.endPadding.fixedSize.roundToInt() + if (allAppsSpec.gutter.fixedSize > 0) gutterPx = allAppsSpec.gutter.fixedSize.roundToInt() + if (allAppsSpec.cellSize.fixedSize > 0) + cellSizePx = allAppsSpec.cellSize.fixedSize.roundToInt() + + // Calculate all available space next + if (allAppsSpec.startPadding.ofAvailableSpace > 0) + startPaddingPx = + (allAppsSpec.startPadding.ofAvailableSpace * availableSpace).roundToInt() + if (allAppsSpec.endPadding.ofAvailableSpace > 0) + endPaddingPx = (allAppsSpec.endPadding.ofAvailableSpace * availableSpace).roundToInt() + if (allAppsSpec.gutter.ofAvailableSpace > 0) + gutterPx = (allAppsSpec.gutter.ofAvailableSpace * availableSpace).roundToInt() + if (allAppsSpec.cellSize.ofAvailableSpace > 0) + cellSizePx = (allAppsSpec.cellSize.ofAvailableSpace * availableSpace).roundToInt() + + // Calculate remainder space last + val gutters = cells - 1 + val usedSpace = startPaddingPx + endPaddingPx + (gutterPx * gutters) + (cellSizePx * cells) + val remainderSpace = availableSpace - usedSpace + if (allAppsSpec.startPadding.ofRemainderSpace > 0) + startPaddingPx = + (allAppsSpec.startPadding.ofRemainderSpace * remainderSpace).roundToInt() + if (allAppsSpec.endPadding.ofRemainderSpace > 0) + endPaddingPx = (allAppsSpec.endPadding.ofRemainderSpace * remainderSpace).roundToInt() + if (allAppsSpec.gutter.ofRemainderSpace > 0) + gutterPx = (allAppsSpec.gutter.ofRemainderSpace * remainderSpace).roundToInt() + if (allAppsSpec.cellSize.ofRemainderSpace > 0) + cellSizePx = (allAppsSpec.cellSize.ofRemainderSpace * remainderSpace).roundToInt() + } + + override fun toString(): String { + return "CalculatedAllAppsSpec(availableSpace=$availableSpace, " + + "cells=$cells, startPaddingPx=$startPaddingPx, endPaddingPx=$endPaddingPx, " + + "gutterPx=$gutterPx, cellSizePx=$cellSizePx, " + + "AllAppsSpec.maxAvailableSize=${allAppsSpec.maxAvailableSize})" + } +} + +data class AllAppsSpec( + val maxAvailableSize: Int, + val specType: SpecType, + val startPadding: SizeSpec, + val endPadding: SizeSpec, + val gutter: SizeSpec, + val cellSize: SizeSpec +) { + + enum class SpecType { + HEIGHT, + WIDTH + } + + fun isValid(): Boolean { + if (maxAvailableSize <= 0) { + Log.e(LOG_TAG, "AllAppsSpec#isValid - maxAvailableSize <= 0") + return false + } + + // All specs need to be individually valid + if (!allSpecsAreValid()) { + Log.e(LOG_TAG, "AllAppsSpec#isValid - !allSpecsAreValid()") + return false + } + + return true + } + + private fun allSpecsAreValid(): Boolean = + startPadding.isValid() && endPadding.isValid() && gutter.isValid() && cellSize.isValid() +} diff --git a/tests/res/values/attrs.xml b/tests/res/values/attrs.xml index 32bc550b2b..0d586c2c12 100644 --- a/tests/res/values/attrs.xml +++ b/tests/res/values/attrs.xml @@ -39,4 +39,8 @@ + + + + diff --git a/tests/res/xml/invalid_all_apps_file_case_1.xml b/tests/res/xml/invalid_all_apps_file_case_1.xml new file mode 100644 index 0000000000..6fd35b161c --- /dev/null +++ b/tests/res/xml/invalid_all_apps_file_case_1.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/invalid_all_apps_file_case_2.xml b/tests/res/xml/invalid_all_apps_file_case_2.xml new file mode 100644 index 0000000000..de9c1ac89b --- /dev/null +++ b/tests/res/xml/invalid_all_apps_file_case_2.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/res/xml/invalid_all_apps_file_case_3.xml b/tests/res/xml/invalid_all_apps_file_case_3.xml new file mode 100644 index 0000000000..7af0af4971 --- /dev/null +++ b/tests/res/xml/invalid_all_apps_file_case_3.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/valid_all_apps_file.xml b/tests/res/xml/valid_all_apps_file.xml new file mode 100644 index 0000000000..0be55d13aa --- /dev/null +++ b/tests/res/xml/valid_all_apps_file.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/src/com/android/launcher3/responsive/AllAppsSpecsTest.kt b/tests/src/com/android/launcher3/responsive/AllAppsSpecsTest.kt new file mode 100644 index 0000000000..77ea5bacaa --- /dev/null +++ b/tests/src/com/android/launcher3/responsive/AllAppsSpecsTest.kt @@ -0,0 +1,118 @@ +/* + * 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.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class AllAppsSpecsTest : AbstractDeviceProfileTest() { + override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context + + @Before + fun setup() { + initializeVarsForPhone(deviceSpecs["phone"]!!) + } + + @Test + fun parseValidFile() { + val allAppsSpecs = + AllAppsSpecs(TestResourceHelper(context!!, TestR.xml.valid_all_apps_file)) + assertThat(allAppsSpecs.allAppsHeightSpecList.size).isEqualTo(1) + assertThat(allAppsSpecs.allAppsHeightSpecList[0].toString()) + .isEqualTo( + "AllAppsSpec(" + + "maxAvailableSize=26247, " + + "specType=HEIGHT, " + + "startPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=false, " + + "maxSize=2147483647), " + + "endPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=false, " + + "maxSize=2147483647), " + + "gutter=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647), " + + "cellSize=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647)" + + ")" + ) + + assertThat(allAppsSpecs.allAppsWidthSpecList.size).isEqualTo(1) + assertThat(allAppsSpecs.allAppsWidthSpecList[0].toString()) + .isEqualTo( + "AllAppsSpec(" + + "maxAvailableSize=26247, " + + "specType=WIDTH, " + + "startPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647), " + + "endPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647), " + + "gutter=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647), " + + "cellSize=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0, " + + "matchWorkspace=true, " + + "maxSize=2147483647)" + + ")" + ) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_missingTag_throwsError() { + AllAppsSpecs(TestResourceHelper(context!!, TestR.xml.invalid_all_apps_file_case_1)) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_moreThanOneValuePerTag_throwsError() { + AllAppsSpecs(TestResourceHelper(context!!, TestR.xml.invalid_all_apps_file_case_2)) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_valueBiggerThan1_throwsError() { + AllAppsSpecs(TestResourceHelper(context!!, TestR.xml.invalid_all_apps_file_case_3)) + } +} diff --git a/tests/src/com/android/launcher3/responsive/CalculatedAllAppsSpecTest.kt b/tests/src/com/android/launcher3/responsive/CalculatedAllAppsSpecTest.kt new file mode 100644 index 0000000000..9f981fa3e7 --- /dev/null +++ b/tests/src/com/android/launcher3/responsive/CalculatedAllAppsSpecTest.kt @@ -0,0 +1,77 @@ +/* + * 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.launcher3.workspace.WorkspaceSpecs +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class CalculatedAllAppsSpecTest : AbstractDeviceProfileTest() { + override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context + + /** + * This test tests: + * - (height spec) copy values from workspace + * - (width spec) copy values from workspace + */ + @Test + fun normalPhone_copiesFromWorkspace() { + val deviceSpec = deviceSpecs["phone"]!! + initializeVarsForPhone(deviceSpec) + + val availableWidth = deviceSpec.naturalSize.first + // Hotseat size is roughly 495px on a real device, + // it doesn't need to be precise on unit tests + val availableHeight = deviceSpec.naturalSize.second - deviceSpec.statusBarNaturalPx - 495 + + val workspaceSpecs = + WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.valid_workspace_file)) + val widthSpec = workspaceSpecs.getCalculatedWidthSpec(4, availableWidth) + val heightSpec = workspaceSpecs.getCalculatedHeightSpec(5, availableHeight) + + val allAppsSpecs = + AllAppsSpecs(TestResourceHelper(context!!, TestR.xml.valid_all_apps_file)) + + with(allAppsSpecs.getCalculatedWidthSpec(4, availableWidth, widthSpec)) { + assertThat(availableSpace).isEqualTo(availableWidth) + assertThat(cells).isEqualTo(4) + assertThat(startPaddingPx).isEqualTo(widthSpec.startPaddingPx) + assertThat(endPaddingPx).isEqualTo(widthSpec.endPaddingPx) + assertThat(gutterPx).isEqualTo(widthSpec.gutterPx) + assertThat(cellSizePx).isEqualTo(widthSpec.cellSizePx) + } + + with(allAppsSpecs.getCalculatedHeightSpec(5, availableHeight, heightSpec)) { + assertThat(availableSpace).isEqualTo(availableHeight) + assertThat(cells).isEqualTo(5) + assertThat(startPaddingPx).isEqualTo(0) + assertThat(endPaddingPx).isEqualTo(0) + assertThat(gutterPx).isEqualTo(heightSpec.gutterPx) + assertThat(cellSizePx).isEqualTo(heightSpec.cellSizePx) + } + } +} diff --git a/tests/src/com/android/launcher3/util/TestResourceHelper.kt b/tests/src/com/android/launcher3/util/TestResourceHelper.kt index 691a069b7b..cf80ece740 100644 --- a/tests/src/com/android/launcher3/util/TestResourceHelper.kt +++ b/tests/src/com/android/launcher3/util/TestResourceHelper.kt @@ -31,6 +31,7 @@ class TestResourceHelper(private val context: Context, specsFileId: Int) : styleId.contentEquals(R.styleable.SizeSpec) -> TestR.styleable.SizeSpec styleId.contentEquals(R.styleable.WorkspaceSpec) -> TestR.styleable.WorkspaceSpec styleId.contentEquals(R.styleable.FolderSpec) -> TestR.styleable.FolderSpec + styleId.contentEquals(R.styleable.AllAppsSpec) -> TestR.styleable.AllAppsSpec else -> styleId.clone() }