Kuang-che Wu | 6e4beca | 2018-06-27 17:45:02 +0800 | [diff] [blame] | 1 | # -*- coding: utf-8 -*- |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 2 | # Copyright 2018 The Chromium OS Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | """Android utility. |
| 6 | |
| 7 | Terminology used in this module: |
| 8 | "product-variant" is sometimes called "target" and sometimes "flavor". |
| 9 | I prefer to use "flavor" in the code because |
| 10 | - "target" is too general |
| 11 | - sometimes, it is not in the form of "product-variant", for example, |
| 12 | "cts_arm_64" |
| 13 | """ |
| 14 | |
| 15 | from __future__ import print_function |
| 16 | import json |
| 17 | import logging |
| 18 | import os |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 19 | import tempfile |
Kuang-che Wu | 999893c | 2020-04-13 22:06:22 +0800 | [diff] [blame] | 20 | import urllib.request |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 21 | |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 22 | import google.oauth2.service_account |
Zheng-Jie Chang | e8a25fe | 2020-10-27 11:02:24 +0800 | [diff] [blame] | 23 | import googleapiclient |
| 24 | import googleapiclient.discovery |
Kuang-che Wu | b17ea8c | 2020-10-27 13:00:25 +0800 | [diff] [blame^] | 25 | import httplib2 |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 26 | |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 27 | from bisect_kit import cli |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 28 | from bisect_kit import codechange |
Kuang-che Wu | e121fae | 2018-11-09 16:18:39 +0800 | [diff] [blame] | 29 | from bisect_kit import errors |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 30 | from bisect_kit import git_util |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 31 | from bisect_kit import repo_util |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 32 | from bisect_kit import util |
| 33 | |
| 34 | logger = logging.getLogger(__name__) |
| 35 | |
| 36 | ANDROID_URL_BASE = ('https://android-build.googleplex.com/' |
| 37 | 'builds/branch/{branch}') |
| 38 | BUILD_IDS_BETWEEN_URL_TEMPLATE = ( |
| 39 | ANDROID_URL_BASE + '/build-ids/between/{end}/{start}') |
| 40 | BUILD_INFO_URL_TEMPLATE = ANDROID_URL_BASE + '/builds?id={build_id}' |
| 41 | |
| 42 | |
| 43 | def is_android_build_id(s): |
| 44 | """Is an Android build id.""" |
| 45 | # Build ID is always a number |
| 46 | return s.isdigit() |
| 47 | |
| 48 | |
| 49 | def argtype_android_build_id(s): |
| 50 | if not is_android_build_id(s): |
| 51 | msg = 'invalid android build id (%s)' % s |
| 52 | raise cli.ArgTypeError(msg, '9876543') |
| 53 | return s |
| 54 | |
| 55 | |
| 56 | def fetch_android_build_data(url): |
| 57 | """Fetches file from android build server. |
| 58 | |
| 59 | Args: |
| 60 | url: URL to fetch |
| 61 | |
| 62 | Returns: |
| 63 | file content (str). None if failed. |
| 64 | """ |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 65 | # Fetching android build data directly will fail without authentication. |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 66 | # Following code is just serving as demo purpose. You should modify or hook |
| 67 | # it with your own implementation. |
Kuang-che Wu | 084eef2 | 2020-03-11 18:29:48 +0800 | [diff] [blame] | 68 | logger.warning('fetch_android_build_data need to be hooked') |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 69 | try: |
Kuang-che Wu | 68f022d | 2019-11-29 14:38:48 +0800 | [diff] [blame] | 70 | return urllib.request.urlopen(url).read().decode('utf8') |
Kuang-che Wu | a7ddf9b | 2019-11-25 18:59:57 +0800 | [diff] [blame] | 71 | except urllib.request.URLError as e: |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 72 | logger.exception('failed to fetch "%s"', url) |
Kuang-che Wu | e121fae | 2018-11-09 16:18:39 +0800 | [diff] [blame] | 73 | raise errors.ExternalError(str(e)) |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 74 | |
| 75 | |
| 76 | def is_good_build(branch, flavor, build_id): |
| 77 | """Determine a build_id was succeeded. |
| 78 | |
| 79 | Args: |
| 80 | branch: The Android branch from which to retrieve the builds. |
| 81 | flavor: Target name of the Android image in question. |
| 82 | E.g. "cheets_x86-userdebug" or "cheets_arm-user". |
| 83 | build_id: Android build id |
| 84 | |
| 85 | Returns: |
| 86 | True if the given build was successful. |
| 87 | """ |
| 88 | |
| 89 | url = BUILD_INFO_URL_TEMPLATE.format(branch=branch, build_id=build_id) |
Kuang-che Wu | 68f022d | 2019-11-29 14:38:48 +0800 | [diff] [blame] | 90 | build = json.loads(fetch_android_build_data(url)) |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 91 | for target in build[0]['targets']: |
| 92 | if target['target']['name'] == flavor and target.get('successful'): |
| 93 | return True |
| 94 | return False |
| 95 | |
| 96 | |
Kuang-che Wu | 0a902f4 | 2019-01-21 18:58:32 +0800 | [diff] [blame] | 97 | def get_build_ids_between(branch, flavor, start, end): |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 98 | """Returns a list of build IDs. |
| 99 | |
| 100 | Args: |
| 101 | branch: The Android branch from which to retrieve the builds. |
Kuang-che Wu | 0a902f4 | 2019-01-21 18:58:32 +0800 | [diff] [blame] | 102 | flavor: Target name of the Android image in question. |
| 103 | E.g. "cheets_x86-userdebug" or "cheets_arm-user". |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 104 | start: The starting point build ID. (inclusive) |
| 105 | end: The ending point build ID. (inclusive) |
| 106 | |
| 107 | Returns: |
| 108 | A list of build IDs. (str) |
| 109 | """ |
Kuang-che Wu | 0a902f4 | 2019-01-21 18:58:32 +0800 | [diff] [blame] | 110 | # Do not remove this argument because our internal implementation need it. |
| 111 | del flavor # not used |
| 112 | |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 113 | # TODO(kcwu): remove pagination hack after b/68239878 fixed |
| 114 | build_id_set = set() |
| 115 | tmp_end = end |
| 116 | while True: |
| 117 | url = BUILD_IDS_BETWEEN_URL_TEMPLATE.format( |
| 118 | branch=branch, start=start, end=tmp_end) |
Kuang-che Wu | 68f022d | 2019-11-29 14:38:48 +0800 | [diff] [blame] | 119 | query_result = json.loads(fetch_android_build_data(url)) |
Kuang-che Wu | c89f2a2 | 2019-11-26 15:30:50 +0800 | [diff] [blame] | 120 | found_new = set(int(x) for x in query_result['ids']) - build_id_set |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 121 | if not found_new: |
| 122 | break |
| 123 | build_id_set.update(found_new) |
| 124 | tmp_end = min(build_id_set) |
| 125 | |
| 126 | logger.debug('Found %d builds in the range.', len(build_id_set)) |
| 127 | |
Kuang-che Wu | c89f2a2 | 2019-11-26 15:30:50 +0800 | [diff] [blame] | 128 | return [str(bid) for bid in sorted(build_id_set)] |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 129 | |
| 130 | |
| 131 | def lunch(android_root, flavor, *args, **kwargs): |
| 132 | """Helper to run commands with Android lunch env. |
| 133 | |
| 134 | Args: |
| 135 | android_root: root path of Android tree |
| 136 | flavor: lunch flavor |
| 137 | args: command to run |
| 138 | kwargs: extra arguments passed to util.Popen |
| 139 | """ |
| 140 | util.check_call('./android_lunch_helper.sh', android_root, flavor, *args, |
| 141 | **kwargs) |
| 142 | |
| 143 | |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 144 | def fetch_artifact_from_g3(flavor, build_id, filename, dest_folder): |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 145 | """Fetches Android build artifact. |
| 146 | |
| 147 | Args: |
| 148 | flavor: Android build flavor |
| 149 | build_id: Android build id |
| 150 | filename: artifact name |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 151 | dest_folder: local path to store the fetched artifact |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 152 | """ |
| 153 | util.check_call('/google/data/ro/projects/android/fetch_artifact', '--target', |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 154 | flavor, '--bid', build_id, filename, dest_folder) |
| 155 | |
| 156 | |
| 157 | def fetch_artifact(flavor, build_id, filename, dest_folder): |
| 158 | """Fetches Android build artifact. |
| 159 | |
| 160 | Args: |
| 161 | flavor: Android build flavor |
| 162 | build_id: Android build id |
| 163 | filename: artifact name |
| 164 | dest_folder: local path to store the fetched artifact |
| 165 | """ |
| 166 | if os.environ.get('SKYLAB_CLOUD_SERVICE_ACCOUNT_JSON'): |
| 167 | api = AndroidInternalApi() |
Zheng-Jie Chang | e62d4fe | 2020-10-14 05:50:37 +0800 | [diff] [blame] | 168 | api.download_artifact(build_id, flavor, filename, |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 169 | os.path.join(dest_folder, filename)) |
| 170 | else: |
| 171 | fetch_artifact_from_g3(flavor, build_id, filename, dest_folder) |
Kuang-che Wu | 708310b | 2018-03-28 17:24:34 +0800 | [diff] [blame] | 172 | |
| 173 | |
| 174 | def fetch_manifest(android_root, flavor, build_id): |
| 175 | """Fetches Android repo manifest of given build. |
| 176 | |
| 177 | Args: |
| 178 | android_root: root path of Android tree. Fetched manifest file will be |
| 179 | stored inside. |
| 180 | flavor: Android build flavor |
| 181 | build_id: Android build id |
| 182 | |
| 183 | Returns: |
| 184 | the local filename of manifest (relative to android_root/.repo/manifests/) |
| 185 | """ |
| 186 | # Assume manifest is flavor independent, thus not encoded into the file name. |
| 187 | manifest_name = 'manifest_%s.xml' % build_id |
| 188 | manifest_path = os.path.join(android_root, '.repo', 'manifests', |
| 189 | manifest_name) |
| 190 | if not os.path.exists(manifest_path): |
| 191 | fetch_artifact(flavor, build_id, manifest_name, manifest_path) |
| 192 | return manifest_name |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 193 | |
| 194 | |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 195 | def lookup_build_timestamp(flavor, build_id): |
| 196 | """Lookup timestamp of Android prebuilt. |
| 197 | |
| 198 | Args: |
| 199 | flavor: Android build flavor |
| 200 | build_id: Android build id |
| 201 | |
| 202 | Returns: |
| 203 | timestamp |
| 204 | """ |
| 205 | tmp_fn = tempfile.mktemp() |
| 206 | try: |
| 207 | fetch_artifact(flavor, build_id, 'BUILD_INFO', tmp_fn) |
Kuang-che Wu | 74bcb64 | 2020-02-20 18:45:53 +0800 | [diff] [blame] | 208 | with open(tmp_fn) as f: |
| 209 | data = json.load(f) |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 210 | return int(data['sync_start_time']) |
| 211 | finally: |
| 212 | if os.path.exists(tmp_fn): |
| 213 | os.unlink(tmp_fn) |
| 214 | |
| 215 | |
| 216 | class AndroidSpecManager(codechange.SpecManager): |
| 217 | """Repo manifest related operations. |
| 218 | |
| 219 | This class fetches and enumerates android manifest files, parses them, |
| 220 | and sync to disk state according to them. |
| 221 | """ |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 222 | |
| 223 | def __init__(self, config): |
| 224 | self.config = config |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 225 | self.manifest_dir = os.path.join(self.config['android_root'], '.repo', |
| 226 | 'manifests') |
| 227 | |
Zheng-Jie Chang | d968f55 | 2020-01-16 13:31:57 +0800 | [diff] [blame] | 228 | def collect_float_spec(self, old, new, fixed_specs=None): |
| 229 | del fixed_specs # unused |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 230 | result = [] |
| 231 | path = 'default.xml' |
| 232 | |
| 233 | commits = [] |
| 234 | old_timestamp = lookup_build_timestamp(self.config['flavor'], old) |
| 235 | new_timestamp = lookup_build_timestamp(self.config['flavor'], new) |
| 236 | for timestamp, git_rev in git_util.get_history(self.manifest_dir, path): |
| 237 | if timestamp < old_timestamp: |
| 238 | commits = [] |
| 239 | commits.append((timestamp, git_rev)) |
| 240 | if timestamp > new_timestamp: |
| 241 | break |
| 242 | |
| 243 | for timestamp, git_rev in commits: |
| 244 | result.append( |
| 245 | codechange.Spec(codechange.SPEC_FLOAT, git_rev, timestamp, path)) |
| 246 | return result |
| 247 | |
| 248 | def collect_fixed_spec(self, old, new): |
| 249 | result = [] |
Kuang-che Wu | 0a902f4 | 2019-01-21 18:58:32 +0800 | [diff] [blame] | 250 | revlist = get_build_ids_between(self.config['branch'], |
| 251 | self.config['flavor'], int(old), int(new)) |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 252 | for rev in revlist: |
| 253 | manifest_name = fetch_manifest(self.config['android_root'], |
| 254 | self.config['flavor'], rev) |
| 255 | path = os.path.join(self.manifest_dir, manifest_name) |
| 256 | timestamp = lookup_build_timestamp(self.config['flavor'], rev) |
| 257 | result.append( |
| 258 | codechange.Spec(codechange.SPEC_FIXED, rev, timestamp, path)) |
| 259 | return result |
| 260 | |
| 261 | def _load_manifest_content(self, spec): |
| 262 | if spec.spec_type == codechange.SPEC_FIXED: |
| 263 | manifest_name = fetch_manifest(self.config['branch'], |
| 264 | self.config['flavor'], spec.name) |
Kuang-che Wu | a572349 | 2019-11-25 20:59:34 +0800 | [diff] [blame] | 265 | with open(os.path.join(self.manifest_dir, manifest_name)) as f: |
| 266 | content = f.read() |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 267 | else: |
Kuang-che Wu | 89ac2e7 | 2018-07-25 17:39:07 +0800 | [diff] [blame] | 268 | content = git_util.get_file_from_revision(self.manifest_dir, spec.name, |
| 269 | spec.path) |
| 270 | return content |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 271 | |
| 272 | def parse_spec(self, spec): |
| 273 | logging.debug('parse_spec %s', spec.name) |
| 274 | manifest_content = self._load_manifest_content(spec) |
Kuang-che Wu | e4bae0b | 2018-07-19 12:10:14 +0800 | [diff] [blame] | 275 | parser = repo_util.ManifestParser(self.manifest_dir) |
| 276 | root = parser.parse_single_xml(manifest_content, allow_include=False) |
| 277 | spec.entries = parser.process_parsed_result(root) |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 278 | if spec.spec_type == codechange.SPEC_FIXED: |
Kuang-che Wu | fe1e88a | 2019-09-10 21:52:25 +0800 | [diff] [blame] | 279 | if not spec.is_static(): |
Kuang-che Wu | d1b7415 | 2020-05-20 08:46:46 +0800 | [diff] [blame] | 280 | raise ValueError('fixed spec %r has unexpected floating entries' % |
| 281 | spec.name) |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 282 | |
| 283 | def sync_disk_state(self, rev): |
Kuang-che Wu | d1d45b4 | 2018-07-05 00:46:45 +0800 | [diff] [blame] | 284 | manifest_name = fetch_manifest(self.config['android_root'], |
| 285 | self.config['flavor'], rev) |
Kuang-che Wu | acb6efd | 2018-04-25 18:52:58 +0800 | [diff] [blame] | 286 | repo_util.sync( |
| 287 | self.config['android_root'], |
| 288 | manifest_name=manifest_name, |
| 289 | current_branch=True) |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 290 | |
| 291 | |
| 292 | class AndroidInternalApi: |
| 293 | """Wrapper of android internal api. Singleton class""" |
| 294 | |
| 295 | _instance = None |
| 296 | _default_chunk_size = 20 * 1024 * 1024 # 20M |
| 297 | _retries = 5 |
| 298 | |
| 299 | def __new__(cls): |
| 300 | if cls._instance is None: |
| 301 | cls._instance = object.__new__(cls) |
| 302 | return cls._instance |
| 303 | |
| 304 | def __init__(self): |
| 305 | if not os.environ.get('SKYLAB_CLOUD_SERVICE_ACCOUNT_JSON'): |
Kuang-che Wu | f0bfd18 | 2020-10-26 15:52:29 +0800 | [diff] [blame] | 306 | raise errors.InternalError('SKYLAB_CLOUD_SERVICE_ACCOUNT_JSON ' |
| 307 | 'must defined when using Android Api') |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 308 | self.credentials = self.get_credentials() |
Zheng-Jie Chang | e8a25fe | 2020-10-27 11:02:24 +0800 | [diff] [blame] | 309 | self.service = googleapiclient.discovery.build( |
Zheng-Jie Chang | 5f41457 | 2020-09-28 12:47:53 +0800 | [diff] [blame] | 310 | 'androidbuildinternal', |
| 311 | 'v3', |
| 312 | credentials=self.credentials, |
| 313 | cache_discovery=False) |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 314 | |
| 315 | def get_credentials(self): |
| 316 | # TODO(zjchang): support compute engine type credential |
Kuang-che Wu | f0bfd18 | 2020-10-26 15:52:29 +0800 | [diff] [blame] | 317 | # pylint: disable=line-too-long |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 318 | credentials = google.oauth2.service_account.Credentials.from_service_account_file( |
| 319 | os.environ.get('SKYLAB_CLOUD_SERVICE_ACCOUNT_JSON'), |
| 320 | scopes=['https://www.googleapis.com/auth/androidbuild.internal'], |
| 321 | ) |
| 322 | return credentials |
| 323 | |
| 324 | def get_artifact_info(self, build_id, target, name, attempt_id=0): |
| 325 | """Get Android build artifact info. |
| 326 | |
| 327 | Args: |
| 328 | build_id: Android build ID. |
| 329 | target: Android build target. |
| 330 | name: Artifact name. |
| 331 | attempt_id: A build attempt. Defaults to 0. |
| 332 | |
| 333 | Returns: |
| 334 | Artifact info. |
| 335 | |
| 336 | Raises: |
| 337 | errors.ExternalError |
| 338 | """ |
| 339 | try: |
| 340 | artifact = self.service.buildartifact().get( |
| 341 | buildId=build_id, |
| 342 | target=target, |
| 343 | attemptId=attempt_id, |
| 344 | resourceId=name).execute(num_retries=self._retries) |
| 345 | except (googleapiclient.errors.HttpError, httplib2.HttpLib2Error) as e: |
| 346 | raise errors.ExternalError('Android API error: ' + str(e)) |
| 347 | |
| 348 | if artifact is None: |
| 349 | raise errors.ExternalError('Invalid artifact %s/%s/%s/%s' % |
| 350 | (build_id, target, attempt_id, name)) |
| 351 | return artifact |
| 352 | |
| 353 | def download_artifact(self, build_id, target, name, output_file, |
| 354 | attempt_id=0): |
| 355 | """Download one artifact. |
| 356 | |
| 357 | Args: |
| 358 | build_id: Android build ID. |
| 359 | target: Android build target. |
| 360 | name: Artifact name. |
| 361 | output_file: Target file path. |
| 362 | attempt_id: A build attempt. Defaults to 0. |
| 363 | |
| 364 | Raises: |
| 365 | errors.ExternalError |
| 366 | """ |
| 367 | artifact = self.get_artifact_info( |
| 368 | build_id, target, name, attempt_id=attempt_id) |
| 369 | chunksize = min(int(artifact['size']), self._default_chunk_size) |
| 370 | |
| 371 | download_request = self.service.buildartifact().get_media( |
| 372 | buildId=build_id, target=target, attemptId=attempt_id, resourceId=name) |
| 373 | |
| 374 | try: |
| 375 | with open(output_file, mode='wb') as f: |
Zheng-Jie Chang | e8a25fe | 2020-10-27 11:02:24 +0800 | [diff] [blame] | 376 | downloader = googleapiclient.http.MediaIoBaseDownload( |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 377 | f, download_request, chunksize=chunksize) |
| 378 | done = False |
| 379 | while not done: |
Kuang-che Wu | f0bfd18 | 2020-10-26 15:52:29 +0800 | [diff] [blame] | 380 | _status, done = downloader.next_chunk(num_retries=self._retries) |
Zheng-Jie Chang | ba21268 | 2020-09-18 16:30:08 +0800 | [diff] [blame] | 381 | except (googleapiclient.errors.HttpError, httplib2.HttpLib2Error) as e: |
| 382 | raise errors.ExternalError('Android API error: ' + str(e)) |