Merge "Create an XML parser for WorkspaceSpecs" into tm-qpr-dev
diff --git a/quickstep/tests/src/com/android/quickstep/FullscreenDrawParamsTest.kt b/quickstep/tests/src/com/android/quickstep/FullscreenDrawParamsTest.kt
index 9afd893..bc1b87d 100644
--- a/quickstep/tests/src/com/android/quickstep/FullscreenDrawParamsTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/FullscreenDrawParamsTest.kt
@@ -19,7 +19,7 @@
 import android.graphics.RectF
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.launcher3.DeviceProfileBaseTest
+import com.android.launcher3.FakeInvariantDeviceProfileTest
 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT
 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT
 import com.android.quickstep.views.TaskView.FullscreenDrawParams
@@ -36,7 +36,7 @@
 /** Test for FullscreenDrawParams class. */
 @SmallTest
 @RunWith(AndroidJUnit4::class)
-class FullscreenDrawParamsTest : DeviceProfileBaseTest() {
+class FullscreenDrawParamsTest : FakeInvariantDeviceProfileTest() {
 
     private val TASK_SCALE = 0.7f
     private var mThumbnailData: ThumbnailData = mock(ThumbnailData::class.java)
diff --git a/quickstep/tests/src/com/android/quickstep/HotseatWidthCalculationTest.kt b/quickstep/tests/src/com/android/quickstep/HotseatWidthCalculationTest.kt
index adbca32..a347156 100644
--- a/quickstep/tests/src/com/android/quickstep/HotseatWidthCalculationTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/HotseatWidthCalculationTest.kt
@@ -18,7 +18,7 @@
 import android.graphics.Rect
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.launcher3.DeviceProfileBaseTest
+import com.android.launcher3.FakeInvariantDeviceProfileTest
 import com.android.launcher3.util.WindowBounds
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
@@ -26,7 +26,7 @@
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
-class HotseatWidthCalculationTest : DeviceProfileBaseTest() {
+class HotseatWidthCalculationTest : FakeInvariantDeviceProfileTest() {
 
     /**
      * This is a case when after setting the hotseat, the space needs to be recalculated but it
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index 26180f3..c3bd90e 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -224,6 +224,21 @@
         <attr name="alignOnIcon" format="boolean" />
     </declare-styleable>
 
+    <!--  Responsive grids attributes  -->
+    <declare-styleable name="WorkspaceSpec">
+        <attr name="specType" format="integer">
+            <enum name="height" value="0" />
+            <enum name="width" value="1" />
+        </attr>
+        <attr name="maxAvailableSize" format="dimension" />
+    </declare-styleable>
+
+    <declare-styleable name="SpecSize">
+        <attr name="fixedSize" format="dimension" />
+        <attr name="ofAvailableSpace" format="float" />
+        <attr name="ofRemainderSpace" format="float" />
+    </declare-styleable>
+
     <declare-styleable name="ProfileDisplayOption">
         <attr name="name" />
         <attr name="minWidthDps" format="float" />
diff --git a/src/com/android/launcher3/util/ResourceHelper.kt b/src/com/android/launcher3/util/ResourceHelper.kt
new file mode 100644
index 0000000..0ca7888
--- /dev/null
+++ b/src/com/android/launcher3/util/ResourceHelper.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.util
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.content.res.XmlResourceParser
+import android.util.AttributeSet
+import kotlin.IntArray
+
+/**
+ * This class is a helper that can be subclassed in tests to provide a way to parse attributes
+ * correctly.
+ */
+open class ResourceHelper(private val context: Context, private val specsFileId: Int) {
+    open fun getXml(): XmlResourceParser {
+        return context.resources.getXml(specsFileId)
+    }
+
+    open fun obtainStyledAttributes(attrs: AttributeSet, styleId: IntArray): TypedArray {
+        return context.obtainStyledAttributes(attrs, styleId)
+    }
+}
diff --git a/src/com/android/launcher3/workspace/WorkspaceSpecs.kt b/src/com/android/launcher3/workspace/WorkspaceSpecs.kt
new file mode 100644
index 0000000..0f6e1b0
--- /dev/null
+++ b/src/com/android/launcher3/workspace/WorkspaceSpecs.kt
@@ -0,0 +1,252 @@
+/*
+ * 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.workspace
+
+import android.content.res.TypedArray
+import android.content.res.XmlResourceParser
+import android.util.AttributeSet
+import android.util.Log
+import android.util.TypedValue
+import android.util.Xml
+import com.android.launcher3.R
+import com.android.launcher3.util.ResourceHelper
+import java.io.IOException
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserException
+
+private const val TAG = "WorkspaceSpecs"
+
+class WorkspaceSpecs(resourceHelper: ResourceHelper) {
+    object XmlTags {
+        const val WORKSPACE_SPECS = "workspaceSpecs"
+
+        const val WORKSPACE_SPEC = "workspaceSpec"
+        const val START_PADDING = "startPadding"
+        const val END_PADDING = "endPadding"
+        const val GUTTER = "gutter"
+        const val CELL_SIZE = "cellSize"
+    }
+
+    val workspaceHeightSpecList = mutableListOf<WorkspaceSpec>()
+    val workspaceWidthSpecList = mutableListOf<WorkspaceSpec>()
+
+    init {
+        try {
+            val parser: XmlResourceParser = 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.WORKSPACE_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.WORKSPACE_SPEC == parser.name
+                        ) {
+                            val attrs =
+                                resourceHelper.obtainStyledAttributes(
+                                    Xml.asAttributeSet(parser),
+                                    R.styleable.WorkspaceSpec
+                                )
+                            val maxAvailableSize =
+                                attrs.getDimensionPixelSize(
+                                    R.styleable.WorkspaceSpec_maxAvailableSize,
+                                    0
+                                )
+                            val specType =
+                                WorkspaceSpec.SpecType.values()[
+                                        attrs.getInt(
+                                            R.styleable.WorkspaceSpec_specType,
+                                            WorkspaceSpec.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(resourceHelper, attr)
+                                        }
+                                        XmlTags.END_PADDING -> {
+                                            endPadding = SizeSpec(resourceHelper, attr)
+                                        }
+                                        XmlTags.GUTTER -> {
+                                            gutter = SizeSpec(resourceHelper, attr)
+                                        }
+                                        XmlTags.CELL_SIZE -> {
+                                            cellSize = SizeSpec(resourceHelper, attr)
+                                        }
+                                    }
+                                }
+                            }
+
+                            if (
+                                startPadding == null ||
+                                    endPadding == null ||
+                                    gutter == null ||
+                                    cellSize == null
+                            ) {
+                                throw IllegalStateException(
+                                    "All attributes in workspaceSpec must be defined"
+                                )
+                            }
+
+                            val workspaceSpec =
+                                WorkspaceSpec(
+                                    maxAvailableSize,
+                                    specType,
+                                    startPadding,
+                                    endPadding,
+                                    gutter,
+                                    cellSize
+                                )
+                            if (workspaceSpec.isValid()) {
+                                if (workspaceSpec.specType == WorkspaceSpec.SpecType.HEIGHT)
+                                    workspaceHeightSpecList.add(workspaceSpec)
+                                else workspaceWidthSpecList.add(workspaceSpec)
+                            } else {
+                                throw IllegalStateException("Invalid workspaceSpec found.")
+                            }
+                        }
+                    }
+
+                    if (workspaceWidthSpecList.isEmpty() || workspaceHeightSpecList.isEmpty()) {
+                        throw IllegalStateException(
+                            "WorkspaceSpecs is incomplete - " +
+                                "height list size = ${workspaceHeightSpecList.size}; " +
+                                "width list size = ${workspaceWidthSpecList.size}."
+                        )
+                    }
+                }
+            }
+            parser.close()
+        } catch (e: Exception) {
+            when (e) {
+                is IOException,
+                is XmlPullParserException -> {
+                    throw RuntimeException("Failure parsing workspaces specs file.", e)
+                }
+                else -> throw e
+            }
+        }
+    }
+}
+
+data class WorkspaceSpec(
+    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(TAG, "WorkspaceSpec#isValid - maxAvailableSize <= 0")
+            return false
+        }
+
+        // All specs need to be individually valid
+        if (!allSpecsAreValid()) {
+            Log.e(TAG, "WorkspaceSpec#isValid - !allSpecsAreValid()")
+            return false
+        }
+
+        return true
+    }
+
+    private fun allSpecsAreValid(): Boolean =
+        startPadding.isValid() && endPadding.isValid() && gutter.isValid() && cellSize.isValid()
+}
+
+class SizeSpec(resourceHelper: ResourceHelper, attrs: AttributeSet) {
+    val fixedSize: Float
+    val ofAvailableSpace: Float
+    val ofRemainderSpace: Float
+
+    init {
+        val styledAttrs = resourceHelper.obtainStyledAttributes(attrs, R.styleable.SpecSize)
+
+        fixedSize = getValue(styledAttrs, R.styleable.SpecSize_fixedSize)
+        ofAvailableSpace = getValue(styledAttrs, R.styleable.SpecSize_ofAvailableSpace)
+        ofRemainderSpace = getValue(styledAttrs, R.styleable.SpecSize_ofRemainderSpace)
+
+        styledAttrs.recycle()
+    }
+
+    private fun getValue(a: TypedArray, index: Int): Float {
+        if (a.getType(index) == TypedValue.TYPE_DIMENSION) {
+            return a.getDimensionPixelSize(index, 0).toFloat()
+        } else if (a.getType(index) == TypedValue.TYPE_FLOAT) {
+            return a.getFloat(index, 0f)
+        }
+        return 0f
+    }
+
+    fun isValid(): Boolean {
+        // All attributes are empty
+        if (fixedSize <= 0f && ofAvailableSpace <= 0f && ofRemainderSpace <= 0f) {
+            Log.e(TAG, "SizeSpec#isValid - all attributes are empty")
+            return false
+        }
+
+        // More than one attribute is filled
+        val attrCount =
+            (if (fixedSize > 0) 1 else 0) +
+                (if (ofAvailableSpace > 0) 1 else 0) +
+                (if (ofRemainderSpace > 0) 1 else 0)
+        if (attrCount > 1) {
+            Log.e(TAG, "SizeSpec#isValid - more than one attribute is filled")
+            return false
+        }
+
+        // Values should be between 0 and 1
+        if (ofAvailableSpace !in 0f..1f || ofRemainderSpace !in 0f..1f) {
+            Log.e(TAG, "SizeSpec#isValid - values should be between 0 and 1")
+            return false
+        }
+
+        return true
+    }
+
+    override fun toString(): String {
+        return "SizeSpec(fixedSize=$fixedSize, ofAvailableSpace=$ofAvailableSpace, " +
+            "ofRemainderSpace=$ofRemainderSpace)"
+    }
+}
diff --git a/tests/res/values/attrs.xml b/tests/res/values/attrs.xml
new file mode 100644
index 0000000..2310d9e
--- /dev/null
+++ b/tests/res/values/attrs.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+/* Copyright 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.
+*/
+-->
+
+<!-- Attributes have to be copied to test for correct parsing of files -->
+<resources>
+    <!--  Responsive grids attributes  -->
+    <declare-styleable name="WorkspaceSpec">
+        <attr name="specType" format="integer">
+            <enum name="height" value="0" />
+            <enum name="width" value="1" />
+        </attr>
+        <attr name="maxAvailableSize" format="dimension" />
+    </declare-styleable>
+
+    <declare-styleable name="SpecSize">
+        <attr name="fixedSize" format="dimension" />
+        <attr name="ofAvailableSpace" format="float" />
+        <attr name="ofRemainderSpace" format="float" />
+    </declare-styleable>
+
+</resources>
diff --git a/tests/res/xml/invalid_workspace_file_case_1.xml b/tests/res/xml/invalid_workspace_file_case_1.xml
new file mode 100644
index 0000000..0be704b
--- /dev/null
+++ b/tests/res/xml/invalid_workspace_file_case_1.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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.
+  -->
+
+<workspaceSpecs xmlns:launcher="http://schemas.android.com/apk/res-auto">
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="648dp">
+        <!--  missing startPadding  -->
+        <endPadding
+            launcher:ofAvailableSpace="0.05" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0306" />
+        <endPadding
+            launcher:ofAvailableSpace="0.068" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <!-- Width spec is always the same -->
+    <workspaceSpec
+        launcher:specType="width"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <endPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <gutter
+            launcher:ofRemainderSpace="0.11425509" />
+        <cellSize
+            launcher:fixedSize="120dp" />
+    </workspaceSpec>
+</workspaceSpecs>
diff --git a/tests/res/xml/invalid_workspace_file_case_2.xml b/tests/res/xml/invalid_workspace_file_case_2.xml
new file mode 100644
index 0000000..5a37d97
--- /dev/null
+++ b/tests/res/xml/invalid_workspace_file_case_2.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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.
+  -->
+
+<workspaceSpecs xmlns:launcher="http://schemas.android.com/apk/res-auto">
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="648dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0125" />
+        <endPadding
+            launcher:ofAvailableSpace="0.05" />
+        <!--  more than 1 value in one tag -->
+        <gutter
+            launcher:ofAvailableSpace="0.0125"
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0306" />
+        <endPadding
+            launcher:ofAvailableSpace="0.068" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <!-- Width spec is always the same -->
+    <workspaceSpec
+        launcher:specType="width"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <endPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <gutter
+            launcher:ofRemainderSpace="0.11425509" />
+        <cellSize
+            launcher:fixedSize="120dp" />
+    </workspaceSpec>
+</workspaceSpecs>
diff --git a/tests/res/xml/invalid_workspace_file_case_3.xml b/tests/res/xml/invalid_workspace_file_case_3.xml
new file mode 100644
index 0000000..3e68edb
--- /dev/null
+++ b/tests/res/xml/invalid_workspace_file_case_3.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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.
+  -->
+
+<workspaceSpecs xmlns:launcher="http://schemas.android.com/apk/res-auto">
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="648dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0125" />
+        <endPadding
+            launcher:ofAvailableSpace="0.05" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <!--  value bigger than 1 -->
+        <cellSize
+            launcher:ofRemainderSpace="1.001" />
+    </workspaceSpec>
+
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0306" />
+        <endPadding
+            launcher:ofAvailableSpace="0.068" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <!-- Width spec is always the same -->
+    <workspaceSpec
+        launcher:specType="width"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <endPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <gutter
+            launcher:ofRemainderSpace="0.11425509" />
+        <cellSize
+            launcher:fixedSize="120dp" />
+    </workspaceSpec>
+</workspaceSpecs>
diff --git a/tests/res/xml/valid_workspace_file.xml b/tests/res/xml/valid_workspace_file.xml
new file mode 100644
index 0000000..91a3e48
--- /dev/null
+++ b/tests/res/xml/valid_workspace_file.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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.
+  -->
+
+<workspaceSpecs xmlns:launcher="http://schemas.android.com/apk/res-auto">
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="648dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0125" />
+        <endPadding
+            launcher:ofAvailableSpace="0.05" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <workspaceSpec
+        launcher:specType="height"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofAvailableSpace="0.0306" />
+        <endPadding
+            launcher:ofAvailableSpace="0.068" />
+        <gutter
+            launcher:fixedSize="16dp" />
+        <cellSize
+            launcher:ofRemainderSpace="0.2" />
+    </workspaceSpec>
+
+    <!-- Width spec is always the same -->
+    <workspaceSpec
+        launcher:specType="width"
+        launcher:maxAvailableSize="9999dp">
+        <startPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <endPadding
+            launcher:ofRemainderSpace="0.21436227" />
+        <gutter
+            launcher:ofRemainderSpace="0.11425509" />
+        <cellSize
+            launcher:fixedSize="120dp" />
+    </workspaceSpec>
+</workspaceSpecs>
diff --git a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt
new file mode 100644
index 0000000..dcc669b
--- /dev/null
+++ b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt
@@ -0,0 +1,272 @@
+/*
+ * 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
+
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Point
+import android.graphics.Rect
+import android.util.DisplayMetrics
+import android.view.Surface
+import androidx.test.core.app.ApplicationProvider
+import com.android.launcher3.util.DisplayController
+import com.android.launcher3.util.NavigationMode
+import com.android.launcher3.util.WindowBounds
+import com.android.launcher3.util.window.CachedDisplayInfo
+import com.android.launcher3.util.window.WindowManagerProxy
+import kotlin.math.max
+import kotlin.math.min
+import org.junit.After
+import org.junit.Before
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when` as whenever
+
+/**
+ * This is an abstract class for DeviceProfile tests that create an InvariantDeviceProfile based on
+ * a real device spec.
+ *
+ * For an implementation that mocks InvariantDeviceProfile, use [FakeInvariantDeviceProfileTest]
+ */
+abstract class AbstractDeviceProfileTest {
+    protected var context: Context? = null
+    protected open val runningContext: Context = ApplicationProvider.getApplicationContext()
+    private var displayController: DisplayController = mock(DisplayController::class.java)
+    private var windowManagerProxy: WindowManagerProxy = mock(WindowManagerProxy::class.java)
+    private lateinit var originalDisplayController: DisplayController
+    private lateinit var originalWindowManagerProxy: WindowManagerProxy
+
+    @Before
+    fun setUp() {
+        val appContext: Context = ApplicationProvider.getApplicationContext()
+        originalWindowManagerProxy = WindowManagerProxy.INSTANCE.get(appContext)
+        originalDisplayController = DisplayController.INSTANCE.get(appContext)
+        WindowManagerProxy.INSTANCE.initializeForTesting(windowManagerProxy)
+        DisplayController.INSTANCE.initializeForTesting(displayController)
+    }
+
+    @After
+    fun tearDown() {
+        WindowManagerProxy.INSTANCE.initializeForTesting(originalWindowManagerProxy)
+        DisplayController.INSTANCE.initializeForTesting(originalDisplayController)
+    }
+
+    class DeviceSpec(
+        val naturalSize: Pair<Int, Int>,
+        val densityDpi: Int,
+        val statusBarNaturalPx: Int,
+        val statusBarRotatedPx: Int,
+        val gesturePx: Int,
+        val cutoutPx: Int
+    )
+
+    open val deviceSpecs =
+        mapOf(
+            "phone" to
+                DeviceSpec(
+                    Pair(1080, 2400),
+                    densityDpi = 420,
+                    statusBarNaturalPx = 118,
+                    statusBarRotatedPx = 74,
+                    gesturePx = 63,
+                    cutoutPx = 118
+                ),
+            "tablet" to
+                DeviceSpec(
+                    Pair(2560, 1600),
+                    densityDpi = 320,
+                    statusBarNaturalPx = 104,
+                    statusBarRotatedPx = 104,
+                    gesturePx = 0,
+                    cutoutPx = 0
+                ),
+        )
+
+    protected fun initializeVarsForPhone(
+        deviceSpec: DeviceSpec,
+        isGestureMode: Boolean = true,
+        isVerticalBar: Boolean = false
+    ) {
+        val (naturalX, naturalY) = deviceSpec.naturalSize
+        val windowsBounds = phoneWindowsBounds(deviceSpec, isGestureMode, naturalX, naturalY)
+        val displayInfo =
+            CachedDisplayInfo(Point(naturalX, naturalY), Surface.ROTATION_0, Rect(0, 0, 0, 0))
+        val perDisplayBoundsCache = mapOf(displayInfo to windowsBounds)
+
+        initializeCommonVars(
+            perDisplayBoundsCache,
+            displayInfo,
+            rotation = if (isVerticalBar) Surface.ROTATION_90 else Surface.ROTATION_0,
+            isGestureMode,
+            densityDpi = deviceSpec.densityDpi
+        )
+    }
+
+    protected fun initializeVarsForTablet(
+        deviceSpec: DeviceSpec,
+        isLandscape: Boolean = false,
+        isGestureMode: Boolean = true
+    ) {
+        val (naturalX, naturalY) = deviceSpec.naturalSize
+        val windowsBounds = tabletWindowsBounds(deviceSpec, naturalX, naturalY)
+        val displayInfo =
+            CachedDisplayInfo(Point(naturalX, naturalY), Surface.ROTATION_0, Rect(0, 0, 0, 0))
+        val perDisplayBoundsCache = mapOf(displayInfo to windowsBounds)
+
+        initializeCommonVars(
+            perDisplayBoundsCache,
+            displayInfo,
+            rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90,
+            isGestureMode,
+            densityDpi = deviceSpec.densityDpi
+        )
+    }
+
+    protected fun initializeVarsForTwoPanel(
+        deviceTabletSpec: DeviceSpec,
+        deviceSpec: DeviceSpec,
+        isLandscape: Boolean = false,
+        isGestureMode: Boolean = true
+    ) {
+        val (tabletNaturalX, tabletNaturalY) = deviceTabletSpec.naturalSize
+        val tabletWindowsBounds =
+            tabletWindowsBounds(deviceTabletSpec, tabletNaturalX, tabletNaturalY)
+        val tabletDisplayInfo =
+            CachedDisplayInfo(
+                Point(tabletNaturalX, tabletNaturalY),
+                Surface.ROTATION_0,
+                Rect(0, 0, 0, 0)
+            )
+
+        val (phoneNaturalX, phoneNaturalY) = deviceSpec.naturalSize
+        val phoneWindowsBounds =
+            phoneWindowsBounds(deviceSpec, isGestureMode, phoneNaturalX, phoneNaturalY)
+        val phoneDisplayInfo =
+            CachedDisplayInfo(
+                Point(phoneNaturalX, phoneNaturalY),
+                Surface.ROTATION_0,
+                Rect(0, 0, 0, 0)
+            )
+
+        val perDisplayBoundsCache =
+            mapOf(tabletDisplayInfo to tabletWindowsBounds, phoneDisplayInfo to phoneWindowsBounds)
+
+        initializeCommonVars(
+            perDisplayBoundsCache,
+            tabletDisplayInfo,
+            rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90,
+            isGestureMode,
+            densityDpi = deviceTabletSpec.densityDpi
+        )
+    }
+
+    private fun phoneWindowsBounds(
+        deviceSpec: DeviceSpec,
+        isGestureMode: Boolean,
+        naturalX: Int,
+        naturalY: Int
+    ): Array<WindowBounds> {
+        val buttonsNavHeight = Utilities.dpToPx(48f, deviceSpec.densityDpi)
+
+        val rotation0Insets =
+            Rect(
+                0,
+                max(deviceSpec.statusBarNaturalPx, deviceSpec.cutoutPx),
+                0,
+                if (isGestureMode) deviceSpec.gesturePx else buttonsNavHeight
+            )
+        val rotation90Insets =
+            Rect(
+                deviceSpec.cutoutPx,
+                deviceSpec.statusBarRotatedPx,
+                if (isGestureMode) 0 else buttonsNavHeight,
+                if (isGestureMode) deviceSpec.gesturePx else 0
+            )
+        val rotation180Insets =
+            Rect(
+                0,
+                deviceSpec.statusBarNaturalPx,
+                0,
+                max(
+                    if (isGestureMode) deviceSpec.gesturePx else buttonsNavHeight,
+                    deviceSpec.cutoutPx
+                )
+            )
+        val rotation270Insets =
+            Rect(
+                if (isGestureMode) 0 else buttonsNavHeight,
+                deviceSpec.statusBarRotatedPx,
+                deviceSpec.cutoutPx,
+                if (isGestureMode) deviceSpec.gesturePx else 0
+            )
+
+        return arrayOf(
+            WindowBounds(Rect(0, 0, naturalX, naturalY), rotation0Insets, Surface.ROTATION_0),
+            WindowBounds(Rect(0, 0, naturalY, naturalX), rotation90Insets, Surface.ROTATION_90),
+            WindowBounds(Rect(0, 0, naturalX, naturalY), rotation180Insets, Surface.ROTATION_180),
+            WindowBounds(Rect(0, 0, naturalY, naturalX), rotation270Insets, Surface.ROTATION_270)
+        )
+    }
+
+    private fun tabletWindowsBounds(
+        deviceSpec: DeviceSpec,
+        naturalX: Int,
+        naturalY: Int
+    ): Array<WindowBounds> {
+        val naturalInsets = Rect(0, deviceSpec.statusBarNaturalPx, 0, 0)
+        val rotatedInsets = Rect(0, deviceSpec.statusBarRotatedPx, 0, 0)
+
+        return arrayOf(
+            WindowBounds(Rect(0, 0, naturalX, naturalY), naturalInsets, Surface.ROTATION_0),
+            WindowBounds(Rect(0, 0, naturalY, naturalX), rotatedInsets, Surface.ROTATION_90),
+            WindowBounds(Rect(0, 0, naturalX, naturalY), naturalInsets, Surface.ROTATION_180),
+            WindowBounds(Rect(0, 0, naturalY, naturalX), rotatedInsets, Surface.ROTATION_270)
+        )
+    }
+
+    private fun initializeCommonVars(
+        perDisplayBoundsCache: Map<CachedDisplayInfo, Array<WindowBounds>>,
+        displayInfo: CachedDisplayInfo,
+        rotation: Int,
+        isGestureMode: Boolean = true,
+        densityDpi: Int
+    ) {
+        val windowsBounds = perDisplayBoundsCache[displayInfo]!!
+        val realBounds = windowsBounds[rotation]
+        whenever(windowManagerProxy.getDisplayInfo(ArgumentMatchers.any())).thenReturn(displayInfo)
+        whenever(windowManagerProxy.getRealBounds(ArgumentMatchers.any(), ArgumentMatchers.any()))
+            .thenReturn(realBounds)
+        whenever(windowManagerProxy.getRotation(ArgumentMatchers.any())).thenReturn(rotation)
+        whenever(windowManagerProxy.getNavigationMode(ArgumentMatchers.any()))
+            .thenReturn(
+                if (isGestureMode) NavigationMode.NO_BUTTON else NavigationMode.THREE_BUTTONS
+            )
+
+        val density = densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()
+        val config =
+            Configuration(runningContext.resources.configuration).apply {
+                this.densityDpi = densityDpi
+                screenWidthDp = (realBounds.bounds.width() / density).toInt()
+                screenHeightDp = (realBounds.bounds.height() / density).toInt()
+                smallestScreenWidthDp = min(screenWidthDp, screenHeightDp)
+            }
+        context = runningContext.createConfigurationContext(config)
+
+        val info = DisplayController.Info(context, windowManagerProxy, perDisplayBoundsCache)
+        whenever(displayController.info).thenReturn(info)
+        whenever(displayController.isTransientTaskbar).thenReturn(isGestureMode)
+    }
+}
diff --git a/tests/src/com/android/launcher3/DeviceProfileBaseTest.kt b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
similarity index 96%
rename from tests/src/com/android/launcher3/DeviceProfileBaseTest.kt
rename to tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
index daf4608..a5f33c0 100644
--- a/tests/src/com/android/launcher3/DeviceProfileBaseTest.kt
+++ b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
@@ -31,7 +31,13 @@
 import org.mockito.Mockito.mock
 import org.mockito.Mockito.`when` as whenever
 
-abstract class DeviceProfileBaseTest {
+/**
+ * This is an abstract class for DeviceProfile tests that don't need the real Context and mock an
+ * InvariantDeviceProfile instead of creating one based on real values.
+ *
+ * For an implementation that creates InvariantDeviceProfile, use [AbstractDeviceProfileTest]
+ */
+abstract class FakeInvariantDeviceProfileTest {
 
     protected var context: Context? = null
     protected var inv: InvariantDeviceProfile? = null
diff --git a/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt b/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt
index 951f5f8..2a27487 100644
--- a/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt
+++ b/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt
@@ -18,7 +18,7 @@
 import android.graphics.Rect
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.launcher3.DeviceProfileBaseTest
+import com.android.launcher3.FakeInvariantDeviceProfileTest
 import com.android.launcher3.util.WindowBounds
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
@@ -26,7 +26,7 @@
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
-class HotseatWidthCalculationTest : DeviceProfileBaseTest() {
+class HotseatWidthCalculationTest : FakeInvariantDeviceProfileTest() {
 
     /**
      * This is a case when after setting the hotseat, the space needs to be recalculated but it
diff --git a/tests/src/com/android/launcher3/util/TestResourceHelper.kt b/tests/src/com/android/launcher3/util/TestResourceHelper.kt
new file mode 100644
index 0000000..fb03fe1
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/TestResourceHelper.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.util
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.util.AttributeSet
+import com.android.launcher3.R
+import com.android.launcher3.tests.R as TestR
+import kotlin.IntArray
+
+class TestResourceHelper(private val context: Context, private val specsFileId: Int) :
+    ResourceHelper(context, specsFileId) {
+    override fun obtainStyledAttributes(attrs: AttributeSet, styleId: IntArray): TypedArray {
+        var clone = styleId.clone()
+        if (styleId == R.styleable.SpecSize) clone = TestR.styleable.SpecSize
+        else if (styleId == R.styleable.WorkspaceSpec) clone = TestR.styleable.WorkspaceSpec
+        return context.obtainStyledAttributes(attrs, clone)
+    }
+}
diff --git a/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt b/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt
new file mode 100644
index 0000000..0fd8a54
--- /dev/null
+++ b/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.workspace
+
+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 WorkspaceSpecsTest : AbstractDeviceProfileTest() {
+    override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context
+
+    @Before
+    fun setup() {
+        initializeVarsForPhone(deviceSpecs["phone"]!!)
+    }
+
+    @Test
+    fun parseValidFile() {
+        val workspaceSpecs =
+            WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.valid_workspace_file))
+        assertThat(workspaceSpecs.workspaceHeightSpecList.size).isEqualTo(2)
+        assertThat(workspaceSpecs.workspaceHeightSpecList[0].toString())
+            .isEqualTo(
+                "WorkspaceSpec(" +
+                    "maxAvailableSize=1701, " +
+                    "specType=HEIGHT, " +
+                    "startPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0125, " +
+                    "ofRemainderSpace=0.0), " +
+                    "endPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.05, " +
+                    "ofRemainderSpace=0.0), " +
+                    "gutter=SizeSpec(fixedSize=42.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.0), " +
+                    "cellSize=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.2)" +
+                    ")"
+            )
+        assertThat(workspaceSpecs.workspaceHeightSpecList[1].toString())
+            .isEqualTo(
+                "WorkspaceSpec(" +
+                    "maxAvailableSize=26247, " +
+                    "specType=HEIGHT, " +
+                    "startPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0306, " +
+                    "ofRemainderSpace=0.0), " +
+                    "endPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.068, " +
+                    "ofRemainderSpace=0.0), " +
+                    "gutter=SizeSpec(fixedSize=42.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.0), " +
+                    "cellSize=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.2)" +
+                    ")"
+            )
+        assertThat(workspaceSpecs.workspaceWidthSpecList.size).isEqualTo(1)
+        assertThat(workspaceSpecs.workspaceWidthSpecList[0].toString())
+            .isEqualTo(
+                "WorkspaceSpec(" +
+                    "maxAvailableSize=26247, " +
+                    "specType=WIDTH, " +
+                    "startPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.21436226), " +
+                    "endPadding=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.21436226), " +
+                    "gutter=SizeSpec(fixedSize=0.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.11425509), " +
+                    "cellSize=SizeSpec(fixedSize=315.0, " +
+                    "ofAvailableSpace=0.0, " +
+                    "ofRemainderSpace=0.0)" +
+                    ")"
+            )
+    }
+
+    @Test(expected = IllegalStateException::class)
+    fun parseInvalidFile_missingTag_throwsError() {
+        WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_1))
+    }
+
+    @Test(expected = IllegalStateException::class)
+    fun parseInvalidFile_moreThanOneValuePerTag_throwsError() {
+        WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_2))
+    }
+
+    @Test(expected = IllegalStateException::class)
+    fun parseInvalidFile_valueBiggerThan1_throwsError() {
+        WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_3))
+    }
+}