chiark / gitweb /
e630602066ca947601aa84e6281f2d54df5e76b5
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import sys
22 import re
23 import shutil
24 import glob
25 import requests
26 import stat
27 import subprocess
28 import time
29 import operator
30 import Queue
31 import threading
32 import magic
33 import logging
34 import hashlib
35 import socket
36 import xml.etree.ElementTree as XMLElementTree
37
38 from distutils.version import LooseVersion
39 from zipfile import ZipFile
40
41 import metadata
42
43 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
44
45 config = None
46 options = None
47 env = None
48 orig_path = None
49
50
51 default_config = {
52     'sdk_path': "$ANDROID_HOME",
53     'ndk_paths': {
54         'r9b': None,
55         'r10e': "$ANDROID_NDK"
56     },
57     'build_tools': "22.0.1",
58     'ant': "ant",
59     'mvn3': "mvn",
60     'gradle': 'gradle',
61     'sync_from_local_copy_dir': False,
62     'make_current_version_link': True,
63     'current_version_name_source': 'Name',
64     'update_stats': False,
65     'stats_ignore': [],
66     'stats_server': None,
67     'stats_user': None,
68     'stats_to_carbon': False,
69     'repo_maxage': 0,
70     'build_server_always': False,
71     'keystore': 'keystore.jks',
72     'smartcardoptions': [],
73     'char_limits': {
74         'Summary': 80,
75         'Description': 4000
76     },
77     'keyaliases': {},
78     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
79     'repo_name': "My First FDroid Repo Demo",
80     'repo_icon': "fdroid-icon.png",
81     'repo_description': '''
82         This is a repository of apps to be used with FDroid. Applications in this
83         repository are either official binaries built by the original application
84         developers, or are binaries built from source by the admin of f-droid.org
85         using the tools on https://gitlab.com/u/fdroid.
86         ''',
87     'archive_older': 0,
88 }
89
90
91 def fill_config_defaults(thisconfig):
92     for k, v in default_config.items():
93         if k not in thisconfig:
94             thisconfig[k] = v
95
96     # Expand paths (~users and $vars)
97     def expand_path(path):
98         if path is None:
99             return None
100         orig = path
101         path = os.path.expanduser(path)
102         path = os.path.expandvars(path)
103         if orig == path:
104             return None
105         return path
106
107     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
108         v = thisconfig[k]
109         exp = expand_path(v)
110         if exp is not None:
111             thisconfig[k] = exp
112             thisconfig[k + '_orig'] = v
113
114     for k in ['ndk_paths']:
115         d = thisconfig[k]
116         for k2 in d.copy():
117             v = d[k2]
118             exp = expand_path(v)
119             if exp is not None:
120                 thisconfig[k][k2] = exp
121                 thisconfig[k][k2 + '_orig'] = v
122
123
124 def regsub_file(pattern, repl, path):
125     with open(path, 'r') as f:
126         text = f.read()
127     text = re.sub(pattern, repl, text)
128     with open(path, 'w') as f:
129         f.write(text)
130
131
132 def read_config(opts, config_file='config.py'):
133     """Read the repository config
134
135     The config is read from config_file, which is in the current directory when
136     any of the repo management commands are used.
137     """
138     global config, options, env, orig_path
139
140     if config is not None:
141         return config
142     if not os.path.isfile(config_file):
143         logging.critical("Missing config file - is this a repo directory?")
144         sys.exit(2)
145
146     options = opts
147
148     config = {}
149
150     logging.debug("Reading %s" % config_file)
151     execfile(config_file, config)
152
153     # smartcardoptions must be a list since its command line args for Popen
154     if 'smartcardoptions' in config:
155         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
156     elif 'keystore' in config and config['keystore'] == 'NONE':
157         # keystore='NONE' means use smartcard, these are required defaults
158         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
159                                       'SunPKCS11-OpenSC', '-providerClass',
160                                       'sun.security.pkcs11.SunPKCS11',
161                                       '-providerArg', 'opensc-fdroid.cfg']
162
163     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
164         st = os.stat(config_file)
165         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
166             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
167
168     fill_config_defaults(config)
169
170     # There is no standard, so just set up the most common environment
171     # variables
172     env = os.environ
173     orig_path = env['PATH']
174     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
175         env[n] = config['sdk_path']
176
177     for k in ["keystorepass", "keypass"]:
178         if k in config:
179             write_password_file(k)
180
181     for k in ["repo_description", "archive_description"]:
182         if k in config:
183             config[k] = clean_description(config[k])
184
185     if 'serverwebroot' in config:
186         if isinstance(config['serverwebroot'], basestring):
187             roots = [config['serverwebroot']]
188         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
189             roots = config['serverwebroot']
190         else:
191             raise TypeError('only accepts strings, lists, and tuples')
192         rootlist = []
193         for rootstr in roots:
194             # since this is used with rsync, where trailing slashes have
195             # meaning, ensure there is always a trailing slash
196             if rootstr[-1] != '/':
197                 rootstr += '/'
198             rootlist.append(rootstr.replace('//', '/'))
199         config['serverwebroot'] = rootlist
200
201     return config
202
203
204 def get_ndk_path(version):
205     if version is None:
206         version = 'r10e'  # latest
207     paths = config['ndk_paths']
208     if version not in paths:
209         return ''
210     return paths[version] or ''
211
212
213 def find_sdk_tools_cmd(cmd):
214     '''find a working path to a tool from the Android SDK'''
215
216     tooldirs = []
217     if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
218         # try to find a working path to this command, in all the recent possible paths
219         if 'build_tools' in config:
220             build_tools = os.path.join(config['sdk_path'], 'build-tools')
221             # if 'build_tools' was manually set and exists, check only that one
222             configed_build_tools = os.path.join(build_tools, config['build_tools'])
223             if os.path.exists(configed_build_tools):
224                 tooldirs.append(configed_build_tools)
225             else:
226                 # no configed version, so hunt known paths for it
227                 for f in sorted(os.listdir(build_tools), reverse=True):
228                     if os.path.isdir(os.path.join(build_tools, f)):
229                         tooldirs.append(os.path.join(build_tools, f))
230                 tooldirs.append(build_tools)
231         sdk_tools = os.path.join(config['sdk_path'], 'tools')
232         if os.path.exists(sdk_tools):
233             tooldirs.append(sdk_tools)
234         sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
235         if os.path.exists(sdk_platform_tools):
236             tooldirs.append(sdk_platform_tools)
237     tooldirs.append('/usr/bin')
238     for d in tooldirs:
239         if os.path.isfile(os.path.join(d, cmd)):
240             return os.path.join(d, cmd)
241     # did not find the command, exit with error message
242     ensure_build_tools_exists(config)
243
244
245 def test_sdk_exists(thisconfig):
246     if 'sdk_path' not in thisconfig:
247         if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
248             return True
249         else:
250             logging.error("'sdk_path' not set in config.py!")
251             return False
252     if thisconfig['sdk_path'] == default_config['sdk_path']:
253         logging.error('No Android SDK found!')
254         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
255         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
256         return False
257     if not os.path.exists(thisconfig['sdk_path']):
258         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
259         return False
260     if not os.path.isdir(thisconfig['sdk_path']):
261         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
262         return False
263     for d in ['build-tools', 'platform-tools', 'tools']:
264         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
265             logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
266                 thisconfig['sdk_path'], d))
267             return False
268     return True
269
270
271 def ensure_build_tools_exists(thisconfig):
272     if not test_sdk_exists(thisconfig):
273         sys.exit(3)
274     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
275     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
276     if not os.path.isdir(versioned_build_tools):
277         logging.critical('Android Build Tools path "'
278                          + versioned_build_tools + '" does not exist!')
279         sys.exit(3)
280
281
282 def write_password_file(pwtype, password=None):
283     '''
284     writes out passwords to a protected file instead of passing passwords as
285     command line argments
286     '''
287     filename = '.fdroid.' + pwtype + '.txt'
288     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
289     if password is None:
290         os.write(fd, config[pwtype])
291     else:
292         os.write(fd, password)
293     os.close(fd)
294     config[pwtype + 'file'] = filename
295
296
297 # Given the arguments in the form of multiple appid:[vc] strings, this returns
298 # a dictionary with the set of vercodes specified for each package.
299 def read_pkg_args(args, allow_vercodes=False):
300
301     vercodes = {}
302     if not args:
303         return vercodes
304
305     for p in args:
306         if allow_vercodes and ':' in p:
307             package, vercode = p.split(':')
308         else:
309             package, vercode = p, None
310         if package not in vercodes:
311             vercodes[package] = [vercode] if vercode else []
312             continue
313         elif vercode and vercode not in vercodes[package]:
314             vercodes[package] += [vercode] if vercode else []
315
316     return vercodes
317
318
319 # On top of what read_pkg_args does, this returns the whole app metadata, but
320 # limiting the builds list to the builds matching the vercodes specified.
321 def read_app_args(args, allapps, allow_vercodes=False):
322
323     vercodes = read_pkg_args(args, allow_vercodes)
324
325     if not vercodes:
326         return allapps
327
328     apps = {}
329     for appid, app in allapps.iteritems():
330         if appid in vercodes:
331             apps[appid] = app
332
333     if len(apps) != len(vercodes):
334         for p in vercodes:
335             if p not in allapps:
336                 logging.critical("No such package: %s" % p)
337         raise FDroidException("Found invalid app ids in arguments")
338     if not apps:
339         raise FDroidException("No packages specified")
340
341     error = False
342     for appid, app in apps.iteritems():
343         vc = vercodes[appid]
344         if not vc:
345             continue
346         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
347         if len(app['builds']) != len(vercodes[appid]):
348             error = True
349             allvcs = [b['vercode'] for b in app['builds']]
350             for v in vercodes[appid]:
351                 if v not in allvcs:
352                     logging.critical("No such vercode %s for app %s" % (v, appid))
353
354     if error:
355         raise FDroidException("Found invalid vercodes for some apps")
356
357     return apps
358
359
360 def has_extension(filename, extension):
361     name, ext = os.path.splitext(filename)
362     ext = ext.lower()[1:]
363     return ext == extension
364
365 apk_regex = None
366
367
368 def clean_description(description):
369     'Remove unneeded newlines and spaces from a block of description text'
370     returnstring = ''
371     # this is split up by paragraph to make removing the newlines easier
372     for paragraph in re.split(r'\n\n', description):
373         paragraph = re.sub('\r', '', paragraph)
374         paragraph = re.sub('\n', ' ', paragraph)
375         paragraph = re.sub(' {2,}', ' ', paragraph)
376         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
377         returnstring += paragraph + '\n\n'
378     return returnstring.rstrip('\n')
379
380
381 def apknameinfo(filename):
382     global apk_regex
383     filename = os.path.basename(filename)
384     if apk_regex is None:
385         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
386     m = apk_regex.match(filename)
387     try:
388         result = (m.group(1), m.group(2))
389     except AttributeError:
390         raise FDroidException("Invalid apk name: %s" % filename)
391     return result
392
393
394 def getapkname(app, build):
395     return "%s_%s.apk" % (app['id'], build['vercode'])
396
397
398 def getsrcname(app, build):
399     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
400
401
402 def getappname(app):
403     if app['Name']:
404         return app['Name']
405     if app['Auto Name']:
406         return app['Auto Name']
407     return app['id']
408
409
410 def getcvname(app):
411     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
412
413
414 def getvcs(vcstype, remote, local):
415     if vcstype == 'git':
416         return vcs_git(remote, local)
417     if vcstype == 'git-svn':
418         return vcs_gitsvn(remote, local)
419     if vcstype == 'hg':
420         return vcs_hg(remote, local)
421     if vcstype == 'bzr':
422         return vcs_bzr(remote, local)
423     if vcstype == 'srclib':
424         if local != os.path.join('build', 'srclib', remote):
425             raise VCSException("Error: srclib paths are hard-coded!")
426         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
427     if vcstype == 'svn':
428         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
429     raise VCSException("Invalid vcs type " + vcstype)
430
431
432 def getsrclibvcs(name):
433     if name not in metadata.srclibs:
434         raise VCSException("Missing srclib " + name)
435     return metadata.srclibs[name]['Repo Type']
436
437
438 class vcs:
439
440     def __init__(self, remote, local):
441
442         # svn, git-svn and bzr may require auth
443         self.username = None
444         if self.repotype() in ('git-svn', 'bzr'):
445             if '@' in remote:
446                 if self.repotype == 'git-svn':
447                     raise VCSException("Authentication is not supported for git-svn")
448                 self.username, remote = remote.split('@')
449                 if ':' not in self.username:
450                     raise VCSException("Password required with username")
451                 self.username, self.password = self.username.split(':')
452
453         self.remote = remote
454         self.local = local
455         self.clone_failed = False
456         self.refreshed = False
457         self.srclib = None
458
459     def repotype(self):
460         return None
461
462     # Take the local repository to a clean version of the given revision, which
463     # is specificed in the VCS's native format. Beforehand, the repository can
464     # be dirty, or even non-existent. If the repository does already exist
465     # locally, it will be updated from the origin, but only once in the
466     # lifetime of the vcs object.
467     # None is acceptable for 'rev' if you know you are cloning a clean copy of
468     # the repo - otherwise it must specify a valid revision.
469     def gotorevision(self, rev, refresh=True):
470
471         if self.clone_failed:
472             raise VCSException("Downloading the repository already failed once, not trying again.")
473
474         # The .fdroidvcs-id file for a repo tells us what VCS type
475         # and remote that directory was created from, allowing us to drop it
476         # automatically if either of those things changes.
477         fdpath = os.path.join(self.local, '..',
478                               '.fdroidvcs-' + os.path.basename(self.local))
479         cdata = self.repotype() + ' ' + self.remote
480         writeback = True
481         deleterepo = False
482         if os.path.exists(self.local):
483             if os.path.exists(fdpath):
484                 with open(fdpath, 'r') as f:
485                     fsdata = f.read().strip()
486                 if fsdata == cdata:
487                     writeback = False
488                 else:
489                     deleterepo = True
490                     logging.info("Repository details for %s changed - deleting" % (
491                         self.local))
492             else:
493                 deleterepo = True
494                 logging.info("Repository details for %s missing - deleting" % (
495                     self.local))
496         if deleterepo:
497             shutil.rmtree(self.local)
498
499         exc = None
500         if not refresh:
501             self.refreshed = True
502
503         try:
504             self.gotorevisionx(rev)
505         except FDroidException, e:
506             exc = e
507
508         # If necessary, write the .fdroidvcs file.
509         if writeback and not self.clone_failed:
510             with open(fdpath, 'w') as f:
511                 f.write(cdata)
512
513         if exc is not None:
514             raise exc
515
516     # Derived classes need to implement this. It's called once basic checking
517     # has been performend.
518     def gotorevisionx(self, rev):
519         raise VCSException("This VCS type doesn't define gotorevisionx")
520
521     # Initialise and update submodules
522     def initsubmodules(self):
523         raise VCSException('Submodules not supported for this vcs type')
524
525     # Get a list of all known tags
526     def gettags(self):
527         if not self._gettags:
528             raise VCSException('gettags not supported for this vcs type')
529         rtags = []
530         for tag in self._gettags():
531             if re.match('[-A-Za-z0-9_. ]+$', tag):
532                 rtags.append(tag)
533         return rtags
534
535     def latesttags(self, tags, number):
536         """Get the most recent tags in a given list.
537
538         :param tags: a list of tags
539         :param number: the number to return
540         :returns: A list containing the most recent tags in the provided
541                   list, up to the maximum number given.
542         """
543         raise VCSException('latesttags not supported for this vcs type')
544
545     # Get current commit reference (hash, revision, etc)
546     def getref(self):
547         raise VCSException('getref not supported for this vcs type')
548
549     # Returns the srclib (name, path) used in setting up the current
550     # revision, or None.
551     def getsrclib(self):
552         return self.srclib
553
554
555 class vcs_git(vcs):
556
557     def repotype(self):
558         return 'git'
559
560     # If the local directory exists, but is somehow not a git repository, git
561     # will traverse up the directory tree until it finds one that is (i.e.
562     # fdroidserver) and then we'll proceed to destroy it! This is called as
563     # a safety check.
564     def checkrepo(self):
565         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
566         result = p.output.rstrip()
567         if not result.endswith(self.local):
568             raise VCSException('Repository mismatch')
569
570     def gotorevisionx(self, rev):
571         if not os.path.exists(self.local):
572             # Brand new checkout
573             p = FDroidPopen(['git', 'clone', self.remote, self.local])
574             if p.returncode != 0:
575                 self.clone_failed = True
576                 raise VCSException("Git clone failed", p.output)
577             self.checkrepo()
578         else:
579             self.checkrepo()
580             # Discard any working tree changes
581             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
582                              'git', 'reset', '--hard'], cwd=self.local, output=False)
583             if p.returncode != 0:
584                 raise VCSException("Git reset failed", p.output)
585             # Remove untracked files now, in case they're tracked in the target
586             # revision (it happens!)
587             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
588                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
589             if p.returncode != 0:
590                 raise VCSException("Git clean failed", p.output)
591             if not self.refreshed:
592                 # Get latest commits and tags from remote
593                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
594                 if p.returncode != 0:
595                     raise VCSException("Git fetch failed", p.output)
596                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
597                 if p.returncode != 0:
598                     raise VCSException("Git fetch failed", p.output)
599                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
600                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
601                 if p.returncode != 0:
602                     lines = p.output.splitlines()
603                     if 'Multiple remote HEAD branches' not in lines[0]:
604                         raise VCSException("Git remote set-head failed", p.output)
605                     branch = lines[1].split(' ')[-1]
606                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
607                     if p2.returncode != 0:
608                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
609                 self.refreshed = True
610         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
611         # a github repo. Most of the time this is the same as origin/master.
612         rev = rev or 'origin/HEAD'
613         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
614         if p.returncode != 0:
615             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
616         # Get rid of any uncontrolled files left behind
617         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
618         if p.returncode != 0:
619             raise VCSException("Git clean failed", p.output)
620
621     def initsubmodules(self):
622         self.checkrepo()
623         submfile = os.path.join(self.local, '.gitmodules')
624         if not os.path.isfile(submfile):
625             raise VCSException("No git submodules available")
626
627         # fix submodules not accessible without an account and public key auth
628         with open(submfile, 'r') as f:
629             lines = f.readlines()
630         with open(submfile, 'w') as f:
631             for line in lines:
632                 if 'git@github.com' in line:
633                     line = line.replace('git@github.com:', 'https://github.com/')
634                 f.write(line)
635
636         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
637         if p.returncode != 0:
638             raise VCSException("Git submodule sync failed", p.output)
639         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
640         if p.returncode != 0:
641             raise VCSException("Git submodule update failed", p.output)
642
643     def _gettags(self):
644         self.checkrepo()
645         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
646         return p.output.splitlines()
647
648     def latesttags(self, tags, number):
649         self.checkrepo()
650         tl = []
651         for tag in tags:
652             p = FDroidPopen(
653                 ['git', 'show', '--format=format:%ct', '-s', tag],
654                 cwd=self.local, output=False)
655             # Timestamp is on the last line. For a normal tag, it's the only
656             # line, but for annotated tags, the rest of the info precedes it.
657             ts = int(p.output.splitlines()[-1])
658             tl.append((ts, tag))
659         latest = []
660         for _, t in sorted(tl)[-number:]:
661             latest.append(t)
662         return latest
663
664
665 class vcs_gitsvn(vcs):
666
667     def repotype(self):
668         return 'git-svn'
669
670     # If the local directory exists, but is somehow not a git repository, git
671     # will traverse up the directory tree until it finds one that is (i.e.
672     # fdroidserver) and then we'll proceed to destory it! This is called as
673     # a safety check.
674     def checkrepo(self):
675         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
676         result = p.output.rstrip()
677         if not result.endswith(self.local):
678             raise VCSException('Repository mismatch')
679
680     def gotorevisionx(self, rev):
681         if not os.path.exists(self.local):
682             # Brand new checkout
683             gitsvn_args = ['git', 'svn', 'clone']
684             if ';' in self.remote:
685                 remote_split = self.remote.split(';')
686                 for i in remote_split[1:]:
687                     if i.startswith('trunk='):
688                         gitsvn_args.extend(['-T', i[6:]])
689                     elif i.startswith('tags='):
690                         gitsvn_args.extend(['-t', i[5:]])
691                     elif i.startswith('branches='):
692                         gitsvn_args.extend(['-b', i[9:]])
693                 gitsvn_args.extend([remote_split[0], self.local])
694                 p = FDroidPopen(gitsvn_args, output=False)
695                 if p.returncode != 0:
696                     self.clone_failed = True
697                     raise VCSException("Git svn clone failed", p.output)
698             else:
699                 gitsvn_args.extend([self.remote, self.local])
700                 p = FDroidPopen(gitsvn_args, output=False)
701                 if p.returncode != 0:
702                     self.clone_failed = True
703                     raise VCSException("Git svn clone failed", p.output)
704             self.checkrepo()
705         else:
706             self.checkrepo()
707             # Discard any working tree changes
708             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
709             if p.returncode != 0:
710                 raise VCSException("Git reset failed", p.output)
711             # Remove untracked files now, in case they're tracked in the target
712             # revision (it happens!)
713             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
714             if p.returncode != 0:
715                 raise VCSException("Git clean failed", p.output)
716             if not self.refreshed:
717                 # Get new commits, branches and tags from repo
718                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
719                 if p.returncode != 0:
720                     raise VCSException("Git svn fetch failed")
721                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
722                 if p.returncode != 0:
723                     raise VCSException("Git svn rebase failed", p.output)
724                 self.refreshed = True
725
726         rev = rev or 'master'
727         if rev:
728             nospaces_rev = rev.replace(' ', '%20')
729             # Try finding a svn tag
730             for treeish in ['origin/', '']:
731                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
732                 if p.returncode == 0:
733                     break
734             if p.returncode != 0:
735                 # No tag found, normal svn rev translation
736                 # Translate svn rev into git format
737                 rev_split = rev.split('/')
738
739                 p = None
740                 for treeish in ['origin/', '']:
741                     if len(rev_split) > 1:
742                         treeish += rev_split[0]
743                         svn_rev = rev_split[1]
744
745                     else:
746                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
747                         treeish += 'master'
748                         svn_rev = rev
749
750                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
751
752                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
753                     git_rev = p.output.rstrip()
754
755                     if p.returncode == 0 and git_rev:
756                         break
757
758                 if p.returncode != 0 or not git_rev:
759                     # Try a plain git checkout as a last resort
760                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
761                     if p.returncode != 0:
762                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
763                 else:
764                     # Check out the git rev equivalent to the svn rev
765                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
766                     if p.returncode != 0:
767                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
768
769         # Get rid of any uncontrolled files left behind
770         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
771         if p.returncode != 0:
772             raise VCSException("Git clean failed", p.output)
773
774     def _gettags(self):
775         self.checkrepo()
776         for treeish in ['origin/', '']:
777             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
778             if os.path.isdir(d):
779                 return os.listdir(d)
780
781     def getref(self):
782         self.checkrepo()
783         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
784         if p.returncode != 0:
785             return None
786         return p.output.strip()
787
788
789 class vcs_hg(vcs):
790
791     def repotype(self):
792         return 'hg'
793
794     def gotorevisionx(self, rev):
795         if not os.path.exists(self.local):
796             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
797             if p.returncode != 0:
798                 self.clone_failed = True
799                 raise VCSException("Hg clone failed", p.output)
800         else:
801             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
802             if p.returncode != 0:
803                 raise VCSException("Hg status failed", p.output)
804             for line in p.output.splitlines():
805                 if not line.startswith('? '):
806                     raise VCSException("Unexpected output from hg status -uS: " + line)
807                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
808             if not self.refreshed:
809                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
810                 if p.returncode != 0:
811                     raise VCSException("Hg pull failed", p.output)
812                 self.refreshed = True
813
814         rev = rev or 'default'
815         if not rev:
816             return
817         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
818         if p.returncode != 0:
819             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
820         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
821         # Also delete untracked files, we have to enable purge extension for that:
822         if "'purge' is provided by the following extension" in p.output:
823             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
824                 myfile.write("\n[extensions]\nhgext.purge=\n")
825             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
826             if p.returncode != 0:
827                 raise VCSException("HG purge failed", p.output)
828         elif p.returncode != 0:
829             raise VCSException("HG purge failed", p.output)
830
831     def _gettags(self):
832         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
833         return p.output.splitlines()[1:]
834
835
836 class vcs_bzr(vcs):
837
838     def repotype(self):
839         return 'bzr'
840
841     def gotorevisionx(self, rev):
842         if not os.path.exists(self.local):
843             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
844             if p.returncode != 0:
845                 self.clone_failed = True
846                 raise VCSException("Bzr branch failed", p.output)
847         else:
848             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
849             if p.returncode != 0:
850                 raise VCSException("Bzr revert failed", p.output)
851             if not self.refreshed:
852                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
853                 if p.returncode != 0:
854                     raise VCSException("Bzr update failed", p.output)
855                 self.refreshed = True
856
857         revargs = list(['-r', rev] if rev else [])
858         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
859         if p.returncode != 0:
860             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
861
862     def _gettags(self):
863         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
864         return [tag.split('   ')[0].strip() for tag in
865                 p.output.splitlines()]
866
867
868 def unescape_string(string):
869     if string[0] == '"' and string[-1] == '"':
870         return string[1:-1]
871
872     return string.replace("\\'", "'")
873
874
875 def retrieve_string(app_dir, string, xmlfiles=None):
876
877     if xmlfiles is None:
878         xmlfiles = []
879         for res_dir in [
880             os.path.join(app_dir, 'res'),
881             os.path.join(app_dir, 'src', 'main', 'res'),
882         ]:
883             for r, d, f in os.walk(res_dir):
884                 if os.path.basename(r) == 'values':
885                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
886
887     if not string.startswith('@string/'):
888         return unescape_string(string)
889
890     name = string[len('@string/'):]
891
892     for path in xmlfiles:
893         if not os.path.isfile(path):
894             continue
895         xml = parse_xml(path)
896         element = xml.find('string[@name="' + name + '"]')
897         if element is not None and element.text is not None:
898             return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
899
900     return ''
901
902
903 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
904     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
905
906
907 # Return list of existing files that will be used to find the highest vercode
908 def manifest_paths(app_dir, flavours):
909
910     possible_manifests = \
911         [os.path.join(app_dir, 'AndroidManifest.xml'),
912          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
913          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
914          os.path.join(app_dir, 'build.gradle')]
915
916     for flavour in flavours:
917         if flavour == 'yes':
918             continue
919         possible_manifests.append(
920             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
921
922     return [path for path in possible_manifests if os.path.isfile(path)]
923
924
925 # Retrieve the package name. Returns the name, or None if not found.
926 def fetch_real_name(app_dir, flavours):
927     for path in manifest_paths(app_dir, flavours):
928         if not has_extension(path, 'xml') or not os.path.isfile(path):
929             continue
930         logging.debug("fetch_real_name: Checking manifest at " + path)
931         xml = parse_xml(path)
932         app = xml.find('application')
933         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
934             continue
935         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
936         result = retrieve_string_singleline(app_dir, label)
937         if result:
938             result = result.strip()
939         return result
940     return None
941
942
943 def get_library_references(root_dir):
944     libraries = []
945     proppath = os.path.join(root_dir, 'project.properties')
946     if not os.path.isfile(proppath):
947         return libraries
948     for line in file(proppath):
949         if not line.startswith('android.library.reference.'):
950             continue
951         path = line.split('=')[1].strip()
952         relpath = os.path.join(root_dir, path)
953         if not os.path.isdir(relpath):
954             continue
955         logging.debug("Found subproject at %s" % path)
956         libraries.append(path)
957     return libraries
958
959
960 def ant_subprojects(root_dir):
961     subprojects = get_library_references(root_dir)
962     for subpath in subprojects:
963         subrelpath = os.path.join(root_dir, subpath)
964         for p in get_library_references(subrelpath):
965             relp = os.path.normpath(os.path.join(subpath, p))
966             if relp not in subprojects:
967                 subprojects.insert(0, relp)
968     return subprojects
969
970
971 def remove_debuggable_flags(root_dir):
972     # Remove forced debuggable flags
973     logging.debug("Removing debuggable flags from %s" % root_dir)
974     for root, dirs, files in os.walk(root_dir):
975         if 'AndroidManifest.xml' in files:
976             regsub_file(r'android:debuggable="[^"]*"',
977                         '',
978                         os.path.join(root, 'AndroidManifest.xml'))
979
980
981 # Extract some information from the AndroidManifest.xml at the given path.
982 # Returns (version, vercode, package), any or all of which might be None.
983 # All values returned are strings.
984 def parse_androidmanifests(paths, ignoreversions=None):
985
986     if not paths:
987         return (None, None, None)
988
989     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
990     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
991     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
992
993     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
994
995     max_version = None
996     max_vercode = None
997     max_package = None
998
999     for path in paths:
1000
1001         if not os.path.isfile(path):
1002             continue
1003
1004         logging.debug("Parsing manifest at {0}".format(path))
1005         gradle = has_extension(path, 'gradle')
1006         version = None
1007         vercode = None
1008         # Remember package name, may be defined separately from version+vercode
1009         package = max_package
1010
1011         if gradle:
1012             for line in file(path):
1013                 if not package:
1014                     matches = psearch_g(line)
1015                     if matches:
1016                         package = matches.group(1)
1017                 if not version:
1018                     matches = vnsearch_g(line)
1019                     if matches:
1020                         version = matches.group(2)
1021                 if not vercode:
1022                     matches = vcsearch_g(line)
1023                     if matches:
1024                         vercode = matches.group(1)
1025         else:
1026             xml = parse_xml(path)
1027             if "package" in xml.attrib:
1028                 package = xml.attrib["package"].encode('utf-8')
1029             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1030                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1031                 base_dir = os.path.dirname(path)
1032                 version = retrieve_string_singleline(base_dir, version)
1033             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1034                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1035                 if string_is_integer(a):
1036                     vercode = a
1037
1038         logging.debug("..got package={0}, version={1}, vercode={2}"
1039                       .format(package, version, vercode))
1040
1041         # Always grab the package name and version name in case they are not
1042         # together with the highest version code
1043         if max_package is None and package is not None:
1044             max_package = package
1045         if max_version is None and version is not None:
1046             max_version = version
1047
1048         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1049             if not ignoresearch or not ignoresearch(version):
1050                 if version is not None:
1051                     max_version = version
1052                 if vercode is not None:
1053                     max_vercode = vercode
1054                 if package is not None:
1055                     max_package = package
1056             else:
1057                 max_version = "Ignore"
1058
1059     if max_version is None:
1060         max_version = "Unknown"
1061
1062     if max_package and not is_valid_package_name(max_package):
1063         raise FDroidException("Invalid package name {0}".format(max_package))
1064
1065     return (max_version, max_vercode, max_package)
1066
1067
1068 def is_valid_package_name(name):
1069     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1070
1071
1072 class FDroidException(Exception):
1073
1074     def __init__(self, value, detail=None):
1075         self.value = value
1076         self.detail = detail
1077
1078     def get_wikitext(self):
1079         ret = repr(self.value) + "\n"
1080         if self.detail:
1081             ret += "=detail=\n"
1082             ret += "<pre>\n"
1083             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1084             ret += str(txt)
1085             ret += "</pre>\n"
1086         return ret
1087
1088     def __str__(self):
1089         ret = self.value
1090         if self.detail:
1091             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1092         return ret
1093
1094
1095 class VCSException(FDroidException):
1096     pass
1097
1098
1099 class BuildException(FDroidException):
1100     pass
1101
1102
1103 # Get the specified source library.
1104 # Returns the path to it. Normally this is the path to be used when referencing
1105 # it, which may be a subdirectory of the actual project. If you want the base
1106 # directory of the project, pass 'basepath=True'.
1107 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1108               raw=False, prepare=True, preponly=False, refresh=True):
1109
1110     number = None
1111     subdir = None
1112     if raw:
1113         name = spec
1114         ref = None
1115     else:
1116         name, ref = spec.split('@')
1117         if ':' in name:
1118             number, name = name.split(':', 1)
1119         if '/' in name:
1120             name, subdir = name.split('/', 1)
1121
1122     if name not in metadata.srclibs:
1123         raise VCSException('srclib ' + name + ' not found.')
1124
1125     srclib = metadata.srclibs[name]
1126
1127     sdir = os.path.join(srclib_dir, name)
1128
1129     if not preponly:
1130         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1131         vcs.srclib = (name, number, sdir)
1132         if ref:
1133             vcs.gotorevision(ref, refresh)
1134
1135         if raw:
1136             return vcs
1137
1138     libdir = None
1139     if subdir:
1140         libdir = os.path.join(sdir, subdir)
1141     elif srclib["Subdir"]:
1142         for subdir in srclib["Subdir"]:
1143             libdir_candidate = os.path.join(sdir, subdir)
1144             if os.path.exists(libdir_candidate):
1145                 libdir = libdir_candidate
1146                 break
1147
1148     if libdir is None:
1149         libdir = sdir
1150
1151     remove_signing_keys(sdir)
1152     remove_debuggable_flags(sdir)
1153
1154     if prepare:
1155
1156         if srclib["Prepare"]:
1157             cmd = replace_config_vars(srclib["Prepare"], None)
1158
1159             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1160             if p.returncode != 0:
1161                 raise BuildException("Error running prepare command for srclib %s"
1162                                      % name, p.output)
1163
1164     if basepath:
1165         libdir = sdir
1166
1167     return (name, number, libdir)
1168
1169
1170 # Prepare the source code for a particular build
1171 #  'vcs'         - the appropriate vcs object for the application
1172 #  'app'         - the application details from the metadata
1173 #  'build'       - the build details from the metadata
1174 #  'build_dir'   - the path to the build directory, usually
1175 #                   'build/app.id'
1176 #  'srclib_dir'  - the path to the source libraries directory, usually
1177 #                   'build/srclib'
1178 #  'extlib_dir'  - the path to the external libraries directory, usually
1179 #                   'build/extlib'
1180 # Returns the (root, srclibpaths) where:
1181 #   'root' is the root directory, which may be the same as 'build_dir' or may
1182 #          be a subdirectory of it.
1183 #   'srclibpaths' is information on the srclibs being used
1184 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1185
1186     # Optionally, the actual app source can be in a subdirectory
1187     if build['subdir']:
1188         root_dir = os.path.join(build_dir, build['subdir'])
1189     else:
1190         root_dir = build_dir
1191
1192     # Get a working copy of the right revision
1193     logging.info("Getting source for revision " + build['commit'])
1194     vcs.gotorevision(build['commit'], refresh)
1195
1196     # Initialise submodules if required
1197     if build['submodules']:
1198         logging.info("Initialising submodules")
1199         vcs.initsubmodules()
1200
1201     # Check that a subdir (if we're using one) exists. This has to happen
1202     # after the checkout, since it might not exist elsewhere
1203     if not os.path.exists(root_dir):
1204         raise BuildException('Missing subdir ' + root_dir)
1205
1206     # Run an init command if one is required
1207     if build['init']:
1208         cmd = replace_config_vars(build['init'], build)
1209         logging.info("Running 'init' commands in %s" % root_dir)
1210
1211         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1212         if p.returncode != 0:
1213             raise BuildException("Error running init command for %s:%s" %
1214                                  (app['id'], build['version']), p.output)
1215
1216     # Apply patches if any
1217     if build['patch']:
1218         logging.info("Applying patches")
1219         for patch in build['patch']:
1220             patch = patch.strip()
1221             logging.info("Applying " + patch)
1222             patch_path = os.path.join('metadata', app['id'], patch)
1223             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1224             if p.returncode != 0:
1225                 raise BuildException("Failed to apply patch %s" % patch_path)
1226
1227     # Get required source libraries
1228     srclibpaths = []
1229     if build['srclibs']:
1230         logging.info("Collecting source libraries")
1231         for lib in build['srclibs']:
1232             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1233
1234     for name, number, libpath in srclibpaths:
1235         place_srclib(root_dir, int(number) if number else None, libpath)
1236
1237     basesrclib = vcs.getsrclib()
1238     # If one was used for the main source, add that too.
1239     if basesrclib:
1240         srclibpaths.append(basesrclib)
1241
1242     # Update the local.properties file
1243     localprops = [os.path.join(build_dir, 'local.properties')]
1244     if build['subdir']:
1245         localprops += [os.path.join(root_dir, 'local.properties')]
1246     for path in localprops:
1247         props = ""
1248         if os.path.isfile(path):
1249             logging.info("Updating local.properties file at %s" % path)
1250             f = open(path, 'r')
1251             props += f.read()
1252             f.close()
1253             props += '\n'
1254         else:
1255             logging.info("Creating local.properties file at %s" % path)
1256         # Fix old-fashioned 'sdk-location' by copying
1257         # from sdk.dir, if necessary
1258         if build['oldsdkloc']:
1259             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1260                               re.S | re.M).group(1)
1261             props += "sdk-location=%s\n" % sdkloc
1262         else:
1263             props += "sdk.dir=%s\n" % config['sdk_path']
1264             props += "sdk-location=%s\n" % config['sdk_path']
1265         if build['ndk_path']:
1266             # Add ndk location
1267             props += "ndk.dir=%s\n" % build['ndk_path']
1268             props += "ndk-location=%s\n" % build['ndk_path']
1269         # Add java.encoding if necessary
1270         if build['encoding']:
1271             props += "java.encoding=%s\n" % build['encoding']
1272         f = open(path, 'w')
1273         f.write(props)
1274         f.close()
1275
1276     flavours = []
1277     if build['type'] == 'gradle':
1278         flavours = build['gradle']
1279
1280         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1281         gradlepluginver = None
1282
1283         gradle_dirs = [root_dir]
1284
1285         # Parent dir build.gradle
1286         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1287         if parent_dir.startswith(build_dir):
1288             gradle_dirs.append(parent_dir)
1289
1290         for dir_path in gradle_dirs:
1291             if gradlepluginver:
1292                 break
1293             if not os.path.isdir(dir_path):
1294                 continue
1295             for filename in os.listdir(dir_path):
1296                 if not filename.endswith('.gradle'):
1297                     continue
1298                 path = os.path.join(dir_path, filename)
1299                 if not os.path.isfile(path):
1300                     continue
1301                 for line in file(path):
1302                     match = version_regex.match(line)
1303                     if match:
1304                         gradlepluginver = match.group(1)
1305                         break
1306
1307         if gradlepluginver:
1308             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1309         else:
1310             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1311             build['gradlepluginver'] = LooseVersion('0.11')
1312
1313         if build['target']:
1314             n = build["target"].split('-')[1]
1315             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1316                         r'compileSdkVersion %s' % n,
1317                         os.path.join(root_dir, 'build.gradle'))
1318
1319     # Remove forced debuggable flags
1320     remove_debuggable_flags(root_dir)
1321
1322     # Insert version code and number into the manifest if necessary
1323     if build['forceversion']:
1324         logging.info("Changing the version name")
1325         for path in manifest_paths(root_dir, flavours):
1326             if not os.path.isfile(path):
1327                 continue
1328             if has_extension(path, 'xml'):
1329                 regsub_file(r'android:versionName="[^"]*"',
1330                             r'android:versionName="%s"' % build['version'],
1331                             path)
1332             elif has_extension(path, 'gradle'):
1333                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1334                             r"""\1versionName '%s'""" % build['version'],
1335                             path)
1336
1337     if build['forcevercode']:
1338         logging.info("Changing the version code")
1339         for path in manifest_paths(root_dir, flavours):
1340             if not os.path.isfile(path):
1341                 continue
1342             if has_extension(path, 'xml'):
1343                 regsub_file(r'android:versionCode="[^"]*"',
1344                             r'android:versionCode="%s"' % build['vercode'],
1345                             path)
1346             elif has_extension(path, 'gradle'):
1347                 regsub_file(r'versionCode[ =]+[0-9]+',
1348                             r'versionCode %s' % build['vercode'],
1349                             path)
1350
1351     # Delete unwanted files
1352     if build['rm']:
1353         logging.info("Removing specified files")
1354         for part in getpaths(build_dir, build, 'rm'):
1355             dest = os.path.join(build_dir, part)
1356             logging.info("Removing {0}".format(part))
1357             if os.path.lexists(dest):
1358                 if os.path.islink(dest):
1359                     FDroidPopen(['unlink', dest], output=False)
1360                 else:
1361                     FDroidPopen(['rm', '-rf', dest], output=False)
1362             else:
1363                 logging.info("...but it didn't exist")
1364
1365     remove_signing_keys(build_dir)
1366
1367     # Add required external libraries
1368     if build['extlibs']:
1369         logging.info("Collecting prebuilt libraries")
1370         libsdir = os.path.join(root_dir, 'libs')
1371         if not os.path.exists(libsdir):
1372             os.mkdir(libsdir)
1373         for lib in build['extlibs']:
1374             lib = lib.strip()
1375             logging.info("...installing extlib {0}".format(lib))
1376             libf = os.path.basename(lib)
1377             libsrc = os.path.join(extlib_dir, lib)
1378             if not os.path.exists(libsrc):
1379                 raise BuildException("Missing extlib file {0}".format(libsrc))
1380             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1381
1382     # Run a pre-build command if one is required
1383     if build['prebuild']:
1384         logging.info("Running 'prebuild' commands in %s" % root_dir)
1385
1386         cmd = replace_config_vars(build['prebuild'], build)
1387
1388         # Substitute source library paths into prebuild commands
1389         for name, number, libpath in srclibpaths:
1390             libpath = os.path.relpath(libpath, root_dir)
1391             cmd = cmd.replace('$$' + name + '$$', libpath)
1392
1393         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1394         if p.returncode != 0:
1395             raise BuildException("Error running prebuild command for %s:%s" %
1396                                  (app['id'], build['version']), p.output)
1397
1398     # Generate (or update) the ant build file, build.xml...
1399     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1400         parms = ['android', 'update', 'lib-project']
1401         lparms = ['android', 'update', 'project']
1402
1403         if build['target']:
1404             parms += ['-t', build['target']]
1405             lparms += ['-t', build['target']]
1406         if build['update'] == ['auto']:
1407             update_dirs = ant_subprojects(root_dir) + ['.']
1408         else:
1409             update_dirs = build['update']
1410
1411         for d in update_dirs:
1412             subdir = os.path.join(root_dir, d)
1413             if d == '.':
1414                 logging.debug("Updating main project")
1415                 cmd = parms + ['-p', d]
1416             else:
1417                 logging.debug("Updating subproject %s" % d)
1418                 cmd = lparms + ['-p', d]
1419             p = SdkToolsPopen(cmd, cwd=root_dir)
1420             # Check to see whether an error was returned without a proper exit
1421             # code (this is the case for the 'no target set or target invalid'
1422             # error)
1423             if p.returncode != 0 or p.output.startswith("Error: "):
1424                 raise BuildException("Failed to update project at %s" % d, p.output)
1425             # Clean update dirs via ant
1426             if d != '.':
1427                 logging.info("Cleaning subproject %s" % d)
1428                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1429
1430     return (root_dir, srclibpaths)
1431
1432
1433 # Split and extend via globbing the paths from a field
1434 def getpaths(build_dir, build, field):
1435     paths = []
1436     for p in build[field]:
1437         p = p.strip()
1438         full_path = os.path.join(build_dir, p)
1439         full_path = os.path.normpath(full_path)
1440         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1441     return paths
1442
1443
1444 # Scan the source code in the given directory (and all subdirectories)
1445 # and return the number of fatal problems encountered
1446 def scan_source(build_dir, root_dir, thisbuild):
1447
1448     count = 0
1449
1450     # Common known non-free blobs (always lower case):
1451     usual_suspects = [
1452         re.compile(r'.*flurryagent', re.IGNORECASE),
1453         re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1454         re.compile(r'.*google.*analytics', re.IGNORECASE),
1455         re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1456         re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1457         re.compile(r'.*google.*admob', re.IGNORECASE),
1458         re.compile(r'.*google.*play.*services', re.IGNORECASE),
1459         re.compile(r'.*crittercism', re.IGNORECASE),
1460         re.compile(r'.*heyzap', re.IGNORECASE),
1461         re.compile(r'.*jpct.*ae', re.IGNORECASE),
1462         re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1463         re.compile(r'.*bugsense', re.IGNORECASE),
1464         re.compile(r'.*crashlytics', re.IGNORECASE),
1465         re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1466         re.compile(r'.*libspen23', re.IGNORECASE),
1467     ]
1468
1469     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1470     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1471
1472     scanignore_worked = set()
1473     scandelete_worked = set()
1474
1475     try:
1476         ms = magic.open(magic.MIME_TYPE)
1477         ms.load()
1478     except AttributeError:
1479         ms = None
1480
1481     def toignore(fd):
1482         for p in scanignore:
1483             if fd.startswith(p):
1484                 scanignore_worked.add(p)
1485                 return True
1486         return False
1487
1488     def todelete(fd):
1489         for p in scandelete:
1490             if fd.startswith(p):
1491                 scandelete_worked.add(p)
1492                 return True
1493         return False
1494
1495     def ignoreproblem(what, fd, fp):
1496         logging.info('Ignoring %s at %s' % (what, fd))
1497         return 0
1498
1499     def removeproblem(what, fd, fp):
1500         logging.info('Removing %s at %s' % (what, fd))
1501         os.remove(fp)
1502         return 0
1503
1504     def warnproblem(what, fd):
1505         logging.warn('Found %s at %s' % (what, fd))
1506
1507     def handleproblem(what, fd, fp):
1508         if toignore(fd):
1509             return ignoreproblem(what, fd, fp)
1510         if todelete(fd):
1511             return removeproblem(what, fd, fp)
1512         logging.error('Found %s at %s' % (what, fd))
1513         return 1
1514
1515     # Iterate through all files in the source code
1516     for r, d, f in os.walk(build_dir, topdown=True):
1517
1518         # It's topdown, so checking the basename is enough
1519         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1520             if ignoredir in d:
1521                 d.remove(ignoredir)
1522
1523         for curfile in f:
1524
1525             # Path (relative) to the file
1526             fp = os.path.join(r, curfile)
1527             fd = fp[len(build_dir) + 1:]
1528
1529             try:
1530                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1531             except UnicodeError:
1532                 warnproblem('malformed magic number', fd)
1533
1534             if mime == 'application/x-sharedlib':
1535                 count += handleproblem('shared library', fd, fp)
1536
1537             elif mime == 'application/x-archive':
1538                 count += handleproblem('static library', fd, fp)
1539
1540             elif mime == 'application/x-executable':
1541                 count += handleproblem('binary executable', fd, fp)
1542
1543             elif mime == 'application/x-java-applet':
1544                 count += handleproblem('Java compiled class', fd, fp)
1545
1546             elif mime in (
1547                     'application/jar',
1548                     'application/zip',
1549                     'application/java-archive',
1550                     'application/octet-stream',
1551                     'binary', ):
1552
1553                 if has_extension(fp, 'apk'):
1554                     removeproblem('APK file', fd, fp)
1555
1556                 elif has_extension(fp, 'jar'):
1557
1558                     if any(suspect.match(curfile) for suspect in usual_suspects):
1559                         count += handleproblem('usual supect', fd, fp)
1560                     else:
1561                         warnproblem('JAR file', fd)
1562
1563                 elif has_extension(fp, 'zip'):
1564                     warnproblem('ZIP file', fd)
1565
1566                 else:
1567                     warnproblem('unknown compressed or binary file', fd)
1568
1569             elif has_extension(fp, 'java'):
1570                 if not os.path.isfile(fp):
1571                     continue
1572                 for line in file(fp):
1573                     if 'DexClassLoader' in line:
1574                         count += handleproblem('DexClassLoader', fd, fp)
1575                         break
1576
1577             elif has_extension(fp, 'gradle'):
1578                 if not os.path.isfile(fp):
1579                     continue
1580                 for i, line in enumerate(file(fp)):
1581                     if any(suspect.match(line) for suspect in usual_suspects):
1582                         count += handleproblem('usual suspect at line %d' % i, fd, fp)
1583                         break
1584     if ms is not None:
1585         ms.close()
1586
1587     for p in scanignore:
1588         if p not in scanignore_worked:
1589             logging.error('Unused scanignore path: %s' % p)
1590             count += 1
1591
1592     for p in scandelete:
1593         if p not in scandelete_worked:
1594             logging.error('Unused scandelete path: %s' % p)
1595             count += 1
1596
1597     # Presence of a jni directory without buildjni=yes might
1598     # indicate a problem (if it's not a problem, explicitly use
1599     # buildjni=no to bypass this check)
1600     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1601             not thisbuild['buildjni']):
1602         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1603         count += 1
1604
1605     return count
1606
1607
1608 class KnownApks:
1609
1610     def __init__(self):
1611         self.path = os.path.join('stats', 'known_apks.txt')
1612         self.apks = {}
1613         if os.path.isfile(self.path):
1614             for line in file(self.path):
1615                 t = line.rstrip().split(' ')
1616                 if len(t) == 2:
1617                     self.apks[t[0]] = (t[1], None)
1618                 else:
1619                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1620         self.changed = False
1621
1622     def writeifchanged(self):
1623         if self.changed:
1624             if not os.path.exists('stats'):
1625                 os.mkdir('stats')
1626             f = open(self.path, 'w')
1627             lst = []
1628             for apk, app in self.apks.iteritems():
1629                 appid, added = app
1630                 line = apk + ' ' + appid
1631                 if added:
1632                     line += ' ' + time.strftime('%Y-%m-%d', added)
1633                 lst.append(line)
1634             for line in sorted(lst):
1635                 f.write(line + '\n')
1636             f.close()
1637
1638     # Record an apk (if it's new, otherwise does nothing)
1639     # Returns the date it was added.
1640     def recordapk(self, apk, app):
1641         if apk not in self.apks:
1642             self.apks[apk] = (app, time.gmtime(time.time()))
1643             self.changed = True
1644         _, added = self.apks[apk]
1645         return added
1646
1647     # Look up information - given the 'apkname', returns (app id, date added/None).
1648     # Or returns None for an unknown apk.
1649     def getapp(self, apkname):
1650         if apkname in self.apks:
1651             return self.apks[apkname]
1652         return None
1653
1654     # Get the most recent 'num' apps added to the repo, as a list of package ids
1655     # with the most recent first.
1656     def getlatest(self, num):
1657         apps = {}
1658         for apk, app in self.apks.iteritems():
1659             appid, added = app
1660             if added:
1661                 if appid in apps:
1662                     if apps[appid] > added:
1663                         apps[appid] = added
1664                 else:
1665                     apps[appid] = added
1666         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1667         lst = [app for app, _ in sortedapps]
1668         lst.reverse()
1669         return lst
1670
1671
1672 def isApkDebuggable(apkfile, config):
1673     """Returns True if the given apk file is debuggable
1674
1675     :param apkfile: full path to the apk to check"""
1676
1677     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1678                       output=False)
1679     if p.returncode != 0:
1680         logging.critical("Failed to get apk manifest information")
1681         sys.exit(1)
1682     for line in p.output.splitlines():
1683         if 'android:debuggable' in line and not line.endswith('0x0'):
1684             return True
1685     return False
1686
1687
1688 class AsynchronousFileReader(threading.Thread):
1689
1690     '''
1691     Helper class to implement asynchronous reading of a file
1692     in a separate thread. Pushes read lines on a queue to
1693     be consumed in another thread.
1694     '''
1695
1696     def __init__(self, fd, queue):
1697         assert isinstance(queue, Queue.Queue)
1698         assert callable(fd.readline)
1699         threading.Thread.__init__(self)
1700         self._fd = fd
1701         self._queue = queue
1702
1703     def run(self):
1704         '''The body of the tread: read lines and put them on the queue.'''
1705         for line in iter(self._fd.readline, ''):
1706             self._queue.put(line)
1707
1708     def eof(self):
1709         '''Check whether there is no more content to expect.'''
1710         return not self.is_alive() and self._queue.empty()
1711
1712
1713 class PopenResult:
1714     returncode = None
1715     output = ''
1716
1717
1718 def SdkToolsPopen(commands, cwd=None, output=True):
1719     cmd = commands[0]
1720     if cmd not in config:
1721         config[cmd] = find_sdk_tools_cmd(commands[0])
1722     return FDroidPopen([config[cmd]] + commands[1:],
1723                        cwd=cwd, output=output)
1724
1725
1726 def FDroidPopen(commands, cwd=None, output=True):
1727     """
1728     Run a command and capture the possibly huge output.
1729
1730     :param commands: command and argument list like in subprocess.Popen
1731     :param cwd: optionally specifies a working directory
1732     :returns: A PopenResult.
1733     """
1734
1735     global env
1736
1737     if cwd:
1738         cwd = os.path.normpath(cwd)
1739         logging.debug("Directory: %s" % cwd)
1740     logging.debug("> %s" % ' '.join(commands))
1741
1742     result = PopenResult()
1743     p = None
1744     try:
1745         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1746                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1747     except OSError, e:
1748         raise BuildException("OSError while trying to execute " +
1749                              ' '.join(commands) + ': ' + str(e))
1750
1751     stdout_queue = Queue.Queue()
1752     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1753     stdout_reader.start()
1754
1755     # Check the queue for output (until there is no more to get)
1756     while not stdout_reader.eof():
1757         while not stdout_queue.empty():
1758             line = stdout_queue.get()
1759             if output and options.verbose:
1760                 # Output directly to console
1761                 sys.stderr.write(line)
1762                 sys.stderr.flush()
1763             result.output += line
1764
1765         time.sleep(0.1)
1766
1767     result.returncode = p.wait()
1768     return result
1769
1770
1771 def remove_signing_keys(build_dir):
1772     comment = re.compile(r'[ ]*//')
1773     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1774     line_matches = [
1775         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1776         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1777         re.compile(r'.*variant\.outputFile = .*'),
1778         re.compile(r'.*output\.outputFile = .*'),
1779         re.compile(r'.*\.readLine\(.*'),
1780     ]
1781     for root, dirs, files in os.walk(build_dir):
1782         if 'build.gradle' in files:
1783             path = os.path.join(root, 'build.gradle')
1784
1785             with open(path, "r") as o:
1786                 lines = o.readlines()
1787
1788             changed = False
1789
1790             opened = 0
1791             i = 0
1792             with open(path, "w") as o:
1793                 while i < len(lines):
1794                     line = lines[i]
1795                     i += 1
1796                     while line.endswith('\\\n'):
1797                         line = line.rstrip('\\\n') + lines[i]
1798                         i += 1
1799
1800                     if comment.match(line):
1801                         continue
1802
1803                     if opened > 0:
1804                         opened += line.count('{')
1805                         opened -= line.count('}')
1806                         continue
1807
1808                     if signing_configs.match(line):
1809                         changed = True
1810                         opened += 1
1811                         continue
1812
1813                     if any(s.match(line) for s in line_matches):
1814                         changed = True
1815                         continue
1816
1817                     if opened == 0:
1818                         o.write(line)
1819
1820             if changed:
1821                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1822
1823         for propfile in [
1824                 'project.properties',
1825                 'build.properties',
1826                 'default.properties',
1827                 'ant.properties', ]:
1828             if propfile in files:
1829                 path = os.path.join(root, propfile)
1830
1831                 with open(path, "r") as o:
1832                     lines = o.readlines()
1833
1834                 changed = False
1835
1836                 with open(path, "w") as o:
1837                     for line in lines:
1838                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1839                             changed = True
1840                             continue
1841
1842                         o.write(line)
1843
1844                 if changed:
1845                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1846
1847
1848 def reset_env_path():
1849     global env, orig_path
1850     env['PATH'] = orig_path
1851
1852
1853 def add_to_env_path(path):
1854     global env
1855     paths = env['PATH'].split(os.pathsep)
1856     if path in paths:
1857         return
1858     paths.append(path)
1859     env['PATH'] = os.pathsep.join(paths)
1860
1861
1862 def replace_config_vars(cmd, build):
1863     global env
1864     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1865     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1866     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1867     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1868     if build is not None:
1869         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1870         cmd = cmd.replace('$$VERSION$$', build['version'])
1871         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1872     return cmd
1873
1874
1875 def place_srclib(root_dir, number, libpath):
1876     if not number:
1877         return
1878     relpath = os.path.relpath(libpath, root_dir)
1879     proppath = os.path.join(root_dir, 'project.properties')
1880
1881     lines = []
1882     if os.path.isfile(proppath):
1883         with open(proppath, "r") as o:
1884             lines = o.readlines()
1885
1886     with open(proppath, "w") as o:
1887         placed = False
1888         for line in lines:
1889             if line.startswith('android.library.reference.%d=' % number):
1890                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1891                 placed = True
1892             else:
1893                 o.write(line)
1894         if not placed:
1895             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1896
1897
1898 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1899     """Verify that two apks are the same
1900
1901     One of the inputs is signed, the other is unsigned. The signature metadata
1902     is transferred from the signed to the unsigned apk, and then jarsigner is
1903     used to verify that the signature from the signed apk is also varlid for
1904     the unsigned one.
1905     :param signed_apk: Path to a signed apk file
1906     :param unsigned_apk: Path to an unsigned apk file expected to match it
1907     :param tmp_dir: Path to directory for temporary files
1908     :returns: None if the verification is successful, otherwise a string
1909               describing what went wrong.
1910     """
1911     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1912     with ZipFile(signed_apk) as signed_apk_as_zip:
1913         meta_inf_files = ['META-INF/MANIFEST.MF']
1914         for f in signed_apk_as_zip.namelist():
1915             if sigfile.match(f):
1916                 meta_inf_files.append(f)
1917         if len(meta_inf_files) < 3:
1918             return "Signature files missing from {0}".format(signed_apk)
1919         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1920     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1921         for meta_inf_file in meta_inf_files:
1922             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1923
1924     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1925         logging.info("...NOT verified - {0}".format(signed_apk))
1926         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1927     logging.info("...successfully verified")
1928     return None
1929
1930
1931 def compare_apks(apk1, apk2, tmp_dir):
1932     """Compare two apks
1933
1934     Returns None if the apk content is the same (apart from the signing key),
1935     otherwise a string describing what's different, or what went wrong when
1936     trying to do the comparison.
1937     """
1938
1939     badchars = re.compile('''[/ :;'"]''')
1940     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1941     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1942     for d in [apk1dir, apk2dir]:
1943         if os.path.exists(d):
1944             shutil.rmtree(d)
1945         os.mkdir(d)
1946         os.mkdir(os.path.join(d, 'jar-xf'))
1947
1948     if subprocess.call(['jar', 'xf',
1949                         os.path.abspath(apk1)],
1950                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1951         return("Failed to unpack " + apk1)
1952     if subprocess.call(['jar', 'xf',
1953                         os.path.abspath(apk2)],
1954                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1955         return("Failed to unpack " + apk2)
1956
1957     # try to find apktool in the path, if it hasn't been manually configed
1958     if 'apktool' not in config:
1959         tmp = find_command('apktool')
1960         if tmp is not None:
1961             config['apktool'] = tmp
1962     if 'apktool' in config:
1963         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1964                            cwd=apk1dir) != 0:
1965             return("Failed to unpack " + apk1)
1966         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1967                            cwd=apk2dir) != 0:
1968             return("Failed to unpack " + apk2)
1969
1970     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1971     lines = p.output.splitlines()
1972     if len(lines) != 1 or 'META-INF' not in lines[0]:
1973         meld = find_command('meld')
1974         if meld is not None:
1975             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1976         return("Unexpected diff output - " + p.output)
1977
1978     # since everything verifies, delete the comparison to keep cruft down
1979     shutil.rmtree(apk1dir)
1980     shutil.rmtree(apk2dir)
1981
1982     # If we get here, it seems like they're the same!
1983     return None
1984
1985
1986 def find_command(command):
1987     '''find the full path of a command, or None if it can't be found in the PATH'''
1988
1989     def is_exe(fpath):
1990         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1991
1992     fpath, fname = os.path.split(command)
1993     if fpath:
1994         if is_exe(command):
1995             return command
1996     else:
1997         for path in os.environ["PATH"].split(os.pathsep):
1998             path = path.strip('"')
1999             exe_file = os.path.join(path, command)
2000             if is_exe(exe_file):
2001                 return exe_file
2002
2003     return None
2004
2005
2006 def genpassword():
2007     '''generate a random password for when generating keys'''
2008     h = hashlib.sha256()
2009     h.update(os.urandom(16))  # salt
2010     h.update(bytes(socket.getfqdn()))
2011     return h.digest().encode('base64').strip()
2012
2013
2014 def genkeystore(localconfig):
2015     '''Generate a new key with random passwords and add it to new keystore'''
2016     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2017     keystoredir = os.path.dirname(localconfig['keystore'])
2018     if keystoredir is None or keystoredir == '':
2019         keystoredir = os.path.join(os.getcwd(), keystoredir)
2020     if not os.path.exists(keystoredir):
2021         os.makedirs(keystoredir, mode=0o700)
2022
2023     write_password_file("keystorepass", localconfig['keystorepass'])
2024     write_password_file("keypass", localconfig['keypass'])
2025     p = FDroidPopen(['keytool', '-genkey',
2026                      '-keystore', localconfig['keystore'],
2027                      '-alias', localconfig['repo_keyalias'],
2028                      '-keyalg', 'RSA', '-keysize', '4096',
2029                      '-sigalg', 'SHA256withRSA',
2030                      '-validity', '10000',
2031                      '-storepass:file', config['keystorepassfile'],
2032                      '-keypass:file', config['keypassfile'],
2033                      '-dname', localconfig['keydname']])
2034     # TODO keypass should be sent via stdin
2035     os.chmod(localconfig['keystore'], 0o0600)
2036     if p.returncode != 0:
2037         raise BuildException("Failed to generate key", p.output)
2038     # now show the lovely key that was just generated
2039     p = FDroidPopen(['keytool', '-list', '-v',
2040                      '-keystore', localconfig['keystore'],
2041                      '-alias', localconfig['repo_keyalias'],
2042                      '-storepass:file', config['keystorepassfile']])
2043     logging.info(p.output.strip() + '\n\n')
2044
2045
2046 def write_to_config(thisconfig, key, value=None):
2047     '''write a key/value to the local config.py'''
2048     if value is None:
2049         origkey = key + '_orig'
2050         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2051     with open('config.py', 'r') as f:
2052         data = f.read()
2053     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2054     repl = '\n' + key + ' = "' + value + '"'
2055     data = re.sub(pattern, repl, data)
2056     # if this key is not in the file, append it
2057     if not re.match('\s*' + key + '\s*=\s*"', data):
2058         data += repl
2059     # make sure the file ends with a carraige return
2060     if not re.match('\n$', data):
2061         data += '\n'
2062     with open('config.py', 'w') as f:
2063         f.writelines(data)
2064
2065
2066 def parse_xml(path):
2067     return XMLElementTree.parse(path).getroot()
2068
2069
2070 def string_is_integer(string):
2071     try:
2072         int(string)
2073         return True
2074     except ValueError:
2075         return False
2076
2077
2078 def download_file(url, local_filename=None, dldir='tmp'):
2079     filename = url.split('/')[-1]
2080     if local_filename is None:
2081         local_filename = os.path.join(dldir, filename)
2082     # the stream=True parameter keeps memory usage low
2083     r = requests.get(url, stream=True)
2084     with open(local_filename, 'wb') as f:
2085         for chunk in r.iter_content(chunk_size=1024):
2086             if chunk:  # filter out keep-alive new chunks
2087                 f.write(chunk)
2088                 f.flush()
2089     return local_filename