Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit d9fe6734 authored by Joël Stemmer's avatar Joël Stemmer
Browse files

Extend backup rules scheme with cross-platform-transfer configuration

The data extraction rules configuration is extended with a new
`cross-platform-transfer` tag that apps can use to opt-in to cross
platform transfers and configure it separately from cloud backup and
regular Android to Android device transfers.

In addition to `include` and `exclude` tags, the new
`cross-platform-transfer` tag should also contain a
`platform-specific-params` tag. This is used to help identify the
corresponding app on the other platform.

Bug: 403956528
Test: atest FullBackupTest.java
Flag: com.android.server.backup.enable_cross_platform_transfer

Change-Id: Ifedb55d835cf4098176b8ddc3d29ea63684f3fb8
parent b46db322
Loading
Loading
Loading
Loading
+4 −1
Original line number Original line Diff line number Diff line
@@ -50,7 +50,8 @@ public class BackupAnnotations {
    @IntDef({
    @IntDef({
        BackupDestination.CLOUD,
        BackupDestination.CLOUD,
        BackupDestination.DEVICE_TRANSFER,
        BackupDestination.DEVICE_TRANSFER,
        BackupDestination.ADB_BACKUP
        BackupDestination.ADB_BACKUP,
        BackupDestination.CROSS_PLATFORM_TRANSFER,
    })
    })
    public @interface BackupDestination {
    public @interface BackupDestination {
        // A cloud backup.
        // A cloud backup.
@@ -59,5 +60,7 @@ public class BackupAnnotations {
        int DEVICE_TRANSFER = 1;
        int DEVICE_TRANSFER = 1;
        // An adb backup.
        // An adb backup.
        int ADB_BACKUP = 2;
        int ADB_BACKUP = 2;
        // A device migration to or from another platform.
        int CROSS_PLATFORM_TRANSFER = 3;
    }
    }
}
}
+152 −31
Original line number Original line Diff line number Diff line
@@ -42,6 +42,7 @@ import android.util.Log;
import android.util.Slog;
import android.util.Slog;


import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.backup.Flags;


