diff --git a/core/java/com/android/internal/content/om/OverlayConfigParser.java b/core/java/com/android/internal/content/om/OverlayConfigParser.java
index faaf7d5cef18f158a8fcb7016a971e7b418d30b9..8132652ed6f4cf3bc3793419164a91e65330abe5 100644
--- a/core/java/com/android/internal/content/om/OverlayConfigParser.java
+++ b/core/java/com/android/internal/content/om/OverlayConfigParser.java
@@ -24,6 +24,8 @@ import android.content.pm.PackagePartitions;
 import android.content.pm.PackagePartitions.SystemPartition;
 import android.os.Build;
 import android.os.FileUtils;
+import android.os.SystemProperties;
+import android.text.TextUtils;
 import android.util.ArraySet;
 import android.util.Log;
 import android.util.Xml;
@@ -241,6 +243,18 @@ public final class OverlayConfigParser {
         }
     }
 
+    @FunctionalInterface
+    public interface SysPropWrapper{
+        /**
+         * Get system property
+         *
+         * @param property the key to look up.
+         *
+         * @return The property value if found, empty string otherwise.
+         */
+        String get(String property);
+    }
+
     /**
      * Retrieves overlays configured within the partition in increasing priority order.
      *
@@ -319,6 +333,76 @@ public final class OverlayConfigParser {
         }
     }
 
+    /**
+     * Expand the property inside a rro configuration path.
+     *
+     * A RRO configuration can contain a property, this method expands
+     * the property to its value.
+     *
+     * Only read only properties allowed, prefixed with ro. Other
+     * properties will raise exception.
+     *
+     * Only a single property in the path is allowed.
+     *
+     * Example "${ro.boot.hardware.sku}/config.xml" would expand to
+     *     "G020N/config.xml"
+     *
+     * @param configPath path to expand
+     * @param sysPropWrapper method used for reading properties
+     *
+     * @return The expanded path. Returns null if configPath is null.
+     */
+    @VisibleForTesting
+    public static String expandProperty(String configPath,
+            SysPropWrapper sysPropWrapper) {
+        if (configPath == null) {
+            return null;
+        }
+
+        int propStartPos = configPath.indexOf("${");
+        if (propStartPos == -1) {
+            // No properties inside the string, return as is
+            return configPath;
+        }
+
+        final StringBuilder sb = new StringBuilder();
+        sb.append(configPath.substring(0, propStartPos));
+
+        // Read out the end position
+        int propEndPos = configPath.indexOf("}", propStartPos);
+        if (propEndPos == -1) {
+            throw new IllegalStateException("Malformed property, unmatched braces, in: "
+                    + configPath);
+        }
+
+        // Confirm that there is only one property inside the string
+        if (configPath.indexOf("${", propStartPos + 2) != -1) {
+            throw new IllegalStateException("Only a single property supported in path: "
+                    + configPath);
+        }
+
+        final String propertyName = configPath.substring(propStartPos + 2, propEndPos);
+        if (!propertyName.startsWith("ro.")) {
+            throw new IllegalStateException("Only read only properties can be used when "
+                    + "merging RRO config files: " + propertyName);
+        }
+        final String propertyValue = sysPropWrapper.get(propertyName);
+        if (TextUtils.isEmpty(propertyValue)) {
+            throw new IllegalStateException("Property is empty or doesn't exist: " + propertyName);
+        }
+        Log.d(TAG, String.format("Using property in overlay config path: \"%s\"", propertyName));
+        sb.append(propertyValue);
+
+        // propEndPos points to '}', need to step to next character, might be outside of string
+        propEndPos = propEndPos + 1;
+        // Append the remainder, if exists
+        if (propEndPos < configPath.length()) {
+            sb.append(configPath.substring(propEndPos));
+        }
+
+        return sb.toString();
+    }
+
     /**
      * Parses a <merge> tag within an overlay configuration file.
      *
@@ -331,10 +415,21 @@ public final class OverlayConfigParser {
             @Nullable OverlayScanner scanner,
             @Nullable Map<String, ParsedOverlayInfo> packageManagerOverlayInfos,
             @NonNull ParsingContext parsingContext) {
-        final String path = parser.getAttributeValue(null, "path");
+        final String path;
+
+        try {
+            SysPropWrapper sysPropWrapper = p -> {
+                return SystemProperties.get(p, "");
+            };
+            path = expandProperty(parser.getAttributeValue(null, "path"), sysPropWrapper);
+        } catch (IllegalStateException e) {
+            throw new IllegalStateException(String.format("<merge> path expand error in %s at %s",
+                    configFile, parser.getPositionDescription()), e);
+        }
+
         if (path == null) {
-            throw new IllegalStateException(String.format("<merge> without path in %s at %s"
-                    + configFile, parser.getPositionDescription()));
+            throw new IllegalStateException(String.format("<merge> without path in %s at %s",
+                    configFile, parser.getPositionDescription()));
         }
 
         if (path.startsWith("/")) {
diff --git a/core/tests/coretests/src/com/android/internal/content/res/OverlayConfigParserTest.java b/core/tests/coretests/src/com/android/internal/content/res/OverlayConfigParserTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..4eccbe5f1dc473acadddf136e2045c346014c0fb
--- /dev/null
+++ b/core/tests/coretests/src/com/android/internal/content/res/OverlayConfigParserTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2024 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.internal.content.res;
+
+import static com.android.internal.content.om.OverlayConfigParser.SysPropWrapper;
+
+import static org.junit.Assert.assertEquals;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.content.om.OverlayConfigParser;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+public class OverlayConfigParserTest {
+    @Test(expected = IllegalStateException.class)
+    public void testMergePropNotRoProp() {
+        SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+        OverlayConfigParser.expandProperty("${persist.value}/path", sysProp);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testMergePropMissingEndBracket() {
+        SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+        OverlayConfigParser.expandProperty("${ro.value/path", sysProp);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testMergeOnlyPropStart() {
+        SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+        OverlayConfigParser.expandProperty("path/${", sysProp);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testMergePropInProp() {
+        SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+        OverlayConfigParser.expandProperty("path/${${ro.value}}", sysProp);
+    }
+
+    /**
+     * The path is only allowed to contain one property.
+     */
+    @Test(expected = IllegalStateException.class)
+    public void testMergePropMultipleProps() {
+        SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+        OverlayConfigParser.expandProperty("${ro.value}/path${ro.value2}/path", sysProp);
+    }
+
+    @Test
+    public void testMergePropOneProp() {
+        final SysPropWrapper sysProp = p -> {
+            if ("ro.value".equals(p)) {
+                return "dummy_value";
+            } else {
+                return "invalid";
+            }
+        };
+
+        // Property in the beginnig of the string
+        String result = OverlayConfigParser.expandProperty("${ro.value}/path",
+                sysProp);
+        assertEquals("dummy_value/path", result);
+
+        // Property in the middle of the string
+        result = OverlayConfigParser.expandProperty("path/${ro.value}/file",
+                sysProp);
+        assertEquals("path/dummy_value/file", result);
+
+        // Property at the of the string
+        result = OverlayConfigParser.expandProperty("path/${ro.value}",
+                sysProp);
+        assertEquals("path/dummy_value", result);
+
+        // Property is the entire string
+        result = OverlayConfigParser.expandProperty("${ro.value}",
+                sysProp);
+        assertEquals("dummy_value", result);
+    }
+
+    @Test
+    public void testMergePropNoProp() {
+        final SysPropWrapper sysProp = p -> {
+            return "dummy_value";
+        };
+
+        final String path = "no_props/path";
+        String result = OverlayConfigParser.expandProperty(path, sysProp);
+        assertEquals(path, result);
+    }
+}