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