1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.timezone.xts;
17 
18 import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper;
19 import com.android.tradefed.build.IBuildInfo;
20 import com.android.tradefed.config.Option;
21 import com.android.tradefed.device.ITestDevice;
22 import com.android.tradefed.log.LogUtil;
23 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
24 import com.android.tradefed.testtype.IBuildReceiver;
25 import com.android.tradefed.testtype.IDeviceTest;
26 import com.android.tradefed.util.FileUtil;
27 
28 import org.junit.After;
29 import org.junit.Before;
30 import org.junit.Test;
31 import org.junit.runner.RunWith;
32 
33 import java.io.File;
34 import java.util.function.BooleanSupplier;
35 
36 import static org.junit.Assert.assertEquals;
37 import static org.junit.Assert.assertFalse;
38 import static org.junit.Assert.assertNotNull;
39 import static org.junit.Assert.assertTrue;
40 
41 /**
42  * Class for host-side tests that the time zone rules update feature works as intended. This is
43  * intended to give confidence to OEMs that they have implemented / configured the OEM parts of the
44  * feature correctly.
45  *
46  * <p>There are two main operations involved in time zone updates:
47  * <ol>
48  *     <li>Package installs/uninstalls - asynchronously stage operations for install</li>
49  *     <li>Reboots - perform the staged operations / delete bad installed data</li>
50  * </ol>
51  * Both these operations are time consuming and there's a degree of non-determinism involved.
52  *
53  * <p>A "clean" device can also be in one of two main states depending on whether it has been wiped
54  * and/or rebooted before this test runs:
55  * <ul>
56  *     <li>A device may have nothing staged / installed in /data/misc/zoneinfo at all.</li>
57  *     <li>A device may have the time zone data from the default system image version of the time
58  *     zone data app staged or installed.</li>
59  * </ul>
60  * This test attempts to handle both of these cases.
61  *
62  */
63 @RunWith(DeviceJUnit4ClassRunner.class)
64 public class TimeZoneUpdateHostTest implements IDeviceTest, IBuildReceiver {
65 
66     // These must match equivalent values in RulesManagerService dumpsys code.
67     private static final String STAGED_OPERATION_NONE = "None";
68     private static final String STAGED_OPERATION_INSTALL = "Install";
69     private static final String STAGED_OPERATION_UNINSTALL = "Uninstall";
70     private static final String INSTALL_STATE_INSTALLED = "Installed";
71 
72     private IBuildInfo mBuildInfo;
73     private ITestDevice mDevice;
74     private File mTempDir;
75 
76     @Option(name = "oem-data-app-package-name",
77             description="The OEM-specific package name for the data app",
78             mandatory = true)
79     private String mOemDataAppPackageName;
80 
getTimeZoneDataPackageName()81     private String getTimeZoneDataPackageName() {
82         assertNotNull(mOemDataAppPackageName);
83         return mOemDataAppPackageName;
84     }
85 
86     @Option(name = "oem-data-app-apk-prefix",
87             description="The OEM-specific APK name for the data app test files, e.g."
88                     + "for TimeZoneDataOemCorp_test1.apk the prefix would be"
89                     + "\"TimeZoneDataOemCorp\"",
90             mandatory = true)
91     private String mOemDataAppApkPrefix;
92 
getTimeZoneDataApkName(String testId)93     private String getTimeZoneDataApkName(String testId) {
94         assertNotNull(mOemDataAppApkPrefix);
95         return mOemDataAppApkPrefix + "_" + testId + ".apk";
96     }
97 
98     @Override
setBuild(IBuildInfo buildInfo)99     public void setBuild(IBuildInfo buildInfo) {
100         mBuildInfo = buildInfo;
101     }
102 
103     @Override
setDevice(ITestDevice device)104     public void setDevice(ITestDevice device) {
105         mDevice = device;
106     }
107 
108     @Override
getDevice()109     public ITestDevice getDevice() {
110         return mDevice;
111     }
112 
113     @Before
setUp()114     public void setUp() throws Exception {
115         createTempDir();
116         resetDeviceToClean();
117     }
118 
119     @After
tearDown()120     public void tearDown() throws Exception {
121         resetDeviceToClean();
122         deleteTempDir();
123     }
124 
createTempDir()125     private void createTempDir() throws Exception {
126         mTempDir = File.createTempFile("timeZoneUpdateTest", null);
127         assertTrue(mTempDir.delete());
128         assertTrue(mTempDir.mkdir());
129     }
130 
deleteTempDir()131     private void deleteTempDir() throws Exception {
132         FileUtil.recursiveDelete(mTempDir);
133     }
134 
135     /**
136      * Reset the device to having no installed time zone data outside of the /system/priv-app
137      * version that came with the system image.
138      */
resetDeviceToClean()139     private void resetDeviceToClean() throws Exception {
140         // If this fails the data app isn't present on device. No point in starting.
141         assertTrue(getTimeZoneDataPackageName() + " not installed",
142                 isPackageInstalled(getTimeZoneDataPackageName()));
143 
144         // Reboot as needed to apply any staged operation.
145         if (!STAGED_OPERATION_NONE.equals(getStagedOperationType())) {
146             rebootDeviceAndWaitForRestart();
147         }
148 
149         // A "clean" device means no time zone data .apk installed in /data at all, try to get to
150         // that state.
151         for (int i = 0; i < 2; i++) {
152             logDeviceTimeZoneState();
153 
154             // Even if there's no distro installed, there may be an updated APK installed, so try to
155             // remove it unconditionally.
156             String errorCode = uninstallPackage(getTimeZoneDataPackageName());
157             if (errorCode != null) {
158                 // Failed to uninstall, which we take to mean the device is "clean".
159                 break;
160             }
161             // Success, meaning there was an APK that could be uninstalled.
162             // If there is a distro installed we need wait for the distro uninstall that should now
163             // become staged.
164             boolean distroIsInstalled = INSTALL_STATE_INSTALLED.equals(getCurrentInstallState());
165             if (distroIsInstalled) {
166                 // It may take a short while before we can detect anything: the package manager
167                 // should have triggered an intent, and the PackageTracker has to receive that and
168                 // send its own intent, which then has to be acted on before we could detect an
169                 // operation in progress. We expect the device eventually to get to the staged state
170                 // "UNINSTALL", meaning it will try to revert to no distro installed on next boot.
171                 waitForStagedUninstall();
172 
173                 rebootDeviceAndWaitForRestart();
174             } else {
175                 // There was an apk installed, but no time zone distro was installed. It was
176                 // probably a "bad" .apk that was rejected. The update app will request an uninstall
177                 // anyway just to be sure, so we'll give it a chance to do that before continuing
178                 // otherwise we could get an "operation in progress" later on when we're not
179                 // expecting it.
180                 Thread.sleep(10000);
181             }
182         }
183         assertActiveRulesVersion(getBaseRulesVersion());
184         assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
185     }
186 
187     @Test
testInstallNewerRulesVersion()188     public void testInstallNewerRulesVersion() throws Exception {
189         // This information must match the rules version in test1: IANA version=2030a, revision=1
190         String test1VersionInfo = "2030a,1";
191 
192         // Confirm the staged / install state before we start.
193         assertFalse(test1VersionInfo.equals(getCurrentInstalledVersion()));
194         assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
195 
196         File appFile = getTimeZoneDataApkFile("test1");
197         getDevice().installPackage(appFile, true /* reinstall */);
198 
199         waitForStagedInstall(test1VersionInfo);
200 
201         // Confirm the install state hasn't changed.
202         assertFalse(test1VersionInfo.equals(getCurrentInstalledVersion()));
203 
204         // Now reboot, and the staged version should become the installed version.
205         rebootDeviceAndWaitForRestart();
206 
207         // After reboot, check the state.
208         assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
209         assertEquals(INSTALL_STATE_INSTALLED, getCurrentInstallState());
210         assertEquals(test1VersionInfo, getCurrentInstalledVersion());
211     }
212 
213     @Test
testInstallNewerRulesVersion_secondaryUser()214     public void testInstallNewerRulesVersion_secondaryUser() throws Exception {
215         ITestDevice device = getDevice();
216         if (!device.isMultiUserSupported()) {
217             // Just pass on non-multi-user devices.
218             return;
219         }
220 
221         int userId = device.createUser("TimeZoneTest", false /* guest */, false /* ephemeral */);
222         try {
223 
224             // This information must match the rules version in test1: IANA version=2030a, revision=1
225             String test1VersionInfo = "2030a,1";
226 
227             // Confirm the staged / install state before we start.
228             assertFalse(test1VersionInfo.equals(getCurrentInstalledVersion()));
229             assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
230 
231             File appFile = getTimeZoneDataApkFile("test1");
232 
233             // Install the app for the test user. It should still all work.
234             device.installPackageForUser(appFile, true /* reinstall */, userId);
235 
236             waitForStagedInstall(test1VersionInfo);
237 
238             // Confirm the install state hasn't changed.
239             assertFalse(test1VersionInfo.equals(getCurrentInstalledVersion()));
240 
241             // Now reboot, and the staged version should become the installed version.
242             rebootDeviceAndWaitForRestart();
243 
244             // After reboot, check the state.
245             assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
246             assertEquals(INSTALL_STATE_INSTALLED, getCurrentInstallState());
247             assertEquals(test1VersionInfo, getCurrentInstalledVersion());
248         }
249         finally {
250             // If this fails, the device may be left in a bad state.
251             device.removeUser(userId);
252         }
253     }
254 
255     @Test
testInstallOlderRulesVersion()256     public void testInstallOlderRulesVersion() throws Exception {
257         File appFile = getTimeZoneDataApkFile("test2");
258         getDevice().installPackage(appFile, true /* reinstall */);
259 
260         // The attempt to install a version of the data that is older than the version in the system
261         // image should be rejected and nothing should be staged. There's currently no way (short of
262         // looking at logs) to tell this has happened, but combined with other tests and given a
263         // suitable delay it gives us some confidence that the attempt has been made and it was
264         // rejected.
265 
266         Thread.sleep(30000);
267 
268         assertEquals(STAGED_OPERATION_NONE, getStagedOperationType());
269     }
270 
rebootDeviceAndWaitForRestart()271     private void rebootDeviceAndWaitForRestart() throws Exception {
272         log("Rebooting device");
273         getDevice().reboot();
274     }
275 
logDeviceTimeZoneState()276     private void logDeviceTimeZoneState() throws Exception {
277         log("Initial device state: " + dumpEntireTimeZoneStatusToString());
278     }
279 
log(String msg)280     private static void log(String msg) {
281         LogUtil.CLog.i(msg);
282     }
283 
assertActiveRulesVersion(String expectedRulesVersion)284     private void assertActiveRulesVersion(String expectedRulesVersion) throws Exception {
285         // Dumpsys reports the version reported by ICU, ZoneInfoDb and TimeZoneFinder and they
286         // should always match.
287         String expectedActiveRulesVersion =
288                 expectedRulesVersion + "," + expectedRulesVersion + "," + expectedRulesVersion;
289 
290         String actualActiveRulesVersion =
291                 waitForNoOperationInProgressAndReturn(StateType.ACTIVE_RULES_VERSION);
292         assertEquals(expectedActiveRulesVersion, actualActiveRulesVersion);
293     }
294 
getCurrentInstalledVersion()295     private String getCurrentInstalledVersion() throws Exception {
296         return waitForNoOperationInProgressAndReturn(StateType.CURRENTLY_INSTALLED_VERSION);
297     }
298 
getCurrentInstallState()299     private String getCurrentInstallState() throws Exception {
300         return waitForNoOperationInProgressAndReturn(StateType.CURRENT_INSTALL_STATE);
301     }
302 
getStagedInstallVersion()303     private String getStagedInstallVersion() throws Exception {
304         return waitForNoOperationInProgressAndReturn(StateType.STAGED_INSTALL_VERSION);
305     }
306 
getStagedOperationType()307     private String getStagedOperationType() throws Exception {
308         return waitForNoOperationInProgressAndReturn(StateType.STAGED_OPERATION_TYPE);
309     }
310 
getBaseRulesVersion()311     private String getBaseRulesVersion() throws Exception {
312         return waitForNoOperationInProgressAndReturn(StateType.BASE_RULES_VERSION);
313     }
314 
isOperationInProgress()315     private boolean isOperationInProgress() {
316         try {
317             String operationInProgressString =
318                     getDeviceTimeZoneState(StateType.OPERATION_IN_PROGRESS);
319             return Boolean.parseBoolean(operationInProgressString);
320         } catch (Exception e) {
321             throw new AssertionError("Failed to read staged status", e);
322         }
323     }
324 
waitForNoOperationInProgressAndReturn(StateType stateType)325     private String waitForNoOperationInProgressAndReturn(StateType stateType) throws Exception {
326         waitForCondition(() -> !isOperationInProgress());
327         return getDeviceTimeZoneState(stateType);
328     }
329 
waitForStagedUninstall()330     private void waitForStagedUninstall() throws Exception {
331         waitForCondition(() -> isStagedUninstall());
332     }
333 
waitForStagedInstall(String versionString)334     private void waitForStagedInstall(String versionString) throws Exception {
335         waitForCondition(() -> isStagedInstall(versionString));
336     }
337 
isStagedUninstall()338     private boolean isStagedUninstall() {
339         try {
340             return getStagedOperationType().equals(STAGED_OPERATION_UNINSTALL);
341         } catch (Exception e) {
342             throw new AssertionError("Failed to read staged status", e);
343         }
344     }
345 
isStagedInstall(String versionString)346     private boolean isStagedInstall(String versionString) {
347         try {
348             return getStagedOperationType().equals(STAGED_OPERATION_INSTALL)
349                     && getStagedInstallVersion().equals(versionString);
350         } catch (Exception e) {
351             throw new AssertionError("Failed to read staged status", e);
352         }
353     }
354 
waitForCondition(BooleanSupplier condition)355     private static void waitForCondition(BooleanSupplier condition) throws Exception {
356         int count = 0;
357         boolean lastResult;
358         while (!(lastResult = condition.getAsBoolean()) && count++ < 120) {
359             Thread.sleep(1000);
360         }
361         // Some conditions may not be stable so using the lastResult instead of
362         // condition.getAsBoolean() ensures we understand why we exited the loop.
363         assertTrue("Failed condition: " + condition, lastResult);
364     }
365 
366     private enum StateType {
367         OPERATION_IN_PROGRESS,
368         BASE_RULES_VERSION,
369         CURRENT_INSTALL_STATE,
370         CURRENTLY_INSTALLED_VERSION,
371         STAGED_OPERATION_TYPE,
372         STAGED_INSTALL_VERSION,
373         ACTIVE_RULES_VERSION;
374 
getFormatStateChar()375         public String getFormatStateChar() {
376             // This switch must match values in com.android.server.timezone.RulesManagerService.
377             switch (this) {
378                 case OPERATION_IN_PROGRESS:
379                     return "p";
380                 case BASE_RULES_VERSION:
381                     return "b";
382                 case CURRENT_INSTALL_STATE:
383                     return "c";
384                 case CURRENTLY_INSTALLED_VERSION:
385                     return "i";
386                 case STAGED_OPERATION_TYPE:
387                     return "o";
388                 case STAGED_INSTALL_VERSION:
389                     return "t";
390                 case ACTIVE_RULES_VERSION:
391                     return "a";
392                 default:
393                     throw new AssertionError("Unknown state type: " + this);
394             }
395         }
396     }
397 
getDeviceTimeZoneState(StateType stateType)398     private String getDeviceTimeZoneState(StateType stateType) throws Exception {
399         String output = getDevice().executeShellCommand(
400                 "dumpsys timezone -format_state " + stateType.getFormatStateChar());
401         assertNotNull(output);
402         // Output will be "Foo: bar\n". We want the "bar".
403         String value = output.split(":")[1];
404         return value.substring(1, value.length() - 1);
405     }
406 
dumpEntireTimeZoneStatusToString()407     private String dumpEntireTimeZoneStatusToString() throws Exception {
408         String output = getDevice().executeShellCommand("dumpsys timezone");
409         assertNotNull(output);
410         return output;
411     }
412 
getTimeZoneDataApkFile(String testId)413     private File getTimeZoneDataApkFile(String testId) throws Exception {
414         CompatibilityBuildHelper buildHelper = new CompatibilityBuildHelper(mBuildInfo);
415         String fileName = getTimeZoneDataApkName(testId);
416         return buildHelper.getTestFile(fileName);
417     }
418 
isPackageInstalled(String pkg)419     private boolean isPackageInstalled(String pkg) throws Exception {
420         for (String installedPackage : getDevice().getInstalledPackageNames()) {
421             if (pkg.equals(installedPackage)) {
422                 return true;
423             }
424         }
425         return false;
426     }
427 
uninstallPackage(String packageName)428     private String uninstallPackage(String packageName) throws Exception {
429         return getDevice().uninstallPackage(packageName);
430     }
431 }
432