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', 'yml'],
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()
132 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
133 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
134 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
135 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
136 if os.getenv('JAVA_HOME') is not None:
137 pathlist += os.getenv('JAVA_HOME')
138 if os.getenv('PROGRAMFILES') is not None:
139 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
140 for d in sorted(pathlist):
141 if os.path.islink(d):
143 j = os.path.basename(d)
144 # the last one found will be the canonical one, so order appropriately
146 r'^1\.([6-9])\.0\.jdk$', # OSX
147 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
148 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
149 r'^jdk([6-9])-openjdk$', # Arch
150 r'^java-([6-9])-openjdk$', # Arch
151 r'^java-([6-9])-jdk$', # Arch (oracle)
152 r'^java-1\.([6-9])\.0-.*$', # RedHat
153 r'^java-([6-9])-oracle$', # Debian WebUpd8
154 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
155 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
157 m = re.match(regex, j)
160 osxhome = os.path.join(d, 'Contents', 'Home')
161 if os.path.exists(osxhome):
162 thisconfig['java_paths'][m.group(1)] = osxhome
164 thisconfig['java_paths'][m.group(1)] = d
166 for java_version in ('7', '8', '9'):
167 if java_version not in thisconfig['java_paths']:
169 java_home = thisconfig['java_paths'][java_version]
170 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
171 if os.path.exists(jarsigner):
172 thisconfig['jarsigner'] = jarsigner
173 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
174 break # Java7 is preferred, so quit if found
176 for k in ['ndk_paths', 'java_paths']:
182 thisconfig[k][k2] = exp
183 thisconfig[k][k2 + '_orig'] = v
186 def regsub_file(pattern, repl, path):
187 with open(path, 'r') as f:
189 text = re.sub(pattern, repl, text)
190 with open(path, 'w') as f:
194 def read_config(opts, config_file='config.py'):
195 """Read the repository config
197 The config is read from config_file, which is in the current
198 directory when any of the repo management commands are used. If
199 there is a local metadata file in the git repo, then config.py is
200 not required, just use defaults.
203 global config, options
205 if config is not None:
212 if os.path.isfile(config_file):
213 logging.debug("Reading %s" % config_file)
214 with io.open(config_file, "rb") as f:
215 code = compile(f.read(), config_file, 'exec')
216 exec(code, None, config)
217 elif len(get_local_metadata_files()) == 0:
218 logging.critical("Missing config file - is this a repo directory?")
221 # smartcardoptions must be a list since its command line args for Popen
222 if 'smartcardoptions' in config:
223 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
224 elif 'keystore' in config and config['keystore'] == 'NONE':
225 # keystore='NONE' means use smartcard, these are required defaults
226 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
227 'SunPKCS11-OpenSC', '-providerClass',
228 'sun.security.pkcs11.SunPKCS11',
229 '-providerArg', 'opensc-fdroid.cfg']
231 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
232 st = os.stat(config_file)
233 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
234 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
236 fill_config_defaults(config)
238 for k in ["keystorepass", "keypass"]:
240 write_password_file(k)
242 for k in ["repo_description", "archive_description"]:
244 config[k] = clean_description(config[k])
246 if 'serverwebroot' in config:
247 if isinstance(config['serverwebroot'], str):
248 roots = [config['serverwebroot']]
249 elif all(isinstance(item, str) for item in config['serverwebroot']):
250 roots = config['serverwebroot']
252 raise TypeError('only accepts strings, lists, and tuples')
254 for rootstr in roots:
255 # since this is used with rsync, where trailing slashes have
256 # meaning, ensure there is always a trailing slash
257 if rootstr[-1] != '/':
259 rootlist.append(rootstr.replace('//', '/'))
260 config['serverwebroot'] = rootlist
265 def get_ndk_path(version):
266 if config is None or 'ndk_paths' not in config:
267 ndk_path = os.getenv('ANDROID_NDK_HOME')
269 logging.error('No NDK found! Either set ANDROID_NDK_HOME or add ndk_path to your config.py')
273 version = 'r10e' # falls back to latest
274 paths = config['ndk_paths']
275 if version not in paths:
277 return paths[version] or ''
280 def find_sdk_tools_cmd(cmd):
281 '''find a working path to a tool from the Android SDK'''
284 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
285 # try to find a working path to this command, in all the recent possible paths
286 if 'build_tools' in config:
287 build_tools = os.path.join(config['sdk_path'], 'build-tools')
288 # if 'build_tools' was manually set and exists, check only that one
289 configed_build_tools = os.path.join(build_tools, config['build_tools'])
290 if os.path.exists(configed_build_tools):
291 tooldirs.append(configed_build_tools)
293 # no configed version, so hunt known paths for it
294 for f in sorted(os.listdir(build_tools), reverse=True):
295 if os.path.isdir(os.path.join(build_tools, f)):
296 tooldirs.append(os.path.join(build_tools, f))
297 tooldirs.append(build_tools)
298 sdk_tools = os.path.join(config['sdk_path'], 'tools')
299 if os.path.exists(sdk_tools):
300 tooldirs.append(sdk_tools)
301 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
302 if os.path.exists(sdk_platform_tools):
303 tooldirs.append(sdk_platform_tools)
304 tooldirs.append('/usr/bin')
306 if os.path.isfile(os.path.join(d, cmd)):
307 return os.path.join(d, cmd)
308 # did not find the command, exit with error message
309 ensure_build_tools_exists(config)
312 def test_sdk_exists(thisconfig):
313 if 'sdk_path' not in thisconfig:
314 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
317 logging.error("'sdk_path' not set in config.py!")
319 if thisconfig['sdk_path'] == default_config['sdk_path']:
320 logging.error('No Android SDK found!')
321 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
322 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
324 if not os.path.exists(thisconfig['sdk_path']):
325 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
327 if not os.path.isdir(thisconfig['sdk_path']):
328 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
330 for d in ['build-tools', 'platform-tools', 'tools']:
331 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
332 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
333 thisconfig['sdk_path'], d))
338 def ensure_build_tools_exists(thisconfig):
339 if not test_sdk_exists(thisconfig):
341 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
342 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
343 if not os.path.isdir(versioned_build_tools):
344 logging.critical('Android Build Tools path "'
345 + versioned_build_tools + '" does not exist!')
349 def write_password_file(pwtype, password=None):
351 writes out passwords to a protected file instead of passing passwords as
352 command line argments
354 filename = '.fdroid.' + pwtype + '.txt'
355 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
357 os.write(fd, config[pwtype].encode('utf-8'))
359 os.write(fd, password.encode('utf-8'))
361 config[pwtype + 'file'] = filename
364 def get_local_metadata_files():
365 '''get any metadata files local to an app's source repo
367 This tries to ignore anything that does not count as app metdata,
368 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
371 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
374 # Given the arguments in the form of multiple appid:[vc] strings, this returns
375 # a dictionary with the set of vercodes specified for each package.
376 def read_pkg_args(args, allow_vercodes=False):
383 if allow_vercodes and ':' in p:
384 package, vercode = p.split(':')
386 package, vercode = p, None
387 if package not in vercodes:
388 vercodes[package] = [vercode] if vercode else []
390 elif vercode and vercode not in vercodes[package]:
391 vercodes[package] += [vercode] if vercode else []
396 # On top of what read_pkg_args does, this returns the whole app metadata, but
397 # limiting the builds list to the builds matching the vercodes specified.
398 def read_app_args(args, allapps, allow_vercodes=False):
400 vercodes = read_pkg_args(args, allow_vercodes)
406 for appid, app in allapps.items():
407 if appid in vercodes:
410 if len(apps) != len(vercodes):
413 logging.critical("No such package: %s" % p)
414 raise FDroidException("Found invalid app ids in arguments")
416 raise FDroidException("No packages specified")
419 for appid, app in apps.items():
423 app.builds = [b for b in app.builds if b.vercode in vc]
424 if len(app.builds) != len(vercodes[appid]):
426 allvcs = [b.vercode for b in app.builds]
427 for v in vercodes[appid]:
429 logging.critical("No such vercode %s for app %s" % (v, appid))
432 raise FDroidException("Found invalid vercodes for some apps")
437 def get_extension(filename):
438 base, ext = os.path.splitext(filename)
441 return base, ext.lower()[1:]
444 def has_extension(filename, ext):
445 _, f_ext = get_extension(filename)
449 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
452 def clean_description(description):
453 'Remove unneeded newlines and spaces from a block of description text'
455 # this is split up by paragraph to make removing the newlines easier
456 for paragraph in re.split(r'\n\n', description):
457 paragraph = re.sub('\r', '', paragraph)
458 paragraph = re.sub('\n', ' ', paragraph)
459 paragraph = re.sub(' {2,}', ' ', paragraph)
460 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
461 returnstring += paragraph + '\n\n'
462 return returnstring.rstrip('\n')
465 def apknameinfo(filename):
466 filename = os.path.basename(filename)
467 m = apk_regex.match(filename)
469 result = (m.group(1), m.group(2))
470 except AttributeError:
471 raise FDroidException("Invalid apk name: %s" % filename)
475 def getapkname(app, build):
476 return "%s_%s.apk" % (app.id, build.vercode)
479 def getsrcname(app, build):
480 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
492 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
495 def getvcs(vcstype, remote, local):
497 return vcs_git(remote, local)
498 if vcstype == 'git-svn':
499 return vcs_gitsvn(remote, local)
501 return vcs_hg(remote, local)
503 return vcs_bzr(remote, local)
504 if vcstype == 'srclib':
505 if local != os.path.join('build', 'srclib', remote):
506 raise VCSException("Error: srclib paths are hard-coded!")
507 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
509 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
510 raise VCSException("Invalid vcs type " + vcstype)
513 def getsrclibvcs(name):
514 if name not in fdroidserver.metadata.srclibs:
515 raise VCSException("Missing srclib " + name)
516 return fdroidserver.metadata.srclibs[name]['Repo Type']
521 def __init__(self, remote, local):
523 # svn, git-svn and bzr may require auth
525 if self.repotype() in ('git-svn', 'bzr'):
527 if self.repotype == 'git-svn':
528 raise VCSException("Authentication is not supported for git-svn")
529 self.username, remote = remote.split('@')
530 if ':' not in self.username:
531 raise VCSException("Password required with username")
532 self.username, self.password = self.username.split(':')
536 self.clone_failed = False
537 self.refreshed = False
543 # Take the local repository to a clean version of the given revision, which
544 # is specificed in the VCS's native format. Beforehand, the repository can
545 # be dirty, or even non-existent. If the repository does already exist
546 # locally, it will be updated from the origin, but only once in the
547 # lifetime of the vcs object.
548 # None is acceptable for 'rev' if you know you are cloning a clean copy of
549 # the repo - otherwise it must specify a valid revision.
550 def gotorevision(self, rev, refresh=True):
552 if self.clone_failed:
553 raise VCSException("Downloading the repository already failed once, not trying again.")
555 # The .fdroidvcs-id file for a repo tells us what VCS type
556 # and remote that directory was created from, allowing us to drop it
557 # automatically if either of those things changes.
558 fdpath = os.path.join(self.local, '..',
559 '.fdroidvcs-' + os.path.basename(self.local))
560 fdpath = os.path.normpath(fdpath)
561 cdata = self.repotype() + ' ' + self.remote
564 if os.path.exists(self.local):
565 if os.path.exists(fdpath):
566 with open(fdpath, 'r') as f:
567 fsdata = f.read().strip()
572 logging.info("Repository details for %s changed - deleting" % (
576 logging.info("Repository details for %s missing - deleting" % (
579 shutil.rmtree(self.local)
583 self.refreshed = True
586 self.gotorevisionx(rev)
587 except FDroidException as e:
590 # If necessary, write the .fdroidvcs file.
591 if writeback and not self.clone_failed:
592 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
593 with open(fdpath, 'w+') as f:
599 # Derived classes need to implement this. It's called once basic checking
600 # has been performend.
601 def gotorevisionx(self, rev):
602 raise VCSException("This VCS type doesn't define gotorevisionx")
604 # Initialise and update submodules
605 def initsubmodules(self):
606 raise VCSException('Submodules not supported for this vcs type')
608 # Get a list of all known tags
610 if not self._gettags:
611 raise VCSException('gettags not supported for this vcs type')
613 for tag in self._gettags():
614 if re.match('[-A-Za-z0-9_. /]+$', tag):
618 # Get a list of all the known tags, sorted from newest to oldest
619 def latesttags(self):
620 raise VCSException('latesttags not supported for this vcs type')
622 # Get current commit reference (hash, revision, etc)
624 raise VCSException('getref not supported for this vcs type')
626 # Returns the srclib (name, path) used in setting up the current
637 # If the local directory exists, but is somehow not a git repository, git
638 # will traverse up the directory tree until it finds one that is (i.e.
639 # fdroidserver) and then we'll proceed to destroy it! This is called as
642 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
643 result = p.output.rstrip()
644 if not result.endswith(self.local):
645 raise VCSException('Repository mismatch')
647 def gotorevisionx(self, rev):
648 if not os.path.exists(self.local):
650 p = FDroidPopen(['git', 'clone', self.remote, self.local])
651 if p.returncode != 0:
652 self.clone_failed = True
653 raise VCSException("Git clone failed", p.output)
657 # Discard any working tree changes
658 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
659 'git', 'reset', '--hard'], cwd=self.local, output=False)
660 if p.returncode != 0:
661 raise VCSException("Git reset failed", p.output)
662 # Remove untracked files now, in case they're tracked in the target
663 # revision (it happens!)
664 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
665 'git', 'clean', '-dffx'], cwd=self.local, output=False)
666 if p.returncode != 0:
667 raise VCSException("Git clean failed", p.output)
668 if not self.refreshed:
669 # Get latest commits and tags from remote
670 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
671 if p.returncode != 0:
672 raise VCSException("Git fetch failed", p.output)
673 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
674 if p.returncode != 0:
675 raise VCSException("Git fetch failed", p.output)
676 # Recreate origin/HEAD as git clone would do it, in case it disappeared
677 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
678 if p.returncode != 0:
679 lines = p.output.splitlines()
680 if 'Multiple remote HEAD branches' not in lines[0]:
681 raise VCSException("Git remote set-head failed", p.output)
682 branch = lines[1].split(' ')[-1]
683 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
684 if p2.returncode != 0:
685 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
686 self.refreshed = True
687 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
688 # a github repo. Most of the time this is the same as origin/master.
689 rev = rev or 'origin/HEAD'
690 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
691 if p.returncode != 0:
692 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
693 # Get rid of any uncontrolled files left behind
694 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
695 if p.returncode != 0:
696 raise VCSException("Git clean failed", p.output)
698 def initsubmodules(self):
700 submfile = os.path.join(self.local, '.gitmodules')
701 if not os.path.isfile(submfile):
702 raise VCSException("No git submodules available")
704 # fix submodules not accessible without an account and public key auth
705 with open(submfile, 'r') as f:
706 lines = f.readlines()
707 with open(submfile, 'w') as f:
709 if 'git@github.com' in line:
710 line = line.replace('git@github.com:', 'https://github.com/')
711 if 'git@gitlab.com' in line:
712 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
715 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
716 if p.returncode != 0:
717 raise VCSException("Git submodule sync failed", p.output)
718 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
719 if p.returncode != 0:
720 raise VCSException("Git submodule update failed", p.output)
724 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
725 return p.output.splitlines()
727 tag_format = re.compile(r'tag: ([^),]*)')
729 def latesttags(self):
731 p = FDroidPopen(['git', 'log', '--tags',
732 '--simplify-by-decoration', '--pretty=format:%d'],
733 cwd=self.local, output=False)
735 for line in p.output.splitlines():
736 for tag in self.tag_format.findall(line):
741 class vcs_gitsvn(vcs):
746 # If the local directory exists, but is somehow not a git repository, git
747 # will traverse up the directory tree until it finds one that is (i.e.
748 # fdroidserver) and then we'll proceed to destory it! This is called as
751 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
752 result = p.output.rstrip()
753 if not result.endswith(self.local):
754 raise VCSException('Repository mismatch')
756 def gotorevisionx(self, rev):
757 if not os.path.exists(self.local):
759 gitsvn_args = ['git', 'svn', 'clone']
760 if ';' in self.remote:
761 remote_split = self.remote.split(';')
762 for i in remote_split[1:]:
763 if i.startswith('trunk='):
764 gitsvn_args.extend(['-T', i[6:]])
765 elif i.startswith('tags='):
766 gitsvn_args.extend(['-t', i[5:]])
767 elif i.startswith('branches='):
768 gitsvn_args.extend(['-b', i[9:]])
769 gitsvn_args.extend([remote_split[0], self.local])
770 p = FDroidPopen(gitsvn_args, output=False)
771 if p.returncode != 0:
772 self.clone_failed = True
773 raise VCSException("Git svn clone failed", p.output)
775 gitsvn_args.extend([self.remote, self.local])
776 p = FDroidPopen(gitsvn_args, output=False)
777 if p.returncode != 0:
778 self.clone_failed = True
779 raise VCSException("Git svn clone failed", p.output)
783 # Discard any working tree changes
784 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
785 if p.returncode != 0:
786 raise VCSException("Git reset failed", p.output)
787 # Remove untracked files now, in case they're tracked in the target
788 # revision (it happens!)
789 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
790 if p.returncode != 0:
791 raise VCSException("Git clean failed", p.output)
792 if not self.refreshed:
793 # Get new commits, branches and tags from repo
794 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
795 if p.returncode != 0:
796 raise VCSException("Git svn fetch failed")
797 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
798 if p.returncode != 0:
799 raise VCSException("Git svn rebase failed", p.output)
800 self.refreshed = True
802 rev = rev or 'master'
804 nospaces_rev = rev.replace(' ', '%20')
805 # Try finding a svn tag
806 for treeish in ['origin/', '']:
807 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
808 if p.returncode == 0:
810 if p.returncode != 0:
811 # No tag found, normal svn rev translation
812 # Translate svn rev into git format
813 rev_split = rev.split('/')
816 for treeish in ['origin/', '']:
817 if len(rev_split) > 1:
818 treeish += rev_split[0]
819 svn_rev = rev_split[1]
822 # if no branch is specified, then assume trunk (i.e. 'master' branch):
826 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
828 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
829 git_rev = p.output.rstrip()
831 if p.returncode == 0 and git_rev:
834 if p.returncode != 0 or not git_rev:
835 # Try a plain git checkout as a last resort
836 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
837 if p.returncode != 0:
838 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
840 # Check out the git rev equivalent to the svn rev
841 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
845 # Get rid of any uncontrolled files left behind
846 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
847 if p.returncode != 0:
848 raise VCSException("Git clean failed", p.output)
852 for treeish in ['origin/', '']:
853 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
859 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
860 if p.returncode != 0:
862 return p.output.strip()
870 def gotorevisionx(self, rev):
871 if not os.path.exists(self.local):
872 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
873 if p.returncode != 0:
874 self.clone_failed = True
875 raise VCSException("Hg clone failed", p.output)
877 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
878 if p.returncode != 0:
879 raise VCSException("Hg status failed", p.output)
880 for line in p.output.splitlines():
881 if not line.startswith('? '):
882 raise VCSException("Unexpected output from hg status -uS: " + line)
883 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
884 if not self.refreshed:
885 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
886 if p.returncode != 0:
887 raise VCSException("Hg pull failed", p.output)
888 self.refreshed = True
890 rev = rev or 'default'
893 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
894 if p.returncode != 0:
895 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
896 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
897 # Also delete untracked files, we have to enable purge extension for that:
898 if "'purge' is provided by the following extension" in p.output:
899 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
900 myfile.write("\n[extensions]\nhgext.purge=\n")
901 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
902 if p.returncode != 0:
903 raise VCSException("HG purge failed", p.output)
904 elif p.returncode != 0:
905 raise VCSException("HG purge failed", p.output)
908 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
909 return p.output.splitlines()[1:]
917 def gotorevisionx(self, rev):
918 if not os.path.exists(self.local):
919 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
920 if p.returncode != 0:
921 self.clone_failed = True
922 raise VCSException("Bzr branch failed", p.output)
924 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
925 if p.returncode != 0:
926 raise VCSException("Bzr revert failed", p.output)
927 if not self.refreshed:
928 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
929 if p.returncode != 0:
930 raise VCSException("Bzr update failed", p.output)
931 self.refreshed = True
933 revargs = list(['-r', rev] if rev else [])
934 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
935 if p.returncode != 0:
936 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
939 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
940 return [tag.split(' ')[0].strip() for tag in
941 p.output.splitlines()]
944 def unescape_string(string):
947 if string[0] == '"' and string[-1] == '"':
950 return string.replace("\\'", "'")
953 def retrieve_string(app_dir, string, xmlfiles=None):
955 if not string.startswith('@string/'):
956 return unescape_string(string)
961 os.path.join(app_dir, 'res'),
962 os.path.join(app_dir, 'src', 'main', 'res'),
964 for r, d, f in os.walk(res_dir):
965 if os.path.basename(r) == 'values':
966 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
968 name = string[len('@string/'):]
970 def element_content(element):
971 if element.text is None:
973 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
974 return s.decode('utf-8').strip()
976 for path in xmlfiles:
977 if not os.path.isfile(path):
979 xml = parse_xml(path)
980 element = xml.find('string[@name="' + name + '"]')
981 if element is not None:
982 content = element_content(element)
983 return retrieve_string(app_dir, content, xmlfiles)
988 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
989 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
992 # Return list of existing files that will be used to find the highest vercode
993 def manifest_paths(app_dir, flavours):
995 possible_manifests = \
996 [os.path.join(app_dir, 'AndroidManifest.xml'),
997 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
998 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
999 os.path.join(app_dir, 'build.gradle')]
1001 for flavour in flavours:
1002 if flavour == 'yes':
1004 possible_manifests.append(
1005 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1007 return [path for path in possible_manifests if os.path.isfile(path)]
1010 # Retrieve the package name. Returns the name, or None if not found.
1011 def fetch_real_name(app_dir, flavours):
1012 for path in manifest_paths(app_dir, flavours):
1013 if not has_extension(path, 'xml') or not os.path.isfile(path):
1015 logging.debug("fetch_real_name: Checking manifest at " + path)
1016 xml = parse_xml(path)
1017 app = xml.find('application')
1020 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1022 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1023 result = retrieve_string_singleline(app_dir, label)
1025 result = result.strip()
1030 def get_library_references(root_dir):
1032 proppath = os.path.join(root_dir, 'project.properties')
1033 if not os.path.isfile(proppath):
1035 with open(proppath, 'r') as f:
1037 if not line.startswith('android.library.reference.'):
1039 path = line.split('=')[1].strip()
1040 relpath = os.path.join(root_dir, path)
1041 if not os.path.isdir(relpath):
1043 logging.debug("Found subproject at %s" % path)
1044 libraries.append(path)
1048 def ant_subprojects(root_dir):
1049 subprojects = get_library_references(root_dir)
1050 for subpath in subprojects:
1051 subrelpath = os.path.join(root_dir, subpath)
1052 for p in get_library_references(subrelpath):
1053 relp = os.path.normpath(os.path.join(subpath, p))
1054 if relp not in subprojects:
1055 subprojects.insert(0, relp)
1059 def remove_debuggable_flags(root_dir):
1060 # Remove forced debuggable flags
1061 logging.debug("Removing debuggable flags from %s" % root_dir)
1062 for root, dirs, files in os.walk(root_dir):
1063 if 'AndroidManifest.xml' in files:
1064 regsub_file(r'android:debuggable="[^"]*"',
1066 os.path.join(root, 'AndroidManifest.xml'))
1069 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1070 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1071 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1074 def app_matches_packagename(app, package):
1077 appid = app.UpdateCheckName or app.id
1078 if appid is None or appid == "Ignore":
1080 return appid == package
1083 # Extract some information from the AndroidManifest.xml at the given path.
1084 # Returns (version, vercode, package), any or all of which might be None.
1085 # All values returned are strings.
1086 def parse_androidmanifests(paths, app):
1088 ignoreversions = app.UpdateCheckIgnore
1089 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1092 return (None, None, None)
1100 if not os.path.isfile(path):
1103 logging.debug("Parsing manifest at {0}".format(path))
1104 gradle = has_extension(path, 'gradle')
1110 with open(path, 'r') as f:
1112 if gradle_comment.match(line):
1114 # Grab first occurence of each to avoid running into
1115 # alternative flavours and builds.
1117 matches = psearch_g(line)
1119 s = matches.group(2)
1120 if app_matches_packagename(app, s):
1123 matches = vnsearch_g(line)
1125 version = matches.group(2)
1127 matches = vcsearch_g(line)
1129 vercode = matches.group(1)
1132 xml = parse_xml(path)
1133 if "package" in xml.attrib:
1134 s = xml.attrib["package"]
1135 if app_matches_packagename(app, s):
1137 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1138 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1139 base_dir = os.path.dirname(path)
1140 version = retrieve_string_singleline(base_dir, version)
1141 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1142 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1143 if string_is_integer(a):
1146 logging.warning("Problem with xml at {0}".format(path))
1148 # Remember package name, may be defined separately from version+vercode
1150 package = max_package
1152 logging.debug("..got package={0}, version={1}, vercode={2}"
1153 .format(package, version, vercode))
1155 # Always grab the package name and version name in case they are not
1156 # together with the highest version code
1157 if max_package is None and package is not None:
1158 max_package = package
1159 if max_version is None and version is not None:
1160 max_version = version
1162 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1163 if not ignoresearch or not ignoresearch(version):
1164 if version is not None:
1165 max_version = version
1166 if vercode is not None:
1167 max_vercode = vercode
1168 if package is not None:
1169 max_package = package
1171 max_version = "Ignore"
1173 if max_version is None:
1174 max_version = "Unknown"
1176 if max_package and not is_valid_package_name(max_package):
1177 raise FDroidException("Invalid package name {0}".format(max_package))
1179 return (max_version, max_vercode, max_package)
1182 def is_valid_package_name(name):
1183 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1186 class FDroidException(Exception):
1188 def __init__(self, value, detail=None):
1190 self.detail = detail
1192 def shortened_detail(self):
1193 if len(self.detail) < 16000:
1195 return '[...]\n' + self.detail[-16000:]
1197 def get_wikitext(self):
1198 ret = repr(self.value) + "\n"
1201 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1207 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1211 class VCSException(FDroidException):
1215 class BuildException(FDroidException):
1219 # Get the specified source library.
1220 # Returns the path to it. Normally this is the path to be used when referencing
1221 # it, which may be a subdirectory of the actual project. If you want the base
1222 # directory of the project, pass 'basepath=True'.
1223 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1224 raw=False, prepare=True, preponly=False, refresh=True):
1232 name, ref = spec.split('@')
1234 number, name = name.split(':', 1)
1236 name, subdir = name.split('/', 1)
1238 if name not in fdroidserver.metadata.srclibs:
1239 raise VCSException('srclib ' + name + ' not found.')
1241 srclib = fdroidserver.metadata.srclibs[name]
1243 sdir = os.path.join(srclib_dir, name)
1246 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1247 vcs.srclib = (name, number, sdir)
1249 vcs.gotorevision(ref, refresh)
1256 libdir = os.path.join(sdir, subdir)
1257 elif srclib["Subdir"]:
1258 for subdir in srclib["Subdir"]:
1259 libdir_candidate = os.path.join(sdir, subdir)
1260 if os.path.exists(libdir_candidate):
1261 libdir = libdir_candidate
1267 remove_signing_keys(sdir)
1268 remove_debuggable_flags(sdir)
1272 if srclib["Prepare"]:
1273 cmd = replace_config_vars(srclib["Prepare"], None)
1275 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1276 if p.returncode != 0:
1277 raise BuildException("Error running prepare command for srclib %s"
1283 return (name, number, libdir)
1285 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1288 # Prepare the source code for a particular build
1289 # 'vcs' - the appropriate vcs object for the application
1290 # 'app' - the application details from the metadata
1291 # 'build' - the build details from the metadata
1292 # 'build_dir' - the path to the build directory, usually
1294 # 'srclib_dir' - the path to the source libraries directory, usually
1296 # 'extlib_dir' - the path to the external libraries directory, usually
1298 # Returns the (root, srclibpaths) where:
1299 # 'root' is the root directory, which may be the same as 'build_dir' or may
1300 # be a subdirectory of it.
1301 # 'srclibpaths' is information on the srclibs being used
1302 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1304 # Optionally, the actual app source can be in a subdirectory
1306 root_dir = os.path.join(build_dir, build.subdir)
1308 root_dir = build_dir
1310 # Get a working copy of the right revision
1311 logging.info("Getting source for revision " + build.commit)
1312 vcs.gotorevision(build.commit, refresh)
1314 # Initialise submodules if required
1315 if build.submodules:
1316 logging.info("Initialising submodules")
1317 vcs.initsubmodules()
1319 # Check that a subdir (if we're using one) exists. This has to happen
1320 # after the checkout, since it might not exist elsewhere
1321 if not os.path.exists(root_dir):
1322 raise BuildException('Missing subdir ' + root_dir)
1324 # Run an init command if one is required
1326 cmd = replace_config_vars(build.init, build)
1327 logging.info("Running 'init' commands in %s" % root_dir)
1329 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1330 if p.returncode != 0:
1331 raise BuildException("Error running init command for %s:%s" %
1332 (app.id, build.version), p.output)
1334 # Apply patches if any
1336 logging.info("Applying patches")
1337 for patch in build.patch:
1338 patch = patch.strip()
1339 logging.info("Applying " + patch)
1340 patch_path = os.path.join('metadata', app.id, patch)
1341 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1342 if p.returncode != 0:
1343 raise BuildException("Failed to apply patch %s" % patch_path)
1345 # Get required source libraries
1348 logging.info("Collecting source libraries")
1349 for lib in build.srclibs:
1350 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1352 for name, number, libpath in srclibpaths:
1353 place_srclib(root_dir, int(number) if number else None, libpath)
1355 basesrclib = vcs.getsrclib()
1356 # If one was used for the main source, add that too.
1358 srclibpaths.append(basesrclib)
1360 # Update the local.properties file
1361 localprops = [os.path.join(build_dir, 'local.properties')]
1363 parts = build.subdir.split(os.sep)
1366 cur = os.path.join(cur, d)
1367 localprops += [os.path.join(cur, 'local.properties')]
1368 for path in localprops:
1370 if os.path.isfile(path):
1371 logging.info("Updating local.properties file at %s" % path)
1372 with open(path, 'r') as f:
1376 logging.info("Creating local.properties file at %s" % path)
1377 # Fix old-fashioned 'sdk-location' by copying
1378 # from sdk.dir, if necessary
1380 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1381 re.S | re.M).group(1)
1382 props += "sdk-location=%s\n" % sdkloc
1384 props += "sdk.dir=%s\n" % config['sdk_path']
1385 props += "sdk-location=%s\n" % config['sdk_path']
1386 ndk_path = build.ndk_path()
1389 props += "ndk.dir=%s\n" % ndk_path
1390 props += "ndk-location=%s\n" % ndk_path
1391 # Add java.encoding if necessary
1393 props += "java.encoding=%s\n" % build.encoding
1394 with open(path, 'w') as f:
1398 if build.build_method() == 'gradle':
1399 flavours = build.gradle
1402 n = build.target.split('-')[1]
1403 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1404 r'compileSdkVersion %s' % n,
1405 os.path.join(root_dir, 'build.gradle'))
1407 # Remove forced debuggable flags
1408 remove_debuggable_flags(root_dir)
1410 # Insert version code and number into the manifest if necessary
1411 if build.forceversion:
1412 logging.info("Changing the version name")
1413 for path in manifest_paths(root_dir, flavours):
1414 if not os.path.isfile(path):
1416 if has_extension(path, 'xml'):
1417 regsub_file(r'android:versionName="[^"]*"',
1418 r'android:versionName="%s"' % build.version,
1420 elif has_extension(path, 'gradle'):
1421 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1422 r"""\1versionName '%s'""" % build.version,
1425 if build.forcevercode:
1426 logging.info("Changing the version code")
1427 for path in manifest_paths(root_dir, flavours):
1428 if not os.path.isfile(path):
1430 if has_extension(path, 'xml'):
1431 regsub_file(r'android:versionCode="[^"]*"',
1432 r'android:versionCode="%s"' % build.vercode,
1434 elif has_extension(path, 'gradle'):
1435 regsub_file(r'versionCode[ =]+[0-9]+',
1436 r'versionCode %s' % build.vercode,
1439 # Delete unwanted files
1441 logging.info("Removing specified files")
1442 for part in getpaths(build_dir, build.rm):
1443 dest = os.path.join(build_dir, part)
1444 logging.info("Removing {0}".format(part))
1445 if os.path.lexists(dest):
1446 if os.path.islink(dest):
1447 FDroidPopen(['unlink', dest], output=False)
1449 FDroidPopen(['rm', '-rf', dest], output=False)
1451 logging.info("...but it didn't exist")
1453 remove_signing_keys(build_dir)
1455 # Add required external libraries
1457 logging.info("Collecting prebuilt libraries")
1458 libsdir = os.path.join(root_dir, 'libs')
1459 if not os.path.exists(libsdir):
1461 for lib in build.extlibs:
1463 logging.info("...installing extlib {0}".format(lib))
1464 libf = os.path.basename(lib)
1465 libsrc = os.path.join(extlib_dir, lib)
1466 if not os.path.exists(libsrc):
1467 raise BuildException("Missing extlib file {0}".format(libsrc))
1468 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1470 # Run a pre-build command if one is required
1472 logging.info("Running 'prebuild' commands in %s" % root_dir)
1474 cmd = replace_config_vars(build.prebuild, build)
1476 # Substitute source library paths into prebuild commands
1477 for name, number, libpath in srclibpaths:
1478 libpath = os.path.relpath(libpath, root_dir)
1479 cmd = cmd.replace('$$' + name + '$$', libpath)
1481 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1482 if p.returncode != 0:
1483 raise BuildException("Error running prebuild command for %s:%s" %
1484 (app.id, build.version), p.output)
1486 # Generate (or update) the ant build file, build.xml...
1487 if build.build_method() == 'ant' and build.update != ['no']:
1488 parms = ['android', 'update', 'lib-project']
1489 lparms = ['android', 'update', 'project']
1492 parms += ['-t', build.target]
1493 lparms += ['-t', build.target]
1495 update_dirs = build.update
1497 update_dirs = ant_subprojects(root_dir) + ['.']
1499 for d in update_dirs:
1500 subdir = os.path.join(root_dir, d)
1502 logging.debug("Updating main project")
1503 cmd = parms + ['-p', d]
1505 logging.debug("Updating subproject %s" % d)
1506 cmd = lparms + ['-p', d]
1507 p = SdkToolsPopen(cmd, cwd=root_dir)
1508 # Check to see whether an error was returned without a proper exit
1509 # code (this is the case for the 'no target set or target invalid'
1511 if p.returncode != 0 or p.output.startswith("Error: "):
1512 raise BuildException("Failed to update project at %s" % d, p.output)
1513 # Clean update dirs via ant
1515 logging.info("Cleaning subproject %s" % d)
1516 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1518 return (root_dir, srclibpaths)
1521 # Extend via globbing the paths from a field and return them as a map from
1522 # original path to resulting paths
1523 def getpaths_map(build_dir, globpaths):
1527 full_path = os.path.join(build_dir, p)
1528 full_path = os.path.normpath(full_path)
1529 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1531 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1535 # Extend via globbing the paths from a field and return them as a set
1536 def getpaths(build_dir, globpaths):
1537 paths_map = getpaths_map(build_dir, globpaths)
1539 for k, v in paths_map.items():
1546 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1552 self.path = os.path.join('stats', 'known_apks.txt')
1554 if os.path.isfile(self.path):
1555 with open(self.path, 'r') as f:
1557 t = line.rstrip().split(' ')
1559 self.apks[t[0]] = (t[1], None)
1561 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1562 self.changed = False
1564 def writeifchanged(self):
1565 if not self.changed:
1568 if not os.path.exists('stats'):
1572 for apk, app in self.apks.items():
1574 line = apk + ' ' + appid
1576 line += ' ' + time.strftime('%Y-%m-%d', added)
1579 with open(self.path, 'w') as f:
1580 for line in sorted(lst, key=natural_key):
1581 f.write(line + '\n')
1583 # Record an apk (if it's new, otherwise does nothing)
1584 # Returns the date it was added.
1585 def recordapk(self, apk, app):
1586 if apk not in self.apks:
1587 self.apks[apk] = (app, time.gmtime(time.time()))
1589 _, added = self.apks[apk]
1592 # Look up information - given the 'apkname', returns (app id, date added/None).
1593 # Or returns None for an unknown apk.
1594 def getapp(self, apkname):
1595 if apkname in self.apks:
1596 return self.apks[apkname]
1599 # Get the most recent 'num' apps added to the repo, as a list of package ids
1600 # with the most recent first.
1601 def getlatest(self, num):
1603 for apk, app in self.apks.items():
1607 if apps[appid] > added:
1611 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1612 lst = [app for app, _ in sortedapps]
1617 def isApkDebuggable(apkfile, config):
1618 """Returns True if the given apk file is debuggable
1620 :param apkfile: full path to the apk to check"""
1622 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1624 if p.returncode != 0:
1625 logging.critical("Failed to get apk manifest information")
1627 for line in p.output.splitlines():
1628 if 'android:debuggable' in line and not line.endswith('0x0'):
1635 self.returncode = None
1639 def SdkToolsPopen(commands, cwd=None, output=True):
1641 if cmd not in config:
1642 config[cmd] = find_sdk_tools_cmd(commands[0])
1643 abscmd = config[cmd]
1645 logging.critical("Could not find '%s' on your system" % cmd)
1647 return FDroidPopen([abscmd] + commands[1:],
1648 cwd=cwd, output=output)
1651 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1653 Run a command and capture the possibly huge output as bytes.
1655 :param commands: command and argument list like in subprocess.Popen
1656 :param cwd: optionally specifies a working directory
1657 :returns: A PopenResult.
1662 set_FDroidPopen_env()
1665 cwd = os.path.normpath(cwd)
1666 logging.debug("Directory: %s" % cwd)
1667 logging.debug("> %s" % ' '.join(commands))
1669 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1670 result = PopenResult()
1673 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1674 stdout=subprocess.PIPE, stderr=stderr_param)
1675 except OSError as e:
1676 raise BuildException("OSError while trying to execute " +
1677 ' '.join(commands) + ': ' + str(e))
1679 if not stderr_to_stdout and options.verbose:
1680 stderr_queue = Queue()
1681 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1683 while not stderr_reader.eof():
1684 while not stderr_queue.empty():
1685 line = stderr_queue.get()
1686 sys.stderr.buffer.write(line)
1691 stdout_queue = Queue()
1692 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1695 # Check the queue for output (until there is no more to get)
1696 while not stdout_reader.eof():
1697 while not stdout_queue.empty():
1698 line = stdout_queue.get()
1699 if output and options.verbose:
1700 # Output directly to console
1701 sys.stderr.buffer.write(line)
1707 result.returncode = p.wait()
1708 result.output = buf.getvalue()
1713 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1715 Run a command and capture the possibly huge output as a str.
1717 :param commands: command and argument list like in subprocess.Popen
1718 :param cwd: optionally specifies a working directory
1719 :returns: A PopenResult.
1721 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1722 result.output = result.output.decode('utf-8')
1726 gradle_comment = re.compile(r'[ ]*//')
1727 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1728 gradle_line_matches = [
1729 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1730 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1731 re.compile(r'.*\.readLine\(.*'),
1735 def remove_signing_keys(build_dir):
1736 for root, dirs, files in os.walk(build_dir):
1737 if 'build.gradle' in files:
1738 path = os.path.join(root, 'build.gradle')
1740 with open(path, "r") as o:
1741 lines = o.readlines()
1747 with open(path, "w") as o:
1748 while i < len(lines):
1751 while line.endswith('\\\n'):
1752 line = line.rstrip('\\\n') + lines[i]
1755 if gradle_comment.match(line):
1760 opened += line.count('{')
1761 opened -= line.count('}')
1764 if gradle_signing_configs.match(line):
1769 if any(s.match(line) for s in gradle_line_matches):
1777 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1780 'project.properties',
1782 'default.properties',
1783 'ant.properties', ]:
1784 if propfile in files:
1785 path = os.path.join(root, propfile)
1787 with open(path, "r") as o:
1788 lines = o.readlines()
1792 with open(path, "w") as o:
1794 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1801 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1804 def set_FDroidPopen_env(build=None):
1806 set up the environment variables for the build environment
1808 There is only a weak standard, the variables used by gradle, so also set
1809 up the most commonly used environment variables for SDK and NDK
1811 global env, orig_path
1815 orig_path = env['PATH']
1816 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1817 env[n] = config['sdk_path']
1818 for k, v in config['java_paths'].items():
1819 env['JAVA%s_HOME' % k] = v
1821 # Set up environment vars that depend on each build, only set the
1822 # NDK env vars if the NDK is not already in the PATH
1823 if build is not None:
1824 path = build.ndk_path()
1825 paths = orig_path.split(os.pathsep)
1829 env['PATH'] = os.pathsep.join(paths)
1831 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1832 env[n] = build.ndk_path()
1835 def replace_config_vars(cmd, build):
1836 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1837 cmd = cmd.replace('$$NDK$$', get_ndk_path(build['ndk']))
1838 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1839 if build is not None:
1840 cmd = cmd.replace('$$COMMIT$$', build.commit)
1841 cmd = cmd.replace('$$VERSION$$', build.version)
1842 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1846 def place_srclib(root_dir, number, libpath):
1849 relpath = os.path.relpath(libpath, root_dir)
1850 proppath = os.path.join(root_dir, 'project.properties')
1853 if os.path.isfile(proppath):
1854 with open(proppath, "r") as o:
1855 lines = o.readlines()
1857 with open(proppath, "w") as o:
1860 if line.startswith('android.library.reference.%d=' % number):
1861 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1866 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1868 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1871 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1872 """Verify that two apks are the same
1874 One of the inputs is signed, the other is unsigned. The signature metadata
1875 is transferred from the signed to the unsigned apk, and then jarsigner is
1876 used to verify that the signature from the signed apk is also varlid for
1878 :param signed_apk: Path to a signed apk file
1879 :param unsigned_apk: Path to an unsigned apk file expected to match it
1880 :param tmp_dir: Path to directory for temporary files
1881 :returns: None if the verification is successful, otherwise a string
1882 describing what went wrong.
1884 with ZipFile(signed_apk) as signed_apk_as_zip:
1885 meta_inf_files = ['META-INF/MANIFEST.MF']
1886 for f in signed_apk_as_zip.namelist():
1887 if apk_sigfile.match(f):
1888 meta_inf_files.append(f)
1889 if len(meta_inf_files) < 3:
1890 return "Signature files missing from {0}".format(signed_apk)
1891 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1892 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1893 for meta_inf_file in meta_inf_files:
1894 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1896 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1897 logging.info("...NOT verified - {0}".format(signed_apk))
1898 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1899 logging.info("...successfully verified")
1902 apk_badchars = re.compile('''[/ :;'"]''')
1905 def compare_apks(apk1, apk2, tmp_dir):
1908 Returns None if the apk content is the same (apart from the signing key),
1909 otherwise a string describing what's different, or what went wrong when
1910 trying to do the comparison.
1913 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1914 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1915 for d in [apk1dir, apk2dir]:
1916 if os.path.exists(d):
1919 os.mkdir(os.path.join(d, 'jar-xf'))
1921 if subprocess.call(['jar', 'xf',
1922 os.path.abspath(apk1)],
1923 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1924 return("Failed to unpack " + apk1)
1925 if subprocess.call(['jar', 'xf',
1926 os.path.abspath(apk2)],
1927 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1928 return("Failed to unpack " + apk2)
1930 # try to find apktool in the path, if it hasn't been manually configed
1931 if 'apktool' not in config:
1932 tmp = find_command('apktool')
1934 config['apktool'] = tmp
1935 if 'apktool' in config:
1936 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1938 return("Failed to unpack " + apk1)
1939 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1941 return("Failed to unpack " + apk2)
1943 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1944 lines = p.output.splitlines()
1945 if len(lines) != 1 or 'META-INF' not in lines[0]:
1946 meld = find_command('meld')
1947 if meld is not None:
1948 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1949 return("Unexpected diff output - " + p.output)
1951 # since everything verifies, delete the comparison to keep cruft down
1952 shutil.rmtree(apk1dir)
1953 shutil.rmtree(apk2dir)
1955 # If we get here, it seems like they're the same!
1959 def find_command(command):
1960 '''find the full path of a command, or None if it can't be found in the PATH'''
1963 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1965 fpath, fname = os.path.split(command)
1970 for path in os.environ["PATH"].split(os.pathsep):
1971 path = path.strip('"')
1972 exe_file = os.path.join(path, command)
1973 if is_exe(exe_file):
1980 '''generate a random password for when generating keys'''
1981 h = hashlib.sha256()
1982 h.update(os.urandom(16)) # salt
1983 h.update(socket.getfqdn().encode('utf-8'))
1984 passwd = base64.b64encode(h.digest()).strip()
1985 return passwd.decode('utf-8')
1988 def genkeystore(localconfig):
1989 '''Generate a new key with random passwords and add it to new keystore'''
1990 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1991 keystoredir = os.path.dirname(localconfig['keystore'])
1992 if keystoredir is None or keystoredir == '':
1993 keystoredir = os.path.join(os.getcwd(), keystoredir)
1994 if not os.path.exists(keystoredir):
1995 os.makedirs(keystoredir, mode=0o700)
1997 write_password_file("keystorepass", localconfig['keystorepass'])
1998 write_password_file("keypass", localconfig['keypass'])
1999 p = FDroidPopen([config['keytool'], '-genkey',
2000 '-keystore', localconfig['keystore'],
2001 '-alias', localconfig['repo_keyalias'],
2002 '-keyalg', 'RSA', '-keysize', '4096',
2003 '-sigalg', 'SHA256withRSA',
2004 '-validity', '10000',
2005 '-storepass:file', config['keystorepassfile'],
2006 '-keypass:file', config['keypassfile'],
2007 '-dname', localconfig['keydname']])
2008 # TODO keypass should be sent via stdin
2009 if p.returncode != 0:
2010 raise BuildException("Failed to generate key", p.output)
2011 os.chmod(localconfig['keystore'], 0o0600)
2012 # now show the lovely key that was just generated
2013 p = FDroidPopen([config['keytool'], '-list', '-v',
2014 '-keystore', localconfig['keystore'],
2015 '-alias', localconfig['repo_keyalias'],
2016 '-storepass:file', config['keystorepassfile']])
2017 logging.info(p.output.strip() + '\n\n')
2020 def write_to_config(thisconfig, key, value=None):
2021 '''write a key/value to the local config.py'''
2023 origkey = key + '_orig'
2024 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2025 with open('config.py', 'r') as f:
2027 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2028 repl = '\n' + key + ' = "' + value + '"'
2029 data = re.sub(pattern, repl, data)
2030 # if this key is not in the file, append it
2031 if not re.match('\s*' + key + '\s*=\s*"', data):
2033 # make sure the file ends with a carraige return
2034 if not re.match('\n$', data):
2036 with open('config.py', 'w') as f:
2040 def parse_xml(path):
2041 return XMLElementTree.parse(path).getroot()
2044 def string_is_integer(string):
2052 def get_per_app_repos():
2053 '''per-app repos are dirs named with the packageName of a single app'''
2055 # Android packageNames are Java packages, they may contain uppercase or
2056 # lowercase letters ('A' through 'Z'), numbers, and underscores
2057 # ('_'). However, individual package name parts may only start with
2058 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2059 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2062 for root, dirs, files in os.walk(os.getcwd()):
2064 print('checking', root, 'for', d)
2065 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2066 # standard parts of an fdroid repo, so never packageNames
2069 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):