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