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 # Get a list of all the known tags, sorted from newest to oldest
593 def latesttags(self):
594 raise VCSException('latesttags not supported for this vcs type')
596 # Get current commit reference (hash, revision, etc)
598 raise VCSException('getref not supported for this vcs type')
600 # Returns the srclib (name, path) used in setting up the current
611 # If the local directory exists, but is somehow not a git repository, git
612 # will traverse up the directory tree until it finds one that is (i.e.
613 # fdroidserver) and then we'll proceed to destroy it! This is called as
616 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
617 result = p.output.rstrip()
618 if not result.endswith(self.local):
619 raise VCSException('Repository mismatch')
621 def gotorevisionx(self, rev):
622 if not os.path.exists(self.local):
624 p = FDroidPopen(['git', 'clone', self.remote, self.local])
625 if p.returncode != 0:
626 self.clone_failed = True
627 raise VCSException("Git clone failed", p.output)
631 # Discard any working tree changes
632 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
633 'git', 'reset', '--hard'], cwd=self.local, output=False)
634 if p.returncode != 0:
635 raise VCSException("Git reset failed", p.output)
636 # Remove untracked files now, in case they're tracked in the target
637 # revision (it happens!)
638 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
639 'git', 'clean', '-dffx'], cwd=self.local, output=False)
640 if p.returncode != 0:
641 raise VCSException("Git clean failed", p.output)
642 if not self.refreshed:
643 # Get latest commits and tags from remote
644 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
645 if p.returncode != 0:
646 raise VCSException("Git fetch failed", p.output)
647 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
648 if p.returncode != 0:
649 raise VCSException("Git fetch failed", p.output)
650 # Recreate origin/HEAD as git clone would do it, in case it disappeared
651 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
652 if p.returncode != 0:
653 lines = p.output.splitlines()
654 if 'Multiple remote HEAD branches' not in lines[0]:
655 raise VCSException("Git remote set-head failed", p.output)
656 branch = lines[1].split(' ')[-1]
657 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
658 if p2.returncode != 0:
659 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
660 self.refreshed = True
661 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
662 # a github repo. Most of the time this is the same as origin/master.
663 rev = rev or 'origin/HEAD'
664 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
665 if p.returncode != 0:
666 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
667 # Get rid of any uncontrolled files left behind
668 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
669 if p.returncode != 0:
670 raise VCSException("Git clean failed", p.output)
672 def initsubmodules(self):
674 submfile = os.path.join(self.local, '.gitmodules')
675 if not os.path.isfile(submfile):
676 raise VCSException("No git submodules available")
678 # fix submodules not accessible without an account and public key auth
679 with open(submfile, 'r') as f:
680 lines = f.readlines()
681 with open(submfile, 'w') as f:
683 if 'git@github.com' in line:
684 line = line.replace('git@github.com:', 'https://github.com/')
685 if 'git@gitlab.com' in line:
686 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
689 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
690 if p.returncode != 0:
691 raise VCSException("Git submodule sync failed", p.output)
692 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
693 if p.returncode != 0:
694 raise VCSException("Git submodule update failed", p.output)
698 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
699 return p.output.splitlines()
701 tag_format = re.compile(r'.*tag: ([^),]*).*')
703 def latesttags(self):
705 p = FDroidPopen(['git', 'log', '--tags',
706 '--simplify-by-decoration', '--pretty=format:%d'],
707 cwd=self.local, output=False)
709 for line in p.output.splitlines():
710 m = self.tag_format.match(line)
718 class vcs_gitsvn(vcs):
723 # If the local directory exists, but is somehow not a git repository, git
724 # will traverse up the directory tree until it finds one that is (i.e.
725 # fdroidserver) and then we'll proceed to destory it! This is called as
728 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
729 result = p.output.rstrip()
730 if not result.endswith(self.local):
731 raise VCSException('Repository mismatch')
733 def gotorevisionx(self, rev):
734 if not os.path.exists(self.local):
736 gitsvn_args = ['git', 'svn', 'clone']
737 if ';' in self.remote:
738 remote_split = self.remote.split(';')
739 for i in remote_split[1:]:
740 if i.startswith('trunk='):
741 gitsvn_args.extend(['-T', i[6:]])
742 elif i.startswith('tags='):
743 gitsvn_args.extend(['-t', i[5:]])
744 elif i.startswith('branches='):
745 gitsvn_args.extend(['-b', i[9:]])
746 gitsvn_args.extend([remote_split[0], self.local])
747 p = FDroidPopen(gitsvn_args, output=False)
748 if p.returncode != 0:
749 self.clone_failed = True
750 raise VCSException("Git svn clone failed", p.output)
752 gitsvn_args.extend([self.remote, 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)
760 # Discard any working tree changes
761 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
762 if p.returncode != 0:
763 raise VCSException("Git reset failed", p.output)
764 # Remove untracked files now, in case they're tracked in the target
765 # revision (it happens!)
766 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
767 if p.returncode != 0:
768 raise VCSException("Git clean failed", p.output)
769 if not self.refreshed:
770 # Get new commits, branches and tags from repo
771 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException("Git svn fetch failed")
774 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
775 if p.returncode != 0:
776 raise VCSException("Git svn rebase failed", p.output)
777 self.refreshed = True
779 rev = rev or 'master'
781 nospaces_rev = rev.replace(' ', '%20')
782 # Try finding a svn tag
783 for treeish in ['origin/', '']:
784 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
785 if p.returncode == 0:
787 if p.returncode != 0:
788 # No tag found, normal svn rev translation
789 # Translate svn rev into git format
790 rev_split = rev.split('/')
793 for treeish in ['origin/', '']:
794 if len(rev_split) > 1:
795 treeish += rev_split[0]
796 svn_rev = rev_split[1]
799 # if no branch is specified, then assume trunk (i.e. 'master' branch):
803 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
805 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
806 git_rev = p.output.rstrip()
808 if p.returncode == 0 and git_rev:
811 if p.returncode != 0 or not git_rev:
812 # Try a plain git checkout as a last resort
813 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
814 if p.returncode != 0:
815 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
817 # Check out the git rev equivalent to the svn rev
818 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
819 if p.returncode != 0:
820 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
822 # Get rid of any uncontrolled files left behind
823 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
824 if p.returncode != 0:
825 raise VCSException("Git clean failed", p.output)
829 for treeish in ['origin/', '']:
830 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
836 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
837 if p.returncode != 0:
839 return p.output.strip()
847 def gotorevisionx(self, rev):
848 if not os.path.exists(self.local):
849 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
850 if p.returncode != 0:
851 self.clone_failed = True
852 raise VCSException("Hg clone failed", p.output)
854 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Hg status failed", p.output)
857 for line in p.output.splitlines():
858 if not line.startswith('? '):
859 raise VCSException("Unexpected output from hg status -uS: " + line)
860 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
861 if not self.refreshed:
862 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
863 if p.returncode != 0:
864 raise VCSException("Hg pull failed", p.output)
865 self.refreshed = True
867 rev = rev or 'default'
870 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
873 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
874 # Also delete untracked files, we have to enable purge extension for that:
875 if "'purge' is provided by the following extension" in p.output:
876 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
877 myfile.write("\n[extensions]\nhgext.purge=\n")
878 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
879 if p.returncode != 0:
880 raise VCSException("HG purge failed", p.output)
881 elif p.returncode != 0:
882 raise VCSException("HG purge failed", p.output)
885 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
886 return p.output.splitlines()[1:]
894 def gotorevisionx(self, rev):
895 if not os.path.exists(self.local):
896 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
897 if p.returncode != 0:
898 self.clone_failed = True
899 raise VCSException("Bzr branch failed", p.output)
901 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
902 if p.returncode != 0:
903 raise VCSException("Bzr revert failed", p.output)
904 if not self.refreshed:
905 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("Bzr update failed", p.output)
908 self.refreshed = True
910 revargs = list(['-r', rev] if rev else [])
911 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
912 if p.returncode != 0:
913 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
916 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
917 return [tag.split(' ')[0].strip() for tag in
918 p.output.splitlines()]
921 def unescape_string(string):
924 if string[0] == '"' and string[-1] == '"':
927 return string.replace("\\'", "'")
930 def retrieve_string(app_dir, string, xmlfiles=None):
932 if not string.startswith('@string/'):
933 return unescape_string(string)
938 os.path.join(app_dir, 'res'),
939 os.path.join(app_dir, 'src', 'main', 'res'),
941 for r, d, f in os.walk(res_dir):
942 if os.path.basename(r) == 'values':
943 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
945 name = string[len('@string/'):]
947 def element_content(element):
948 if element.text is None:
950 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
951 return s.decode('utf-8').strip()
953 for path in xmlfiles:
954 if not os.path.isfile(path):
956 xml = parse_xml(path)
957 element = xml.find('string[@name="' + name + '"]')
958 if element is not None:
959 content = element_content(element)
960 return retrieve_string(app_dir, content, xmlfiles)
965 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
966 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
969 # Return list of existing files that will be used to find the highest vercode
970 def manifest_paths(app_dir, flavours):
972 possible_manifests = \
973 [os.path.join(app_dir, 'AndroidManifest.xml'),
974 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
975 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
976 os.path.join(app_dir, 'build.gradle')]
978 for flavour in flavours:
981 possible_manifests.append(
982 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
984 return [path for path in possible_manifests if os.path.isfile(path)]
987 # Retrieve the package name. Returns the name, or None if not found.
988 def fetch_real_name(app_dir, flavours):
989 for path in manifest_paths(app_dir, flavours):
990 if not has_extension(path, 'xml') or not os.path.isfile(path):
992 logging.debug("fetch_real_name: Checking manifest at " + path)
993 xml = parse_xml(path)
994 app = xml.find('application')
997 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
999 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1000 result = retrieve_string_singleline(app_dir, label)
1002 result = result.strip()
1007 def get_library_references(root_dir):
1009 proppath = os.path.join(root_dir, 'project.properties')
1010 if not os.path.isfile(proppath):
1012 with open(proppath, 'r') as f:
1014 if not line.startswith('android.library.reference.'):
1016 path = line.split('=')[1].strip()
1017 relpath = os.path.join(root_dir, path)
1018 if not os.path.isdir(relpath):
1020 logging.debug("Found subproject at %s" % path)
1021 libraries.append(path)
1025 def ant_subprojects(root_dir):
1026 subprojects = get_library_references(root_dir)
1027 for subpath in subprojects:
1028 subrelpath = os.path.join(root_dir, subpath)
1029 for p in get_library_references(subrelpath):
1030 relp = os.path.normpath(os.path.join(subpath, p))
1031 if relp not in subprojects:
1032 subprojects.insert(0, relp)
1036 def remove_debuggable_flags(root_dir):
1037 # Remove forced debuggable flags
1038 logging.debug("Removing debuggable flags from %s" % root_dir)
1039 for root, dirs, files in os.walk(root_dir):
1040 if 'AndroidManifest.xml' in files:
1041 regsub_file(r'android:debuggable="[^"]*"',
1043 os.path.join(root, 'AndroidManifest.xml'))
1046 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1047 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1048 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1051 def app_matches_packagename(app, package):
1054 appid = app.UpdateCheckName or app.id
1055 if appid is None or appid == "Ignore":
1057 return appid == package
1060 # Extract some information from the AndroidManifest.xml at the given path.
1061 # Returns (version, vercode, package), any or all of which might be None.
1062 # All values returned are strings.
1063 def parse_androidmanifests(paths, app):
1065 ignoreversions = app.UpdateCheckIgnore
1066 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1069 return (None, None, None)
1077 if not os.path.isfile(path):
1080 logging.debug("Parsing manifest at {0}".format(path))
1081 gradle = has_extension(path, 'gradle')
1087 with open(path, 'r') as f:
1089 if gradle_comment.match(line):
1091 # Grab first occurence of each to avoid running into
1092 # alternative flavours and builds.
1094 matches = psearch_g(line)
1096 s = matches.group(2)
1097 if app_matches_packagename(app, s):
1100 matches = vnsearch_g(line)
1102 version = matches.group(2)
1104 matches = vcsearch_g(line)
1106 vercode = matches.group(1)
1109 xml = parse_xml(path)
1110 if "package" in xml.attrib:
1111 s = xml.attrib["package"]
1112 if app_matches_packagename(app, s):
1114 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1115 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1116 base_dir = os.path.dirname(path)
1117 version = retrieve_string_singleline(base_dir, version)
1118 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1119 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1120 if string_is_integer(a):
1123 logging.warning("Problem with xml at {0}".format(path))
1125 # Remember package name, may be defined separately from version+vercode
1127 package = max_package
1129 logging.debug("..got package={0}, version={1}, vercode={2}"
1130 .format(package, version, vercode))
1132 # Always grab the package name and version name in case they are not
1133 # together with the highest version code
1134 if max_package is None and package is not None:
1135 max_package = package
1136 if max_version is None and version is not None:
1137 max_version = version
1139 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1140 if not ignoresearch or not ignoresearch(version):
1141 if version is not None:
1142 max_version = version
1143 if vercode is not None:
1144 max_vercode = vercode
1145 if package is not None:
1146 max_package = package
1148 max_version = "Ignore"
1150 if max_version is None:
1151 max_version = "Unknown"
1153 if max_package and not is_valid_package_name(max_package):
1154 raise FDroidException("Invalid package name {0}".format(max_package))
1156 return (max_version, max_vercode, max_package)
1159 def is_valid_package_name(name):
1160 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1163 class FDroidException(Exception):
1165 def __init__(self, value, detail=None):
1167 self.detail = detail
1169 def shortened_detail(self):
1170 if len(self.detail) < 16000:
1172 return '[...]\n' + self.detail[-16000:]
1174 def get_wikitext(self):
1175 ret = repr(self.value) + "\n"
1178 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1184 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1188 class VCSException(FDroidException):
1192 class BuildException(FDroidException):
1196 # Get the specified source library.
1197 # Returns the path to it. Normally this is the path to be used when referencing
1198 # it, which may be a subdirectory of the actual project. If you want the base
1199 # directory of the project, pass 'basepath=True'.
1200 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1201 raw=False, prepare=True, preponly=False, refresh=True):
1209 name, ref = spec.split('@')
1211 number, name = name.split(':', 1)
1213 name, subdir = name.split('/', 1)
1215 if name not in fdroidserver.metadata.srclibs:
1216 raise VCSException('srclib ' + name + ' not found.')
1218 srclib = fdroidserver.metadata.srclibs[name]
1220 sdir = os.path.join(srclib_dir, name)
1223 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1224 vcs.srclib = (name, number, sdir)
1226 vcs.gotorevision(ref, refresh)
1233 libdir = os.path.join(sdir, subdir)
1234 elif srclib["Subdir"]:
1235 for subdir in srclib["Subdir"]:
1236 libdir_candidate = os.path.join(sdir, subdir)
1237 if os.path.exists(libdir_candidate):
1238 libdir = libdir_candidate
1244 remove_signing_keys(sdir)
1245 remove_debuggable_flags(sdir)
1249 if srclib["Prepare"]:
1250 cmd = replace_config_vars(srclib["Prepare"], None)
1252 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1253 if p.returncode != 0:
1254 raise BuildException("Error running prepare command for srclib %s"
1260 return (name, number, libdir)
1262 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1265 # Prepare the source code for a particular build
1266 # 'vcs' - the appropriate vcs object for the application
1267 # 'app' - the application details from the metadata
1268 # 'build' - the build details from the metadata
1269 # 'build_dir' - the path to the build directory, usually
1271 # 'srclib_dir' - the path to the source libraries directory, usually
1273 # 'extlib_dir' - the path to the external libraries directory, usually
1275 # Returns the (root, srclibpaths) where:
1276 # 'root' is the root directory, which may be the same as 'build_dir' or may
1277 # be a subdirectory of it.
1278 # 'srclibpaths' is information on the srclibs being used
1279 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1281 # Optionally, the actual app source can be in a subdirectory
1283 root_dir = os.path.join(build_dir, build.subdir)
1285 root_dir = build_dir
1287 # Get a working copy of the right revision
1288 logging.info("Getting source for revision " + build.commit)
1289 vcs.gotorevision(build.commit, refresh)
1291 # Initialise submodules if required
1292 if build.submodules:
1293 logging.info("Initialising submodules")
1294 vcs.initsubmodules()
1296 # Check that a subdir (if we're using one) exists. This has to happen
1297 # after the checkout, since it might not exist elsewhere
1298 if not os.path.exists(root_dir):
1299 raise BuildException('Missing subdir ' + root_dir)
1301 # Run an init command if one is required
1303 cmd = replace_config_vars(build.init, build)
1304 logging.info("Running 'init' commands in %s" % root_dir)
1306 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1307 if p.returncode != 0:
1308 raise BuildException("Error running init command for %s:%s" %
1309 (app.id, build.version), p.output)
1311 # Apply patches if any
1313 logging.info("Applying patches")
1314 for patch in build.patch:
1315 patch = patch.strip()
1316 logging.info("Applying " + patch)
1317 patch_path = os.path.join('metadata', app.id, patch)
1318 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1319 if p.returncode != 0:
1320 raise BuildException("Failed to apply patch %s" % patch_path)
1322 # Get required source libraries
1325 logging.info("Collecting source libraries")
1326 for lib in build.srclibs:
1327 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1329 for name, number, libpath in srclibpaths:
1330 place_srclib(root_dir, int(number) if number else None, libpath)
1332 basesrclib = vcs.getsrclib()
1333 # If one was used for the main source, add that too.
1335 srclibpaths.append(basesrclib)
1337 # Update the local.properties file
1338 localprops = [os.path.join(build_dir, 'local.properties')]
1340 parts = build.subdir.split(os.sep)
1343 cur = os.path.join(cur, d)
1344 localprops += [os.path.join(cur, 'local.properties')]
1345 for path in localprops:
1347 if os.path.isfile(path):
1348 logging.info("Updating local.properties file at %s" % path)
1349 with open(path, 'r') as f:
1353 logging.info("Creating local.properties file at %s" % path)
1354 # Fix old-fashioned 'sdk-location' by copying
1355 # from sdk.dir, if necessary
1357 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1358 re.S | re.M).group(1)
1359 props += "sdk-location=%s\n" % sdkloc
1361 props += "sdk.dir=%s\n" % config['sdk_path']
1362 props += "sdk-location=%s\n" % config['sdk_path']
1363 ndk_path = build.ndk_path()
1366 props += "ndk.dir=%s\n" % ndk_path
1367 props += "ndk-location=%s\n" % ndk_path
1368 # Add java.encoding if necessary
1370 props += "java.encoding=%s\n" % build.encoding
1371 with open(path, 'w') as f:
1375 if build.build_method() == 'gradle':
1376 flavours = build.gradle
1379 n = build.target.split('-')[1]
1380 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1381 r'compileSdkVersion %s' % n,
1382 os.path.join(root_dir, 'build.gradle'))
1384 # Remove forced debuggable flags
1385 remove_debuggable_flags(root_dir)
1387 # Insert version code and number into the manifest if necessary
1388 if build.forceversion:
1389 logging.info("Changing the version name")
1390 for path in manifest_paths(root_dir, flavours):
1391 if not os.path.isfile(path):
1393 if has_extension(path, 'xml'):
1394 regsub_file(r'android:versionName="[^"]*"',
1395 r'android:versionName="%s"' % build.version,
1397 elif has_extension(path, 'gradle'):
1398 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1399 r"""\1versionName '%s'""" % build.version,
1402 if build.forcevercode:
1403 logging.info("Changing the version code")
1404 for path in manifest_paths(root_dir, flavours):
1405 if not os.path.isfile(path):
1407 if has_extension(path, 'xml'):
1408 regsub_file(r'android:versionCode="[^"]*"',
1409 r'android:versionCode="%s"' % build.vercode,
1411 elif has_extension(path, 'gradle'):
1412 regsub_file(r'versionCode[ =]+[0-9]+',
1413 r'versionCode %s' % build.vercode,
1416 # Delete unwanted files
1418 logging.info("Removing specified files")
1419 for part in getpaths(build_dir, build.rm):
1420 dest = os.path.join(build_dir, part)
1421 logging.info("Removing {0}".format(part))
1422 if os.path.lexists(dest):
1423 if os.path.islink(dest):
1424 FDroidPopen(['unlink', dest], output=False)
1426 FDroidPopen(['rm', '-rf', dest], output=False)
1428 logging.info("...but it didn't exist")
1430 remove_signing_keys(build_dir)
1432 # Add required external libraries
1434 logging.info("Collecting prebuilt libraries")
1435 libsdir = os.path.join(root_dir, 'libs')
1436 if not os.path.exists(libsdir):
1438 for lib in build.extlibs:
1440 logging.info("...installing extlib {0}".format(lib))
1441 libf = os.path.basename(lib)
1442 libsrc = os.path.join(extlib_dir, lib)
1443 if not os.path.exists(libsrc):
1444 raise BuildException("Missing extlib file {0}".format(libsrc))
1445 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1447 # Run a pre-build command if one is required
1449 logging.info("Running 'prebuild' commands in %s" % root_dir)
1451 cmd = replace_config_vars(build.prebuild, build)
1453 # Substitute source library paths into prebuild commands
1454 for name, number, libpath in srclibpaths:
1455 libpath = os.path.relpath(libpath, root_dir)
1456 cmd = cmd.replace('$$' + name + '$$', libpath)
1458 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1459 if p.returncode != 0:
1460 raise BuildException("Error running prebuild command for %s:%s" %
1461 (app.id, build.version), p.output)
1463 # Generate (or update) the ant build file, build.xml...
1464 if build.build_method() == 'ant' and build.update != ['no']:
1465 parms = ['android', 'update', 'lib-project']
1466 lparms = ['android', 'update', 'project']
1469 parms += ['-t', build.target]
1470 lparms += ['-t', build.target]
1472 update_dirs = build.update
1474 update_dirs = ant_subprojects(root_dir) + ['.']
1476 for d in update_dirs:
1477 subdir = os.path.join(root_dir, d)
1479 logging.debug("Updating main project")
1480 cmd = parms + ['-p', d]
1482 logging.debug("Updating subproject %s" % d)
1483 cmd = lparms + ['-p', d]
1484 p = SdkToolsPopen(cmd, cwd=root_dir)
1485 # Check to see whether an error was returned without a proper exit
1486 # code (this is the case for the 'no target set or target invalid'
1488 if p.returncode != 0 or p.output.startswith("Error: "):
1489 raise BuildException("Failed to update project at %s" % d, p.output)
1490 # Clean update dirs via ant
1492 logging.info("Cleaning subproject %s" % d)
1493 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1495 return (root_dir, srclibpaths)
1498 # Extend via globbing the paths from a field and return them as a map from
1499 # original path to resulting paths
1500 def getpaths_map(build_dir, globpaths):
1504 full_path = os.path.join(build_dir, p)
1505 full_path = os.path.normpath(full_path)
1506 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1508 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1512 # Extend via globbing the paths from a field and return them as a set
1513 def getpaths(build_dir, globpaths):
1514 paths_map = getpaths_map(build_dir, globpaths)
1516 for k, v in paths_map.items():
1523 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1529 self.path = os.path.join('stats', 'known_apks.txt')
1531 if os.path.isfile(self.path):
1532 with open(self.path, 'r') as f:
1534 t = line.rstrip().split(' ')
1536 self.apks[t[0]] = (t[1], None)
1538 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1539 self.changed = False
1541 def writeifchanged(self):
1542 if not self.changed:
1545 if not os.path.exists('stats'):
1549 for apk, app in self.apks.items():
1551 line = apk + ' ' + appid
1553 line += ' ' + time.strftime('%Y-%m-%d', added)
1556 with open(self.path, 'w') as f:
1557 for line in sorted(lst, key=natural_key):
1558 f.write(line + '\n')
1560 # Record an apk (if it's new, otherwise does nothing)
1561 # Returns the date it was added.
1562 def recordapk(self, apk, app):
1563 if apk not in self.apks:
1564 self.apks[apk] = (app, time.gmtime(time.time()))
1566 _, added = self.apks[apk]
1569 # Look up information - given the 'apkname', returns (app id, date added/None).
1570 # Or returns None for an unknown apk.
1571 def getapp(self, apkname):
1572 if apkname in self.apks:
1573 return self.apks[apkname]
1576 # Get the most recent 'num' apps added to the repo, as a list of package ids
1577 # with the most recent first.
1578 def getlatest(self, num):
1580 for apk, app in self.apks.items():
1584 if apps[appid] > added:
1588 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1589 lst = [app for app, _ in sortedapps]
1594 def isApkDebuggable(apkfile, config):
1595 """Returns True if the given apk file is debuggable
1597 :param apkfile: full path to the apk to check"""
1599 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1601 if p.returncode != 0:
1602 logging.critical("Failed to get apk manifest information")
1604 for line in p.output.splitlines():
1605 if 'android:debuggable' in line and not line.endswith('0x0'):
1612 self.returncode = None
1616 def SdkToolsPopen(commands, cwd=None, output=True):
1618 if cmd not in config:
1619 config[cmd] = find_sdk_tools_cmd(commands[0])
1620 abscmd = config[cmd]
1622 logging.critical("Could not find '%s' on your system" % cmd)
1624 return FDroidPopen([abscmd] + commands[1:],
1625 cwd=cwd, output=output)
1628 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1630 Run a command and capture the possibly huge output as bytes.
1632 :param commands: command and argument list like in subprocess.Popen
1633 :param cwd: optionally specifies a working directory
1634 :returns: A PopenResult.
1640 cwd = os.path.normpath(cwd)
1641 logging.debug("Directory: %s" % cwd)
1642 logging.debug("> %s" % ' '.join(commands))
1644 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1645 result = PopenResult()
1648 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1649 stdout=subprocess.PIPE, stderr=stderr_param)
1650 except OSError as e:
1651 raise BuildException("OSError while trying to execute " +
1652 ' '.join(commands) + ': ' + str(e))
1654 if not stderr_to_stdout and options.verbose:
1655 stderr_queue = Queue()
1656 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1658 while not stderr_reader.eof():
1659 while not stderr_queue.empty():
1660 line = stderr_queue.get()
1661 sys.stderr.buffer.write(line)
1666 stdout_queue = Queue()
1667 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1670 # Check the queue for output (until there is no more to get)
1671 while not stdout_reader.eof():
1672 while not stdout_queue.empty():
1673 line = stdout_queue.get()
1674 if output and options.verbose:
1675 # Output directly to console
1676 sys.stderr.buffer.write(line)
1682 result.returncode = p.wait()
1683 result.output = buf.getvalue()
1688 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1690 Run a command and capture the possibly huge output as a str.
1692 :param commands: command and argument list like in subprocess.Popen
1693 :param cwd: optionally specifies a working directory
1694 :returns: A PopenResult.
1696 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1697 result.output = result.output.decode('utf-8')
1701 gradle_comment = re.compile(r'[ ]*//')
1702 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1703 gradle_line_matches = [
1704 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1705 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1706 re.compile(r'.*\.readLine\(.*'),
1710 def remove_signing_keys(build_dir):
1711 for root, dirs, files in os.walk(build_dir):
1712 if 'build.gradle' in files:
1713 path = os.path.join(root, 'build.gradle')
1715 with open(path, "r") as o:
1716 lines = o.readlines()
1722 with open(path, "w") as o:
1723 while i < len(lines):
1726 while line.endswith('\\\n'):
1727 line = line.rstrip('\\\n') + lines[i]
1730 if gradle_comment.match(line):
1735 opened += line.count('{')
1736 opened -= line.count('}')
1739 if gradle_signing_configs.match(line):
1744 if any(s.match(line) for s in gradle_line_matches):
1752 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1755 'project.properties',
1757 'default.properties',
1758 'ant.properties', ]:
1759 if propfile in files:
1760 path = os.path.join(root, propfile)
1762 with open(path, "r") as o:
1763 lines = o.readlines()
1767 with open(path, "w") as o:
1769 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1776 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1779 def reset_env_path():
1780 global env, orig_path
1781 env['PATH'] = orig_path
1784 def add_to_env_path(path):
1786 paths = env['PATH'].split(os.pathsep)
1790 env['PATH'] = os.pathsep.join(paths)
1793 def replace_config_vars(cmd, build):
1795 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1796 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1797 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1798 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1799 if build is not None:
1800 cmd = cmd.replace('$$COMMIT$$', build.commit)
1801 cmd = cmd.replace('$$VERSION$$', build.version)
1802 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1806 def place_srclib(root_dir, number, libpath):
1809 relpath = os.path.relpath(libpath, root_dir)
1810 proppath = os.path.join(root_dir, 'project.properties')
1813 if os.path.isfile(proppath):
1814 with open(proppath, "r") as o:
1815 lines = o.readlines()
1817 with open(proppath, "w") as o:
1820 if line.startswith('android.library.reference.%d=' % number):
1821 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1826 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1828 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1831 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1832 """Verify that two apks are the same
1834 One of the inputs is signed, the other is unsigned. The signature metadata
1835 is transferred from the signed to the unsigned apk, and then jarsigner is
1836 used to verify that the signature from the signed apk is also varlid for
1838 :param signed_apk: Path to a signed apk file
1839 :param unsigned_apk: Path to an unsigned apk file expected to match it
1840 :param tmp_dir: Path to directory for temporary files
1841 :returns: None if the verification is successful, otherwise a string
1842 describing what went wrong.
1844 with ZipFile(signed_apk) as signed_apk_as_zip:
1845 meta_inf_files = ['META-INF/MANIFEST.MF']
1846 for f in signed_apk_as_zip.namelist():
1847 if apk_sigfile.match(f):
1848 meta_inf_files.append(f)
1849 if len(meta_inf_files) < 3:
1850 return "Signature files missing from {0}".format(signed_apk)
1851 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1852 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1853 for meta_inf_file in meta_inf_files:
1854 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1856 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1857 logging.info("...NOT verified - {0}".format(signed_apk))
1858 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1859 logging.info("...successfully verified")
1862 apk_badchars = re.compile('''[/ :;'"]''')
1865 def compare_apks(apk1, apk2, tmp_dir):
1868 Returns None if the apk content is the same (apart from the signing key),
1869 otherwise a string describing what's different, or what went wrong when
1870 trying to do the comparison.
1873 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1874 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1875 for d in [apk1dir, apk2dir]:
1876 if os.path.exists(d):
1879 os.mkdir(os.path.join(d, 'jar-xf'))
1881 if subprocess.call(['jar', 'xf',
1882 os.path.abspath(apk1)],
1883 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1884 return("Failed to unpack " + apk1)
1885 if subprocess.call(['jar', 'xf',
1886 os.path.abspath(apk2)],
1887 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1888 return("Failed to unpack " + apk2)
1890 # try to find apktool in the path, if it hasn't been manually configed
1891 if 'apktool' not in config:
1892 tmp = find_command('apktool')
1894 config['apktool'] = tmp
1895 if 'apktool' in config:
1896 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1898 return("Failed to unpack " + apk1)
1899 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1901 return("Failed to unpack " + apk2)
1903 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1904 lines = p.output.splitlines()
1905 if len(lines) != 1 or 'META-INF' not in lines[0]:
1906 meld = find_command('meld')
1907 if meld is not None:
1908 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1909 return("Unexpected diff output - " + p.output)
1911 # since everything verifies, delete the comparison to keep cruft down
1912 shutil.rmtree(apk1dir)
1913 shutil.rmtree(apk2dir)
1915 # If we get here, it seems like they're the same!
1919 def find_command(command):
1920 '''find the full path of a command, or None if it can't be found in the PATH'''
1923 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1925 fpath, fname = os.path.split(command)
1930 for path in os.environ["PATH"].split(os.pathsep):
1931 path = path.strip('"')
1932 exe_file = os.path.join(path, command)
1933 if is_exe(exe_file):
1940 '''generate a random password for when generating keys'''
1941 h = hashlib.sha256()
1942 h.update(os.urandom(16)) # salt
1943 h.update(socket.getfqdn().encode('utf-8'))
1944 passwd = base64.b64encode(h.digest()).strip()
1945 return passwd.decode('utf-8')
1948 def genkeystore(localconfig):
1949 '''Generate a new key with random passwords and add it to new keystore'''
1950 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1951 keystoredir = os.path.dirname(localconfig['keystore'])
1952 if keystoredir is None or keystoredir == '':
1953 keystoredir = os.path.join(os.getcwd(), keystoredir)
1954 if not os.path.exists(keystoredir):
1955 os.makedirs(keystoredir, mode=0o700)
1957 write_password_file("keystorepass", localconfig['keystorepass'])
1958 write_password_file("keypass", localconfig['keypass'])
1959 p = FDroidPopen([config['keytool'], '-genkey',
1960 '-keystore', localconfig['keystore'],
1961 '-alias', localconfig['repo_keyalias'],
1962 '-keyalg', 'RSA', '-keysize', '4096',
1963 '-sigalg', 'SHA256withRSA',
1964 '-validity', '10000',
1965 '-storepass:file', config['keystorepassfile'],
1966 '-keypass:file', config['keypassfile'],
1967 '-dname', localconfig['keydname']])
1968 # TODO keypass should be sent via stdin
1969 if p.returncode != 0:
1970 raise BuildException("Failed to generate key", p.output)
1971 os.chmod(localconfig['keystore'], 0o0600)
1972 # now show the lovely key that was just generated
1973 p = FDroidPopen([config['keytool'], '-list', '-v',
1974 '-keystore', localconfig['keystore'],
1975 '-alias', localconfig['repo_keyalias'],
1976 '-storepass:file', config['keystorepassfile']])
1977 logging.info(p.output.strip() + '\n\n')
1980 def write_to_config(thisconfig, key, value=None):
1981 '''write a key/value to the local config.py'''
1983 origkey = key + '_orig'
1984 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1985 with open('config.py', 'r') as f:
1987 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1988 repl = '\n' + key + ' = "' + value + '"'
1989 data = re.sub(pattern, repl, data)
1990 # if this key is not in the file, append it
1991 if not re.match('\s*' + key + '\s*=\s*"', data):
1993 # make sure the file ends with a carraige return
1994 if not re.match('\n$', data):
1996 with open('config.py', 'w') as f:
2000 def parse_xml(path):
2001 return XMLElementTree.parse(path).getroot()
2004 def string_is_integer(string):
2012 def get_per_app_repos():
2013 '''per-app repos are dirs named with the packageName of a single app'''
2015 # Android packageNames are Java packages, they may contain uppercase or
2016 # lowercase letters ('A' through 'Z'), numbers, and underscores
2017 # ('_'). However, individual package name parts may only start with
2018 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2019 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2022 for root, dirs, files in os.walk(os.getcwd()):
2024 print('checking', root, 'for', d)
2025 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2026 # standard parts of an fdroid repo, so never packageNames
2029 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):