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>
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.
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.
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/>.
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.
37 import xml.etree.ElementTree as XMLElementTree
39 from queue import Queue
41 from zipfile import ZipFile
43 import fdroidserver.metadata
44 from .asynchronousfilereader import AsynchronousFileReader
47 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
56 'sdk_path': "$ANDROID_HOME",
59 'r10e': "$ANDROID_NDK",
61 'build_tools': "23.0.2",
66 'accepted_formats': ['txt', 'yaml'],
67 'sync_from_local_copy_dir': False,
68 'per_app_repos': False,
69 'make_current_version_link': True,
70 'current_version_name_source': 'Name',
71 'update_stats': False,
75 'stats_to_carbon': False,
77 'build_server_always': False,
78 'keystore': 'keystore.jks',
79 'smartcardoptions': [],
85 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
86 'repo_name': "My First FDroid Repo Demo",
87 'repo_icon': "fdroid-icon.png",
88 'repo_description': '''
89 This is a repository of apps to be used with FDroid. Applications in this
90 repository are either official binaries built by the original application
91 developers, or are binaries built from source by the admin of f-droid.org
92 using the tools on https://gitlab.com/u/fdroid.
98 def setup_global_opts(parser):
99 parser.add_argument("-v", "--verbose", action="store_true", default=False,
100 help="Spew out even more information than normal")
101 parser.add_argument("-q", "--quiet", action="store_true", default=False,
102 help="Restrict output to warnings and errors")
105 def fill_config_defaults(thisconfig):
106 for k, v in default_config.items():
107 if k not in thisconfig:
110 # Expand paths (~users and $vars)
111 def expand_path(path):
115 path = os.path.expanduser(path)
116 path = os.path.expandvars(path)
121 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
126 thisconfig[k + '_orig'] = v
128 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
129 if thisconfig['java_paths'] is None:
130 thisconfig['java_paths'] = dict()
131 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
132 + glob.glob('/usr/java/jdk1.[6-9]*')
133 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
134 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
135 if os.path.islink(d):
137 j = os.path.basename(d)
138 # the last one found will be the canonical one, so order appropriately
140 r'^1\.([6-9])\.0\.jdk$', # OSX
141 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
142 r'^jdk([6-9])-openjdk$', # Arch
143 r'^java-([6-9])-openjdk$', # Arch
144 r'^java-([6-9])-jdk$', # Arch (oracle)
145 r'^java-1\.([6-9])\.0-.*$', # RedHat
146 r'^java-([6-9])-oracle$', # Debian WebUpd8
147 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
148 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
150 m = re.match(regex, j)
153 osxhome = os.path.join(d, 'Contents', 'Home')
154 if os.path.exists(osxhome):
155 thisconfig['java_paths'][m.group(1)] = osxhome
157 thisconfig['java_paths'][m.group(1)] = d
159 for java_version in ('7', '8', '9'):
160 if java_version not in thisconfig['java_paths']:
162 java_home = thisconfig['java_paths'][java_version]
163 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
164 if os.path.exists(jarsigner):
165 thisconfig['jarsigner'] = jarsigner
166 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
167 break # Java7 is preferred, so quit if found
169 for k in ['ndk_paths', 'java_paths']:
175 thisconfig[k][k2] = exp
176 thisconfig[k][k2 + '_orig'] = v
179 def regsub_file(pattern, repl, path):
180 with open(path, 'r') as f:
182 text = re.sub(pattern, repl, text)
183 with open(path, 'w') as f:
187 def read_config(opts, config_file='config.py'):
188 """Read the repository config
190 The config is read from config_file, which is in the current directory when
191 any of the repo management commands are used.
193 global config, options, env, orig_path
195 if config is not None:
197 if not os.path.isfile(config_file):
198 logging.critical("Missing config file - is this a repo directory?")
205 logging.debug("Reading %s" % config_file)
206 with io.open(config_file, "rb") as f:
207 code = compile(f.read(), config_file, 'exec')
208 exec(code, None, config)
210 # smartcardoptions must be a list since its command line args for Popen
211 if 'smartcardoptions' in config:
212 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
213 elif 'keystore' in config and config['keystore'] == 'NONE':
214 # keystore='NONE' means use smartcard, these are required defaults
215 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
216 'SunPKCS11-OpenSC', '-providerClass',
217 'sun.security.pkcs11.SunPKCS11',
218 '-providerArg', 'opensc-fdroid.cfg']
220 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
221 st = os.stat(config_file)
222 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
223 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
225 fill_config_defaults(config)
227 # There is no standard, so just set up the most common environment
230 orig_path = env['PATH']
231 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
232 env[n] = config['sdk_path']
234 for k, v in config['java_paths'].items():
235 env['JAVA%s_HOME' % k] = v
237 for k in ["keystorepass", "keypass"]:
239 write_password_file(k)
241 for k in ["repo_description", "archive_description"]:
243 config[k] = clean_description(config[k])
245 if 'serverwebroot' in config:
246 if isinstance(config['serverwebroot'], str):
247 roots = [config['serverwebroot']]
248 elif all(isinstance(item, str) for item in config['serverwebroot']):
249 roots = config['serverwebroot']
251 raise TypeError('only accepts strings, lists, and tuples')
253 for rootstr in roots:
254 # since this is used with rsync, where trailing slashes have
255 # meaning, ensure there is always a trailing slash
256 if rootstr[-1] != '/':
258 rootlist.append(rootstr.replace('//', '/'))
259 config['serverwebroot'] = rootlist
264 def find_sdk_tools_cmd(cmd):
265 '''find a working path to a tool from the Android SDK'''
268 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
269 # try to find a working path to this command, in all the recent possible paths
270 if 'build_tools' in config:
271 build_tools = os.path.join(config['sdk_path'], 'build-tools')
272 # if 'build_tools' was manually set and exists, check only that one
273 configed_build_tools = os.path.join(build_tools, config['build_tools'])
274 if os.path.exists(configed_build_tools):
275 tooldirs.append(configed_build_tools)
277 # no configed version, so hunt known paths for it
278 for f in sorted(os.listdir(build_tools), reverse=True):
279 if os.path.isdir(os.path.join(build_tools, f)):
280 tooldirs.append(os.path.join(build_tools, f))
281 tooldirs.append(build_tools)
282 sdk_tools = os.path.join(config['sdk_path'], 'tools')
283 if os.path.exists(sdk_tools):
284 tooldirs.append(sdk_tools)
285 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
286 if os.path.exists(sdk_platform_tools):
287 tooldirs.append(sdk_platform_tools)
288 tooldirs.append('/usr/bin')
290 if os.path.isfile(os.path.join(d, cmd)):
291 return os.path.join(d, cmd)
292 # did not find the command, exit with error message
293 ensure_build_tools_exists(config)
296 def test_sdk_exists(thisconfig):
297 if 'sdk_path' not in thisconfig:
298 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
301 logging.error("'sdk_path' not set in config.py!")
303 if thisconfig['sdk_path'] == default_config['sdk_path']:
304 logging.error('No Android SDK found!')
305 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
306 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
308 if not os.path.exists(thisconfig['sdk_path']):
309 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
311 if not os.path.isdir(thisconfig['sdk_path']):
312 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
314 for d in ['build-tools', 'platform-tools', 'tools']:
315 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
316 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
317 thisconfig['sdk_path'], d))
322 def ensure_build_tools_exists(thisconfig):
323 if not test_sdk_exists(thisconfig):
325 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
326 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
327 if not os.path.isdir(versioned_build_tools):
328 logging.critical('Android Build Tools path "'
329 + versioned_build_tools + '" does not exist!')
333 def write_password_file(pwtype, password=None):
335 writes out passwords to a protected file instead of passing passwords as
336 command line argments
338 filename = '.fdroid.' + pwtype + '.txt'
339 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
341 os.write(fd, config[pwtype].encode('utf-8'))
343 os.write(fd, password.encode('utf-8'))
345 config[pwtype + 'file'] = filename
348 # Given the arguments in the form of multiple appid:[vc] strings, this returns
349 # a dictionary with the set of vercodes specified for each package.
350 def read_pkg_args(args, allow_vercodes=False):
357 if allow_vercodes and ':' in p:
358 package, vercode = p.split(':')
360 package, vercode = p, None
361 if package not in vercodes:
362 vercodes[package] = [vercode] if vercode else []
364 elif vercode and vercode not in vercodes[package]:
365 vercodes[package] += [vercode] if vercode else []
370 # On top of what read_pkg_args does, this returns the whole app metadata, but
371 # limiting the builds list to the builds matching the vercodes specified.
372 def read_app_args(args, allapps, allow_vercodes=False):
374 vercodes = read_pkg_args(args, allow_vercodes)
380 for appid, app in allapps.items():
381 if appid in vercodes:
384 if len(apps) != len(vercodes):
387 logging.critical("No such package: %s" % p)
388 raise FDroidException("Found invalid app ids in arguments")
390 raise FDroidException("No packages specified")
393 for appid, app in apps.items():
397 app.builds = [b for b in app.builds if b.vercode in vc]
398 if len(app.builds) != len(vercodes[appid]):
400 allvcs = [b.vercode for b in app.builds]
401 for v in vercodes[appid]:
403 logging.critical("No such vercode %s for app %s" % (v, appid))
406 raise FDroidException("Found invalid vercodes for some apps")
411 def get_extension(filename):
412 base, ext = os.path.splitext(filename)
415 return base, ext.lower()[1:]
418 def has_extension(filename, ext):
419 _, f_ext = get_extension(filename)
423 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
426 def clean_description(description):
427 'Remove unneeded newlines and spaces from a block of description text'
429 # this is split up by paragraph to make removing the newlines easier
430 for paragraph in re.split(r'\n\n', description):
431 paragraph = re.sub('\r', '', paragraph)
432 paragraph = re.sub('\n', ' ', paragraph)
433 paragraph = re.sub(' {2,}', ' ', paragraph)
434 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
435 returnstring += paragraph + '\n\n'
436 return returnstring.rstrip('\n')
439 def apknameinfo(filename):
440 filename = os.path.basename(filename)
441 m = apk_regex.match(filename)
443 result = (m.group(1), m.group(2))
444 except AttributeError:
445 raise FDroidException("Invalid apk name: %s" % filename)
449 def getapkname(app, build):
450 return "%s_%s.apk" % (app.id, build.vercode)
453 def getsrcname(app, build):
454 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
466 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
469 def getvcs(vcstype, remote, local):
471 return vcs_git(remote, local)
472 if vcstype == 'git-svn':
473 return vcs_gitsvn(remote, local)
475 return vcs_hg(remote, local)
477 return vcs_bzr(remote, local)
478 if vcstype == 'srclib':
479 if local != os.path.join('build', 'srclib', remote):
480 raise VCSException("Error: srclib paths are hard-coded!")
481 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
483 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
484 raise VCSException("Invalid vcs type " + vcstype)
487 def getsrclibvcs(name):
488 if name not in fdroidserver.metadata.srclibs:
489 raise VCSException("Missing srclib " + name)
490 return fdroidserver.metadata.srclibs[name]['Repo Type']
495 def __init__(self, remote, local):
497 # svn, git-svn and bzr may require auth
499 if self.repotype() in ('git-svn', 'bzr'):
501 if self.repotype == 'git-svn':
502 raise VCSException("Authentication is not supported for git-svn")
503 self.username, remote = remote.split('@')
504 if ':' not in self.username:
505 raise VCSException("Password required with username")
506 self.username, self.password = self.username.split(':')
510 self.clone_failed = False
511 self.refreshed = False
517 # Take the local repository to a clean version of the given revision, which
518 # is specificed in the VCS's native format. Beforehand, the repository can
519 # be dirty, or even non-existent. If the repository does already exist
520 # locally, it will be updated from the origin, but only once in the
521 # lifetime of the vcs object.
522 # None is acceptable for 'rev' if you know you are cloning a clean copy of
523 # the repo - otherwise it must specify a valid revision.
524 def gotorevision(self, rev, refresh=True):
526 if self.clone_failed:
527 raise VCSException("Downloading the repository already failed once, not trying again.")
529 # The .fdroidvcs-id file for a repo tells us what VCS type
530 # and remote that directory was created from, allowing us to drop it
531 # automatically if either of those things changes.
532 fdpath = os.path.join(self.local, '..',
533 '.fdroidvcs-' + os.path.basename(self.local))
534 fdpath = os.path.normpath(fdpath)
535 cdata = self.repotype() + ' ' + self.remote
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()
546 logging.info("Repository details for %s changed - deleting" % (
550 logging.info("Repository details for %s missing - deleting" % (
553 shutil.rmtree(self.local)
557 self.refreshed = True
560 self.gotorevisionx(rev)
561 except FDroidException as e:
564 # If necessary, write the .fdroidvcs file.
565 if writeback and not self.clone_failed:
566 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
567 with open(fdpath, 'w+') as f:
573 # Derived classes need to implement this. It's called once basic checking
574 # has been performend.
575 def gotorevisionx(self, rev):
576 raise VCSException("This VCS type doesn't define gotorevisionx")
578 # Initialise and update submodules
579 def initsubmodules(self):
580 raise VCSException('Submodules not supported for this vcs type')
582 # Get a list of all known tags
584 if not self._gettags:
585 raise VCSException('gettags not supported for this vcs type')
587 for tag in self._gettags():
588 if re.match('[-A-Za-z0-9_. /]+$', tag):
592 def latesttags(self, tags, number):
593 """Get the most recent tags in a given list.
595 :param tags: a list of tags
596 :param number: the number to return
597 :returns: A list containing the most recent tags in the provided
598 list, up to the maximum number given.
600 raise VCSException('latesttags not supported for this vcs type')
602 # Get current commit reference (hash, revision, etc)
604 raise VCSException('getref not supported for this vcs type')
606 # Returns the srclib (name, path) used in setting up the current
617 # If the local directory exists, but is somehow not a git repository, git
618 # will traverse up the directory tree until it finds one that is (i.e.
619 # fdroidserver) and then we'll proceed to destroy it! This is called as
622 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
623 result = p.output.rstrip()
624 if not result.endswith(self.local):
625 raise VCSException('Repository mismatch')
627 def gotorevisionx(self, rev):
628 if not os.path.exists(self.local):
630 p = FDroidPopen(['git', 'clone', self.remote, self.local])
631 if p.returncode != 0:
632 self.clone_failed = True
633 raise VCSException("Git clone failed", p.output)
637 # Discard any working tree changes
638 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
639 'git', 'reset', '--hard'], cwd=self.local, output=False)
640 if p.returncode != 0:
641 raise VCSException("Git reset failed", p.output)
642 # Remove untracked files now, in case they're tracked in the target
643 # revision (it happens!)
644 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
645 'git', 'clean', '-dffx'], cwd=self.local, output=False)
646 if p.returncode != 0:
647 raise VCSException("Git clean failed", p.output)
648 if not self.refreshed:
649 # Get latest commits and tags from remote
650 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
651 if p.returncode != 0:
652 raise VCSException("Git fetch failed", p.output)
653 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
654 if p.returncode != 0:
655 raise VCSException("Git fetch failed", p.output)
656 # Recreate origin/HEAD as git clone would do it, in case it disappeared
657 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
658 if p.returncode != 0:
659 lines = p.output.splitlines()
660 if 'Multiple remote HEAD branches' not in lines[0]:
661 raise VCSException("Git remote set-head failed", p.output)
662 branch = lines[1].split(' ')[-1]
663 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
664 if p2.returncode != 0:
665 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
666 self.refreshed = True
667 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
668 # a github repo. Most of the time this is the same as origin/master.
669 rev = rev or 'origin/HEAD'
670 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
671 if p.returncode != 0:
672 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
673 # Get rid of any uncontrolled files left behind
674 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
675 if p.returncode != 0:
676 raise VCSException("Git clean failed", p.output)
678 def initsubmodules(self):
680 submfile = os.path.join(self.local, '.gitmodules')
681 if not os.path.isfile(submfile):
682 raise VCSException("No git submodules available")
684 # fix submodules not accessible without an account and public key auth
685 with open(submfile, 'r') as f:
686 lines = f.readlines()
687 with open(submfile, 'w') as f:
689 if 'git@github.com' in line:
690 line = line.replace('git@github.com:', 'https://github.com/')
691 if 'git@gitlab.com' in line:
692 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
695 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
696 if p.returncode != 0:
697 raise VCSException("Git submodule sync failed", p.output)
698 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
699 if p.returncode != 0:
700 raise VCSException("Git submodule update failed", p.output)
704 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
705 return p.output.splitlines()
707 def latesttags(self, tags, number):
712 ['git', 'show', '--format=format:%ct', '-s', tag],
713 cwd=self.local, output=False)
714 # Timestamp is on the last line. For a normal tag, it's the only
715 # line, but for annotated tags, the rest of the info precedes it.
716 ts = int(p.output.splitlines()[-1])
719 for _, t in sorted(tl)[-number:]:
724 class vcs_gitsvn(vcs):
729 # If the local directory exists, but is somehow not a git repository, git
730 # will traverse up the directory tree until it finds one that is (i.e.
731 # fdroidserver) and then we'll proceed to destory it! This is called as
734 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
735 result = p.output.rstrip()
736 if not result.endswith(self.local):
737 raise VCSException('Repository mismatch')
739 def gotorevisionx(self, rev):
740 if not os.path.exists(self.local):
742 gitsvn_args = ['git', 'svn', 'clone']
743 if ';' in self.remote:
744 remote_split = self.remote.split(';')
745 for i in remote_split[1:]:
746 if i.startswith('trunk='):
747 gitsvn_args.extend(['-T', i[6:]])
748 elif i.startswith('tags='):
749 gitsvn_args.extend(['-t', i[5:]])
750 elif i.startswith('branches='):
751 gitsvn_args.extend(['-b', i[9:]])
752 gitsvn_args.extend([remote_split[0], self.local])
753 p = FDroidPopen(gitsvn_args, output=False)
754 if p.returncode != 0:
755 self.clone_failed = True
756 raise VCSException("Git svn clone failed", p.output)
758 gitsvn_args.extend([self.remote, self.local])
759 p = FDroidPopen(gitsvn_args, output=False)
760 if p.returncode != 0:
761 self.clone_failed = True
762 raise VCSException("Git svn clone failed", p.output)
766 # Discard any working tree changes
767 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
768 if p.returncode != 0:
769 raise VCSException("Git reset failed", p.output)
770 # Remove untracked files now, in case they're tracked in the target
771 # revision (it happens!)
772 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
773 if p.returncode != 0:
774 raise VCSException("Git clean failed", p.output)
775 if not self.refreshed:
776 # Get new commits, branches and tags from repo
777 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
778 if p.returncode != 0:
779 raise VCSException("Git svn fetch failed")
780 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
781 if p.returncode != 0:
782 raise VCSException("Git svn rebase failed", p.output)
783 self.refreshed = True
785 rev = rev or 'master'
787 nospaces_rev = rev.replace(' ', '%20')
788 # Try finding a svn tag
789 for treeish in ['origin/', '']:
790 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
791 if p.returncode == 0:
793 if p.returncode != 0:
794 # No tag found, normal svn rev translation
795 # Translate svn rev into git format
796 rev_split = rev.split('/')
799 for treeish in ['origin/', '']:
800 if len(rev_split) > 1:
801 treeish += rev_split[0]
802 svn_rev = rev_split[1]
805 # if no branch is specified, then assume trunk (i.e. 'master' branch):
809 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
811 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
812 git_rev = p.output.rstrip()
814 if p.returncode == 0 and git_rev:
817 if p.returncode != 0 or not git_rev:
818 # Try a plain git checkout as a last resort
819 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
820 if p.returncode != 0:
821 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
823 # Check out the git rev equivalent to the svn rev
824 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
825 if p.returncode != 0:
826 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
828 # Get rid of any uncontrolled files left behind
829 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
830 if p.returncode != 0:
831 raise VCSException("Git clean failed", p.output)
835 for treeish in ['origin/', '']:
836 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
842 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
843 if p.returncode != 0:
845 return p.output.strip()
853 def gotorevisionx(self, rev):
854 if not os.path.exists(self.local):
855 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
856 if p.returncode != 0:
857 self.clone_failed = True
858 raise VCSException("Hg clone failed", p.output)
860 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
861 if p.returncode != 0:
862 raise VCSException("Hg status failed", p.output)
863 for line in p.output.splitlines():
864 if not line.startswith('? '):
865 raise VCSException("Unexpected output from hg status -uS: " + line)
866 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
867 if not self.refreshed:
868 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Hg pull failed", p.output)
871 self.refreshed = True
873 rev = rev or 'default'
876 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
877 if p.returncode != 0:
878 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
879 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
880 # Also delete untracked files, we have to enable purge extension for that:
881 if "'purge' is provided by the following extension" in p.output:
882 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
883 myfile.write("\n[extensions]\nhgext.purge=\n")
884 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
885 if p.returncode != 0:
886 raise VCSException("HG purge failed", p.output)
887 elif p.returncode != 0:
888 raise VCSException("HG purge failed", p.output)
891 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
892 return p.output.splitlines()[1:]
900 def gotorevisionx(self, rev):
901 if not os.path.exists(self.local):
902 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
903 if p.returncode != 0:
904 self.clone_failed = True
905 raise VCSException("Bzr branch failed", p.output)
907 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
908 if p.returncode != 0:
909 raise VCSException("Bzr revert failed", p.output)
910 if not self.refreshed:
911 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
912 if p.returncode != 0:
913 raise VCSException("Bzr update failed", p.output)
914 self.refreshed = True
916 revargs = list(['-r', rev] if rev else [])
917 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
918 if p.returncode != 0:
919 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
922 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
923 return [tag.split(' ')[0].strip() for tag in
924 p.output.splitlines()]
927 def unescape_string(string):
930 if string[0] == '"' and string[-1] == '"':
933 return string.replace("\\'", "'")
936 def retrieve_string(app_dir, string, xmlfiles=None):
938 if not string.startswith('@string/'):
939 return unescape_string(string)
944 os.path.join(app_dir, 'res'),
945 os.path.join(app_dir, 'src', 'main', 'res'),
947 for r, d, f in os.walk(res_dir):
948 if os.path.basename(r) == 'values':
949 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
951 name = string[len('@string/'):]
953 def element_content(element):
954 if element.text is None:
956 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
957 return s.decode('utf-8').strip()
959 for path in xmlfiles:
960 if not os.path.isfile(path):
962 xml = parse_xml(path)
963 element = xml.find('string[@name="' + name + '"]')
964 if element is not None:
965 content = element_content(element)
966 return retrieve_string(app_dir, content, xmlfiles)
971 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
972 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
975 # Return list of existing files that will be used to find the highest vercode
976 def manifest_paths(app_dir, flavours):
978 possible_manifests = \
979 [os.path.join(app_dir, 'AndroidManifest.xml'),
980 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
981 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
982 os.path.join(app_dir, 'build.gradle')]
984 for flavour in flavours:
987 possible_manifests.append(
988 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
990 return [path for path in possible_manifests if os.path.isfile(path)]
993 # Retrieve the package name. Returns the name, or None if not found.
994 def fetch_real_name(app_dir, flavours):
995 for path in manifest_paths(app_dir, flavours):
996 if not has_extension(path, 'xml') or not os.path.isfile(path):
998 logging.debug("fetch_real_name: Checking manifest at " + path)
999 xml = parse_xml(path)
1000 app = xml.find('application')
1003 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1005 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1006 result = retrieve_string_singleline(app_dir, label)
1008 result = result.strip()
1013 def get_library_references(root_dir):
1015 proppath = os.path.join(root_dir, 'project.properties')
1016 if not os.path.isfile(proppath):
1018 with open(proppath, 'r') as f:
1020 if not line.startswith('android.library.reference.'):
1022 path = line.split('=')[1].strip()
1023 relpath = os.path.join(root_dir, path)
1024 if not os.path.isdir(relpath):
1026 logging.debug("Found subproject at %s" % path)
1027 libraries.append(path)
1031 def ant_subprojects(root_dir):
1032 subprojects = get_library_references(root_dir)
1033 for subpath in subprojects:
1034 subrelpath = os.path.join(root_dir, subpath)
1035 for p in get_library_references(subrelpath):
1036 relp = os.path.normpath(os.path.join(subpath, p))
1037 if relp not in subprojects:
1038 subprojects.insert(0, relp)
1042 def remove_debuggable_flags(root_dir):
1043 # Remove forced debuggable flags
1044 logging.debug("Removing debuggable flags from %s" % root_dir)
1045 for root, dirs, files in os.walk(root_dir):
1046 if 'AndroidManifest.xml' in files:
1047 regsub_file(r'android:debuggable="[^"]*"',
1049 os.path.join(root, 'AndroidManifest.xml'))
1052 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1053 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1054 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1057 def app_matches_packagename(app, package):
1060 appid = app.UpdateCheckName or app.id
1061 if appid is None or appid == "Ignore":
1063 return appid == package
1066 # Extract some information from the AndroidManifest.xml at the given path.
1067 # Returns (version, vercode, package), any or all of which might be None.
1068 # All values returned are strings.
1069 def parse_androidmanifests(paths, app):
1071 ignoreversions = app.UpdateCheckIgnore
1072 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1075 return (None, None, None)
1083 if not os.path.isfile(path):
1086 logging.debug("Parsing manifest at {0}".format(path))
1087 gradle = has_extension(path, 'gradle')
1093 with open(path, 'r') as f:
1095 if gradle_comment.match(line):
1097 # Grab first occurence of each to avoid running into
1098 # alternative flavours and builds.
1100 matches = psearch_g(line)
1102 s = matches.group(2)
1103 if app_matches_packagename(app, s):
1106 matches = vnsearch_g(line)
1108 version = matches.group(2)
1110 matches = vcsearch_g(line)
1112 vercode = matches.group(1)
1115 xml = parse_xml(path)
1116 if "package" in xml.attrib:
1117 s = xml.attrib["package"]
1118 if app_matches_packagename(app, s):
1120 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1121 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1122 base_dir = os.path.dirname(path)
1123 version = retrieve_string_singleline(base_dir, version)
1124 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1125 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1126 if string_is_integer(a):
1129 logging.warning("Problem with xml at {0}".format(path))
1131 # Remember package name, may be defined separately from version+vercode
1133 package = max_package
1135 logging.debug("..got package={0}, version={1}, vercode={2}"
1136 .format(package, version, vercode))
1138 # Always grab the package name and version name in case they are not
1139 # together with the highest version code
1140 if max_package is None and package is not None:
1141 max_package = package
1142 if max_version is None and version is not None:
1143 max_version = version
1145 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1146 if not ignoresearch or not ignoresearch(version):
1147 if version is not None:
1148 max_version = version
1149 if vercode is not None:
1150 max_vercode = vercode
1151 if package is not None:
1152 max_package = package
1154 max_version = "Ignore"
1156 if max_version is None:
1157 max_version = "Unknown"
1159 if max_package and not is_valid_package_name(max_package):
1160 raise FDroidException("Invalid package name {0}".format(max_package))
1162 return (max_version, max_vercode, max_package)
1165 def is_valid_package_name(name):
1166 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1169 class FDroidException(Exception):
1171 def __init__(self, value, detail=None):
1173 self.detail = detail
1175 def shortened_detail(self):
1176 if len(self.detail) < 16000:
1178 return '[...]\n' + self.detail[-16000:]
1180 def get_wikitext(self):
1181 ret = repr(self.value) + "\n"
1184 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1190 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1194 class VCSException(FDroidException):
1198 class BuildException(FDroidException):
1202 # Get the specified source library.
1203 # Returns the path to it. Normally this is the path to be used when referencing
1204 # it, which may be a subdirectory of the actual project. If you want the base
1205 # directory of the project, pass 'basepath=True'.
1206 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1207 raw=False, prepare=True, preponly=False, refresh=True):
1215 name, ref = spec.split('@')
1217 number, name = name.split(':', 1)
1219 name, subdir = name.split('/', 1)
1221 if name not in fdroidserver.metadata.srclibs:
1222 raise VCSException('srclib ' + name + ' not found.')
1224 srclib = fdroidserver.metadata.srclibs[name]
1226 sdir = os.path.join(srclib_dir, name)
1229 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1230 vcs.srclib = (name, number, sdir)
1232 vcs.gotorevision(ref, refresh)
1239 libdir = os.path.join(sdir, subdir)
1240 elif srclib["Subdir"]:
1241 for subdir in srclib["Subdir"]:
1242 libdir_candidate = os.path.join(sdir, subdir)
1243 if os.path.exists(libdir_candidate):
1244 libdir = libdir_candidate
1250 remove_signing_keys(sdir)
1251 remove_debuggable_flags(sdir)
1255 if srclib["Prepare"]:
1256 cmd = replace_config_vars(srclib["Prepare"], None)
1258 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1259 if p.returncode != 0:
1260 raise BuildException("Error running prepare command for srclib %s"
1266 return (name, number, libdir)
1268 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1271 # Prepare the source code for a particular build
1272 # 'vcs' - the appropriate vcs object for the application
1273 # 'app' - the application details from the metadata
1274 # 'build' - the build details from the metadata
1275 # 'build_dir' - the path to the build directory, usually
1277 # 'srclib_dir' - the path to the source libraries directory, usually
1279 # 'extlib_dir' - the path to the external libraries directory, usually
1281 # Returns the (root, srclibpaths) where:
1282 # 'root' is the root directory, which may be the same as 'build_dir' or may
1283 # be a subdirectory of it.
1284 # 'srclibpaths' is information on the srclibs being used
1285 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1287 # Optionally, the actual app source can be in a subdirectory
1289 root_dir = os.path.join(build_dir, build.subdir)
1291 root_dir = build_dir
1293 # Get a working copy of the right revision
1294 logging.info("Getting source for revision " + build.commit)
1295 vcs.gotorevision(build.commit, refresh)
1297 # Initialise submodules if required
1298 if build.submodules:
1299 logging.info("Initialising submodules")
1300 vcs.initsubmodules()
1302 # Check that a subdir (if we're using one) exists. This has to happen
1303 # after the checkout, since it might not exist elsewhere
1304 if not os.path.exists(root_dir):
1305 raise BuildException('Missing subdir ' + root_dir)
1307 # Run an init command if one is required
1309 cmd = replace_config_vars(build.init, build)
1310 logging.info("Running 'init' commands in %s" % root_dir)
1312 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1313 if p.returncode != 0:
1314 raise BuildException("Error running init command for %s:%s" %
1315 (app.id, build.version), p.output)
1317 # Apply patches if any
1319 logging.info("Applying patches")
1320 for patch in build.patch:
1321 patch = patch.strip()
1322 logging.info("Applying " + patch)
1323 patch_path = os.path.join('metadata', app.id, patch)
1324 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1325 if p.returncode != 0:
1326 raise BuildException("Failed to apply patch %s" % patch_path)
1328 # Get required source libraries
1331 logging.info("Collecting source libraries")
1332 for lib in build.srclibs:
1333 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1335 for name, number, libpath in srclibpaths:
1336 place_srclib(root_dir, int(number) if number else None, libpath)
1338 basesrclib = vcs.getsrclib()
1339 # If one was used for the main source, add that too.
1341 srclibpaths.append(basesrclib)
1343 # Update the local.properties file
1344 localprops = [os.path.join(build_dir, 'local.properties')]
1346 parts = build.subdir.split(os.sep)
1349 cur = os.path.join(cur, d)
1350 localprops += [os.path.join(cur, 'local.properties')]
1351 for path in localprops:
1353 if os.path.isfile(path):
1354 logging.info("Updating local.properties file at %s" % path)
1355 with open(path, 'r') as f:
1359 logging.info("Creating local.properties file at %s" % path)
1360 # Fix old-fashioned 'sdk-location' by copying
1361 # from sdk.dir, if necessary
1363 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1364 re.S | re.M).group(1)
1365 props += "sdk-location=%s\n" % sdkloc
1367 props += "sdk.dir=%s\n" % config['sdk_path']
1368 props += "sdk-location=%s\n" % config['sdk_path']
1369 ndk_path = build.ndk_path()
1372 props += "ndk.dir=%s\n" % ndk_path
1373 props += "ndk-location=%s\n" % ndk_path
1374 # Add java.encoding if necessary
1376 props += "java.encoding=%s\n" % build.encoding
1377 with open(path, 'w') as f:
1381 if build.build_method() == 'gradle':
1382 flavours = build.gradle
1385 n = build.target.split('-')[1]
1386 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1387 r'compileSdkVersion %s' % n,
1388 os.path.join(root_dir, 'build.gradle'))
1390 # Remove forced debuggable flags
1391 remove_debuggable_flags(root_dir)
1393 # Insert version code and number into the manifest if necessary
1394 if build.forceversion:
1395 logging.info("Changing the version name")
1396 for path in manifest_paths(root_dir, flavours):
1397 if not os.path.isfile(path):
1399 if has_extension(path, 'xml'):
1400 regsub_file(r'android:versionName="[^"]*"',
1401 r'android:versionName="%s"' % build.version,
1403 elif has_extension(path, 'gradle'):
1404 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1405 r"""\1versionName '%s'""" % build.version,
1408 if build.forcevercode:
1409 logging.info("Changing the version code")
1410 for path in manifest_paths(root_dir, flavours):
1411 if not os.path.isfile(path):
1413 if has_extension(path, 'xml'):
1414 regsub_file(r'android:versionCode="[^"]*"',
1415 r'android:versionCode="%s"' % build.vercode,
1417 elif has_extension(path, 'gradle'):
1418 regsub_file(r'versionCode[ =]+[0-9]+',
1419 r'versionCode %s' % build.vercode,
1422 # Delete unwanted files
1424 logging.info("Removing specified files")
1425 for part in getpaths(build_dir, build.rm):
1426 dest = os.path.join(build_dir, part)
1427 logging.info("Removing {0}".format(part))
1428 if os.path.lexists(dest):
1429 if os.path.islink(dest):
1430 FDroidPopen(['unlink', dest], output=False)
1432 FDroidPopen(['rm', '-rf', dest], output=False)
1434 logging.info("...but it didn't exist")
1436 remove_signing_keys(build_dir)
1438 # Add required external libraries
1440 logging.info("Collecting prebuilt libraries")
1441 libsdir = os.path.join(root_dir, 'libs')
1442 if not os.path.exists(libsdir):
1444 for lib in build.extlibs:
1446 logging.info("...installing extlib {0}".format(lib))
1447 libf = os.path.basename(lib)
1448 libsrc = os.path.join(extlib_dir, lib)
1449 if not os.path.exists(libsrc):
1450 raise BuildException("Missing extlib file {0}".format(libsrc))
1451 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1453 # Run a pre-build command if one is required
1455 logging.info("Running 'prebuild' commands in %s" % root_dir)
1457 cmd = replace_config_vars(build.prebuild, build)
1459 # Substitute source library paths into prebuild commands
1460 for name, number, libpath in srclibpaths:
1461 libpath = os.path.relpath(libpath, root_dir)
1462 cmd = cmd.replace('$$' + name + '$$', libpath)
1464 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1465 if p.returncode != 0:
1466 raise BuildException("Error running prebuild command for %s:%s" %
1467 (app.id, build.version), p.output)
1469 # Generate (or update) the ant build file, build.xml...
1470 if build.build_method() == 'ant' and build.update != ['no']:
1471 parms = ['android', 'update', 'lib-project']
1472 lparms = ['android', 'update', 'project']
1475 parms += ['-t', build.target]
1476 lparms += ['-t', build.target]
1478 update_dirs = build.update
1480 update_dirs = ant_subprojects(root_dir) + ['.']
1482 for d in update_dirs:
1483 subdir = os.path.join(root_dir, d)
1485 logging.debug("Updating main project")
1486 cmd = parms + ['-p', d]
1488 logging.debug("Updating subproject %s" % d)
1489 cmd = lparms + ['-p', d]
1490 p = SdkToolsPopen(cmd, cwd=root_dir)
1491 # Check to see whether an error was returned without a proper exit
1492 # code (this is the case for the 'no target set or target invalid'
1494 if p.returncode != 0 or p.output.startswith("Error: "):
1495 raise BuildException("Failed to update project at %s" % d, p.output)
1496 # Clean update dirs via ant
1498 logging.info("Cleaning subproject %s" % d)
1499 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1501 return (root_dir, srclibpaths)
1504 # Extend via globbing the paths from a field and return them as a map from
1505 # original path to resulting paths
1506 def getpaths_map(build_dir, globpaths):
1510 full_path = os.path.join(build_dir, p)
1511 full_path = os.path.normpath(full_path)
1512 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1514 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1518 # Extend via globbing the paths from a field and return them as a set
1519 def getpaths(build_dir, globpaths):
1520 paths_map = getpaths_map(build_dir, globpaths)
1522 for k, v in paths_map.items():
1529 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1535 self.path = os.path.join('stats', 'known_apks.txt')
1537 if os.path.isfile(self.path):
1538 with open(self.path, 'r') as f:
1540 t = line.rstrip().split(' ')
1542 self.apks[t[0]] = (t[1], None)
1544 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1545 self.changed = False
1547 def writeifchanged(self):
1548 if not self.changed:
1551 if not os.path.exists('stats'):
1555 for apk, app in self.apks.items():
1557 line = apk + ' ' + appid
1559 line += ' ' + time.strftime('%Y-%m-%d', added)
1562 with open(self.path, 'w') as f:
1563 for line in sorted(lst, key=natural_key):
1564 f.write(line + '\n')
1566 # Record an apk (if it's new, otherwise does nothing)
1567 # Returns the date it was added.
1568 def recordapk(self, apk, app):
1569 if apk not in self.apks:
1570 self.apks[apk] = (app, time.gmtime(time.time()))
1572 _, added = self.apks[apk]
1575 # Look up information - given the 'apkname', returns (app id, date added/None).
1576 # Or returns None for an unknown apk.
1577 def getapp(self, apkname):
1578 if apkname in self.apks:
1579 return self.apks[apkname]
1582 # Get the most recent 'num' apps added to the repo, as a list of package ids
1583 # with the most recent first.
1584 def getlatest(self, num):
1586 for apk, app in self.apks.items():
1590 if apps[appid] > added:
1594 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1595 lst = [app for app, _ in sortedapps]
1600 def isApkDebuggable(apkfile, config):
1601 """Returns True if the given apk file is debuggable
1603 :param apkfile: full path to the apk to check"""
1605 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1607 if p.returncode != 0:
1608 logging.critical("Failed to get apk manifest information")
1610 for line in p.output.splitlines():
1611 if 'android:debuggable' in line and not line.endswith('0x0'):
1618 self.returncode = None
1622 def SdkToolsPopen(commands, cwd=None, output=True):
1624 if cmd not in config:
1625 config[cmd] = find_sdk_tools_cmd(commands[0])
1626 abscmd = config[cmd]
1628 logging.critical("Could not find '%s' on your system" % cmd)
1630 return FDroidPopen([abscmd] + commands[1:],
1631 cwd=cwd, output=output)
1634 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1636 Run a command and capture the possibly huge output as bytes.
1638 :param commands: command and argument list like in subprocess.Popen
1639 :param cwd: optionally specifies a working directory
1640 :returns: A PopenResult.
1646 cwd = os.path.normpath(cwd)
1647 logging.debug("Directory: %s" % cwd)
1648 logging.debug("> %s" % ' '.join(commands))
1650 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1651 result = PopenResult()
1654 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1655 stdout=subprocess.PIPE, stderr=stderr_param)
1656 except OSError as e:
1657 raise BuildException("OSError while trying to execute " +
1658 ' '.join(commands) + ': ' + str(e))
1660 if not stderr_to_stdout and options.verbose:
1661 stderr_queue = Queue()
1662 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1664 while not stderr_reader.eof():
1665 while not stderr_queue.empty():
1666 line = stderr_queue.get()
1667 sys.stderr.buffer.write(line)
1672 stdout_queue = Queue()
1673 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1676 # Check the queue for output (until there is no more to get)
1677 while not stdout_reader.eof():
1678 while not stdout_queue.empty():
1679 line = stdout_queue.get()
1680 if output and options.verbose:
1681 # Output directly to console
1682 sys.stderr.buffer.write(line)
1688 result.returncode = p.wait()
1689 result.output = buf.getvalue()
1694 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1696 Run a command and capture the possibly huge output as a str.
1698 :param commands: command and argument list like in subprocess.Popen
1699 :param cwd: optionally specifies a working directory
1700 :returns: A PopenResult.
1702 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1703 result.output = result.output.decode('utf-8')
1707 gradle_comment = re.compile(r'[ ]*//')
1708 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1709 gradle_line_matches = [
1710 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1711 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1712 re.compile(r'.*\.readLine\(.*'),
1716 def remove_signing_keys(build_dir):
1717 for root, dirs, files in os.walk(build_dir):
1718 if 'build.gradle' in files:
1719 path = os.path.join(root, 'build.gradle')
1721 with open(path, "r") as o:
1722 lines = o.readlines()
1728 with open(path, "w") as o:
1729 while i < len(lines):
1732 while line.endswith('\\\n'):
1733 line = line.rstrip('\\\n') + lines[i]
1736 if gradle_comment.match(line):
1741 opened += line.count('{')
1742 opened -= line.count('}')
1745 if gradle_signing_configs.match(line):
1750 if any(s.match(line) for s in gradle_line_matches):
1758 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1761 'project.properties',
1763 'default.properties',
1764 'ant.properties', ]:
1765 if propfile in files:
1766 path = os.path.join(root, propfile)
1768 with open(path, "r") as o:
1769 lines = o.readlines()
1773 with open(path, "w") as o:
1775 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1782 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1785 def reset_env_path():
1786 global env, orig_path
1787 env['PATH'] = orig_path
1790 def add_to_env_path(path):
1792 paths = env['PATH'].split(os.pathsep)
1796 env['PATH'] = os.pathsep.join(paths)
1799 def replace_config_vars(cmd, build):
1801 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1802 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1803 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1804 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1805 if build is not None:
1806 cmd = cmd.replace('$$COMMIT$$', build.commit)
1807 cmd = cmd.replace('$$VERSION$$', build.version)
1808 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1812 def place_srclib(root_dir, number, libpath):
1815 relpath = os.path.relpath(libpath, root_dir)
1816 proppath = os.path.join(root_dir, 'project.properties')
1819 if os.path.isfile(proppath):
1820 with open(proppath, "r") as o:
1821 lines = o.readlines()
1823 with open(proppath, "w") as o:
1826 if line.startswith('android.library.reference.%d=' % number):
1827 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1832 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1834 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1837 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1838 """Verify that two apks are the same
1840 One of the inputs is signed, the other is unsigned. The signature metadata
1841 is transferred from the signed to the unsigned apk, and then jarsigner is
1842 used to verify that the signature from the signed apk is also varlid for
1844 :param signed_apk: Path to a signed apk file
1845 :param unsigned_apk: Path to an unsigned apk file expected to match it
1846 :param tmp_dir: Path to directory for temporary files
1847 :returns: None if the verification is successful, otherwise a string
1848 describing what went wrong.
1850 with ZipFile(signed_apk) as signed_apk_as_zip:
1851 meta_inf_files = ['META-INF/MANIFEST.MF']
1852 for f in signed_apk_as_zip.namelist():
1853 if apk_sigfile.match(f):
1854 meta_inf_files.append(f)
1855 if len(meta_inf_files) < 3:
1856 return "Signature files missing from {0}".format(signed_apk)
1857 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1858 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1859 for meta_inf_file in meta_inf_files:
1860 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1862 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1863 logging.info("...NOT verified - {0}".format(signed_apk))
1864 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1865 logging.info("...successfully verified")
1868 apk_badchars = re.compile('''[/ :;'"]''')
1871 def compare_apks(apk1, apk2, tmp_dir):
1874 Returns None if the apk content is the same (apart from the signing key),
1875 otherwise a string describing what's different, or what went wrong when
1876 trying to do the comparison.
1879 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1880 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1881 for d in [apk1dir, apk2dir]:
1882 if os.path.exists(d):
1885 os.mkdir(os.path.join(d, 'jar-xf'))
1887 if subprocess.call(['jar', 'xf',
1888 os.path.abspath(apk1)],
1889 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1890 return("Failed to unpack " + apk1)
1891 if subprocess.call(['jar', 'xf',
1892 os.path.abspath(apk2)],
1893 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1894 return("Failed to unpack " + apk2)
1896 # try to find apktool in the path, if it hasn't been manually configed
1897 if 'apktool' not in config:
1898 tmp = find_command('apktool')
1900 config['apktool'] = tmp
1901 if 'apktool' in config:
1902 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1904 return("Failed to unpack " + apk1)
1905 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1907 return("Failed to unpack " + apk2)
1909 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1910 lines = p.output.splitlines()
1911 if len(lines) != 1 or 'META-INF' not in lines[0]:
1912 meld = find_command('meld')
1913 if meld is not None:
1914 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1915 return("Unexpected diff output - " + p.output)
1917 # since everything verifies, delete the comparison to keep cruft down
1918 shutil.rmtree(apk1dir)
1919 shutil.rmtree(apk2dir)
1921 # If we get here, it seems like they're the same!
1925 def find_command(command):
1926 '''find the full path of a command, or None if it can't be found in the PATH'''
1929 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1931 fpath, fname = os.path.split(command)
1936 for path in os.environ["PATH"].split(os.pathsep):
1937 path = path.strip('"')
1938 exe_file = os.path.join(path, command)
1939 if is_exe(exe_file):
1946 '''generate a random password for when generating keys'''
1947 h = hashlib.sha256()
1948 h.update(os.urandom(16)) # salt
1949 h.update(socket.getfqdn().encode('utf-8'))
1950 passwd = base64.b64encode(h.digest()).strip()
1951 return passwd.decode('utf-8')
1954 def genkeystore(localconfig):
1955 '''Generate a new key with random passwords and add it to new keystore'''
1956 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1957 keystoredir = os.path.dirname(localconfig['keystore'])
1958 if keystoredir is None or keystoredir == '':
1959 keystoredir = os.path.join(os.getcwd(), keystoredir)
1960 if not os.path.exists(keystoredir):
1961 os.makedirs(keystoredir, mode=0o700)
1963 write_password_file("keystorepass", localconfig['keystorepass'])
1964 write_password_file("keypass", localconfig['keypass'])
1965 p = FDroidPopen([config['keytool'], '-genkey',
1966 '-keystore', localconfig['keystore'],
1967 '-alias', localconfig['repo_keyalias'],
1968 '-keyalg', 'RSA', '-keysize', '4096',
1969 '-sigalg', 'SHA256withRSA',
1970 '-validity', '10000',
1971 '-storepass:file', config['keystorepassfile'],
1972 '-keypass:file', config['keypassfile'],
1973 '-dname', localconfig['keydname']])
1974 # TODO keypass should be sent via stdin
1975 if p.returncode != 0:
1976 raise BuildException("Failed to generate key", p.output)
1977 os.chmod(localconfig['keystore'], 0o0600)
1978 # now show the lovely key that was just generated
1979 p = FDroidPopen([config['keytool'], '-list', '-v',
1980 '-keystore', localconfig['keystore'],
1981 '-alias', localconfig['repo_keyalias'],
1982 '-storepass:file', config['keystorepassfile']])
1983 logging.info(p.output.strip() + '\n\n')
1986 def write_to_config(thisconfig, key, value=None):
1987 '''write a key/value to the local config.py'''
1989 origkey = key + '_orig'
1990 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1991 with open('config.py', 'r') as f:
1993 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1994 repl = '\n' + key + ' = "' + value + '"'
1995 data = re.sub(pattern, repl, data)
1996 # if this key is not in the file, append it
1997 if not re.match('\s*' + key + '\s*=\s*"', data):
1999 # make sure the file ends with a carraige return
2000 if not re.match('\n$', data):
2002 with open('config.py', 'w') as f:
2006 def parse_xml(path):
2007 return XMLElementTree.parse(path).getroot()
2010 def string_is_integer(string):
2018 def get_per_app_repos():
2019 '''per-app repos are dirs named with the packageName of a single app'''
2021 # Android packageNames are Java packages, they may contain uppercase or
2022 # lowercase letters ('A' through 'Z'), numbers, and underscores
2023 # ('_'). However, individual package name parts may only start with
2024 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2025 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2028 for root, dirs, files in os.walk(os.getcwd()):
2030 print('checking', root, 'for', d)
2031 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2032 # standard parts of an fdroid repo, so never packageNames
2035 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):