generate_xml_from_google_services_json.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. #!/usr/bin/python
  2. # Copyright 2016 Google LLC
  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. """Stand-alone implementation of the Gradle Firebase plugin.
  16. Converts the services json file to xml:
  17. https://googleplex-android.googlesource.com/platform/tools/base/+/studio-master-dev/build-system/google-services/src/main/groovy/com/google/gms/googleservices
  18. """
  19. __author__ = 'Wouter van Oortmerssen'
  20. import argparse
  21. import ctypes
  22. import json
  23. import os
  24. import platform
  25. import sys
  26. from xml.etree import ElementTree
  27. if platform.system().lower() == 'windows':
  28. import ctypes.wintypes # pylint: disable=g-import-not-at-top
  29. # Map Python 2's unicode method to encode a string as bytes in python 3.
  30. try:
  31. unicode('') # See whether unicode class is available (Python < 3)
  32. except NameError:
  33. unicode = str # pylint: disable=redefined-builtin,invalid-name
  34. # Input filename if it isn't set.
  35. DEFAULT_INPUT_FILENAME = 'app/google-services.json'
  36. # Output filename if it isn't set.
  37. DEFAULT_OUTPUT_FILENAME = 'res/values/googleservices.xml'
  38. # Input filename for .plist files, if it isn't set.
  39. DEFAULT_PLIST_INPUT_FILENAME = 'GoogleService-Info.plist'
  40. # Output filename for .json files, if it isn't set.
  41. DEFAULT_JSON_OUTPUT_FILENAME = 'google-services-desktop.json'
  42. OAUTH_CLIENT_TYPE_ANDROID_APP = 1
  43. OAUTH_CLIENT_TYPE_WEB = 3
  44. def read_xml_value(xml_node):
  45. """Utility method for reading values from the plist XML.
  46. Args:
  47. xml_node: An ElementTree node, that contains a value.
  48. Returns:
  49. The value of the node, or None, if it could not be read.
  50. """
  51. if xml_node.tag == 'string':
  52. return xml_node.text
  53. elif xml_node.tag == 'integer':
  54. return int(xml_node.text)
  55. elif xml_node.tag == 'real':
  56. return float(xml_node.text)
  57. elif xml_node.tag == 'false':
  58. return 0
  59. elif xml_node.tag == 'true':
  60. return 1
  61. else:
  62. # other types of input are ignored. (data, dates, arrays, etc.)
  63. return None
  64. def construct_plist_dictionary(xml_root):
  65. """Constructs a dictionary of values based on the contents of a plist file.
  66. Args:
  67. xml_root: An ElementTree node, that represents the root of the xml file
  68. that is to be parsed. (Which should be a dictionary containing
  69. key-value pairs of the properties that need to be extracted.)
  70. Returns:
  71. A dictionary, containing key-value pairs for all (supported) entries in the
  72. node.
  73. """
  74. xml_dict = xml_root.find('dict')
  75. if xml_dict is None:
  76. return None
  77. plist_dict = {}
  78. i = 0
  79. while i < len(xml_dict):
  80. if xml_dict[i].tag == 'key':
  81. key = xml_dict[i].text
  82. i += 1
  83. if i < len(xml_dict):
  84. value = read_xml_value(xml_dict[i])
  85. if value is not None:
  86. plist_dict[key] = value
  87. i += 1
  88. return plist_dict
  89. def update_dict_keys(key_map, input_dict):
  90. """Creates a dict from input_dict with the same values but new keys.
  91. Two dictionaries are passed to this function: the key_map that represents a
  92. mapping of source keys to destination keys, and the input_dict that is the
  93. dictionary that is to be duplicated, replacing any key that matches a source
  94. key with a destination key. Source keys that are not present in the
  95. input_dict will not have their destination key represented in the result.
  96. In other words, if key_map is `{'old': 'new', 'foo': 'bar'}`, and input_dict
  97. is `{'old': 10}`, the result will be `{'new': 10}`.
  98. Args:
  99. key_map (dict): A dictionary of strings to strings that maps source keys to
  100. destination keys.
  101. input_dict (dict): The dictionary of string keys to any value type, which
  102. is to be duplicated, replacing source keys with the corresponding
  103. destination keys from key_map.
  104. Returns:
  105. dict: A new dictionary with updated keys.
  106. """
  107. return {
  108. new_key: input_dict[old_key]
  109. for (old_key, new_key) in key_map.items()
  110. if old_key in input_dict
  111. }
  112. def construct_google_services_json(xml_dict):
  113. """Constructs a google services json file from a dictionary.
  114. Args:
  115. xml_dict: A dictionary of all the key/value pairs that are needed for the
  116. output json file.
  117. Returns:
  118. A string representing the output json file.
  119. """
  120. try:
  121. json_struct = {
  122. 'project_info':
  123. update_dict_keys(
  124. {
  125. 'GCM_SENDER_ID': 'project_number',
  126. 'DATABASE_URL': 'firebase_url',
  127. 'PROJECT_ID': 'project_id',
  128. 'STORAGE_BUCKET': 'storage_bucket'
  129. }, xml_dict),
  130. 'client': [{
  131. 'client_info': {
  132. 'mobilesdk_app_id': xml_dict['GOOGLE_APP_ID'],
  133. 'android_client_info': {
  134. 'package_name': xml_dict['BUNDLE_ID']
  135. }
  136. },
  137. 'oauth_client': [{
  138. 'client_id': xml_dict['CLIENT_ID'],
  139. }],
  140. 'api_key': [{
  141. 'current_key': xml_dict['API_KEY']
  142. }],
  143. 'services': {
  144. 'analytics_service': {
  145. 'status': xml_dict['IS_ANALYTICS_ENABLED']
  146. },
  147. 'appinvite_service': {
  148. 'status': xml_dict['IS_APPINVITE_ENABLED']
  149. }
  150. }
  151. },],
  152. 'configuration_version':
  153. '1'
  154. }
  155. return json.dumps(json_struct, indent=2)
  156. except KeyError as e:
  157. sys.stderr.write('Could not find key in plist file: [%s]\n' % (e.args[0]))
  158. return None
  159. def convert_plist_to_json(plist_string, input_filename):
  160. """Converts an input plist string into a .json file and saves it.
  161. Args:
  162. plist_string: The contents of the loaded plist file.
  163. input_filename: The file name that the plist data was read from.
  164. Returns:
  165. the converted string, or None if there were errors.
  166. """
  167. try:
  168. root = ElementTree.fromstring(plist_string)
  169. except ElementTree.ParseError:
  170. sys.stderr.write('Error parsing file %s.\n'
  171. 'It does not appear to be valid XML.\n' % (input_filename))
  172. return None
  173. plist_dict = construct_plist_dictionary(root)
  174. if plist_dict is None:
  175. sys.stderr.write('In file %s, could not locate a top-level \'dict\' '
  176. 'element.\n'
  177. 'File format should be plist XML, with a top-level '
  178. 'dictionary containing project settings as key-value '
  179. 'pairs.\n' % (input_filename))
  180. return None
  181. json_string = construct_google_services_json(plist_dict)
  182. return json_string
  183. def gen_string(parent, name, text):
  184. """Generate one <string /> element and put into the list of keeps.
  185. Args:
  186. parent: The object that will hold the string.
  187. name: The name to store the string under.
  188. text: The text of the string.
  189. """
  190. if text:
  191. prev = parent.get('tools:keep', '')
  192. if prev:
  193. prev += ','
  194. parent.set('tools:keep', prev + '@string/' + name)
  195. child = ElementTree.SubElement(parent, 'string', {
  196. 'name': name,
  197. 'translatable': 'false'
  198. })
  199. child.text = text
  200. def indent(elem, level=0):
  201. """Recurse through XML tree and add indentation.
  202. Args:
  203. elem: The element to recurse over
  204. level: The current indentation level.
  205. """
  206. i = '\n' + level*' '
  207. if elem is not None:
  208. if not elem.text or not elem.text.strip():
  209. elem.text = i + ' '
  210. if not elem.tail or not elem.tail.strip():
  211. elem.tail = i
  212. for elem in elem:
  213. indent(elem, level+1)
  214. if not elem.tail or not elem.tail.strip():
  215. elem.tail = i
  216. else:
  217. if level and (not elem.tail or not elem.tail.strip()):
  218. elem.tail = i
  219. def argv_as_unicode_win32():
  220. """Returns unicode command line arguments on windows.
  221. """
  222. get_command_line_w = ctypes.cdll.kernel32.GetCommandLineW
  223. get_command_line_w.restype = ctypes.wintypes.LPCWSTR
  224. # CommandLineToArgvW parses the Unicode command line
  225. command_line_to_argv_w = ctypes.windll.shell32.CommandLineToArgvW
  226. command_line_to_argv_w.argtypes = [
  227. ctypes.wintypes.LPCWSTR,
  228. ctypes.POINTER(ctypes.c_int)
  229. ]
  230. command_line_to_argv_w.restype = ctypes.POINTER(
  231. ctypes.wintypes.LPWSTR)
  232. argc = ctypes.c_int(0)
  233. argv = command_line_to_argv_w(get_command_line_w(), argc)
  234. # Strip the python executable from the arguments if it exists
  235. # (It would be listed as the first argument on the windows command line, but
  236. # not in the arguments to the python script)
  237. sys_argv_len = len(sys.argv)
  238. return [unicode(argv[i]) for i in
  239. range(argc.value - sys_argv_len, argc.value)]
  240. def main():
  241. parser = argparse.ArgumentParser(
  242. description=((
  243. 'Converts a Firebase %s into %s similar to the Gradle plugin, or '
  244. 'converts a Firebase %s into a %s suitible for use on desktop apps.' %
  245. (DEFAULT_INPUT_FILENAME, DEFAULT_OUTPUT_FILENAME,
  246. DEFAULT_PLIST_INPUT_FILENAME, DEFAULT_JSON_OUTPUT_FILENAME))))
  247. parser.add_argument('-i', help='Override input file name',
  248. metavar='FILE', required=False)
  249. parser.add_argument('-o', help='Override destination file name',
  250. metavar='FILE', required=False)
  251. parser.add_argument('-p', help=('Package ID to select within the set of '
  252. 'packages in the input file. If this is '
  253. 'not specified, the first package in the '
  254. 'input file is selected.'))
  255. parser.add_argument('-l', help=('List all package IDs referenced by the '
  256. 'input file. If this is specified, '
  257. 'the output file is not created.'),
  258. action='store_true', default=False, required=False)
  259. parser.add_argument('-f', help=('Print project fields from the input file '
  260. 'in the form \'name=value\\n\' for each '
  261. 'field. If this is specified, the output '
  262. 'is not created.'),
  263. action='store_true', default=False, required=False)
  264. parser.add_argument(
  265. '--plist',
  266. help=(
  267. 'Specifies a plist file to convert to a JSON configuration file. '
  268. 'If this is enabled, the script will expect a .plist file as input, '
  269. 'which it will convert into %s file. The output file is '
  270. '*not* suitable for use with Firebase on Android.' %
  271. (DEFAULT_JSON_OUTPUT_FILENAME)),
  272. action='store_true',
  273. default=False,
  274. required=False)
  275. # python 2 on Windows doesn't handle unicode arguments well, so we need to
  276. # pre-process the command line arguments before trying to parse them.
  277. if platform.system() == 'Windows':
  278. sys.argv = argv_as_unicode_win32()
  279. args = parser.parse_args()
  280. if args.plist:
  281. input_filename = DEFAULT_PLIST_INPUT_FILENAME
  282. output_filename = DEFAULT_JSON_OUTPUT_FILENAME
  283. else:
  284. input_filename = DEFAULT_INPUT_FILENAME
  285. output_filename = DEFAULT_OUTPUT_FILENAME
  286. if args.i:
  287. # Encode the input string (type unicode) as a normal string (type str)
  288. # using the 'utf-8' encoding so that it can be worked with the same as
  289. # input names from other sources (like the defaults).
  290. input_filename_raw = args.i.encode('utf-8')
  291. # Decode the filename to a unicode string using the 'utf-8' encoding to
  292. # properly handle filepaths with unicode characters in them.
  293. input_filename = input_filename_raw.decode('utf-8')
  294. if args.o:
  295. output_filename = args.o
  296. with open(input_filename, 'r') as ifile:
  297. file_string = ifile.read()
  298. json_string = None
  299. if args.plist:
  300. json_string = convert_plist_to_json(file_string, input_filename)
  301. if json_string is None:
  302. return 1
  303. jsobj = json.loads(json_string)
  304. else:
  305. jsobj = json.loads(file_string)
  306. root = ElementTree.Element('resources')
  307. root.set('xmlns:tools', 'http://schemas.android.com/tools')
  308. project_info = jsobj.get('project_info')
  309. if project_info:
  310. gen_string(root, 'firebase_database_url', project_info.get('firebase_url'))
  311. gen_string(root, 'gcm_defaultSenderId', project_info.get('project_number'))
  312. gen_string(root, 'google_storage_bucket',
  313. project_info.get('storage_bucket'))
  314. gen_string(root, 'project_id', project_info.get('project_id'))
  315. if args.f:
  316. if not project_info:
  317. sys.stderr.write('No project info found in %s.' % input_filename)
  318. return 1
  319. for field, value in sorted(project_info.items()):
  320. sys.stdout.write('%s=%s\n' % (field, value))
  321. return 0
  322. packages = set()
  323. client_list = jsobj.get('client')
  324. if client_list:
  325. # Search for the user specified package in the file.
  326. selected_package_name = ''
  327. selected_client = client_list[0]
  328. find_package_name = args.p
  329. for client in client_list:
  330. package_name = client.get('client_info', {}).get(
  331. 'android_client_info', {}).get('package_name', '')
  332. if not package_name:
  333. package_name = client.get('oauth_client', {}).get(
  334. 'android_info', {}).get('package_name', '')
  335. if package_name:
  336. if not selected_package_name:
  337. selected_package_name = package_name
  338. selected_client = client
  339. if package_name == find_package_name:
  340. selected_package_name = package_name
  341. selected_client = client
  342. packages.add(package_name)
  343. if args.p and selected_package_name != find_package_name:
  344. sys.stderr.write('No packages found in %s which match the package '
  345. 'name %s\n'
  346. '\n'
  347. 'Found the following:\n'
  348. '%s\n' % (input_filename, find_package_name,
  349. '\n'.join(packages)))
  350. return 1
  351. client_api_key = selected_client.get('api_key')
  352. if client_api_key:
  353. client_api_key0 = client_api_key[0]
  354. gen_string(root, 'google_api_key', client_api_key0.get('current_key'))
  355. gen_string(root, 'google_crash_reporting_api_key',
  356. client_api_key0.get('current_key'))
  357. client_info = selected_client.get('client_info')
  358. if client_info:
  359. gen_string(root, 'google_app_id', client_info.get('mobilesdk_app_id'))
  360. # Only include the first matching OAuth client ID per type.
  361. client_id_web_parsed = False
  362. client_id_android_parsed = False
  363. oauth_client_list = selected_client.get('oauth_client')
  364. if oauth_client_list:
  365. for oauth_client in oauth_client_list:
  366. client_type = oauth_client.get('client_type')
  367. client_id = oauth_client.get('client_id')
  368. if not (client_type and client_id): continue
  369. if (client_type == OAUTH_CLIENT_TYPE_WEB and
  370. not client_id_web_parsed):
  371. gen_string(root, 'default_web_client_id', client_id)
  372. client_id_web_parsed = True
  373. if (client_type == OAUTH_CLIENT_TYPE_ANDROID_APP and
  374. not client_id_android_parsed):
  375. gen_string(root, 'default_android_client_id', client_id)
  376. client_id_android_parsed = True
  377. services = selected_client.get('services')
  378. if services:
  379. ads_service = services.get('ads_service')
  380. if ads_service:
  381. gen_string(root, 'test_banner_ad_unit_id',
  382. ads_service.get('test_banner_ad_unit_id'))
  383. gen_string(root, 'test_interstitial_ad_unit_id',
  384. ads_service.get('test_interstitial_ad_unit_id'))
  385. analytics_service = services.get('analytics_service')
  386. if analytics_service:
  387. analytics_property = analytics_service.get('analytics_property')
  388. if analytics_property:
  389. gen_string(root, 'ga_trackingId',
  390. analytics_property.get('tracking_id'))
  391. # enable this once we have an example if this service being present
  392. # in the json data:
  393. maps_service_enabled = False
  394. if maps_service_enabled:
  395. maps_service = services.get('maps_service')
  396. if maps_service:
  397. maps_api_key = maps_service.get('api_key')
  398. if maps_api_key:
  399. for k in range(0, len(maps_api_key)):
  400. # generates potentially multiple of these keys, which is
  401. # the same behavior as the java plugin.
  402. gen_string(root, 'google_maps_key',
  403. maps_api_key[k].get('maps_api_key'))
  404. tree = ElementTree.ElementTree(root)
  405. indent(root)
  406. if args.l:
  407. for package in sorted(packages):
  408. if package:
  409. sys.stdout.write(package + '\n')
  410. else:
  411. path = os.path.dirname(output_filename)
  412. if path and not os.path.exists(path):
  413. os.makedirs(path)
  414. if not args.plist:
  415. tree.write(output_filename, 'utf-8', True)
  416. else:
  417. with open(output_filename, 'w') as ofile:
  418. ofile.write(json_string)
  419. return 0
  420. if __name__ == '__main__':
  421. sys.exit(main())