1 /*
2  * Copyright (C) 2020 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.csuite.config;
17 
18 import com.android.tradefed.build.BuildRetrievalError;
19 import com.android.tradefed.config.ConfigurationException;
20 import com.android.tradefed.config.DynamicRemoteFileResolver;
21 import com.android.tradefed.config.Option;
22 import com.android.tradefed.config.OptionClass;
23 import com.android.tradefed.config.OptionSetter;
24 import com.android.tradefed.config.remote.IRemoteFileResolver;
25 import com.android.tradefed.device.ITestDevice;
26 import com.android.tradefed.log.LogUtil.CLog;
27 
28 import com.google.common.annotations.VisibleForTesting;
29 import com.google.common.base.Preconditions;
30 import com.google.common.base.Stopwatch;
31 import com.google.common.base.Strings;
32 import com.google.common.collect.ImmutableMap;
33 
34 import java.io.File;
35 import java.net.URI;
36 import java.net.URISyntaxException;
37 import java.util.Map;
38 import java.util.Objects;
39 import java.util.regex.Matcher;
40 import java.util.regex.Pattern;
41 
42 import javax.annotation.Nullable;
43 import javax.annotation.concurrent.NotThreadSafe;
44 
45 /**
46  * An implementation of {@code IRemoteFileResolver} for downloading Android apps.
47  *
48  * <p>The scheme supported by this resolver allows Trade Federation test configs to abstract the
49  * actual service used to download Android app APK files. Note that this is a 'meta' resolver that
50  * resolves abstract 'app://' URIs into a URI with a different scheme using a custom template. The
51  * actual downloading of this resolved URI is then delegated to another registered {@link
52  * IRemoteFileResolver} implementation. Variable placeholders in the URI template string are
53  * expanded with corresponding values.
54  *
55  * <h2>Syntax and usage</h2>
56  *
57  * <p>References to apps in TradeFed test configs must have the following syntax:
58  *
59  * <blockquote>
60  *
61  * <b>{@code app://}</b><i>package-name</i>
62  *
63  * </blockquote>
64  *
65  * where <i>package-name</i> is the name of the application package such as:
66  *
67  * <blockquote>
68  *
69  * <table cellpadding=0 cellspacing=0 summary="layout">
70  * <tr><td>{@code app://com.example.myapp}<td></tr>
71  * </table>
72  *
73  * </blockquote>
74  *
75  * App APK files are downloaded to a directory and must be used in contexts that can handle File
76  * objects pointing to directories.
77  *
78  * <h2>Configuration</h2>
79  *
80  * <p>The URI template to use is specified using the {@code dynamic-download-args} TradeFed
81  * command-line argument:
82  *
83  * <blockquote>
84  *
85  * <pre>
86  * --dynamic-download-args app:uri-template=file:///app_files/{package}
87  * </pre>
88  *
89  * </blockquote>
90  *
91  * <p>Where {package} expands to the actual package name being downloaded. Any illegal URI
92  * characters must also be properly escaped as expected by {@link java.net.URI}.
93  *
94  * <p><span style="font-weight: bold; padding-right: 1em">Usage Note:</span> The {@code
95  * --enable-module-dynamic-download} flag must be set to {@code true} when used in test suites.
96  *
97  * @see com.android.tradefed.config.Option
98  */
99 @NotThreadSafe
100 @OptionClass(alias = "app")
101 public final class AppRemoteFileResolver implements IRemoteFileResolver {
102 
103     private static final String URI_SCHEME = "app";
104     private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(\\w+)\\}");
105 
106     @VisibleForTesting static final String URI_TEMPLATE_OPTION = "uri-template";
107 
108     @Option(name = URI_TEMPLATE_OPTION)
109     private String mUriTemplate;
110 
111     @Nullable private ITestDevice mPrimaryDevice;
112 
113     @Override
getSupportedProtocol()114     public String getSupportedProtocol() {
115         return URI_SCHEME;
116     }
117 
118     @Override
setPrimaryDevice(@ullable ITestDevice primaryDevice)119     public void setPrimaryDevice(@Nullable ITestDevice primaryDevice) {
120         this.mPrimaryDevice = primaryDevice;
121     }
122 
123     @Override
resolveRemoteFiles(File uriSchemeAndPathAsFile)124     public File resolveRemoteFiles(File uriSchemeAndPathAsFile) throws BuildRetrievalError {
125         // Note that this method is not really supported or even called by the framework. We
126         // only override it to simplify automated null pointer testing.
127         return resolveRemoteFiles(uriSchemeAndPathAsFile, ImmutableMap.of());
128     }
129 
130     @Override
resolveRemoteFiles( File uriSchemeAndPathAsFile, Map<String, String> uriQueryAndExtraParameters)131     public File resolveRemoteFiles(
132             File uriSchemeAndPathAsFile, Map<String, String> uriQueryAndExtraParameters)
133             throws BuildRetrievalError {
134         URI appUri = checkAppUri(toUri(uriSchemeAndPathAsFile));
135         Objects.requireNonNull(uriQueryAndExtraParameters);
136 
137         // TODO(hzalek): Remove this and make the corresponding option mandatory once test configs
138         // are using app URIs.
139         if (mUriTemplate == null) {
140             CLog.w("Resolver is not properly configured, skipping resolution of URI (%s)", appUri);
141             return null;
142         }
143 
144         Preconditions.checkState(
145                 !mUriTemplate.isEmpty(),
146                 String.format("%s=%s is empty", URI_TEMPLATE_OPTION, mUriTemplate));
147 
148         String packageName = appUri.getAuthority();
149         String expanded = expandVars(mUriTemplate, ImmutableMap.of("package", packageName));
150 
151         URI uri;
152         try {
153             uri = new URI(expanded);
154         } catch (URISyntaxException e) {
155             throw new IllegalStateException(
156                     String.format(
157                             "URI template (%s) did not expand to a a valid URI (%s)",
158                             URI_TEMPLATE_OPTION, mUriTemplate, expanded),
159                     e);
160         }
161 
162         if (URI_SCHEME.equals(uri.getScheme())) {
163             throw new BuildRetrievalError(
164                     String.format(
165                             "Providers must return URIs with a scheme different than '%s': %s > %s",
166                             URI_SCHEME, appUri, uri));
167         }
168 
169         return resolveUriToFile(packageName, uri, uriQueryAndExtraParameters);
170     }
171 
toUri(File uriSchemeAndPathAsFile)172     private static URI toUri(File uriSchemeAndPathAsFile) {
173         try {
174             // TradeFed forces a URI into a File instance which is lossy and forces us to attempt
175             // restoring the original format here so we don't have to use regular expressions. Be
176             // warned that using getAbsolutePath() will incorrectly strip the scheme.
177             String path = uriSchemeAndPathAsFile.getPath();
178             // Restore the original URI form since the first two forward slashes in the URI string
179             // get normalized into one when stored as a file.
180             path = path.replaceFirst(":/", "://");
181             return new URI(path);
182         } catch (URISyntaxException e) {
183             throw new IllegalArgumentException("Could not parse provided URI", e);
184         }
185     }
186 
checkAppUri(URI uri)187     private static URI checkAppUri(URI uri) {
188         String uriScheme = uri.getScheme();
189         if (!URI_SCHEME.equals(uriScheme)) {
190             throw new IllegalArgumentException(
191                     String.format("Unsupported scheme (%s) in provided URI (%s)", uriScheme, uri));
192         }
193 
194         // Note that the below code accesses the 'authority' component of the URI and not 'path'
195         // like the dynamic resolver implementation. The latter has to do so because the authority
196         // component is no longer defined once the '//' gets converted to a single '/'.
197         String packageName = uri.getAuthority();
198         if (Strings.isNullOrEmpty(packageName)) {
199             throw new IllegalArgumentException(
200                     String.format(
201                             "Invalid package name (%s) in provided URI (%s)", packageName, uri));
202         }
203 
204         if (!Strings.isNullOrEmpty(uri.getPath())) {
205             throw new IllegalArgumentException(
206                     String.format(
207                             "Path component (%s) incorrectly specified in provided URI (%s); "
208                                     + "app URIs must be of the form 'app://com.example.app'",
209                             uri.getPath(), uri));
210         }
211 
212         return uri;
213     }
214 
expandVars(CharSequence template, Map<String, String> vars)215     private static String expandVars(CharSequence template, Map<String, String> vars) {
216         StringBuilder sb = new StringBuilder();
217         Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
218         int position = 0;
219 
220         while (matcher.find()) {
221             sb.append(template.subSequence(position, matcher.start(0)));
222 
223             String varName = matcher.group(1);
224             String varValue = vars.get(varName);
225 
226             if (varValue == null) {
227                 throw new IllegalStateException(
228                         String.format(
229                                 "URI template (%s) contains a placeholder for undefined var (%s)",
230                                 template, varName));
231             }
232 
233             sb.append(varValue);
234             position = matcher.end(0);
235         }
236 
237         sb.append(template.subSequence(position, template.length()));
238         String expanded = sb.toString();
239 
240         CLog.i("Template (%s) expanded (%s) using vars (%s)", template, expanded, vars);
241         return expanded;
242     }
243 
resolveUriToFile(String packageName, URI uri, Map<String, String> params)244     private File resolveUriToFile(String packageName, URI uri, Map<String, String> params)
245             throws BuildRetrievalError {
246         DynamicRemoteFileResolver resolver = new DynamicRemoteFileResolver();
247         resolver.setDevice(mPrimaryDevice);
248         resolver.addExtraArgs(params);
249 
250         FileOptionSource optionSource = new FileOptionSource();
251         Stopwatch stopwatch = Stopwatch.createStarted();
252 
253         try {
254             OptionSetter setter = new OptionSetter(optionSource);
255             setter.setOptionValue(FileOptionSource.OPTION_NAME, uri.toString());
256             setter.validateRemoteFilePath(resolver);
257             CLog.i("Resolution of files took %d ms", stopwatch.elapsed().toMillis());
258         } catch (BuildRetrievalError e) {
259             throw new BuildRetrievalError(
260                     String.format("Could not resolve URI (%s) for package '%s'", uri, packageName),
261                     e);
262         } catch (ConfigurationException impossible) {
263             throw new AssertionError(impossible);
264         }
265 
266         if (!optionSource.file.exists()) {
267             CLog.w("URI (%s) resolved to non-existent local file (%s)", uri, optionSource.file);
268         } else {
269             CLog.i("URI (%s) resolved to local file (%s)", uri, optionSource.file);
270         }
271 
272         return optionSource.file;
273     }
274 
275     /** This is required to resolve URIs since the remote resolver only deals with options. */
276     private static final class FileOptionSource {
277         static final String OPTION_NAME = "file";
278 
279         @Option(name = OPTION_NAME, mandatory = true)
280         public File file;
281     }
282 }
283