Delete leftover AdServices files using a one-time boot-completed receiver

AdServices code is included in the ExtServices APK on S-, and generates multiple files that are stored in the data directory during normal operation. After OTA to T, the AdService code is removed from the ExtServices APK, but these data files are left behind. We're adding a new boot-completed receiver that's only enabled on T+ which will delete these files on reboot. After successful delete, the receiver disables itself since there's no need for it to run again.

Bug: 294912203
Test: atest, manual

Change-Id: I0fa1d452c4928cbc1c43efa5ba70124b75c12fd7
diff --git a/Android.bp b/Android.bp
index 0014ddb..1178044 100644
--- a/Android.bp
+++ b/Android.bp
@@ -41,7 +41,7 @@
         "java/res",
     ],
 
-    manifest: "AndroidManifest.xml",
+    manifest: "EmptyManifest.xml",
 
     static_libs: [
         "androidx.annotation_annotation",
@@ -92,6 +92,7 @@
     name: "ExtServices-sminus",
     sdk_version: "module_current",
     min_sdk_version: "30",
+    manifest: "AndroidManifest.xml",
     optimize: {
         optimize: true,
         shrink_resources: true,
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 6af3a30..e914f56 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -36,6 +36,8 @@
     <!-- Remove unused permissions merged from WorkManager library -->
     <uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
+    <!-- Need this permission to receive the Boot-Completed broadcast -->
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
 
     <application
         android:name=".ExtServicesApplication"
@@ -159,5 +161,13 @@
             android:name="androidx.startup.InitializationProvider"
             android:authorities="${applicationId}.androidx-startup"
             tools:node="remove" />
+
+        <receiver android:name=".common.AdServicesFilesCleanupBootCompleteReceiver"
+                  android:enabled="@bool/enableAdServicesDataCleanupReceiver"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED"/>
+            </intent-filter>
+        </receiver>
     </application>
 </manifest>
diff --git a/EmptyManifest.xml b/EmptyManifest.xml
new file mode 100644
index 0000000..66cc859
--- /dev/null
+++ b/EmptyManifest.xml
@@ -0,0 +1,20 @@
+<?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.
+-->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.ext.services">
+</manifest>
diff --git a/java/res/values-v33/bools.xml b/java/res/values-v33/bools.xml
new file mode 100644
index 0000000..fb9ea77
--- /dev/null
+++ b/java/res/values-v33/bools.xml
@@ -0,0 +1,20 @@
+<?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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- Overriding the default value to only run the AdServices cleanup receiver on Android T+ -->
+    <bool name="enableAdServicesDataCleanupReceiver">true</bool>
+</resources>
diff --git a/java/res/values/bools.xml b/java/res/values/bools.xml
new file mode 100644
index 0000000..c08d918
--- /dev/null
+++ b/java/res/values/bools.xml
@@ -0,0 +1,23 @@
+<?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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- By default, we do not want the cleanup receiver to run. This needs to only run on Android
+    T+ because it's meant to clean up AdServices files in the ExtServices apk data directory after
+    OTA from S and lower. Running this receiver on lower Android versions will delete files that
+    should not be deleted-->
+    <bool name="enableAdServicesDataCleanupReceiver">false</bool>
+</resources>
diff --git a/java/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiver.java b/java/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiver.java
new file mode 100644
index 0000000..b33be29
--- /dev/null
+++ b/java/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiver.java
@@ -0,0 +1,193 @@
+/*
+ * 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 android.ext.services.common;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.provider.DeviceConfig;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.io.File;
+import java.util.function.ToIntBiFunction;
+
+/**
+ * Handles the BootCompleted initialization for ExtServices APK on T+.
+ * <p>
+ * The BootCompleted receiver deletes files created by the AdServices code on S- that persist on
+ * disk after an OTA to T+. Once these files are deleted, this receiver disables itself.
+ * <p>
+ * Since this receiver disables itself after the first run, it will not be re-run after any code
+ * changes to this class. In order to re-enable this receiver and run the updated code, the simplest
+ * way is to rename the class every upon every module release that changes the code. Also, in order
+ * to protect against accidental name re-use, the {@code testReceiverDoesNotReuseClassNames} unit
+ * test tracking used names should be updated upon each rename as well.
+ */
+public class AdServicesFilesCleanupBootCompleteReceiver extends BroadcastReceiver {
+    private static final String TAG = "extservices";
+    private static final String KEY_RECEIVER_ENABLED =
+            "extservices_adservices_data_cleanup_enabled";
+
+    // All files created by the AdServices code within ExtServices should have this prefix.
+    private static final String ADSERVICES_PREFIX = "adservices";
+
+    @SuppressWarnings("ReturnValueIgnored") // Intentionally ignoring return value of Log.d/Log.e
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        Log.i(TAG, "AdServices files cleanup receiver received BOOT_COMPLETED broadcast");
+
+        // Check if the feature flag is enabled, otherwise exit without doing anything.
+        if (!isReceiverEnabled()) {
+            Log.d(TAG, "AdServices files cleanup receiver not enabled in config, exiting");
+            return;
+        }
+
+        try {
+            // Look through and delete any files in the data dir that have the `adservices` prefix
+            boolean success = deleteAdServicesFiles(context.getDataDir());
+
+            // Log as `d` or `e` depending on success or failure.
+            ToIntBiFunction<String, String> function = success ? Log::d : Log::e;
+            function.applyAsInt(TAG,
+                    "AdServices files cleanup receiver data deletion success: " + success);
+        } finally {
+            unregisterSelf(context);
+        }
+    }
+
+    private void unregisterSelf(Context context) {
+        context.getPackageManager().setComponentEnabledSetting(
+                new ComponentName(context, this.getClass()),
+                PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+                /* flags= */ 0);
+        Log.d(TAG, "Disabled AdServices files cleanup receiver");
+    }
+
+    @VisibleForTesting
+    public boolean isReceiverEnabled() {
+        return DeviceConfig.getBoolean(
+                DeviceConfig.NAMESPACE_ADSERVICES,
+                /* name= */ KEY_RECEIVER_ENABLED,
+                /* defaultValue= */ true);
+    }
+
+    /**
+     * Recursively delete all files with a prefix of "adservices" from the specified directory.
+     * <p>
+     * Note: It expects the input File object to be a directory and not a regular file. Also,
+     * it only deletes the contents of the input directory, and not the directory itself, even if
+     * the name of the directory starts with the prefix.
+     *
+     * @param currentDirectory the directory to scan for files
+     * @return {@code true} if all adservices files were successfully deleted; else {@code false}.
+     */
+    @VisibleForTesting
+    public boolean deleteAdServicesFiles(File currentDirectory) {
+        if (currentDirectory == null) {
+            Log.d(TAG, "Argument passed to deleteAdServicesFiles is null");
+            return true;
+        }
+
+        try {
+            if (!currentDirectory.isDirectory()) {
+                Log.d(TAG, "Argument passed to deleteAdServicesFiles is not a directory");
+                return true;
+            }
+
+            boolean allSuccess = true;
+
+            File[] files = currentDirectory.listFiles();
+            for (File file : files) {
+                if (file.isDirectory()) {
+                    // Delete ALL data if the directory name starts with the adservices prefix.
+                    // Otherwise, delete any file in the subtree that starts with the prefix.
+                    if (doesFileNameStartWithPrefix(file)) {
+                        // Directory starting with adservices, so delete everything inside it.
+                        allSuccess = deleteAllData(file) && allSuccess;
+                    } else {
+                        // Directory but not starting with adservices, so only delete adservices
+                        // files.
+                        allSuccess = deleteAdServicesFiles(file) && allSuccess;
+                    }
+                } else if (doesFileNameStartWithPrefix(file)) {
+                    allSuccess = safeDelete(file) && allSuccess;
+                }
+            }
+
+            return allSuccess;
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Error deleting directory " + currentDirectory.getName(), e);
+            return false;
+        }
+    }
+
+    private boolean doesFileNameStartWithPrefix(File file) {
+        // Do a case-insensitive comparison
+        return ADSERVICES_PREFIX.regionMatches(
+                /* ignoreCase= */ true,
+                /* toOffset= */ 0,
+                file.getName(),
+                /* ooffset= */ 0,
+                /* len= */ ADSERVICES_PREFIX.length());
+    }
+
+    private boolean deleteAllData(File currentDirectory) {
+        if (currentDirectory == null) {
+            Log.d(TAG, "Argument passed to deleteAllData is null");
+            return true;
+        }
+
+        try {
+            if (!currentDirectory.isDirectory()) {
+                Log.d(TAG, "Argument passed to deleteAllData is not a directory");
+                return true;
+            }
+
+            boolean allSuccess = true;
+
+            for (File file : currentDirectory.listFiles()) {
+                allSuccess = (file.isDirectory() ? deleteAllData(file) : safeDelete(file))
+                        && allSuccess;
+            }
+
+            // If deleting the entire subdirectory has been successful, then (and only then) delete
+            // the current directory.
+            allSuccess = allSuccess && safeDelete(currentDirectory);
+
+            return allSuccess;
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Error deleting directory " + currentDirectory.getName(), e);
+            return false;
+        }
+    }
+
+    private boolean safeDelete(File file) {
+        try {
+            return file.delete();
+        } catch (RuntimeException e) {
+            String message = String.format(
+                    "AdServices files cleanup receiver: Error deleting %s - %s", file.getName(),
+                    e.getMessage());
+            Log.e(TAG, message, e);
+            return false;
+        }
+    }
+}
diff --git a/java/tests/hosttests/Android.bp b/java/tests/hosttests/Android.bp
new file mode 100644
index 0000000..c2f3fec
--- /dev/null
+++ b/java/tests/hosttests/Android.bp
@@ -0,0 +1,59 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_test_host {
+    name: "CtsExtServicesHostTests-tplus",
+    defaults: ["cts_defaults"],
+    srcs: ["src/**/*.java"],
+    // tag this module as a cts test artifact
+    test_suites: [
+        "cts",
+        "general-tests",
+        "mts-extservices",
+    ],
+    libs: [
+        "cts-tradefed",
+        "tradefed",
+    ],
+    static_libs: [
+        "androidx.annotation_annotation",
+    ],
+    per_testcase_directory: true,
+    test_config: "AndroidTest-tplus.xml"
+}
+
+java_test_host {
+    name: "CtsExtServicesHostTests-sminus",
+    defaults: ["cts_defaults"],
+    srcs: ["src/**/*.java"],
+    // tag this module as a cts test artifact
+    test_suites: [
+        "cts",
+        "general-tests",
+        "mts-extservices",
+    ],
+    libs: [
+        "cts-tradefed",
+        "tradefed",
+    ],
+    static_libs: [
+        "androidx.annotation_annotation",
+    ],
+    per_testcase_directory: true,
+    test_config: "AndroidTest-sminus.xml"
+}
diff --git a/java/tests/hosttests/AndroidTest-sminus.xml b/java/tests/hosttests/AndroidTest-sminus.xml
new file mode 100644
index 0000000..d7f1efc
--- /dev/null
+++ b/java/tests/hosttests/AndroidTest-sminus.xml
@@ -0,0 +1,53 @@
+<?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.
+-->
+<configuration description="Runs CTS Host Tests for ExtServices">
+    <option name="test-suite-tag" value="cts" />
+    <option name="config-descriptor:metadata" key="component" value="framework" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="no_foldable_states" />
+    <!-- Needed for correctly being picked up in presubmit -->
+    <option name="config-descriptor:metadata" key="mainline-param"
+            value="com.google.android.extservices.apex" />
+
+    <test class="com.android.compatibility.common.tradefed.testtype.JarHostTest" >
+        <option name="jar" value="CtsExtServicesHostTests-sminus.jar" />
+    </test>
+
+    <!-- TODO(b/297207132) use AdServicesFlagsSetterRule instead of the target preparer -->
+    <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+        <option name="run-command" value="setprop log.tag.extservices VERBOSE"/>
+        <!-- Temporarily disable Device Config sync -->
+        <option name="run-command" value="device_config set_sync_disabled_for_tests persistent" />
+        <option name="teardown-command" value="device_config set_sync_disabled_for_tests none" />
+    </target_preparer>
+
+    <!-- Prevent tests from running on Android Q- -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk30ModuleController"/>
+
+    <!-- Prevent test from running on Android T+ -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MaxSdkModuleController">
+        <option name="max-sdk-level" value="32"/>
+    </object>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.extservices"/>
+    </object>
+</configuration>
diff --git a/java/tests/hosttests/AndroidTest-tplus.xml b/java/tests/hosttests/AndroidTest-tplus.xml
new file mode 100644
index 0000000..aeb59e5
--- /dev/null
+++ b/java/tests/hosttests/AndroidTest-tplus.xml
@@ -0,0 +1,47 @@
+<?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.
+-->
+<configuration description="Runs CTS Host Tests for ExtServices">
+    <option name="test-suite-tag" value="cts" />
+    <option name="config-descriptor:metadata" key="component" value="framework" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="no_foldable_states" />
+    <!-- Needed for correctly being picked up in presubmit -->
+    <option name="config-descriptor:metadata" key="mainline-param"
+            value="com.google.android.extservices-tplus.apex" />
+
+    <test class="com.android.compatibility.common.tradefed.testtype.JarHostTest" >
+        <option name="jar" value="CtsExtServicesHostTests-tplus.jar" />
+    </test>
+
+    <!-- TODO(b/297207132) use AdServicesFlagsSetterRule instead of the target preparer -->
+    <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+        <option name="run-command" value="setprop log.tag.extservices VERBOSE"/>
+        <!-- Temporarily disable Device Config sync -->
+        <option name="run-command" value="device_config set_sync_disabled_for_tests persistent" />
+        <option name="teardown-command" value="device_config set_sync_disabled_for_tests none" />
+    </target_preparer>
+
+    <!-- Prevent tests from running on Android S- -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk33ModuleController"/>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.extservices"/>
+    </object>
+</configuration>
diff --git a/java/tests/hosttests/src/android/ext/services/hosttests/AdServicesFilesCleanupBootCompleteReceiverHostTest.java b/java/tests/hosttests/src/android/ext/services/hosttests/AdServicesFilesCleanupBootCompleteReceiverHostTest.java
new file mode 100644
index 0000000..a4e1b66
--- /dev/null
+++ b/java/tests/hosttests/src/android/ext/services/hosttests/AdServicesFilesCleanupBootCompleteReceiverHostTest.java
@@ -0,0 +1,253 @@
+/*
+ * 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 android.ext.services.hosttests;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.ext.services.hosttests.utils.ExtServicesLogcatReceiver;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.PackageInfo;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.IDeviceTest;
+
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+// TODO(b/297207132) - extend AdServicesHostSideTestCase instead.
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class AdServicesFilesCleanupBootCompleteReceiverHostTest implements IDeviceTest {
+    private static final String EXTSERVICES_PACKAGE_SUFFIX = "android.ext.services";
+    private static final String CLEANUP_RECEIVER_CLASS_NAME =
+            "android.ext.services.common.AdServicesFilesCleanupBootCompleteReceiver";
+    private static final String LOGCAT_COMMAND = "logcat -s extservices";
+    private static final String RECEIVER_DISABLED_LOG_TEXT =
+            "Disabled AdServices files cleanup receiver";
+    private static final String RECEIVER_EXECUTED_LOG_TEXT = "AdServices files cleanup receiver";
+
+    private ITestDevice mDevice;
+    private String mAdServicesFilePath;
+    private String mExtServicesPackageName;
+
+    @Override
+    public void setDevice(ITestDevice device) {
+        mDevice = device;
+    }
+
+    @Override
+    public ITestDevice getDevice() {
+        return mDevice;
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        overridePhSync();
+
+        ITestDevice device = getDevice();
+
+        // Find the extservices package
+        PackageInfo extServicesPackage =
+                device.getAppPackageInfos().stream()
+                        .filter(s -> s.getPackageName().endsWith(EXTSERVICES_PACKAGE_SUFFIX))
+                        .findFirst()
+                        .orElse(null);
+        assertWithMessage("ExtServices package").that(extServicesPackage).isNotNull();
+        mExtServicesPackageName = extServicesPackage.getPackageName();
+
+        // Put some data in the ExtServices apk
+        mAdServicesFilePath =
+                String.format(
+                        "/data/user/%d/%s/adservices_data.txt",
+                        device.getCurrentUser(), extServicesPackage.getPackageName());
+        String dataPutCommand = String.format("echo \"Hello\" > %s", mAdServicesFilePath);
+        device.executeShellCommand(dataPutCommand);
+        assertWithMessage("%s exists", mAdServicesFilePath)
+                .that(device.doesFileExist(mAdServicesFilePath))
+                .isTrue();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        resetPhSync();
+
+        if (mDevice != null && mAdServicesFilePath != null
+                && mDevice.doesFileExist(mAdServicesFilePath)) {
+            mDevice.deleteFile(mAdServicesFilePath);
+        }
+    }
+
+    @Test
+    public void testReceiver_doesNotExecuteOnSMinus() throws Exception {
+        // TODO(b/297207132) - use SdkLevelSupportRule instead of this manual check
+        Assume.assumeTrue(getDevice().getApiLevel() < 33); // Run only on Android S-
+
+        ITestDevice device = getDevice();
+
+        // TODO(b/297207132) - use a rule instead of this shell command
+        // Enable the flag that the receiver checks. By default, the flag is enabled in the binary,
+        // so it's enough to just delete the flag override, if any.
+        device.executeShellCommand(
+                "device_config delete adservices extservices_adservices_data_cleanup_enabled");
+
+        // Reboot, wait, and verify logs.
+        verifyReceiverDidNotExecute(device);
+
+        // Verify that adservices files are still present.
+        assertWithMessage("%s exists", mAdServicesFilePath)
+                .that(device.doesFileExist(mAdServicesFilePath))
+                .isTrue();
+    }
+
+    @Test
+    public void testReceiver_deletesFiles() throws Exception {
+        // TODO(b/297207132) - use SdkLevelSupportRule instead of this manual check
+        Assume.assumeTrue(getDevice().getApiLevel() >= 33); // Run only on Android T+
+
+        ITestDevice device = getDevice();
+
+        // Re-enable the cleanup receiver in case it's been disabled due to a prior run
+        enableReceiver(device);
+
+        // Enable the flag that the receiver checks. By default, the flag is enabled in the binary,
+        // so it's enough to just delete the flag override, if any.
+        device.executeShellCommand(
+                "device_config delete adservices extservices_adservices_data_cleanup_enabled");
+
+        // Reboot, wait, and verify logs.
+        verifyReceiverExecuted(device);
+
+        // Verify that all adservices files were deleted.
+        assertWithMessage("%s exists", mAdServicesFilePath)
+                .that(device.doesFileExist(mAdServicesFilePath))
+                .isFalse();
+
+        String lsCommand =
+                String.format(
+                        "ls /data/user/%d/%s -R", device.getCurrentUser(), mExtServicesPackageName);
+        String lsOutput = device.executeShellCommand(lsCommand).toLowerCase(Locale.ROOT);
+        assertWithMessage("Output of %s", lsCommand).that(lsOutput).doesNotContain("adservices");
+
+        // Verify that after a reboot the receiver does not execute
+        verifyReceiverDidNotExecute(device);
+    }
+
+    @Test
+    public void testReceiver_doesNotExecuteIfFlagDisabled() throws Exception {
+        // TODO(b/297207132) - use SdkLevelSupportRule instead of this manual check
+        Assume.assumeTrue(getDevice().getApiLevel() >= 33); // Run only on Android T+
+
+        ITestDevice device = getDevice();
+
+        // Re-enable the cleanup receiver in case it's been disabled due to a prior run
+        enableReceiver(device);
+
+        // Disable the flag that the receiver checks
+        device.executeShellCommand(
+                "device_config put adservices extservices_adservices_data_cleanup_enabled false");
+
+        // Verify that after a reboot the receiver executes but doesn't disable itself
+        ExtServicesLogcatReceiver logcatReceiver =
+                rebootDeviceAndCollectLogs(device, RECEIVER_DISABLED_LOG_TEXT);
+        Pattern errorPattern = Pattern.compile(makePattern(RECEIVER_DISABLED_LOG_TEXT));
+        assertWithMessage("Presence of log indicating receiver disabled itself")
+                .that(logcatReceiver.patternMatches(errorPattern))
+                .isFalse();
+
+        // Verify that the file is still there and that the receiver didn't delete it.
+        assertWithMessage("%s exists", mAdServicesFilePath)
+                .that(device.doesFileExist(mAdServicesFilePath))
+                .isTrue();
+    }
+
+    private void verifyReceiverExecuted(ITestDevice device)
+            throws DeviceNotAvailableException, InterruptedException {
+        ExtServicesLogcatReceiver logcatReceiver =
+                rebootDeviceAndCollectLogs(device, RECEIVER_DISABLED_LOG_TEXT);
+        Pattern errorPattern = Pattern.compile(makePattern(RECEIVER_DISABLED_LOG_TEXT));
+        assertWithMessage("Presence of log indicating receiver disabled itself")
+                .that(logcatReceiver.patternMatches(errorPattern))
+                .isTrue();
+    }
+
+    private void verifyReceiverDidNotExecute(ITestDevice device)
+            throws DeviceNotAvailableException, InterruptedException {
+        ExtServicesLogcatReceiver logcatReceiver =
+                rebootDeviceAndCollectLogs(device, RECEIVER_EXECUTED_LOG_TEXT);
+
+        Pattern errorPattern = Pattern.compile(makePattern(RECEIVER_EXECUTED_LOG_TEXT));
+        assertWithMessage("Presence of log indicating receiver was invoked")
+                .that(logcatReceiver.patternMatches(errorPattern))
+                .isFalse();
+    }
+
+    private Predicate<String[]> stopIfTextOccurs(String toMatch) {
+        return (s) -> Arrays.stream(s).anyMatch(t -> t.contains(toMatch));
+    }
+
+    private ExtServicesLogcatReceiver rebootDeviceAndCollectLogs(ITestDevice device, String text)
+            throws DeviceNotAvailableException, InterruptedException {
+        // reboot the device
+        device.reboot();
+        device.waitForDeviceAvailable();
+
+        // Enable verbose logs
+        // TODO(b/297207132) - add to the rule instead of this shell command
+        device.executeShellCommand("setprop log.tag.extservices VERBOSE");
+
+        // Start log collection
+        ExtServicesLogcatReceiver logcatReceiver =
+                new ExtServicesLogcatReceiver.Builder()
+                        .setDevice(device)
+                        .setLogCatCommand(LOGCAT_COMMAND)
+                        .setEarlyStopCondition(stopIfTextOccurs(text))
+                        .build();
+        logcatReceiver.collectLogs(/* timeoutMilliseconds= */ 5 * 60 * 1000); // Wait up to 5 mins
+        return logcatReceiver;
+    }
+
+    private String makePattern(String text) {
+        return ".*" + text + ".*";
+    }
+
+    private void overridePhSync() throws DeviceNotAvailableException {
+        getDevice()
+                .executeShellCommand(
+                        "device_config put adservices set_sync_disabled_for_tests persistent");
+    }
+
+    private void resetPhSync() throws DeviceNotAvailableException {
+        getDevice()
+                .executeShellCommand(
+                        "device_config put adservices set_sync_disabled_for_tests none");
+    }
+
+    private void enableReceiver(ITestDevice device) throws DeviceNotAvailableException {
+        String enableCommand =
+                String.format(
+                        "pm enable %s/%s", mExtServicesPackageName, CLEANUP_RECEIVER_CLASS_NAME);
+        device.executeShellCommand(enableCommand);
+    }
+}
diff --git a/java/tests/hosttests/src/android/ext/services/hosttests/utils/ExtServicesLogcatReceiver.java b/java/tests/hosttests/src/android/ext/services/hosttests/utils/ExtServicesLogcatReceiver.java
new file mode 100644
index 0000000..cd2e892
--- /dev/null
+++ b/java/tests/hosttests/src/android/ext/services/hosttests/utils/ExtServicesLogcatReceiver.java
@@ -0,0 +1,133 @@
+/*
+ * 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 android.ext.services.hosttests.utils;
+
+import com.android.ddmlib.MultiLineReceiver;
+import com.android.tradefed.device.BackgroundDeviceAction;
+import com.android.tradefed.device.ITestDevice;
+
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+/**
+ * Enables capturing device logs and exposing them to the host test.
+ */
+// TODO(b/288892905) consolidate with existing logcat receiver
+public final class ExtServicesLogcatReceiver extends MultiLineReceiver {
+    private volatile boolean mCancelled;
+    private final StringBuilder mLogOutputStringBuilder = new StringBuilder();
+    private final CountDownLatch mCountDownLatch = new CountDownLatch(1);
+    private final String mName;
+    private final String mLogcatCmd;
+    private final ITestDevice mTestDevice;
+    private final Predicate<String[]> mEarlyStopCondition;
+    private BackgroundDeviceAction mBackgroundDeviceAction;
+
+    private ExtServicesLogcatReceiver(String name, String logcatCmd, ITestDevice device,
+            Predicate<String[]> earlyStopCondition) {
+        mName = name;
+        mLogcatCmd = logcatCmd;
+        mEarlyStopCondition = earlyStopCondition;
+        mTestDevice = device;
+    }
+
+    @Override
+    public void processNewLines(String[] lines) {
+        if (lines.length == 0) {
+            return;
+        }
+        mLogOutputStringBuilder.append(String.join("\n", lines));
+
+        if (mEarlyStopCondition != null && mEarlyStopCondition.test(lines)) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Override
+    public boolean isCancelled() {
+        return mCancelled;
+    }
+
+    /**
+     * Begins log collection. This method needs to be only used once per instance.
+     *
+     * @param timeoutMilliseconds the maximum time after which log collection should stop, if the
+     *                            early stop condition was not encountered previously.
+     * @return true if log collection stopped because the early stop condition was encountered,
+     * false if log collection stopped due to timeout
+     * @throws InterruptedException if the current thread is interrupted
+     */
+    public boolean collectLogs(long timeoutMilliseconds) throws InterruptedException {
+        if (mBackgroundDeviceAction != null) {
+            throw new IllegalStateException("This method should only be called once per instance");
+        }
+
+        mBackgroundDeviceAction = new BackgroundDeviceAction(mLogcatCmd, mName, mTestDevice, this,
+                0);
+        mBackgroundDeviceAction.start();
+
+        boolean earlyStop = mCountDownLatch.await(timeoutMilliseconds, TimeUnit.MILLISECONDS);
+        stop();
+
+        return earlyStop;
+    }
+
+    private void stop() {
+        if (mBackgroundDeviceAction != null) mBackgroundDeviceAction.cancel();
+        if (isCancelled()) return;
+        mCancelled = true;
+    }
+
+    public boolean patternMatches(Pattern pattern) {
+        return mLogOutputStringBuilder.length() > 0
+                && pattern.matcher(mLogOutputStringBuilder).find();
+    }
+
+    public static final class Builder {
+        private ITestDevice mDevice;
+        private String mLogCatCommand;
+        private Predicate<String[]> mEarlyStopCondition;
+
+        public Builder setDevice(ITestDevice device) {
+            Objects.requireNonNull(device);
+            mDevice = device;
+            return this;
+        }
+
+        public Builder setLogCatCommand(String command) {
+            Objects.requireNonNull(command);
+            mLogCatCommand = command;
+            return this;
+        }
+
+        public Builder setEarlyStopCondition(Predicate<String[]> earlyStopCondition) {
+            mEarlyStopCondition = earlyStopCondition;
+            return this;
+        }
+
+        public ExtServicesLogcatReceiver build() {
+            Objects.requireNonNull(mDevice);
+            Objects.requireNonNull(mLogCatCommand);
+
+            return new ExtServicesLogcatReceiver("extservices-logcat-receiver", mLogCatCommand,
+                    mDevice, mEarlyStopCondition);
+        }
+    }
+}
diff --git a/java/tests/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiverTest.java b/java/tests/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiverTest.java
new file mode 100644
index 0000000..0743570
--- /dev/null
+++ b/java/tests/src/android/ext/services/common/AdServicesFilesCleanupBootCompleteReceiverTest.java
@@ -0,0 +1,349 @@
+/*
+ * 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 android.ext.services.common;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.any;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.anyInt;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.never;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.verify;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.eq;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.sqlite.SQLiteDatabase;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.dx.mockito.inline.extended.ExtendedMockito;
+
+import com.google.common.truth.Expect;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.Spy;
+import org.mockito.quality.Strictness;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.List;
+
+public final class AdServicesFilesCleanupBootCompleteReceiverTest {
+    private static final String ADSERVICES_FILE_NAME = "adservices_file";
+    private static final String NON_ADSERVICES_FILE_NAME = "some_other_file";
+    private static final String NON_ADSERVICES_FILE_WITH_PREFIX_IN_NAME =
+            "some_file_with_adservices_in_name";
+    private static final String ADSERVICES_FILE_NAME_MIXED_CASE = "AdServicesFileMixedCase.txt";
+    private static final String NON_ADSERVICE_FILE_NAME_2 = "adservice_but_no_s.txt";
+
+    // Update this list with the previous name every time the receiver is renamed
+    private static final List<String> PREVIOUSLY_USED_CLASS_NAMES = List.of();
+
+    // TODO(b/297207132): Replace with AdServicesExtendedMockitoRule
+    private MockitoSession mMockitoSession;
+
+    @Spy
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+
+    @Spy
+    private AdServicesFilesCleanupBootCompleteReceiver mReceiver;
+
+    @Mock
+    private PackageManager mPackageManager;
+
+    @Rule
+    public final Expect expect = Expect.create();
+
+    @Before
+    public void setup() {
+        mMockitoSession = ExtendedMockito.mockitoSession()
+                .initMocks(this)
+                .strictness(Strictness.WARN)
+                .startMocking();
+
+        doReturn(mPackageManager).when(mContext).getPackageManager();
+    }
+
+    @After
+    public void tearDown() {
+        if (mMockitoSession != null) {
+            mMockitoSession.finishMocking();
+        }
+    }
+
+    @Test
+    public void testReceiverDoesNotReuseClassNames() {
+        assertThat(PREVIOUSLY_USED_CLASS_NAMES)
+                .doesNotContain(AdServicesFilesCleanupBootCompleteReceiver.class.getName());
+    }
+
+    @Test
+    public void testReceiverSkipsDeletionIfDisabled() {
+        mockReceiverEnabled(false);
+
+        mReceiver.onReceive(mContext, /* intent= */ null);
+
+        verify(mContext, never()).getDataDir();
+        verify(mContext, never()).getPackageManager();
+    }
+
+    @Test
+    public void testReceiverDisablesItselfIfDeleteSuccessful() {
+        mockReceiverEnabled(true);
+        doNothing().when(mPackageManager).setComponentEnabledSetting(any(), anyInt(), anyInt());
+        doReturn(true).when(mReceiver).deleteAdServicesFiles(any());
+
+        mReceiver.onReceive(mContext, /* intent= */ null);
+
+        verifyDisableComponentCalled();
+    }
+
+    @Test
+    public void testReceiverDisablesItselfIfDeleteUnsuccessful() {
+        mockReceiverEnabled(true);
+        doReturn(false).when(mReceiver).deleteAdServicesFiles(any());
+
+        mReceiver.onReceive(mContext, /* intent= */ null);
+
+        verifyDisableComponentCalled();
+    }
+
+    @Test
+    public void testReceiverDeletesAdServicesFiles() throws Exception {
+        List<String> adServicesNames = List.of(ADSERVICES_FILE_NAME,
+                ADSERVICES_FILE_NAME_MIXED_CASE);
+        List<String> nonAdServicesNames = List.of(NON_ADSERVICES_FILE_NAME,
+                NON_ADSERVICES_FILE_WITH_PREFIX_IN_NAME, NON_ADSERVICE_FILE_NAME_2);
+
+        try {
+            createFiles(adServicesNames);
+            createFiles(nonAdServicesNames);
+            createDatabases(adServicesNames);
+            createDatabases(nonAdServicesNames);
+
+            mReceiver.deleteAdServicesFiles(mContext.getDataDir());
+
+            // Check if the appropriate files were deleted
+            String[] remainingFiles = mContext.getFilesDir().list();
+            List<String> remainingFilesList = Arrays.asList(remainingFiles);
+            expect.that(remainingFilesList).containsNoneIn(adServicesNames);
+            expect.that(remainingFilesList).containsAtLeastElementsIn(nonAdServicesNames);
+            expectDatabasesExist(nonAdServicesNames);
+            expectDatabasesDoNotExist(adServicesNames);
+        } finally {
+            deleteFiles(adServicesNames);
+            deleteFiles(nonAdServicesNames);
+            deleteDatabases(adServicesNames);
+            deleteDatabases(nonAdServicesNames);
+        }
+    }
+
+    @Test
+    public void testReceiverDeletesAdServicesDirectories() throws Exception {
+        String dataRoot = "data_root";
+        Path root = mContext.getFilesDir().toPath();
+
+        try {
+            File file1 = createFile(root, dataRoot, "level_1.txt"); // Preserved
+            File file2 = createFile(root, dataRoot, "adservices_level_1.txt"); // Deleted
+            File file3 = createFile(root, dataRoot + "/non_adservices",
+                    "level_2.txt"); // Preserved
+            File file4 = createFile(root, dataRoot + "/non_adservices",
+                    "adservices_level_2.txt"); // Deleted
+            File file5 = createFile(root, dataRoot + "/non_adservices/adservices_nested",
+                    "level_3.txt"); // Deleted
+            File file6 = createFile(root, dataRoot + "/non_adservices/adservices_nested",
+                    "adservices.level_3.txt"); // Deleted
+            File file7 = createFile(root, dataRoot + "/non_adservices",
+                    "AdServices_level_2.txt"); // Deleted
+            File file8 = createFile(root, dataRoot + "/adservices-data",
+                    "level_2.txt"); // Deleted
+            File file9 = createFile(root, dataRoot + "/adservices-data/nested",
+                    "level_3.txt"); // Deleted
+            File file10 = createFile(root, dataRoot + "/AdServices-data/nested",
+                    "level_3_1.txt");
+
+            mReceiver.deleteAdServicesFiles(mContext.getDataDir());
+
+            expectFilesExist(file1, file3);
+            expectFilesDoNotExist(file2, file4, file5, file6, file7, file8, file9, file10);
+        } finally {
+            deletePathRecursively(root.resolve(dataRoot));
+        }
+    }
+
+    @Test
+    public void testReceiverHandlesSecurityException() {
+        // Simulate a directory with three files, and the first one throws an exception on delete
+        File file1 = mock(File.class);
+        doReturn(ADSERVICES_FILE_NAME).when(file1).getName();
+        doThrow(SecurityException.class).when(file1).delete();
+
+        File file2 = mock(File.class);
+        doReturn(ADSERVICES_FILE_NAME_MIXED_CASE).when(file2).getName();
+
+        File file3 = mock(File.class);
+        doReturn(NON_ADSERVICES_FILE_NAME).when(file3).getName();
+
+        File dir = mock(File.class);
+        doReturn(true).when(dir).isDirectory();
+        doReturn(new File[] { file1, file2, file3 }).when(dir).listFiles();
+
+        // Execute the receiver
+        mReceiver.deleteAdServicesFiles(dir);
+
+        // Verify that deletion of both file1 and file2 was attempted, in spite of the exception
+        verify(file1).delete();
+        verify(file2).delete();
+        verify(file3, never()).delete();
+    }
+
+    @Test
+    public void testDeleteAdServicesFiles_invalidInput() {
+        // Null input
+        assertThat(mReceiver.deleteAdServicesFiles(null)).isTrue();
+
+        // Not a directory
+        File file = mock(File.class);
+        assertThat(mReceiver.deleteAdServicesFiles(file)).isTrue();
+        verify(file, never()).listFiles();
+
+        // Throws an exception
+        File file2 = mock(File.class);
+        doThrow(SecurityException.class).when(file2).isDirectory();
+        assertThat(mReceiver.deleteAdServicesFiles(file2)).isFalse();
+        verify(file2, never()).listFiles();
+    }
+
+    private void mockReceiverEnabled(boolean value) {
+        doReturn(value).when(mReceiver).isReceiverEnabled();
+    }
+
+    private void verifyDisableComponentCalled() {
+        verify(mPackageManager).setComponentEnabledSetting(any(),
+                eq(PackageManager.COMPONENT_ENABLED_STATE_DISABLED), eq(0));
+    }
+
+    private void expectFilesExist(File... files) {
+        for (File file: files) {
+            expect.withMessage("%s exists", file.getPath()).that(file.exists()).isTrue();
+        }
+    }
+
+    private void expectFilesDoNotExist(File... files) {
+        for (File file: files) {
+            expect.withMessage("%s exists", file.getPath()).that(file.exists()).isFalse();
+        }
+    }
+
+    private void expectDatabasesExist(List<String> databaseNames) {
+        for (String db: databaseNames) {
+            expect.withMessage("%s exists", db)
+                    .that(mContext.getDatabasePath(db).exists())
+                    .isTrue();
+        }
+    }
+
+    private void expectDatabasesDoNotExist(List<String> databaseNames) {
+        for (String db: databaseNames) {
+            expect.withMessage("%s exists", db)
+                    .that(mContext.getDatabasePath(db).exists())
+                    .isFalse();
+        }
+    }
+
+    private void createFiles(List<String> names) throws Exception {
+        File dir = mContext.getFilesDir();
+        for (String name : names) {
+            createFile(name, dir);
+        }
+    }
+
+    private void createDatabases(List<String> names) {
+        for (String name : names) {
+            try (SQLiteDatabase unused = mContext.openOrCreateDatabase(name, 0, null)) {
+                // Intentionally do nothing.
+            }
+        }
+    }
+
+    private void deleteFiles(List<String> names) {
+        for (String name : names) {
+            File file = new File(mContext.getFilesDir(), name);
+            if (file.exists()) {
+                file.delete();
+            }
+        }
+    }
+
+    private void deleteDatabases(List<String> names) {
+        for (String name : names) {
+            mContext.deleteDatabase(name);
+        }
+    }
+
+    private File createFile(String name, File directory) throws Exception {
+        File file = new File(directory, name);
+        try (FileWriter writer = new FileWriter(file)) {
+            writer.append("test data");
+            writer.flush();
+        }
+
+        return file;
+    }
+
+    private File createFile(Path root, String path, String fileName) throws Exception {
+        Path dir = root.resolve(path);
+        Files.createDirectories(dir);
+        return createFile(fileName, dir.toFile());
+    }
+
+    private void deletePathRecursively(Path path) throws Exception {
+        Files.walkFileTree(path, new SimpleFileVisitor<>() {
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+                    throws IOException {
+                Files.delete(file);
+                return FileVisitResult.CONTINUE;
+            }
+
+            @Override
+            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
+                    throws IOException {
+                Files.delete(dir);
+                return FileVisitResult.CONTINUE;
+            }
+        });
+    }
+}