| /* |
| * Copyright (C) 2015 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.appsecurity.cts; |
| |
| import static android.appsecurity.cts.Utils.ABI_TO_APK; |
| import static android.appsecurity.cts.Utils.APK; |
| import static android.appsecurity.cts.Utils.APK_mdpi; |
| import static android.appsecurity.cts.Utils.APK_xxhdpi; |
| import static android.appsecurity.cts.Utils.CLASS; |
| import static android.appsecurity.cts.Utils.PKG; |
| |
| import static org.junit.Assert.assertTrue; |
| import static org.junit.Assert.fail; |
| |
| import android.platform.test.annotations.AppModeFull; |
| |
| import com.android.tradefed.device.CollectingOutputReceiver; |
| import com.android.tradefed.log.LogUtil.CLog; |
| import com.android.tradefed.testtype.DeviceJUnit4ClassRunner; |
| import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test; |
| import com.android.tradefed.util.RunUtil; |
| |
| import org.junit.After; |
| import org.junit.Assert; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| |
| import java.util.Arrays; |
| import java.util.concurrent.TimeUnit; |
| |
| /** |
| * Set of tests that verify behavior of adopted storage media, if supported. |
| */ |
| @RunWith(DeviceJUnit4ClassRunner.class) |
| @AppModeFull(reason = "Instant applications can only be installed on internal storage") |
| public class AdoptableHostTest extends BaseHostJUnit4Test { |
| |
| public static final String FEATURE_ADOPTABLE_STORAGE = "feature:android.software.adoptable_storage"; |
| private static final int ANDROID_API_LEVEL_R = 30; |
| |
| private String mListVolumesInitialState; |
| |
| @Before |
| public void setUp() throws Exception { |
| // Start all possible users to make sure their storage is unlocked |
| Utils.prepareMultipleUsers(getDevice(), Integer.MAX_VALUE); |
| |
| // Users are starting, wait for all volumes are ready |
| waitForVolumeReady(); |
| |
| // Initial state of all volumes |
| mListVolumesInitialState = getDevice().executeShellCommand("sm list-volumes"); |
| |
| getDevice().uninstallPackage(PKG); |
| |
| // Enable a virtual disk to give us the best shot at being able to pass |
| // the various tests below. This helps verify devices that may not |
| // currently have an SD card inserted. |
| if (isSupportedDevice()) { |
| getDevice().executeShellCommand("sm set-virtual-disk true"); |
| |
| // Ensure virtual disk is mounted. |
| int attempt = 0; |
| boolean hasVirtualDisk = false; |
| String result = ""; |
| while (!hasVirtualDisk && attempt++ < 50) { |
| RunUtil.getDefault().sleep(1000); |
| result = getDevice().executeShellCommand("sm list-disks adoptable").trim(); |
| hasVirtualDisk = result.startsWith("disk:"); |
| } |
| assertTrue("Virtual disk is not ready: " + result, hasVirtualDisk); |
| |
| waitForVolumeReady(); |
| } |
| } |
| |
| @After |
| public void tearDown() throws Exception { |
| getDevice().uninstallPackage(PKG); |
| |
| if (isSupportedDevice()) { |
| getDevice().executeShellCommand("sm set-virtual-disk false"); |
| |
| // Ensure virtual disk is removed. |
| int attempt = 0; |
| boolean hasVirtualDisk = true; |
| String result = ""; |
| while (hasVirtualDisk && attempt++ < 20) { |
| RunUtil.getDefault().sleep(1000); |
| result = getDevice().executeShellCommand("sm list-disks adoptable").trim(); |
| hasVirtualDisk = result.startsWith("disk:"); |
| } |
| if (hasVirtualDisk) { |
| CLog.w("Virtual disk is not removed: " + result); |
| } |
| |
| // Ensure all volumes go back to the original state. |
| attempt = 0; |
| boolean volumeStateRecovered = false; |
| result = ""; |
| while (!volumeStateRecovered && attempt++ < 20) { |
| RunUtil.getDefault().sleep(1000); |
| result = getDevice().executeShellCommand("sm list-volumes"); |
| volumeStateRecovered = mListVolumesInitialState.equals(result); |
| } |
| if (!volumeStateRecovered) { |
| CLog.w("Volume state is not recovered: " + result); |
| } |
| } |
| } |
| |
| /** |
| * Ensure that we have consistency between the feature flag and what we |
| * sniffed from the underlying fstab. |
| */ |
| @Test |
| public void testFeatureConsistent() throws Exception { |
| final boolean hasFeature = hasFeature(); |
| final boolean hasFstab = hasFstab(); |
| if (hasFeature != hasFstab) { |
| fail("Inconsistent adoptable storage status; feature claims " + hasFeature |
| + " but fstab claims " + hasFstab); |
| } |
| } |
| |
| // Ensure no volume is in ejecting or checking state |
| private void waitForVolumeReady() throws Exception { |
| int attempt = 0; |
| boolean noCheckingEjecting = false; |
| String result = ""; |
| while (!noCheckingEjecting && attempt++ < 60) { |
| result = getDevice().executeShellCommand("sm list-volumes"); |
| noCheckingEjecting = !result.contains("ejecting") && !result.contains("checking"); |
| RunUtil.getDefault().sleep(100); |
| } |
| assertTrue("Volumes are not ready: " + result, noCheckingEjecting); |
| } |
| |
| @Test |
| public void testApps() throws Exception { |
| if (!isSupportedDevice()) return; |
| final String diskId = getAdoptionDisk(); |
| try { |
| final String abi = getAbi().getName(); |
| final String apk = ABI_TO_APK.get(abi); |
| Assert.assertNotNull("Failed to find APK for ABI " + abi, apk); |
| |
| // Install simple app on internal |
| new InstallMultiple().useNaturalAbi().addFile(APK).addFile(apk).run(); |
| runDeviceTests(PKG, CLASS, "testDataInternal"); |
| runDeviceTests(PKG, CLASS, "testDataWrite"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| runDeviceTests(PKG, CLASS, "testNative"); |
| |
| // Adopt that disk! |
| assertEmpty(getDevice().executeShellCommand("sm partition " + diskId + " private")); |
| final LocalVolumeInfo vol = getAdoptionVolume(); |
| |
| // Move app and verify |
| assertSuccess(getDevice().executeShellCommand( |
| "pm move-package " + PKG + " " + vol.uuid)); |
| waitForBroadcastsIdle(); |
| runDeviceTests(PKG, CLASS, "testDataNotInternal"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| runDeviceTests(PKG, CLASS, "testNative"); |
| |
| // Unmount, remount and verify |
| getDevice().executeShellCommand("sm unmount " + vol.volId); |
| waitForVolumeReady(); |
| getDevice().executeShellCommand("sm mount " + vol.volId); |
| waitForInstrumentationReady(); |
| |
| runDeviceTests(PKG, CLASS, "testDataNotInternal"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| runDeviceTests(PKG, CLASS, "testNative"); |
| |
| // Move app back and verify |
| assertSuccess(getDevice().executeShellCommand("pm move-package " + PKG + " internal")); |
| waitForBroadcastsIdle(); |
| runDeviceTests(PKG, CLASS, "testDataInternal"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| runDeviceTests(PKG, CLASS, "testNative"); |
| |
| // Un-adopt volume and app should still be fine |
| getDevice().executeShellCommand("sm partition " + diskId + " public"); |
| runDeviceTests(PKG, CLASS, "testDataInternal"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| runDeviceTests(PKG, CLASS, "testNative"); |
| |
| } finally { |
| cleanUp(diskId); |
| } |
| } |
| |
| @Test |
| public void testPrimaryStorage() throws Exception { |
| if (!isSupportedDevice()) return; |
| final String diskId = getAdoptionDisk(); |
| try { |
| final String originalVol = getDevice() |
| .executeShellCommand("sm get-primary-storage-uuid").trim(); |
| |
| if ("null".equals(originalVol)) { |
| verifyPrimaryInternal(diskId); |
| } else if ("primary_physical".equals(originalVol)) { |
| verifyPrimaryPhysical(diskId); |
| } |
| } finally { |
| cleanUp(diskId); |
| } |
| } |
| |
| private void verifyPrimaryInternal(String diskId) throws Exception { |
| // Write some data to shared storage |
| new InstallMultiple().addFile(APK).run(); |
| runDeviceTests(PKG, CLASS, "testPrimaryOnSameVolume"); |
| runDeviceTests(PKG, CLASS, "testPrimaryInternal"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataWrite"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // Adopt that disk! |
| assertEmpty(getDevice().executeShellCommand("sm partition " + diskId + " private")); |
| final LocalVolumeInfo vol = getAdoptionVolume(); |
| |
| // Move storage there and verify that data went along for ride |
| CollectingOutputReceiver out = new CollectingOutputReceiver(); |
| getDevice().executeShellCommand("pm move-primary-storage " + vol.uuid, out, 2, |
| TimeUnit.HOURS, 1); |
| assertSuccess(out.getOutput()); |
| waitForBroadcastsIdle(); |
| runDeviceTests(PKG, CLASS, "testPrimaryAdopted"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // Unmount and verify |
| getDevice().executeShellCommand("sm unmount " + vol.volId); |
| waitForVolumeReady(); |
| runDeviceTests(PKG, CLASS, "testPrimaryUnmounted"); |
| getDevice().executeShellCommand("sm mount " + vol.volId); |
| waitForInstrumentationReady(); |
| waitForVolumeReady(); |
| |
| runDeviceTests(PKG, CLASS, "testPrimaryAdopted"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // Move app and verify backing storage volume is same |
| assertSuccess(getDevice().executeShellCommand("pm move-package " + PKG + " " + vol.uuid)); |
| waitForBroadcastsIdle(); |
| |
| runDeviceTests(PKG, CLASS, "testPrimaryOnSameVolume"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| // And move back to internal |
| out = new CollectingOutputReceiver(); |
| getDevice().executeShellCommand("pm move-primary-storage internal", out, 2, |
| TimeUnit.HOURS, 1); |
| assertSuccess(out.getOutput()); |
| |
| runDeviceTests(PKG, CLASS, "testPrimaryInternal"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| assertSuccess(getDevice().executeShellCommand("pm move-package " + PKG + " internal")); |
| waitForBroadcastsIdle(); |
| |
| runDeviceTests(PKG, CLASS, "testPrimaryOnSameVolume"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| } |
| |
| private void verifyPrimaryPhysical(String diskId) throws Exception { |
| // Write some data to shared storage |
| new InstallMultiple().addFile(APK).run(); |
| runDeviceTests(PKG, CLASS, "testPrimaryPhysical"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataWrite"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // Adopt that disk! |
| assertEmpty(getDevice().executeShellCommand("sm partition " + diskId + " private")); |
| final LocalVolumeInfo vol = getAdoptionVolume(); |
| |
| // Move primary storage there, but since we just nuked primary physical |
| // the storage device will be empty |
| assertSuccess(getDevice().executeShellCommand("pm move-primary-storage " + vol.uuid)); |
| runDeviceTests(PKG, CLASS, "testPrimaryAdopted"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataWrite"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // Unmount and verify |
| getDevice().executeShellCommand("sm unmount " + vol.volId); |
| waitForVolumeReady(); |
| runDeviceTests(PKG, CLASS, "testPrimaryUnmounted"); |
| getDevice().executeShellCommand("sm mount " + vol.volId); |
| waitForInstrumentationReady(); |
| waitForVolumeReady(); |
| |
| runDeviceTests(PKG, CLASS, "testPrimaryAdopted"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| |
| // And move to internal |
| assertSuccess(getDevice().executeShellCommand("pm move-primary-storage internal")); |
| runDeviceTests(PKG, CLASS, "testPrimaryOnSameVolume"); |
| runDeviceTests(PKG, CLASS, "testPrimaryInternal"); |
| runDeviceTests(PKG, CLASS, "testPrimaryDataRead"); |
| } |
| |
| /** |
| * Verify that we can install both new and inherited packages directly on |
| * adopted volumes. |
| */ |
| @Test |
| public void testPackageInstaller() throws Exception { |
| if (!isSupportedDevice()) return; |
| final String diskId = getAdoptionDisk(); |
| try { |
| assertEmpty(getDevice().executeShellCommand("sm partition " + diskId + " private")); |
| final LocalVolumeInfo vol = getAdoptionVolume(); |
| |
| // Install directly onto adopted volume |
| new InstallMultiple().locationAuto().forceUuid(vol.uuid) |
| .addFile(APK).addFile(APK_mdpi).run(); |
| runDeviceTests(PKG, CLASS, "testDataNotInternal"); |
| runDeviceTests(PKG, CLASS, "testDensityBest1"); |
| |
| // Now splice in an additional split which offers better resources |
| new InstallMultiple().locationAuto().inheritFrom(PKG) |
| .addFile(APK_xxhdpi).run(); |
| runDeviceTests(PKG, CLASS, "testDataNotInternal"); |
| runDeviceTests(PKG, CLASS, "testDensityBest2"); |
| |
| } finally { |
| cleanUp(diskId); |
| } |
| } |
| |
| /** |
| * Verify behavior when changes occur while adopted device is ejected and |
| * returned at a later time. |
| */ |
| @Test |
| public void testEjected() throws Exception { |
| if (!isSupportedDevice()) return; |
| final String diskId = getAdoptionDisk(); |
| try { |
| assertEmpty(getDevice().executeShellCommand("sm partition " + diskId + " private")); |
| final LocalVolumeInfo vol = getAdoptionVolume(); |
| |
| // Install directly onto adopted volume, and write data there |
| new InstallMultiple().locationAuto().forceUuid(vol.uuid).addFile(APK).run(); |
| runDeviceTests(PKG, CLASS, "testDataNotInternal"); |
| runDeviceTests(PKG, CLASS, "testDataWrite"); |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| |
| // Now unmount and uninstall; leaving stale package on adopted volume |
| getDevice().executeShellCommand("sm unmount " + vol.volId); |
| waitForVolumeReady(); |
| getDevice().uninstallPackage(PKG); |
| |
| // Install second copy on internal, but don't write anything |
| new InstallMultiple().locationInternalOnly().addFile(APK).run(); |
| runDeviceTests(PKG, CLASS, "testDataInternal"); |
| |
| // Kick through a remount cycle, which should purge the adopted app |
| getDevice().executeShellCommand("sm mount " + vol.volId); |
| waitForInstrumentationReady(); |
| waitForVolumeReady(); |
| |
| runDeviceTests(PKG, CLASS, "testDataInternal"); |
| boolean didThrow = false; |
| try { |
| runDeviceTests(PKG, CLASS, "testDataRead"); |
| } catch (AssertionError expected) { |
| didThrow = true; |
| } |
| if (!didThrow) { |
| fail("Unexpected data from adopted volume picked up"); |
| } |
| getDevice().executeShellCommand("sm unmount " + vol.volId); |
| waitForVolumeReady(); |
| |
| // Uninstall the internal copy and remount; we should have no record of app |
| getDevice().uninstallPackage(PKG); |
| getDevice().executeShellCommand("sm mount " + vol.volId); |
| waitForVolumeReady(); |
| |
| assertEmpty(getDevice().executeShellCommand("pm list packages " + PKG)); |
| } finally { |
| cleanUp(diskId); |
| } |
| } |
| |
| private boolean isSupportedDevice() throws Exception { |
| return hasCasefoldSupport() && (hasFeature() || hasFstab()); |
| } |
| |
| private boolean hasCasefoldSupport() throws Exception { |
| return getDevice().getLaunchApiLevel() >= ANDROID_API_LEVEL_R; |
| } |
| |
| private boolean hasFeature() throws Exception { |
| return getDevice().hasFeature(FEATURE_ADOPTABLE_STORAGE); |
| } |
| |
| private boolean hasFstab() throws Exception { |
| return Boolean.parseBoolean(getDevice().executeShellCommand("sm has-adoptable").trim()); |
| } |
| |
| private String getAdoptionDisk() throws Exception { |
| // In the case where we run multiple test we cleanup the state of the device. This |
| // results in the execution of sm forget all which causes the MountService to "reset" |
| // all its knowledge about available drives. This can cause the adoptable drive to |
| // become temporarily unavailable. |
| int attempt = 0; |
| String disks = getDevice().executeShellCommand("sm list-disks adoptable"); |
| while ((disks == null || disks.isEmpty()) && attempt++ < 15) { |
| RunUtil.getDefault().sleep(1000); |
| disks = getDevice().executeShellCommand("sm list-disks adoptable"); |
| } |
| |
| if (disks == null || disks.isEmpty()) { |
| throw new AssertionError("Devices that claim to support adoptable storage must have " |
| + "adoptable media inserted during CTS to verify correct behavior"); |
| } |
| return disks.split("\n")[0].trim(); |
| } |
| |
| private LocalVolumeInfo getAdoptionVolume() throws Exception { |
| String[] lines = null; |
| int attempt = 0; |
| int mounted_count = 0; |
| while (attempt++ < 15) { |
| lines = getDevice().executeShellCommand("sm list-volumes private").split("\n"); |
| CLog.w("getAdoptionVolume(): " + Arrays.toString(lines)); |
| for (String line : lines) { |
| final LocalVolumeInfo info = new LocalVolumeInfo(line.trim()); |
| if (!"private".equals(info.volId)) { |
| if ("mounted".equals(info.state)) { |
| // make sure the storage is mounted and stable for a while |
| mounted_count++; |
| attempt--; |
| if (mounted_count >= 3) { |
| return waitForVolumeReady(info); |
| } |
| } |
| else { |
| mounted_count = 0; |
| } |
| } |
| } |
| RunUtil.getDefault().sleep(1000); |
| } |
| throw new AssertionError("Expected private volume; found " + Arrays.toString(lines)); |
| } |
| |
| private LocalVolumeInfo waitForVolumeReady(LocalVolumeInfo vol) throws Exception { |
| int attempt = 0; |
| while (attempt++ < 30) { |
| if (getDevice().executeShellCommand("dumpsys package volumes").contains(vol.volId)) { |
| return vol; |
| } |
| RunUtil.getDefault().sleep(1000); |
| } |
| throw new AssertionError("Volume not ready " + vol.volId); |
| } |
| |
| private void waitForBroadcastsIdle() throws Exception { |
| getDevice().executeShellCommand("am wait-for-broadcast-idle"); |
| } |
| |
| private void waitForInstrumentationReady() throws Exception { |
| // Wait for volume ready first |
| getAdoptionVolume(); |
| |
| int attempt = 0; |
| String pkgInstr = getDevice().executeShellCommand("pm list instrumentation"); |
| while ((pkgInstr == null || !pkgInstr.contains(PKG)) && attempt++ < 15) { |
| RunUtil.getDefault().sleep(1000); |
| pkgInstr = getDevice().executeShellCommand("pm list instrumentation"); |
| } |
| |
| if (pkgInstr == null || !pkgInstr.contains(PKG)) { |
| throw new AssertionError("Package not ready yet"); |
| } |
| } |
| |
| private void cleanUp(String diskId) throws Exception { |
| getDevice().executeShellCommand("sm partition " + diskId + " public"); |
| getDevice().executeShellCommand("sm forget all"); |
| } |
| |
| private static void assertSuccess(String str) { |
| if (str == null || !str.startsWith("Success")) { |
| throw new AssertionError("Expected success string but found " + str); |
| } |
| } |
| |
| private static void assertEmpty(String str) { |
| if (str != null && str.trim().length() > 0) { |
| throw new AssertionError("Expected empty string but found " + str); |
| } |
| } |
| |
| private static class LocalVolumeInfo { |
| public String volId; |
| public String state; |
| public String uuid; |
| |
| public LocalVolumeInfo(String line) { |
| final String[] split = line.split(" "); |
| volId = split[0]; |
| state = split[1]; |
| uuid = split[2]; |
| } |
| } |
| |
| private class InstallMultiple extends BaseInstallMultiple<InstallMultiple> { |
| public InstallMultiple() { |
| super(getDevice(), getBuild(), getAbi()); |
| addArg("--force-queryable"); |
| } |
| } |
| } |