1# pylint: disable=g-direct-third-party-import
2# Copyright 2022 The Bazel Authors. All rights reserved.
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"""AndroidManifest tool to enforce a floor on the minSdkVersion attribute.
16
17Ensures that the minSdkVersion attribute is >= than the specified floor,
18and if the attribute is either not specified or less than the floor,
19sets it to the floor.
20"""
21
22import os
23import sys
24
25import xml.etree.ElementTree as ET
26from absl import app
27from absl import flags
28
29BUMP = "bump"
30VALIDATE = "validate"
31SET_DEFAULT = "set_default"
32
33USES_SDK = "uses-sdk"
34MIN_SDK_ATTRIB = "{http://schemas.android.com/apk/res/android}minSdkVersion"
35
36FLAGS = flags.FLAGS
37
38flags.DEFINE_enum(
39    "action",
40    None,
41    [BUMP, VALIDATE, SET_DEFAULT],
42    f"Action to perform, either {BUMP}, {VALIDATE}, or {SET_DEFAULT}")
43flags.DEFINE_string(
44    "manifest",
45    None,
46    "AndroidManifest.xml of the instrumentation APK")
47flags.DEFINE_integer(
48    "min_sdk_floor",
49    0,
50    "Min SDK floor",
51    lower_bound=0)
52# Needed for SET_DEFAULT
53flags.DEFINE_string(
54    "default_min_sdk",
55    None,
56    "Default min SDK")
57# Needed for BUMP and  SET_DEFAULT
58flags.DEFINE_string(
59    "output",
60    None,
61    f"Output AndroidManifest.xml to generate, only needed for {BUMP}")
62flags.DEFINE_string("log", None, "Path to write the log to")
63
64
65class MinSdkError(Exception):
66  """Raised when there is a problem with the min SDK attribute in AndroidManifest.xml."""
67
68
69def ParseNamespaces(xml_content):
70  """Parse namespaces first to keep the prefix.
71
72  Args:
73    xml_content: str, the contents of the AndroidManifest.xml file
74  """
75  # Always register the android namespace first. This will be overriden by
76  # any other definition in the manifest.
77  ET.register_namespace("android", "http://schemas.android.com/apk/res/android")
78  ns_parser = ET.XMLPullParser(events=["start-ns"])
79  ns_parser.feed(xml_content)
80  ns_parser.close()
81  for _, ns_tuple in ns_parser.read_events():
82    try:
83      ET.register_namespace(ns_tuple[0], ns_tuple[1])
84    except ValueError:
85      pass
86
87
88def _BumpMinSdk(xml_content, min_sdk_floor):
89  """Checks the min SDK in xml_content and replaces with min_sdk_floor if needed.
90
91  Args:
92    xml_content: str, the contents of the AndroidManifest.xml file
93    min_sdk_floor: int, the min SDK floor
94
95  Returns:
96    A tuple with the following elements:
97    - str: The xml contents of the manifest with the min SDK floor enforced.
98      This string will be equal to the input if the min SDK is already not less
99      than the floor.
100    - str: log message of action taken
101  """
102  if min_sdk_floor == 0:
103    return xml_content, "No min SDK floor specified. Manifest unchanged."
104
105  ParseNamespaces(xml_content)
106
107  root = ET.fromstring(xml_content)
108  uses_sdk = root.find(USES_SDK)
109  if uses_sdk is None:
110    ET.SubElement(root, USES_SDK, {MIN_SDK_ATTRIB: str(min_sdk_floor)})
111    return (
112        ET.tostring(root, encoding="utf-8", xml_declaration=True),
113        "No uses-sdk element found while floor is specified "
114        + f"({min_sdk_floor}). Min SDK added.")
115
116  min_sdk = uses_sdk.get(MIN_SDK_ATTRIB)
117  if min_sdk is None:
118    uses_sdk.set(MIN_SDK_ATTRIB, str(min_sdk_floor))
119    return (
120        ET.tostring(root, encoding="utf-8", xml_declaration=True),
121        "No minSdkVersion attribute found while floor is specified"
122        + f"({min_sdk_floor}). Min SDK added.")
123
124  try:
125    min_sdk_int = int(min_sdk)
126  except ValueError:
127    return (
128        xml_content,
129        f"Placeholder used for the minSdkVersion attribute ({min_sdk}). "
130        + "Manifest unchanged.")
131
132  if min_sdk_int < min_sdk_floor:
133    uses_sdk.set(MIN_SDK_ATTRIB, str(min_sdk_floor))
134    return (
135        ET.tostring(root, encoding="utf-8", xml_declaration=True),
136        f"minSdkVersion attribute specified in the manifest ({min_sdk}) "
137        + f"is less than the floor ({min_sdk_floor}). Min SDK replaced.")
138  return (
139      xml_content,
140      f"minSdkVersion attribute specified in the manifest ({min_sdk}) "
141      + f"is not less than the floor ({min_sdk_floor}). Manifest unchanged.")
142
143
144def _ValidateMinSdk(xml_content, min_sdk_floor):
145  """Checks the min SDK in xml_content and raises MinSdkError if it is either not specified or less than the floor.
146
147  Args:
148    xml_content: str, the contents of the AndroidManifest.xml file
149    min_sdk_floor: int, the min SDK floor
150  Returns:
151    str: log message
152  Raises:
153    MinSdkError: The min SDK is less than the specified floor.
154  """
155  if min_sdk_floor == 0:
156    return "No min SDK floor specified."
157
158  root = ET.fromstring(xml_content)
159
160  uses_sdk = root.find(USES_SDK)
161  if uses_sdk is None:
162    raise MinSdkError(
163        "No uses-sdk element found in manifest "
164        + f"while floor is specified ({min_sdk_floor}).")
165
166  min_sdk = uses_sdk.get(MIN_SDK_ATTRIB)
167  if min_sdk is None:
168    raise MinSdkError(
169        "No minSdkVersion attribute found in manifest "
170        + f"while floor is specified ({min_sdk_floor}).")
171
172  try:
173    min_sdk_int = int(min_sdk)
174  except ValueError:
175    return f"Placeholder minSdkVersion = {min_sdk}\n min SDK floor = {min_sdk_floor}"
176
177  if min_sdk_int < min_sdk_floor:
178    raise MinSdkError(
179        f"minSdkVersion attribute specified in  the manifest ({min_sdk}) "
180        + f"is less than the floor ({min_sdk_floor}).")
181  return f"minSdkVersion = {min_sdk}\n min SDK floor = {min_sdk_floor}"
182
183
184def _SetDefaultMinSdk(xml_content, default_min_sdk):
185  """Checks the min SDK in xml_content and replaces with default_min_sdk if it is not already set.
186
187  Args:
188    xml_content: str, the contents of the AndroidManifest.xml file
189    default_min_sdk: str, can be set to either a number or an unreleased version
190      full name
191
192  Returns:
193    A tuple with the following elements:
194    - str: The xml contents of the manifest with the min SDK floor enforced.
195      This string will be equal to the input if the min SDK is already set.
196    - str: log message of action taken
197  """
198  if default_min_sdk is None:
199    return xml_content, ("No default min SDK floor specified. Manifest "
200                         "unchanged.")
201
202  ParseNamespaces(xml_content)
203
204  root = ET.fromstring(xml_content)
205  uses_sdk = root.find(USES_SDK)
206  if uses_sdk is None:
207    ET.SubElement(root, USES_SDK, {MIN_SDK_ATTRIB: default_min_sdk})
208    return (
209        ET.tostring(root, encoding="utf-8", xml_declaration=True),
210        "No uses-sdk element found while default is specified. "
211        + f"Min SDK ({default_min_sdk}) added.")
212
213  min_sdk = uses_sdk.get(MIN_SDK_ATTRIB)
214  if min_sdk is None:
215    uses_sdk.set(MIN_SDK_ATTRIB, str(default_min_sdk))
216    return (
217        ET.tostring(root, encoding="utf-8", xml_declaration=True),
218        "No minSdkVersion attribute found while default is specified"
219        + f"({default_min_sdk}). Min SDK set to default.")
220
221  return (
222      xml_content,
223      f"minSdkVersion attribute specified in the manifest ({min_sdk}) "
224      + ". Manifest unchanged.")
225
226
227def main(unused_argv):
228  manifest_path = FLAGS.manifest
229  with open(manifest_path, "rb") as f:
230    manifest = f.read()
231
232  if FLAGS.action == BUMP:
233    output_path = FLAGS.output
234    dirname = os.path.dirname(output_path)
235    if not os.path.exists(dirname):
236      os.makedirs(dirname)
237
238    out_contents, log_message = _BumpMinSdk(manifest, FLAGS.min_sdk_floor)
239    with open(output_path, "wb") as f:
240      f.write(out_contents)
241
242  elif FLAGS.action == SET_DEFAULT:
243    output_path = FLAGS.output
244    dirname = os.path.dirname(output_path)
245    if not os.path.exists(dirname):
246      os.makedirs(dirname)
247
248    out_contents, log_message = _SetDefaultMinSdk(
249        manifest, FLAGS.default_min_sdk
250    )
251    with open(output_path, "wb") as f:
252      f.write(out_contents)
253
254  elif FLAGS.action == VALIDATE:
255    try:
256      log_message = _ValidateMinSdk(manifest, FLAGS.min_sdk_floor)
257    except MinSdkError as e:
258      sys.exit(str(e))
259  else:
260    sys.exit(f"Action must be either {BUMP} or {VALIDATE}")
261
262  if FLAGS.log is not None:
263    log_path = FLAGS.log
264    dirname = os.path.dirname(log_path)
265    if not os.path.exists(dirname):
266      os.makedirs(dirname)
267    with open(log_path, "w") as f:
268      f.write(log_message)
269
270if __name__ == "__main__":
271  app.run(main)
272