ca02af9b5e5198680fade02f606add131544113a
[platform/framework/web/crosswalk.git] / src / xwalk / app / tools / android / customize.py
1 #!/usr/bin/env python
2
3 # Copyright (c) 2013 Intel Corporation. All rights reserved.
4 # Use of this source code is governed by a BSD-style license that can be
5 # found in the LICENSE file.
6
7 import compress_js_and_css
8 import fnmatch
9 import json
10 import optparse
11 import os
12 import re
13 import shutil
14 import stat
15 import sys
16
17 from app_info import AppInfo
18 from customize_launch_screen import CustomizeLaunchScreen
19 from handle_xml import AddElementAttribute
20 from handle_xml import AddElementAttributeAndText
21 from handle_xml import EditElementAttribute
22 from handle_xml import EditElementValueByNodeName
23 from handle_permissions import HandlePermissions
24 from xml.dom import minidom
25
26
27 def VerifyPackageName(value):
28   regex = r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'
29   descrpt = 'Each part of package'
30   sample = 'org.xwalk.example, org.xwalk.example_'
31
32   if len(value) >= 128:
33     print('To be safe, the length of package name or app name '
34           'should be less than 128.')
35     sys.exit(6)
36
37   if not re.match(regex, value):
38     print('Error: %s name should be started with letters and should not '
39           'contain invalid characters.\n'
40           'It may contain lowercase letters, numbers, blank spaces and '
41           'underscores\n'
42           'Sample: %s' % (descrpt, sample))
43     sys.exit(6)
44
45
46 def ReplaceSpaceWithUnderscore(value):
47   return value.replace(' ', '_')
48
49
50 def ReplaceInvalidChars(value, mode='default'):
51   """ Replace the invalid chars with '_' for input string.
52   Args:
53     value: the original string.
54     mode: the target usage mode of original string.
55   """
56   if mode == 'default':
57     invalid_chars = '\/:*?"<>|- '
58   elif mode == 'apkname':
59     invalid_chars = '\/:.*?"<>|- '
60   for c in invalid_chars:
61     if mode == 'apkname' and c in value:
62       print("Illegal character: '%s' is replaced with '_'" % c)
63     value = value.replace(c, '_')
64   return value
65
66
67 def GetFilesByExt(path, ext, sub_dir=True):
68   if os.path.exists(path):
69     file_list = []
70     for name in os.listdir(path):
71       full_name = os.path.join(path, name)
72       st = os.lstat(full_name)
73       if stat.S_ISDIR(st.st_mode) and sub_dir:
74         file_list += GetFilesByExt(full_name, ext)
75       elif os.path.isfile(full_name):
76         if fnmatch.fnmatch(full_name, ext):
77           file_list.append(full_name)
78     return file_list
79   else:
80     return []
81
82
83 def ParseParameterForCompressor(option, value, values, parser):
84   if ((not values or values.startswith('-'))
85       and value.find('--compressor') != -1):
86     values = 'all'
87   val = values
88   if parser.rargs and not parser.rargs[0].startswith('-'):
89     val = parser.rargs[0]
90     parser.rargs.pop(0)
91   setattr(parser.values, option.dest, val)
92
93
94 def CompressSourceFiles(app_root, compressor):
95   js_list = []
96   css_list = []
97   js_ext = '*.js'
98   css_ext = '*.css'
99
100   if compressor == 'all' or compressor == 'js':
101     js_list = GetFilesByExt(app_root, js_ext)
102     compress_js_and_css.CompressJavaScript(js_list)
103
104   if compressor == 'all' or compressor == 'css':
105     css_list = GetFilesByExt(app_root, css_ext)
106     compress_js_and_css.CompressCss(css_list)
107
108
109 def Prepare(app_info, compressor):
110   name = app_info.android_name
111   package = app_info.package
112   app_root = app_info.app_root
113   if os.path.exists(name):
114     shutil.rmtree(name)
115   shutil.copytree('template', name)
116   shutil.rmtree(os.path.join(name, 'src'))
117   src_root = os.path.join('template', 'src', 'org', 'xwalk', 'app', 'template')
118   src_activity = os.path.join(src_root, 'AppTemplateActivity.java')
119   if not os.path.isfile(src_activity):
120     print ('Please make sure that the java file'
121            ' of activity does exist.')
122     sys.exit(7)
123   root_path = os.path.join(name, 'src', package.replace('.', os.path.sep))
124   if not os.path.exists(root_path):
125     os.makedirs(root_path)
126   dest_activity = name + 'Activity.java'
127   shutil.copyfile(src_activity, os.path.join(root_path, dest_activity))
128   if app_root:
129     assets_path = os.path.join(name, 'assets', 'www')
130     if os.path.isdir(assets_path):
131       shutil.rmtree(assets_path)
132     shutil.copytree(app_root, assets_path)
133     if compressor:
134       CompressSourceFiles(assets_path, compressor)
135
136
137 def EncodingUnicodeValue(value):
138   try:
139     if isinstance(value, unicode):
140       value = value.encode("utf-8")
141   except NameError:
142     pass
143   return value
144
145
146 def CustomizeStringXML(name, description):
147   strings_path = os.path.join(name, 'res', 'values', 'strings.xml')
148   if not os.path.isfile(strings_path):
149     print ('Please make sure strings_xml'
150            ' exists under template folder.')
151     sys.exit(6)
152
153   if description:
154     description = EncodingUnicodeValue(description)
155     xmldoc = minidom.parse(strings_path)
156     AddElementAttributeAndText(xmldoc, 'string', 'name', 'description',
157                                description)
158     strings_file = open(strings_path, 'w')
159     xmldoc.writexml(strings_file, encoding='utf-8')
160     strings_file.close()
161
162
163 def CustomizeThemeXML(name, fullscreen, manifest):
164   theme_path = os.path.join(name, 'res', 'values-v14', 'theme.xml')
165   if not os.path.isfile(theme_path):
166     print('Error: theme.xml is missing in the build tool.')
167     sys.exit(6)
168
169   theme_xmldoc = minidom.parse(theme_path)
170   if fullscreen:
171     EditElementValueByNodeName(theme_xmldoc, 'item',
172                                'android:windowFullscreen', 'true')
173   has_background = CustomizeLaunchScreen(manifest, name)
174   if has_background:
175     EditElementValueByNodeName(theme_xmldoc, 'item',
176                                'android:windowBackground',
177                                '@drawable/launchscreen_bg')
178   theme_file = open(theme_path, 'w')
179   theme_xmldoc.writexml(theme_file, encoding='utf-8')
180   theme_file.close()
181
182
183 def CustomizeXML(app_info, description, icon_dict, manifest, permissions):
184   app_version = app_info.app_version
185   app_versionCode = app_info.app_versionCode
186   name = app_info.android_name
187   orientation = app_info.orientation
188   package = app_info.package
189   app_name = app_info.app_name
190   # Chinese character with unicode get from 'manifest.json' will cause
191   # 'UnicodeEncodeError' when finally wrote to 'AndroidManifest.xml'.
192   app_name = EncodingUnicodeValue(app_name)
193   # If string start with '@' or '?', it will be treated as Android resource,
194   # which will cause 'No resource found' error,
195   # append a space before '@' or '?' to fix that.
196   if app_name.startswith('@') or app_name.startswith('?'):
197     app_name = ' ' + app_name
198   manifest_path = os.path.join(name, 'AndroidManifest.xml')
199   if not os.path.isfile(manifest_path):
200     print ('Please make sure AndroidManifest.xml'
201            ' exists under template folder.')
202     sys.exit(6)
203
204   CustomizeStringXML(name, description)
205   CustomizeThemeXML(name, app_info.fullscreen_flag, manifest)
206   xmldoc = minidom.parse(manifest_path)
207   EditElementAttribute(xmldoc, 'manifest', 'package', package)
208   if app_versionCode:
209     EditElementAttribute(xmldoc, 'manifest', 'android:versionCode',
210                          str(app_versionCode))
211   if app_version:
212     EditElementAttribute(xmldoc, 'manifest', 'android:versionName',
213                          app_version)
214   if description:
215     EditElementAttribute(xmldoc, 'manifest', 'android:description',
216                          "@string/description")
217   HandlePermissions(permissions, xmldoc)
218   EditElementAttribute(xmldoc, 'application', 'android:label', app_name)
219   activity_name = package + '.' + name + 'Activity'
220   EditElementAttribute(xmldoc, 'activity', 'android:name', activity_name)
221   EditElementAttribute(xmldoc, 'activity', 'android:label', app_name)
222   if orientation:
223     EditElementAttribute(xmldoc, 'activity', 'android:screenOrientation',
224                          orientation)
225   icon_name = CustomizeIcon(name, app_info.app_root, app_info.icon, icon_dict)
226   if icon_name:
227     EditElementAttribute(xmldoc, 'application', 'android:icon',
228                          '@drawable/%s' % icon_name)
229
230   file_handle = open(os.path.join(name, 'AndroidManifest.xml'), 'w')
231   xmldoc.writexml(file_handle, encoding='utf-8')
232   file_handle.close()
233
234
235 def ReplaceString(file_path, src, dest):
236   file_handle = open(file_path, 'r')
237   src_content = file_handle.read()
238   file_handle.close()
239   file_handle = open(file_path, 'w')
240   dest_content = src_content.replace(src, dest)
241   file_handle.write(dest_content)
242   file_handle.close()
243
244
245 def SetVariable(file_path, string_line, variable, value):
246   function_string = ('%sset%s(%s);\n' %
247                     ('        ', variable, value))
248   temp_file_path = file_path + '.backup'
249   file_handle = open(temp_file_path, 'w+')
250   for line in open(file_path):
251     file_handle.write(line)
252     if (line.find(string_line) >= 0):
253       file_handle.write(function_string)
254   file_handle.close()
255   shutil.move(temp_file_path, file_path)
256
257
258 def CustomizeJava(app_info, app_url, app_local_path, keep_screen_on):
259   name = app_info.android_name
260   package = app_info.package
261   root_path = os.path.join(name, 'src', package.replace('.', os.path.sep))
262   dest_activity = os.path.join(root_path, name + 'Activity.java')
263   ReplaceString(dest_activity, 'org.xwalk.app.template', package)
264   ReplaceString(dest_activity, 'AppTemplate', name)
265   manifest_file = os.path.join(name, 'assets/www', 'manifest.json')
266   if os.path.isfile(manifest_file):
267     ReplaceString(
268         dest_activity,
269         'loadAppFromUrl("file:///android_asset/www/index.html")',
270         'loadAppFromManifest("file:///android_asset/www/manifest.json")')
271   else:
272     if app_url:
273       if re.search(r'^http(|s)', app_url):
274         ReplaceString(dest_activity, 'file:///android_asset/www/index.html',
275                       app_url)
276     elif app_local_path:
277       if os.path.isfile(os.path.join(name, 'assets/www', app_local_path)):
278         ReplaceString(dest_activity, 'file:///android_asset/www/index.html',
279                       'app://' + package + '/' + app_local_path)
280       else:
281         print ('Please make sure that the relative path of entry file'
282                ' is correct.')
283         sys.exit(8)
284
285   if app_info.remote_debugging:
286     SetVariable(dest_activity,
287                 'public void onCreate(Bundle savedInstanceState)',
288                 'RemoteDebugging', 'true')
289   if app_info.fullscreen_flag:
290     SetVariable(dest_activity,
291                 'super.onCreate(savedInstanceState)',
292                 'IsFullscreen', 'true')
293   if keep_screen_on:
294     ReplaceString(
295         dest_activity,
296         'super.onCreate(savedInstanceState);',
297         'super.onCreate(savedInstanceState);\n        ' +
298         'getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);')
299
300
301 def CopyExtensionFile(extension_name, suffix, src_path, dest_path):
302   # Copy the file from src_path into dest_path.
303   dest_extension_path = os.path.join(dest_path, extension_name)
304   if os.path.exists(dest_extension_path):
305     # TODO: Refine it by renaming it internally.
306     print('Error: duplicated extension names "%s" are found. Please rename it.'
307           % extension_name)
308     sys.exit(9)
309   else:
310     os.mkdir(dest_extension_path)
311
312   file_name = extension_name + suffix
313   src_file = os.path.join(src_path, file_name)
314   dest_file = os.path.join(dest_extension_path, file_name)
315   if not os.path.isfile(src_file):
316     print('Error: %s was not found in %s.' % (file_name, src_path))
317     sys.exit(9)
318   else:
319     shutil.copyfile(src_file, dest_file)
320
321
322 def CustomizeExtensions(app_info, extensions):
323   """Copy the files from external extensions and merge them into APK.
324
325   The directory of one external extension should be like:
326     myextension/
327       myextension.jar
328       myextension.js
329       myextension.json
330   That means the name of the internal files should be the same as the
331   directory name.
332   For .jar files, they'll be copied to xwalk-extensions/ and then
333   built into classes.dex in make_apk.py.
334   For .js files, they'll be copied into assets/xwalk-extensions/.
335   For .json files, the'll be merged into one file called
336   extensions-config.json and copied into assets/.
337   """
338   if not extensions:
339     return
340   name = app_info.android_name
341   apk_path = name
342   apk_assets_path = os.path.join(apk_path, 'assets')
343   extensions_string = 'xwalk-extensions'
344
345   # Set up the target directories and files.
346   dest_jar_path = os.path.join(apk_path, extensions_string)
347   os.mkdir(dest_jar_path)
348   dest_js_path = os.path.join(apk_assets_path, extensions_string)
349   os.mkdir(dest_js_path)
350   apk_extensions_json_path = os.path.join(apk_assets_path,
351                                           'extensions-config.json')
352
353   # Split the paths into a list.
354   extension_paths = extensions.split(os.pathsep)
355   extension_json_list = []
356   for source_path in extension_paths:
357     if not os.path.exists(source_path):
358       print('Error: can\'t find the extension directory \'%s\'.' % source_path)
359       sys.exit(9)
360     # Remove redundant separators to avoid empty basename.
361     source_path = os.path.normpath(source_path)
362     extension_name = os.path.basename(source_path)
363
364     # Copy .jar file into xwalk-extensions.
365     CopyExtensionFile(extension_name, '.jar', source_path, dest_jar_path)
366
367     # Copy .js file into assets/xwalk-extensions.
368     CopyExtensionFile(extension_name, '.js', source_path, dest_js_path)
369
370     # Merge .json file into assets/xwalk-extensions.
371     file_name = extension_name + '.json'
372     src_file = os.path.join(source_path, file_name)
373     if not os.path.isfile(src_file):
374       print('Error: %s is not found in %s.' % (file_name, source_path))
375       sys.exit(9)
376     else:
377       src_file_handle = open(src_file)
378       src_file_content = src_file_handle.read()
379       json_output = json.JSONDecoder().decode(src_file_content)
380       # Below 3 properties are used by runtime. See extension manager.
381       # And 'permissions' will be merged.
382       if not ('name' in json_output and
383               'class' in json_output and
384               'jsapi' in json_output):
385         print ('Error: properties \'name\', \'class\' and \'jsapi\' in a json '
386                'file are mandatory.')
387         sys.exit(9)
388       # Reset the path for JavaScript.
389       js_path_prefix = extensions_string + '/' + extension_name + '/'
390       json_output['jsapi'] = js_path_prefix + json_output['jsapi']
391       extension_json_list.append(json_output)
392       # Merge the permissions of extensions into AndroidManifest.xml.
393       manifest_path = os.path.join(name, 'AndroidManifest.xml')
394       xmldoc = minidom.parse(manifest_path)
395       if ('permissions' in json_output):
396         # Get used permission list to avoid repetition as "--permissions"
397         # option can also be used to declare used permissions.
398         existingList = []
399         usedPermissions = xmldoc.getElementsByTagName("uses-permission")
400         for used in usedPermissions:
401           existingList.append(used.getAttribute("android:name"))
402
403         # Add the permissions to manifest file if not used yet.
404         for p in json_output['permissions']:
405           if p in existingList:
406             continue
407           AddElementAttribute(xmldoc, 'uses-permission', 'android:name', p)
408           existingList.append(p)
409
410         # Write to the manifest file to save the update.
411         file_handle = open(manifest_path, 'w')
412         xmldoc.writexml(file_handle, encoding='utf-8')
413         file_handle.close()
414
415   # Write configuration of extensions into the target extensions-config.json.
416   if extension_json_list:
417     extensions_string = json.JSONEncoder().encode(extension_json_list)
418     extension_json_file = open(apk_extensions_json_path, 'w')
419     extension_json_file.write(extensions_string)
420     extension_json_file.close()
421
422
423 def GenerateCommandLineFile(app_info, xwalk_command_line):
424   if xwalk_command_line == '':
425     return
426   assets_path = os.path.join(app_info.android_name, 'assets')
427   file_path = os.path.join(assets_path, 'xwalk-command-line')
428   command_line_file = open(file_path, 'w')
429   command_line_file.write('xwalk ' + xwalk_command_line)
430
431
432 def CustomizeIconByDict(name, app_root, icon_dict):
433   icon_name = None
434   drawable_dict = {'ldpi': [1, 37], 'mdpi': [37, 72], 'hdpi': [72, 96],
435                    'xhdpi': [96, 120], 'xxhdpi': [120, 144],
436                    'xxxhdpi': [144, 168]}
437   if not icon_dict:
438     return icon_name
439
440   try:
441     icon_dict = dict((int(k), v) for k, v in icon_dict.items())
442   except ValueError:
443     print('The key of icon in the manifest file should be a number.')
444
445   if len(icon_dict) > 0:
446     icon_list = sorted(icon_dict.items(), key=lambda d: d[0])
447     for kd, vd in drawable_dict.items():
448       for item in icon_list:
449         if item[0] >= vd[0] and item[0] < vd[1]:
450           drawable_path = os.path.join(name, 'res', 'drawable-' + kd)
451           if not os.path.exists(drawable_path):
452             os.makedirs(drawable_path)
453           icon = os.path.join(app_root, item[1])
454           if icon and os.path.isfile(icon):
455             icon_name = os.path.basename(icon)
456             icon_suffix = icon_name.split('.')[-1]
457             shutil.copyfile(icon, os.path.join(drawable_path,
458                                                'icon.' + icon_suffix))
459             icon_name = 'icon'
460           elif icon and (not os.path.isfile(icon)):
461             print('Error: "%s" does not exist.' % icon)
462             sys.exit(6)
463           break
464   return icon_name
465
466
467 def CustomizeIconByOption(name, icon):
468   if os.path.isfile(icon):
469     drawable_path = os.path.join(name, 'res', 'drawable')
470     if not os.path.exists(drawable_path):
471       os.makedirs(drawable_path)
472     icon_file = os.path.basename(icon)
473     icon_file = ReplaceInvalidChars(icon_file)
474     shutil.copyfile(icon, os.path.join(drawable_path, icon_file))
475     icon_name = os.path.splitext(icon_file)[0]
476     return icon_name
477   else:
478     print('Error: "%s" does not exist.')
479     sys.exit(6)
480
481
482 def CustomizeIcon(name, app_root, icon, icon_dict):
483   icon_name = None
484   if icon:
485     icon_name = CustomizeIconByOption(name, icon)
486   else:
487     icon_name = CustomizeIconByDict(name, app_root, icon_dict)
488   return icon_name
489
490
491 def CustomizeAll(app_info, description, icon_dict, permissions, app_url,
492                  app_local_path, keep_screen_on, extensions, manifest,
493                  xwalk_command_line='', compressor=None):
494   try:
495     Prepare(app_info, compressor)
496     CustomizeXML(app_info, description, icon_dict, manifest, permissions)
497     CustomizeJava(app_info, app_url, app_local_path, keep_screen_on)
498     CustomizeExtensions(app_info, extensions)
499     GenerateCommandLineFile(app_info, xwalk_command_line)
500   except SystemExit as ec:
501     print('Exiting with error code: %d' % ec.code)
502     sys.exit(ec.code)
503
504
505 def main():
506   parser = optparse.OptionParser()
507   info = ('The package name. Such as: '
508           '--package=com.example.YourPackage')
509   parser.add_option('--package', help=info)
510   info = ('The apk name. Such as: --name="Your Application Name"')
511   parser.add_option('--name', help=info)
512   info = ('The version of the app. Such as: --app-version=TheVersionNumber')
513   parser.add_option('--app-version', help=info)
514   info = ('The versionCode of the app. Such as: --app-versionCode=24')
515   parser.add_option('--app-versionCode', type='int', help=info)
516   info = ('The application description. Such as:'
517           '--description=YourApplicationdDescription')
518   parser.add_option('--description', help=info)
519   info = ('The permission list. Such as: --permissions="geolocation"'
520           'For more permissions, such as:'
521           '--permissions="geolocation:permission2"')
522   parser.add_option('--permissions', help=info)
523   info = ('The url of application. '
524           'This flag allows to package website as apk. Such as: '
525           '--app-url=http://www.intel.com')
526   parser.add_option('--app-url', help=info)
527   info = ('The root path of the web app. '
528           'This flag allows to package local web app as apk. Such as: '
529           '--app-root=/root/path/of/the/web/app')
530   parser.add_option('--app-root', help=info)
531   info = ('The reletive path of entry file based on |app_root|. '
532           'This flag should work with "--app-root" together. '
533           'Such as: --app-local-path=/reletive/path/of/entry/file')
534   parser.add_option('--app-local-path', help=info)
535   parser.add_option('--enable-remote-debugging', action='store_true',
536                     dest='enable_remote_debugging', default=False,
537                     help='Enable remote debugging.')
538   parser.add_option('-f', '--fullscreen', action='store_true',
539                     dest='fullscreen', default=False,
540                     help='Make application fullscreen.')
541   parser.add_option('--keep-screen-on', action='store_true', default=False,
542                     help='Support keeping screen on')
543   info = ('The path list for external extensions separated by os separator.'
544           'On Linux and Mac, the separator is ":". On Windows, it is ";".'
545           'Such as: --extensions="/path/to/extension1:/path/to/extension2"')
546   parser.add_option('--extensions', help=info)
547   info = ('The orientation of the web app\'s display on the device. '
548           'Such as: --orientation=landscape. The default value is "unspecified"'
549           'The value options are the same as those on the Android: '
550           'http://developer.android.com/guide/topics/manifest/'
551           'activity-element.html#screen')
552   parser.add_option('--orientation', help=info)
553   parser.add_option('--manifest', help='The manifest path')
554   info = ('Use command lines.'
555           'Crosswalk is powered by Chromium and supports Chromium command line.'
556           'For example, '
557           '--xwalk-command-line=\'--chromium-command-1 --xwalk-command-2\'')
558   parser.add_option('--xwalk-command-line', default='', help=info)
559   info = ('Minify and obfuscate javascript and css.'
560           '--compressor: compress javascript and css.'
561           '--compressor=js: compress javascript.'
562           '--compressor=css: compress css.')
563   parser.add_option('--compressor', dest='compressor', action='callback',
564                     callback=ParseParameterForCompressor,
565                     type='string', nargs=0, help=info)
566   options, _ = parser.parse_args()
567   try:
568     icon_dict = {144: 'icons/icon_144.png',
569                  72: 'icons/icon_72.png',
570                  96: 'icons/icon_96.png',
571                  48: 'icons/icon_48.png'}
572     app_info = AppInfo()
573     if options.name is not None:
574       app_info.android_name = options.name
575     if options.app_root is None:
576       app_info.app_root = os.path.join('test_data', 'manifest')
577     else:
578       app_info.app_root = options.app_root
579     if options.package is not None:
580       app_info.package = options.package
581     if options.orientation is not None:
582       app_info.orientation = options.orientation
583     if options.app_version is not None:
584       app_info.app_version = options.app_version
585     if options.enable_remote_debugging is not None:
586       app_info.remote_debugging = options.enable_remote_debugging
587     if options.fullscreen is not None:
588       app_info.fullscreen_flag = options.fullscreen
589     app_info.icon = os.path.join('test_data', 'manifest', 'icons',
590                                  'icon_96.png')
591     CustomizeAll(app_info, options.description, icon_dict,
592                  options.permissions, options.app_url, options.app_local_path,
593                  options.keep_screen_on, options.extensions, None,
594                  options.xwalk_command_line, options.compressor)
595   except SystemExit as ec:
596     print('Exiting with error code: %d' % ec.code)
597     return ec.code
598   return 0
599
600
601 if __name__ == '__main__':
602   sys.exit(main())