Create an XML parser for WorkspaceSpecs

Extract DeviceProfileTest to Launcher3 so it can be used in other tests as well, and change name of previous base test to be more descriptive.

Bug: 241386436
Test: WorkspaceSpecsTest
Change-Id: I64613bb5a23c374ed15fb6d936192236a541ab9b
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))
+    }
+}