import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserException;
@@ -98,11 +99,16 @@ public class FullBackup {


    public static final String FLAG_REQUIRED_CLIENT_SIDE_ENCRYPTION = "clientSideEncryption";
    public static final String FLAG_REQUIRED_CLIENT_SIDE_ENCRYPTION = "clientSideEncryption";
    public static final String FLAG_REQUIRED_DEVICE_TO_DEVICE_TRANSFER = "deviceToDeviceTransfer";
    public static final String FLAG_REQUIRED_DEVICE_TO_DEVICE_TRANSFER = "deviceToDeviceTransfer";
    public static final String FLAG_REQUIRED_PLATFORM = "platform";
    public static final String FLAG_REQUIRED_FAKE_CLIENT_SIDE_ENCRYPTION =
    public static final String FLAG_REQUIRED_FAKE_CLIENT_SIDE_ENCRYPTION =
            "fakeClientSideEncryption";
            "fakeClientSideEncryption";
    private static final String FLAG_DISABLE_IF_NO_ENCRYPTION_CAPABILITIES =
    private static final String FLAG_DISABLE_IF_NO_ENCRYPTION_CAPABILITIES =
            "disableIfNoEncryptionCapabilities";
            "disableIfNoEncryptionCapabilities";


    private static final String FLAG_PLATFORM_SPECIFIC_PARAMS_BUNDLE_ID = "bundleId";
    private static final String FLAG_PLATFORM_SPECIFIC_PARAMS_TEAM_ID = "teamId";
    private static final String FLAG_PLATFORM_SPECIFIC_PARAMS_CONTENT_VERSION = "contentVersion";

    /**
    /**
     * When this change is enabled, include / exclude rules specified via {@code
     * When this change is enabled, include / exclude rules specified via {@code
     * android:fullBackupContent} are ignored during D2D transfers.
     * android:fullBackupContent} are ignored during D2D transfers.
@@ -112,10 +118,15 @@ public class FullBackup {
    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.S)
    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.S)
    private static final long IGNORE_FULL_BACKUP_CONTENT_IN_D2D = 180523564L;
    private static final long IGNORE_FULL_BACKUP_CONTENT_IN_D2D = 180523564L;


    @StringDef({ConfigSection.CLOUD_BACKUP, ConfigSection.DEVICE_TRANSFER})
    @StringDef({
        ConfigSection.CLOUD_BACKUP,
        ConfigSection.DEVICE_TRANSFER,
        ConfigSection.CROSS_PLATFORM_TRANSFER,
    })
    @interface ConfigSection {
    @interface ConfigSection {
        String CLOUD_BACKUP = "cloud-backup";
        String CLOUD_BACKUP = "cloud-backup";
        String DEVICE_TRANSFER = "device-transfer";
        String DEVICE_TRANSFER = "device-transfer";
        String CROSS_PLATFORM_TRANSFER = "cross-platform-transfer";
    }
    }


    /**
    /**
@@ -176,9 +187,16 @@ public class FullBackup {
    }
    }


    public static BackupScheme getBackupSchemeForTest(Context context) {
    public static BackupScheme getBackupSchemeForTest(Context context) {
        BackupScheme testing = new BackupScheme(context, BackupDestination.CLOUD);
        return getBackupSchemeForTest(context, BackupDestination.CLOUD);
    }

    /** Returns a BackupScheme for testing only. */
    public static BackupScheme getBackupSchemeForTest(
            Context context, @BackupDestination int backupDestination) {
        BackupScheme testing = new BackupScheme(context, backupDestination);
        testing.mExcludes = new ArraySet();
        testing.mExcludes = new ArraySet();
        testing.mIncludes = new ArrayMap();
        testing.mIncludes = new ArrayMap();
        testing.mPlatformSpecificParams = new ArrayMap();
        return testing;
        return testing;
    }
    }


