1 /*
2  * Copyright (C) 2022 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 
17 package com.android.csuite.core;
18 
19 import com.android.csuite.core.TestUtils.TestUtilsException;
20 import com.android.tradefed.device.ITestDevice;
21 import com.android.tradefed.log.LogUtil.CLog;
22 import com.android.tradefed.util.AaptParser;
23 import com.android.tradefed.util.CommandResult;
24 import com.android.tradefed.util.CommandStatus;
25 import com.android.tradefed.util.IRunUtil;
26 import com.android.tradefed.util.RunUtil;
27 
28 import com.google.common.annotations.VisibleForTesting;
29 
30 import java.io.IOException;
31 import java.nio.file.Path;
32 import java.util.ArrayList;
33 import java.util.Arrays;
34 import java.util.Collections;
35 import java.util.List;
36 import java.util.concurrent.TimeUnit;
37 
38 /** A utility class to install APKs. */
39 public final class ApkInstaller {
40     private static long sCommandTimeOut = TimeUnit.MINUTES.toMillis(4);
41     private static long sObbPushCommandTimeOut = TimeUnit.MINUTES.toMillis(12);
42     private final String mDeviceSerial;
43     private final List<String> mInstalledPackages = new ArrayList<>();
44     private final IRunUtil mRunUtil;
45     private final PackageNameParser mPackageNameParser;
46 
getInstance(ITestDevice device)47     public static ApkInstaller getInstance(ITestDevice device) {
48         return getInstance(device.getSerialNumber());
49     }
50 
getInstance(String deviceSerial)51     public static ApkInstaller getInstance(String deviceSerial) {
52         return new ApkInstaller(deviceSerial, new RunUtil(), new AaptPackageNameParser());
53     }
54 
55     @VisibleForTesting
ApkInstaller(String deviceSerial, IRunUtil runUtil, PackageNameParser packageNameParser)56     ApkInstaller(String deviceSerial, IRunUtil runUtil, PackageNameParser packageNameParser) {
57         mDeviceSerial = deviceSerial;
58         mRunUtil = runUtil;
59         mPackageNameParser = packageNameParser;
60     }
61 
62     /**
63      * Installs a package.
64      *
65      * @param apkPath Path to the apk files. Only accept file/directory path containing a single APK
66      *     or split APK files for one package.
67      * @param args Install args for the 'adb install-multiple' command.
68      * @throws ApkInstallerException If the installation failed.
69      * @throws IOException If an IO exception occurred.
70      */
install(Path apkPath, List<String> args)71     public void install(Path apkPath, List<String> args) throws ApkInstallerException, IOException {
72         List<Path> apkFilePaths;
73         try {
74             apkFilePaths = TestUtils.listApks(apkPath);
75         } catch (TestUtilsException e) {
76             throw new ApkInstallerException("Failed to list APK files from the path " + apkPath, e);
77         }
78 
79         String packageName;
80         try {
81             packageName = mPackageNameParser.parsePackageName(apkFilePaths.get(0));
82         } catch (IOException e) {
83             throw new ApkInstallerException(
84                     String.format("Failed to parse the package name from %s", apkPath), e);
85         }
86         CLog.d("Attempting to uninstall package %s before installation", packageName);
87         String[] uninstallCmd = createUninstallCommand(packageName, mDeviceSerial);
88         // TODO(yuexima): Add command result checks after we start to check whether.
89         // the package is installed on device before uninstalling it.
90         // At this point, command failure is expected if the package wasn't installed.
91         mRunUtil.runTimedCmd(sCommandTimeOut, uninstallCmd);
92 
93         CLog.d("Installing package %s from %s", packageName, apkPath);
94 
95         String[] installApkCmd = createApkInstallCommand(apkFilePaths, mDeviceSerial, args);
96 
97         CommandResult apkRes = mRunUtil.runTimedCmd(sCommandTimeOut, installApkCmd);
98         if (apkRes.getStatus() != CommandStatus.SUCCESS) {
99             throw new ApkInstallerException(
100                     String.format(
101                             "Failed to install APKs from the path %s: %s",
102                             apkPath, apkRes.toString()));
103         }
104 
105         mInstalledPackages.add(packageName);
106 
107         List<String[]> installObbCmds =
108                 createObbInstallCommands(apkFilePaths, mDeviceSerial, packageName);
109         for (String[] cmd : installObbCmds) {
110             CommandResult obbRes = mRunUtil.runTimedCmd(sObbPushCommandTimeOut, cmd);
111             if (obbRes.getStatus() != CommandStatus.SUCCESS) {
112                 throw new ApkInstallerException(
113                         String.format(
114                                 "Failed to install an OBB file from the path %s: %s",
115                                 apkPath, obbRes.toString()));
116             }
117         }
118 
119         CLog.i("Successfully installed " + apkPath);
120     }
121 
122     /**
123      * Overload for install method to use when install args are empty
124      *
125      * @param apkPath
126      * @throws ApkInstallerException
127      * @throws IOException
128      */
install(Path apkPath)129     public void install(Path apkPath) throws ApkInstallerException, IOException {
130         install(apkPath, Collections.emptyList());
131     }
132 
133     /**
134      * Installs apks from a list of paths. Can be used to install additional library apks or 3rd
135      * party apks.
136      *
137      * @param apkPaths List of paths to the apk files.
138      * @param args Install args for the 'adb install-multiple' command.
139      * @throws ApkInstallerException If the installation failed.
140      * @throws IOException If an IO exception occurred.
141      */
install(List<Path> apkPaths, List<String> args)142     public void install(List<Path> apkPaths, List<String> args)
143             throws ApkInstallerException, IOException {
144         for (Path apkPath : apkPaths) {
145             install(apkPath, args);
146         }
147     }
148 
149     /**
150      * Attempts to uninstall all the installed packages.
151      *
152      * <p>When failed to uninstall one of the installed packages, this method will still attempt to
153      * uninstall all other packages before throwing an exception.
154      *
155      * @throws ApkInstallerException when failed to uninstall a package.
156      */
uninstallAllInstalledPackages()157     public void uninstallAllInstalledPackages() throws ApkInstallerException {
158         CLog.d("Uninstalling all installed packages.");
159 
160         StringBuilder errorMessage = new StringBuilder();
161         mInstalledPackages.stream()
162                 .distinct()
163                 .forEach(
164                         installedPackage -> {
165                             String[] cmd = createUninstallCommand(installedPackage, mDeviceSerial);
166                             CommandResult res = mRunUtil.runTimedCmd(sCommandTimeOut, cmd);
167                             if (res.getStatus() != CommandStatus.SUCCESS) {
168                                 errorMessage.append(
169                                         String.format(
170                                                 "Failed to uninstall package %s. Reason: %s.\n",
171                                                 installedPackage, res.toString()));
172                             }
173                         });
174 
175         if (errorMessage.length() > 0) {
176             throw new ApkInstallerException(errorMessage.toString());
177         }
178     }
179 
createApkInstallCommand( List<Path> apkFilePaths, String deviceSerial, List<String> args)180     private String[] createApkInstallCommand(
181             List<Path> apkFilePaths, String deviceSerial, List<String> args) {
182         ArrayList<String> cmd = new ArrayList<>();
183         cmd.addAll(Arrays.asList("adb", "-s", deviceSerial, "install-multiple"));
184         cmd.addAll(args);
185 
186         apkFilePaths.stream()
187                 .map(Path::toString)
188                 .filter(path -> path.toLowerCase().endsWith(".apk"))
189                 .forEach(cmd::add);
190 
191         return cmd.toArray(new String[cmd.size()]);
192     }
193 
createObbInstallCommands( List<Path> apkFilePaths, String deviceSerial, String packageName)194     private List<String[]> createObbInstallCommands(
195             List<Path> apkFilePaths, String deviceSerial, String packageName) {
196         ArrayList<String[]> cmds = new ArrayList<>();
197 
198         apkFilePaths.stream()
199                 .filter(path -> path.toString().toLowerCase().endsWith(".obb"))
200                 .forEach(
201                         path -> {
202                             String dest =
203                                     "/sdcard/Android/obb/" + packageName + "/" + path.getFileName();
204                             cmds.add(
205                                     new String[] {
206                                         "adb", "-s", deviceSerial, "shell", "rm", "-f", dest
207                                     });
208                             cmds.add(
209                                     new String[] {
210                                         "adb", "-s", deviceSerial, "push", path.toString(), dest
211                                     });
212                         });
213 
214         if (!cmds.isEmpty()) {
215             cmds.add(
216                     0,
217                     new String[] {
218                         "adb",
219                         "-s",
220                         deviceSerial,
221                         "shell",
222                         "mkdir",
223                         "-p",
224                         "/sdcard/Android/obb/" + packageName
225                     });
226         }
227 
228         return cmds;
229     }
230 
createUninstallCommand(String packageName, String deviceSerial)231     private String[] createUninstallCommand(String packageName, String deviceSerial) {
232         List<String> cmd = Arrays.asList("adb", "-s", deviceSerial, "uninstall", packageName);
233         return cmd.toArray(new String[cmd.size()]);
234     }
235 
236     /** An exception class representing ApkInstaller error. */
237     public static final class ApkInstallerException extends Exception {
238         /**
239          * Constructs a new {@link ApkInstallerException} with a meaningful error message.
240          *
241          * @param message A error message describing the cause of the error.
242          */
ApkInstallerException(String message)243         private ApkInstallerException(String message) {
244             super(message);
245         }
246 
247         /**
248          * Constructs a new {@link ApkInstallerException} with a meaningful error message, and a
249          * cause.
250          *
251          * @param message A detailed error message.
252          * @param cause A {@link Throwable} capturing the original cause of the {@link
253          *     ApkInstallerException}.
254          */
ApkInstallerException(String message, Throwable cause)255         private ApkInstallerException(String message, Throwable cause) {
256             super(message, cause);
257         }
258 
259         /**
260          * Constructs a new {@link ApkInstallerException} with a cause.
261          *
262          * @param cause A {@link Throwable} capturing the original cause of the {@link
263          *     ApkInstallerException}.
264          */
ApkInstallerException(Throwable cause)265         private ApkInstallerException(Throwable cause) {
266             super(cause);
267         }
268     }
269 
270     private static final class AaptPackageNameParser implements PackageNameParser {
271         @Override
parsePackageName(Path apkFile)272         public String parsePackageName(Path apkFile) throws IOException {
273             AaptParser parseResult =
274                     AaptParser.parse(apkFile.toFile(), AaptParser.AaptVersion.AAPT2);
275             if (parseResult == null || parseResult.getPackageName() == null) {
276                 throw new IOException(
277                         String.format("Failed to parse package name with AAPT for %s", apkFile));
278             }
279             return parseResult.getPackageName();
280         }
281     }
282 
283     @VisibleForTesting
284     interface PackageNameParser {
parsePackageName(Path apkFile)285         String parsePackageName(Path apkFile) throws IOException;
286     }
287 }
288