@@ -300,6 +318,7 @@ public class FullBackup {


        private static final String TAG_INCLUDE = "include";
        private static final String TAG_INCLUDE = "include";
        private static final String TAG_EXCLUDE = "exclude";
        private static final String TAG_EXCLUDE = "exclude";
        private static final String TAG_PLATFORM_SPECIFIC_PARAMS = "platform-specific-params";


        final int mDataExtractionRules;
        final int mDataExtractionRules;
        final int mFullBackupContent;
        final int mFullBackupContent;
@@ -409,6 +428,31 @@ public class FullBackup {
            }
            }
        }
        }


        /** Represents platform specific parameters for cross-platform transfers. */
        public static class PlatformSpecificParams {
            private final String mBundleId;
            private final String mTeamId;
            private final String mContentVersion;

            public PlatformSpecificParams(String bundleId, String teamId, String contentVersion) {
                this.mBundleId = bundleId;
                this.mTeamId = teamId;
                this.mContentVersion = contentVersion;
            }

            public String getBundleId() {
                return mBundleId;
            }

            public String getTeamId() {
                return mTeamId;
            }

            public String getContentVersion() {
                return mContentVersion;
            }
        }

        /**
        /**
         * A map of domain -> set of pairs (canonical file; required transport flags) in that domain
         * A map of domain -> set of pairs (canonical file; required transport flags) in that domain
         * that are to be included if the transport has decared the required flags. We keep track of
         * that are to be included if the transport has decared the required flags. We keep track of
@@ -423,6 +467,13 @@ public class FullBackup {
         */
         */
        ArraySet<PathWithRequiredFlags> mExcludes;
        ArraySet<PathWithRequiredFlags> mExcludes;


        /**
         * A map of platform -> platform specific params. The presence of an entry indicates that
         * export to and import from that platform is supported. The platform specific params are
         * used to identify the corresponding app on the other platform.
         */
        Map<String, PlatformSpecificParams> mPlatformSpecificParams;

        BackupScheme(Context context, @BackupDestination int backupDestination) {
        BackupScheme(Context context, @BackupDestination int backupDestination) {
            ApplicationInfo applicationInfo = context.getApplicationInfo();
            ApplicationInfo applicationInfo = context.getApplicationInfo();


@@ -594,7 +645,11 @@ public class FullBackup {
                try (XmlResourceParser parser = getParserForResource(mDataExtractionRules)) {
                try (XmlResourceParser parser = getParserForResource(mDataExtractionRules)) {
                    isSectionPresent =
                    isSectionPresent =
                            parseNewBackupSchemeFromXmlLocked(
                            parseNewBackupSchemeFromXmlLocked(
                                    parser, configSection, mExcludes, mIncludes);
                                    parser,
                                    configSection,
                                    mExcludes,
                                    mIncludes,
                                    mPlatformSpecificParams);
                }
                }
                if (isSectionPresent) {
                if (isSectionPresent) {
                    // Found the relevant section in the new config, we will use it.
                    // Found the relevant section in the new config, we will use it.
@@ -640,11 +695,13 @@ public class FullBackup {
                XmlPullParser parser,
                XmlPullParser parser,
                @ConfigSection String configSection,
                @ConfigSection String configSection,
                Set<PathWithRequiredFlags> excludes,
                Set<PathWithRequiredFlags> excludes,
                Map<String, Set<PathWithRequiredFlags>> includes)
                Map<String, Set<PathWithRequiredFlags>> includes,
                Map<String, PlatformSpecificParams> platformSpecificParams)
                throws IOException, XmlPullParserException {
                throws IOException, XmlPullParserException {
            verifyTopLevelTag(parser, "data-extraction-rules");
            verifyTopLevelTag(parser, "data-extraction-rules");


            boolean isSectionPresent = false;
            boolean isSectionPresent = false;
            String platform = null;


            int event;
            int event;
            while ((event = parser.next()) != XmlPullParser.END_DOCUMENT) {
            while ((event = parser.next()) != XmlPullParser.END_DOCUMENT) {
@@ -654,8 +711,21 @@ public class FullBackup {


                isSectionPresent = true;
                isSectionPresent = true;


                if (Flags.enableCrossPlatformTransfer()
                        && ConfigSection.CROSS_PLATFORM_TRANSFER.equals(configSection)) {
                    platform =
                            parser.getAttributeValue(/* namespace= */ null, FLAG_REQUIRED_PLATFORM);
                }

                parseRequiredTransportFlags(parser, configSection);
                parseRequiredTransportFlags(parser, configSection);
                parseRules(parser, excludes, includes, Optional.of(0), configSection);
                parseRules(
                        parser,
                        excludes,
                        includes,
                        platformSpecificParams,
                        Optional.of(0),
                        configSection,
                        platform);
            }
            }


            logParsingResults(excludes, includes);
            logParsingResults(excludes, includes);
@@ -683,7 +753,14 @@ public class FullBackup {
                throws IOException, XmlPullParserException {
                throws IOException, XmlPullParserException {
            verifyTopLevelTag(parser, "full-backup-content");
            verifyTopLevelTag(parser, "full-backup-content");


            parseRules(parser, excludes, includes, Optional.empty(), "full-backup-content");
            parseRules(
                    parser,
                    excludes,
                    includes,
                    /* platformSpecificParamsMap= */ new ArrayMap<>(),
                    /* maybeRequiredFlags= */ Optional.empty(),
                    "full-backup-content",
                    /* platform= */ null);


            logParsingResults(excludes, includes);
            logParsingResults(excludes, includes);
        }
        }
@@ -718,19 +795,46 @@ public class FullBackup {
                XmlPullParser parser,
                XmlPullParser parser,
                Set<PathWithRequiredFlags> excludes,
                Set<PathWithRequiredFlags> excludes,
                Map<String, Set<PathWithRequiredFlags>> includes,
                Map<String, Set<PathWithRequiredFlags>> includes,
                Map<String, PlatformSpecificParams> platformSpecificParamsMap,
                Optional<Integer> maybeRequiredFlags,
                Optional<Integer> maybeRequiredFlags,
                String endingTag)
                String configSection,
                String platform)
                throws IOException, XmlPullParserException {
                throws IOException, XmlPullParserException {
            int event;
            int event;
            while ((event = parser.next()) != XmlPullParser.END_DOCUMENT
            while ((event = parser.next()) != XmlPullParser.END_DOCUMENT
                    && !parser.getName().equals(endingTag)) {
                    && !parser.getName().equals(configSection)) {
                if (event != XmlPullParser.START_TAG) {
                if (event != XmlPullParser.START_TAG) {
                    continue;
                    continue;
                }
                }
                validateInnerTagContents(parser);
                validateInnerTagContents(parser, configSection);

                if (Flags.enableCrossPlatformTransfer()
                        && ConfigSection.CROSS_PLATFORM_TRANSFER.equals(configSection)
                        && parser.getName().equals(TAG_PLATFORM_SPECIFIC_PARAMS)) {
                    parsePlatformSpecificParamsTag(parser, platform, platformSpecificParamsMap);
                } else {
                    parseIncludeExcludeTag(parser, excludes, includes, maybeRequiredFlags);
                    parseIncludeExcludeTag(parser, excludes, includes, maybeRequiredFlags);
                }
                }
            }
            }
        }

        private void parsePlatformSpecificParamsTag(
                XmlPullParser parser,
                String platform,
                Map<String, PlatformSpecificParams> platformSpecificParamsMap) {
            if (TextUtils.isEmpty(platform)) {
                // Ignore the platform specific parameters if platform wasn't specified.
                return;
            }

            String bundleId =
                    parser.getAttributeValue(null, FLAG_PLATFORM_SPECIFIC_PARAMS_BUNDLE_ID);
            String teamId = parser.getAttributeValue(null, FLAG_PLATFORM_SPECIFIC_PARAMS_TEAM_ID);
            String contentVersion =
                    parser.getAttributeValue(null, FLAG_PLATFORM_SPECIFIC_PARAMS_CONTENT_VERSION);
            platformSpecificParamsMap.put(
                    platform, new PlatformSpecificParams(bundleId, teamId, contentVersion));
        }


        private void parseIncludeExcludeTag(
        private void parseIncludeExcludeTag(
                XmlPullParser parser,
                XmlPullParser parser,
@@ -1041,32 +1145,49 @@ public class FullBackup {
         * Let's be strict about the type of xml the client can write. If we see anything untoward,
         * Let's be strict about the type of xml the client can write. If we see anything untoward,
         * throw an XmlPullParserException.
         * throw an XmlPullParserException.
         */
         */
        private void validateInnerTagContents(XmlPullParser parser) throws XmlPullParserException {
        private void validateInnerTagContents(XmlPullParser parser, String configSection)
            if (parser == null) {
                throws XmlPullParserException {
            if (parser == null || parser.getName() == null) {
                return;
                return;
            }
            }
            switch (parser.getName()) {

                case TAG_INCLUDE:
            if (parser.getName().equals(TAG_INCLUDE)) {
                if (parser.getAttributeCount() > 3) {
                if (parser.getAttributeCount() > 3) {
                    throw new XmlPullParserException(
                    throw new XmlPullParserException(
                            "At most 3 tag attributes allowed for "
                            "At most 3 tag attributes allowed for "
                                    + "\"include\" tag (\"domain\" & \"path\""
                                    + "\"include\" tag (\"domain\" & \"path\""
                                    + " & optional \"requiredFlags\").");
                                    + " & optional \"requiredFlags\").");
                }
                }
                    break;
            } else if (parser.getName().equals(TAG_EXCLUDE)) {
                case TAG_EXCLUDE:
                if (parser.getAttributeCount() > 2) {
                if (parser.getAttributeCount() > 2) {
                    throw new XmlPullParserException(
                    throw new XmlPullParserException(
                            "At most 2 tag attributes allowed for "
                            "At most 2 tag attributes allowed for "
                                    + "\"exclude\" tag (\"domain\" & \"path\".");
                                    + "\"exclude\" tag (\"domain\" & \"path\".");
                }
                }
                    break;
            } else if (Flags.enableCrossPlatformTransfer()
                default:
                    && configSection.equals(ConfigSection.CROSS_PLATFORM_TRANSFER)
                    && parser.getName().equals(TAG_PLATFORM_SPECIFIC_PARAMS)) {
                if (parser.getAttributeCount() > 3) {
                    throw new XmlPullParserException(
                            "At most 3 tag attributes allowed for\"platform-specific-params\""
                                    + " tag (\"bundleId\" & \"teamId\" &"
                                    + " \"contentVersion\".");
                }
            } else {
                if (Flags.enableCrossPlatformTransfer()
                        && configSection.equals(ConfigSection.CROSS_PLATFORM_TRANSFER)) {
                    throw new XmlPullParserException(
                    throw new XmlPullParserException(
                            "A valid tag is one of \"<include/>\" or"
                            "A valid tag is one of \"<include/>\" or \"<exclude/>\" or"
                                    + " \"<exclude/>. You provided \""
                                    + " \"<platform-specific-params/>\". You provided \""
                                    + parser.getName()
                                    + parser.getName()
                                    + "\"");
                                    + "\"");
                } else {
                    throw new XmlPullParserException(
                            "A valid tag is one of \"<include/>\" or \"<exclude/>\". You provided"
                                    + " \""
                                    + parser.getName()
                                    + "\"");
                }
            }
            }
        }
        }
    }
    }
+153 −2
Original line number Original line Diff line number Diff line
@@ -17,14 +17,23 @@
package android.app.backup;
package android.app.backup;


import static android.app.backup.FullBackup.ConfigSection.CLOUD_BACKUP;
import static android.app.backup.FullBackup.ConfigSection.CLOUD_BACKUP;
import static android.app.backup.FullBackup.ConfigSection.CROSS_PLATFORM_TRANSFER;

import static com.google.common.truth.Truth.assertThat;


import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;


import android.app.backup.FullBackup.BackupScheme.PathWithRequiredFlags;
import android.app.backup.FullBackup.BackupScheme.PathWithRequiredFlags;
import android.app.backup.FullBackup.BackupScheme.PlatformSpecificParams;
import android.content.Context;
import android.content.Context;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
import android.util.ArrayMap;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.ArraySet;


@@ -32,7 +41,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.platform.app.InstrumentationRegistry;


import com.android.server.backup.Flags;

import org.junit.Before;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runner.RunWith;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParser;
@@ -50,12 +62,16 @@ import java.util.Set;
@LargeTest
@LargeTest
@RunWith(AndroidJUnit4.class)
@RunWith(AndroidJUnit4.class)
public class FullBackupTest {
public class FullBackupTest {

    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();

    private XmlPullParserFactory mFactory;
    private XmlPullParserFactory mFactory;
    private XmlPullParser mXpp;
    private XmlPullParser mXpp;
    private Context mContext;
    private Context mContext;


    Map<String, Set<PathWithRequiredFlags>> includeMap;
    Map<String, Set<PathWithRequiredFlags>> includeMap;
    Set<PathWithRequiredFlags> excludesSet;
    Set<PathWithRequiredFlags> excludesSet;
    Map<String, PlatformSpecificParams> mPlatformSpecificParamsMap;


    @Before
    @Before
    public void setUp() throws Exception {
    public void setUp() throws Exception {
@@ -65,6 +81,7 @@ public class FullBackupTest {


        includeMap = new ArrayMap<>();
        includeMap = new ArrayMap<>();
        excludesSet = new ArraySet<>();
        excludesSet = new ArraySet<>();
        mPlatformSpecificParamsMap = new ArrayMap<>();
    }
    }


    @Test
    @Test
@@ -498,7 +515,7 @@ public class FullBackupTest {
        FullBackup.BackupScheme backupScheme = FullBackup.getBackupSchemeForTest(mContext);
        FullBackup.BackupScheme backupScheme = FullBackup.getBackupSchemeForTest(mContext);
        boolean result =
        boolean result =
                backupScheme.parseNewBackupSchemeFromXmlLocked(
                backupScheme.parseNewBackupSchemeFromXmlLocked(
                        mXpp, CLOUD_BACKUP, excludesSet, includeMap);
                        mXpp, CLOUD_BACKUP, excludesSet, includeMap, mPlatformSpecificParamsMap);


        assertTrue(result);
        assertTrue(result);
    }
    }
@@ -516,7 +533,7 @@ public class FullBackupTest {
        FullBackup.BackupScheme backupScheme = FullBackup.getBackupSchemeForTest(mContext);
        FullBackup.BackupScheme backupScheme = FullBackup.getBackupSchemeForTest(mContext);
        boolean result =
        boolean result =
                backupScheme.parseNewBackupSchemeFromXmlLocked(
                backupScheme.parseNewBackupSchemeFromXmlLocked(
                        mXpp, CLOUD_BACKUP, excludesSet, includeMap);
                        mXpp, CLOUD_BACKUP, excludesSet, includeMap, mPlatformSpecificParamsMap);


        assertTrue(result);
        assertTrue(result);
        assertEquals(
        assertEquals(
@@ -556,4 +573,138 @@ public class FullBackupTest {


        assertEquals("Didn't throw away invalid path containing \"//\".", 0, excludesSet.size());
        assertEquals("Didn't throw away invalid path containing \"//\".", 0, excludesSet.size());
    }
    }

    @Test
    @EnableFlags({Flags.FLAG_ENABLE_CROSS_PLATFORM_TRANSFER})
    public void testParseOldBackupSchemeFromXml_flagOn_crossPlatformTransferSection_throws()
            throws Exception {
        mXpp.setInput(
                new StringReader(
                        "<full-backup-content><platform-specific-params"
                            + " bundleId=\"com.example.app\" teamId=\"0\" contentVersion=\"1.0\" />"
                            + "</full-backup-content>"));
        FullBackup.BackupScheme bs = FullBackup.getBackupSchemeForTest(mContext);

        assertThrows(
                "Unexpected platform-specific-params in old scheme should throw an exception",
                XmlPullParserException.class,
                () -> bs.parseBackupSchemeFromXmlLocked(mXpp, excludesSet, includeMap));
    }

    @Test
    @DisableFlags({Flags.FLAG_ENABLE_CROSS_PLATFORM_TRANSFER})
    public void testParseNewBackupSchemeFromXml_flagOff_crossPlatformTransferSection_throws()
            throws Exception {
        mXpp.setInput(
                new StringReader(
                        "<data-extraction-rules><cross-platform-transfer platform=\"ios\"><include"
                                + " path=\"file.txt\" domain=\"file\" /><platform-specific-params"
                                + " bundleId=\"com.example.app\" teamId=\"0123abcd\""
                                + " contentVersion=\"1.0\" /></cross-platform-transfer>"
                                + "</data-extraction-rules>"));
        FullBackup.BackupScheme bs =
                FullBackup.getBackupSchemeForTest(
                        mContext, BackupAnnotations.BackupDestination.CROSS_PLATFORM_TRANSFER);

        assertThrows(
                "cross-platform-transfer in backup rules when feature is disabled should throw an"
                    + " exception",
                XmlPullParserException.class,
                () ->
                        bs.parseNewBackupSchemeFromXmlLocked(
                                mXpp,
                                CROSS_PLATFORM_TRANSFER,
                                excludesSet,
                                includeMap,
                                mPlatformSpecificParamsMap));
    }

    @Test
    @EnableFlags({Flags.FLAG_ENABLE_CROSS_PLATFORM_TRANSFER})
    public void testParseNewBackupSchemeFromXml_flagOn_crossPlatformTransferSection_isParsed()
            throws Exception {
        assumeTrue(Flags.enableCrossPlatformTransfer());
        mXpp.setInput(
                new StringReader(
                        "<data-extraction-rules><cross-platform-transfer platform=\"ios\"><include"
                                + " path=\"file.txt\" domain=\"file\" /><platform-specific-params"
                                + " bundleId=\"com.example.app\" teamId=\"0123abcd\""
                                + " contentVersion=\"1.0\" /></cross-platform-transfer>"
                                + "</data-extraction-rules>"));
        FullBackup.BackupScheme bs =
                FullBackup.getBackupSchemeForTest(
                        mContext, BackupAnnotations.BackupDestination.CROSS_PLATFORM_TRANSFER);

        boolean result =
                bs.parseNewBackupSchemeFromXmlLocked(
                        mXpp,
                        CROSS_PLATFORM_TRANSFER,
                        excludesSet,
                        includeMap,
                        mPlatformSpecificParamsMap);

        assertTrue(result);
        assertThat(mPlatformSpecificParamsMap).containsKey("ios");
        PlatformSpecificParams actual = mPlatformSpecificParamsMap.get("ios");
        assertThat(actual.getBundleId()).isEqualTo("com.example.app");
        assertThat(actual.getTeamId()).isEqualTo("0123abcd");
        assertThat(actual.getContentVersion()).isEqualTo("1.0");
    }

    @Test
    @EnableFlags({Flags.FLAG_ENABLE_CROSS_PLATFORM_TRANSFER})
    public void testParseNewBackupSchemeFromXml_flagOn_missingPlatform_isParsedButIgnored()
            throws Exception {
        assumeTrue(Flags.enableCrossPlatformTransfer());
        mXpp.setInput(
                new StringReader(
                        "<data-extraction-rules><cross-platform-transfer><include"
                                + " path=\"file.txt\" domain=\"file\" /><platform-specific-params"
                                + " bundleId=\"com.example.app\" teamId=\"0123abcd\""
                                + " contentVersion=\"1.0\" /></cross-platform-transfer>"
                                + "</data-extraction-rules>"));
        FullBackup.BackupScheme bs =
                FullBackup.getBackupSchemeForTest(
                        mContext, BackupAnnotations.BackupDestination.CROSS_PLATFORM_TRANSFER);

        boolean result =
                bs.parseNewBackupSchemeFromXmlLocked(
                        mXpp,
                        CROSS_PLATFORM_TRANSFER,
                        excludesSet,
                        includeMap,
                        mPlatformSpecificParamsMap);

        assertTrue(result);
        assertThat(mPlatformSpecificParamsMap).isEmpty();
    }

    @Test
    @EnableFlags({Flags.FLAG_ENABLE_CROSS_PLATFORM_TRANSFER})
    public void testParseNewBackupSchemeFromXml_flagOn_invalidAttributeCount_throws()
            throws Exception {
        assumeTrue(Flags.enableCrossPlatformTransfer());
        mXpp.setInput(
                new StringReader(
                        "<data-extraction-rules><cross-platform-transfer platform=\"ios\"><include"
                                + " path=\"file.txt\" domain=\"file\" /><platform-specific-params"
                                + " bundleId=\"com.example.app\" teamId=\"0123abcd\""
                                + " contentVersion=\"1.0\" platform=\"ios\" />"
                                + "</cross-platform-transfer>"
                                + "</data-extraction-rules>"));
        FullBackup.BackupScheme bs =
                FullBackup.getBackupSchemeForTest(
                        mContext, BackupAnnotations.BackupDestination.CROSS_PLATFORM_TRANSFER);

        assertThrows(
                "invalid number of attributes in platform-specific-params should throw",
                XmlPullParserException.class,
                () ->
                        bs.parseNewBackupSchemeFromXmlLocked(
                                mXpp,
                                CROSS_PLATFORM_TRANSFER,
                                excludesSet,
                                includeMap,
                                mPlatformSpecificParamsMap));
    }
}
}