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",
60 'r12b': "$ANDROID_NDK",
62 'build_tools': "24.0.0",
63 'force_build_tools': False,
68 'accepted_formats': ['txt', 'yml'],
69 'sync_from_local_copy_dir': False,
70 'per_app_repos': False,
71 'make_current_version_link': True,
72 'current_version_name_source': 'Name',
73 'update_stats': False,
77 'stats_to_carbon': False,
79 'build_server_always': False,
80 'keystore': 'keystore.jks',
81 'smartcardoptions': [],
87 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
88 'repo_name': "My First FDroid Repo Demo",
89 'repo_icon': "fdroid-icon.png",
90 'repo_description': '''
91 This is a repository of apps to be used with FDroid. Applications in this
92 repository are either official binaries built by the original application
93 developers, or are binaries built from source by the admin of f-droid.org
94 using the tools on https://gitlab.com/u/fdroid.
100 def setup_global_opts(parser):
101 parser.add_argument("-v", "--verbose", action="store_true", default=False,
102 help="Spew out even more information than normal")
103 parser.add_argument("-q", "--quiet", action="store_true", default=False,
104 help="Restrict output to warnings and errors")
107 def fill_config_defaults(thisconfig):
108 for k, v in default_config.items():
109 if k not in thisconfig:
112 # Expand paths (~users and $vars)
113 def expand_path(path):
117 path = os.path.expanduser(path)
118 path = os.path.expandvars(path)
123 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
128 thisconfig[k + '_orig'] = v
130 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
131 if thisconfig['java_paths'] is None:
132 thisconfig['java_paths'] = dict()
134 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
135 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
136 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
137 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
138 if os.getenv('JAVA_HOME') is not None:
139 pathlist += os.getenv('JAVA_HOME')
140 if os.getenv('PROGRAMFILES') is not None:
141 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
142 for d in sorted(pathlist):
143 if os.path.islink(d):
145 j = os.path.basename(d)
146 # the last one found will be the canonical one, so order appropriately
148 r'^1\.([6-9])\.0\.jdk$', # OSX
149 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
150 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
151 r'^jdk([6-9])-openjdk$', # Arch
152 r'^java-([6-9])-openjdk$', # Arch
153 r'^java-([6-9])-jdk$', # Arch (oracle)
154 r'^java-1\.([6-9])\.0-.*$', # RedHat
155 r'^java-([6-9])-oracle$', # Debian WebUpd8
156 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
157 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
159 m = re.match(regex, j)
162 osxhome = os.path.join(d, 'Contents', 'Home')
163 if os.path.exists(osxhome):
164 thisconfig['java_paths'][m.group(1)] = osxhome
166 thisconfig['java_paths'][m.group(1)] = d
168 for java_version in ('7', '8', '9'):
169 if java_version not in thisconfig['java_paths']:
171 java_home = thisconfig['java_paths'][java_version]
172 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
173 if os.path.exists(jarsigner):
174 thisconfig['jarsigner'] = jarsigner
175 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
176 break # Java7 is preferred, so quit if found
178 for k in ['ndk_paths', 'java_paths']:
184 thisconfig[k][k2] = exp
185 thisconfig[k][k2 + '_orig'] = v
188 def regsub_file(pattern, repl, path):
189 with open(path, 'rb') as f:
191 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
192 with open(path, 'wb') as f:
196 def read_config(opts, config_file='config.py'):
197 """Read the repository config
199 The config is read from config_file, which is in the current
200 directory when any of the repo management commands are used. If
201 there is a local metadata file in the git repo, then config.py is
202 not required, just use defaults.
205 global config, options
207 if config is not None:
214 if os.path.isfile(config_file):
215 logging.debug("Reading %s" % config_file)
216 with io.open(config_file, "rb") as f:
217 code = compile(f.read(), config_file, 'exec')
218 exec(code, None, config)
219 elif len(get_local_metadata_files()) == 0:
220 logging.critical("Missing config file - is this a repo directory?")
223 # smartcardoptions must be a list since its command line args for Popen
224 if 'smartcardoptions' in config:
225 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
226 elif 'keystore' in config and config['keystore'] == 'NONE':
227 # keystore='NONE' means use smartcard, these are required defaults
228 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
229 'SunPKCS11-OpenSC', '-providerClass',
230 'sun.security.pkcs11.SunPKCS11',
231 '-providerArg', 'opensc-fdroid.cfg']
233 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
234 st = os.stat(config_file)
235 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
236 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
238 fill_config_defaults(config)
240 for k in ["keystorepass", "keypass"]:
242 write_password_file(k)
244 for k in ["repo_description", "archive_description"]:
246 config[k] = clean_description(config[k])
248 if 'serverwebroot' in config:
249 if isinstance(config['serverwebroot'], str):
250 roots = [config['serverwebroot']]
251 elif all(isinstance(item, str) for item in config['serverwebroot']):
252 roots = config['serverwebroot']
254 raise TypeError('only accepts strings, lists, and tuples')
256 for rootstr in roots:
257 # since this is used with rsync, where trailing slashes have
258 # meaning, ensure there is always a trailing slash
259 if rootstr[-1] != '/':
261 rootlist.append(rootstr.replace('//', '/'))
262 config['serverwebroot'] = rootlist
267 def find_sdk_tools_cmd(cmd):
268 '''find a working path to a tool from the Android SDK'''
271 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
272 # try to find a working path to this command, in all the recent possible paths
273 if 'build_tools' in config:
274 build_tools = os.path.join(config['sdk_path'], 'build-tools')
275 # if 'build_tools' was manually set and exists, check only that one
276 configed_build_tools = os.path.join(build_tools, config['build_tools'])
277 if os.path.exists(configed_build_tools):
278 tooldirs.append(configed_build_tools)
280 # no configed version, so hunt known paths for it
281 for f in sorted(os.listdir(build_tools), reverse=True):
282 if os.path.isdir(os.path.join(build_tools, f)):
283 tooldirs.append(os.path.join(build_tools, f))
284 tooldirs.append(build_tools)
285 sdk_tools = os.path.join(config['sdk_path'], 'tools')
286 if os.path.exists(sdk_tools):
287 tooldirs.append(sdk_tools)
288 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
289 if os.path.exists(sdk_platform_tools):
290 tooldirs.append(sdk_platform_tools)
291 tooldirs.append('/usr/bin')
293 if os.path.isfile(os.path.join(d, cmd)):
294 return os.path.join(d, cmd)
295 # did not find the command, exit with error message
296 ensure_build_tools_exists(config)
299 def test_sdk_exists(thisconfig):
300 if 'sdk_path' not in thisconfig:
301 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
304 logging.error("'sdk_path' not set in config.py!")
306 if thisconfig['sdk_path'] == default_config['sdk_path']:
307 logging.error('No Android SDK found!')
308 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
309 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
311 if not os.path.exists(thisconfig['sdk_path']):
312 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
314 if not os.path.isdir(thisconfig['sdk_path']):
315 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
317 for d in ['build-tools', 'platform-tools', 'tools']:
318 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
319 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
320 thisconfig['sdk_path'], d))
325 def ensure_build_tools_exists(thisconfig):
326 if not test_sdk_exists(thisconfig):
328 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
329 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
330 if not os.path.isdir(versioned_build_tools):
331 logging.critical('Android Build Tools path "'
332 + versioned_build_tools + '" does not exist!')
336 def write_password_file(pwtype, password=None):
338 writes out passwords to a protected file instead of passing passwords as
339 command line argments
341 filename = '.fdroid.' + pwtype + '.txt'
342 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
344 os.write(fd, config[pwtype].encode('utf-8'))
346 os.write(fd, password.encode('utf-8'))
348 config[pwtype + 'file'] = filename
351 def get_local_metadata_files():
352 '''get any metadata files local to an app's source repo
354 This tries to ignore anything that does not count as app metdata,
355 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
358 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
361 # Given the arguments in the form of multiple appid:[vc] strings, this returns
362 # a dictionary with the set of vercodes specified for each package.
363 def read_pkg_args(args, allow_vercodes=False):
370 if allow_vercodes and ':' in p:
371 package, vercode = p.split(':')
373 package, vercode = p, None
374 if package not in vercodes:
375 vercodes[package] = [vercode] if vercode else []
377 elif vercode and vercode not in vercodes[package]:
378 vercodes[package] += [vercode] if vercode else []
383 # On top of what read_pkg_args does, this returns the whole app metadata, but
384 # limiting the builds list to the builds matching the vercodes specified.
385 def read_app_args(args, allapps, allow_vercodes=False):
387 vercodes = read_pkg_args(args, allow_vercodes)
393 for appid, app in allapps.items():
394 if appid in vercodes:
397 if len(apps) != len(vercodes):
400 logging.critical("No such package: %s" % p)
401 raise FDroidException("Found invalid app ids in arguments")
403 raise FDroidException("No packages specified")
406 for appid, app in apps.items():
410 app.builds = [b for b in app.builds if b.vercode in vc]
411 if len(app.builds) != len(vercodes[appid]):
413 allvcs = [b.vercode for b in app.builds]
414 for v in vercodes[appid]:
416 logging.critical("No such vercode %s for app %s" % (v, appid))
419 raise FDroidException("Found invalid vercodes for some apps")
424 def get_extension(filename):
425 base, ext = os.path.splitext(filename)
428 return base, ext.lower()[1:]
431 def has_extension(filename, ext):
432 _, f_ext = get_extension(filename)
436 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
439 def clean_description(description):
440 'Remove unneeded newlines and spaces from a block of description text'
442 # this is split up by paragraph to make removing the newlines easier
443 for paragraph in re.split(r'\n\n', description):
444 paragraph = re.sub('\r', '', paragraph)
445 paragraph = re.sub('\n', ' ', paragraph)
446 paragraph = re.sub(' {2,}', ' ', paragraph)
447 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
448 returnstring += paragraph + '\n\n'
449 return returnstring.rstrip('\n')
452 def apknameinfo(filename):
453 filename = os.path.basename(filename)
454 m = apk_regex.match(filename)
456 result = (m.group(1), m.group(2))
457 except AttributeError:
458 raise FDroidException("Invalid apk name: %s" % filename)
462 def getapkname(app, build):
463 return "%s_%s.apk" % (app.id, build.vercode)
466 def getsrcname(app, build):
467 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
479 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
482 def getvcs(vcstype, remote, local):
484 return vcs_git(remote, local)
485 if vcstype == 'git-svn':
486 return vcs_gitsvn(remote, local)
488 return vcs_hg(remote, local)
490 return vcs_bzr(remote, local)
491 if vcstype == 'srclib':
492 if local != os.path.join('build', 'srclib', remote):
493 raise VCSException("Error: srclib paths are hard-coded!")
494 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
496 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
497 raise VCSException("Invalid vcs type " + vcstype)
500 def getsrclibvcs(name):
501 if name not in fdroidserver.metadata.srclibs:
502 raise VCSException("Missing srclib " + name)
503 return fdroidserver.metadata.srclibs[name]['Repo Type']
508 def __init__(self, remote, local):
510 # svn, git-svn and bzr may require auth
512 if self.repotype() in ('git-svn', 'bzr'):
514 if self.repotype == 'git-svn':
515 raise VCSException("Authentication is not supported for git-svn")
516 self.username, remote = remote.split('@')
517 if ':' not in self.username:
518 raise VCSException("Password required with username")
519 self.username, self.password = self.username.split(':')
523 self.clone_failed = False
524 self.refreshed = False
530 # Take the local repository to a clean version of the given revision, which
531 # is specificed in the VCS's native format. Beforehand, the repository can
532 # be dirty, or even non-existent. If the repository does already exist
533 # locally, it will be updated from the origin, but only once in the
534 # lifetime of the vcs object.
535 # None is acceptable for 'rev' if you know you are cloning a clean copy of
536 # the repo - otherwise it must specify a valid revision.
537 def gotorevision(self, rev, refresh=True):
539 if self.clone_failed:
540 raise VCSException("Downloading the repository already failed once, not trying again.")
542 # The .fdroidvcs-id file for a repo tells us what VCS type
543 # and remote that directory was created from, allowing us to drop it
544 # automatically if either of those things changes.
545 fdpath = os.path.join(self.local, '..',
546 '.fdroidvcs-' + os.path.basename(self.local))
547 fdpath = os.path.normpath(fdpath)
548 cdata = self.repotype() + ' ' + self.remote
551 if os.path.exists(self.local):
552 if os.path.exists(fdpath):
553 with open(fdpath, 'r') as f:
554 fsdata = f.read().strip()
559 logging.info("Repository details for %s changed - deleting" % (
563 logging.info("Repository details for %s missing - deleting" % (
566 shutil.rmtree(self.local)
570 self.refreshed = True
573 self.gotorevisionx(rev)
574 except FDroidException as e:
577 # If necessary, write the .fdroidvcs file.
578 if writeback and not self.clone_failed:
579 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
580 with open(fdpath, 'w+') as f:
586 # Derived classes need to implement this. It's called once basic checking
587 # has been performend.
588 def gotorevisionx(self, rev):
589 raise VCSException("This VCS type doesn't define gotorevisionx")
591 # Initialise and update submodules
592 def initsubmodules(self):
593 raise VCSException('Submodules not supported for this vcs type')
595 # Get a list of all known tags
597 if not self._gettags:
598 raise VCSException('gettags not supported for this vcs type')
600 for tag in self._gettags():
601 if re.match('[-A-Za-z0-9_. /]+$', tag):
605 # Get a list of all the known tags, sorted from newest to oldest
606 def latesttags(self):
607 raise VCSException('latesttags not supported for this vcs type')
609 # Get current commit reference (hash, revision, etc)
611 raise VCSException('getref not supported for this vcs type')
613 # Returns the srclib (name, path) used in setting up the current
624 # If the local directory exists, but is somehow not a git repository, git
625 # will traverse up the directory tree until it finds one that is (i.e.
626 # fdroidserver) and then we'll proceed to destroy it! This is called as
629 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
630 result = p.output.rstrip()
631 if not result.endswith(self.local):
632 raise VCSException('Repository mismatch')
634 def gotorevisionx(self, rev):
635 if not os.path.exists(self.local):
637 p = FDroidPopen(['git', 'clone', self.remote, self.local])
638 if p.returncode != 0:
639 self.clone_failed = True
640 raise VCSException("Git clone failed", p.output)
644 # Discard any working tree changes
645 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
646 'git', 'reset', '--hard'], cwd=self.local, output=False)
647 if p.returncode != 0:
648 raise VCSException("Git reset failed", p.output)
649 # Remove untracked files now, in case they're tracked in the target
650 # revision (it happens!)
651 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
652 'git', 'clean', '-dffx'], cwd=self.local, output=False)
653 if p.returncode != 0:
654 raise VCSException("Git clean failed", p.output)
655 if not self.refreshed:
656 # Get latest commits and tags from remote
657 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
658 if p.returncode != 0:
659 raise VCSException("Git fetch failed", p.output)
660 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
661 if p.returncode != 0:
662 raise VCSException("Git fetch failed", p.output)
663 # Recreate origin/HEAD as git clone would do it, in case it disappeared
664 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
665 if p.returncode != 0:
666 lines = p.output.splitlines()
667 if 'Multiple remote HEAD branches' not in lines[0]:
668 raise VCSException("Git remote set-head failed", p.output)
669 branch = lines[1].split(' ')[-1]
670 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
671 if p2.returncode != 0:
672 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
673 self.refreshed = True
674 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
675 # a github repo. Most of the time this is the same as origin/master.
676 rev = rev or 'origin/HEAD'
677 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
678 if p.returncode != 0:
679 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
680 # Get rid of any uncontrolled files left behind
681 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
682 if p.returncode != 0:
683 raise VCSException("Git clean failed", p.output)
685 def initsubmodules(self):
687 submfile = os.path.join(self.local, '.gitmodules')
688 if not os.path.isfile(submfile):
689 raise VCSException("No git submodules available")
691 # fix submodules not accessible without an account and public key auth
692 with open(submfile, 'r') as f:
693 lines = f.readlines()
694 with open(submfile, 'w') as f:
696 if 'git@github.com' in line:
697 line = line.replace('git@github.com:', 'https://github.com/')
698 if 'git@gitlab.com' in line:
699 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
702 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
703 if p.returncode != 0:
704 raise VCSException("Git submodule sync failed", p.output)
705 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
706 if p.returncode != 0:
707 raise VCSException("Git submodule update failed", p.output)
711 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
712 return p.output.splitlines()
714 tag_format = re.compile(r'tag: ([^),]*)')
716 def latesttags(self):
718 p = FDroidPopen(['git', 'log', '--tags',
719 '--simplify-by-decoration', '--pretty=format:%d'],
720 cwd=self.local, output=False)
722 for line in p.output.splitlines():
723 for tag in self.tag_format.findall(line):
728 class vcs_gitsvn(vcs):
733 # If the local directory exists, but is somehow not a git repository, git
734 # will traverse up the directory tree until it finds one that is (i.e.
735 # fdroidserver) and then we'll proceed to destory it! This is called as
738 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
739 result = p.output.rstrip()
740 if not result.endswith(self.local):
741 raise VCSException('Repository mismatch')
743 def gotorevisionx(self, rev):
744 if not os.path.exists(self.local):
746 gitsvn_args = ['git', 'svn', 'clone']
747 if ';' in self.remote:
748 remote_split = self.remote.split(';')
749 for i in remote_split[1:]:
750 if i.startswith('trunk='):
751 gitsvn_args.extend(['-T', i[6:]])
752 elif i.startswith('tags='):
753 gitsvn_args.extend(['-t', i[5:]])
754 elif i.startswith('branches='):
755 gitsvn_args.extend(['-b', i[9:]])
756 gitsvn_args.extend([remote_split[0], self.local])
757 p = FDroidPopen(gitsvn_args, output=False)
758 if p.returncode != 0:
759 self.clone_failed = True
760 raise VCSException("Git svn clone failed", p.output)
762 gitsvn_args.extend([self.remote, self.local])
763 p = FDroidPopen(gitsvn_args, output=False)
764 if p.returncode != 0:
765 self.clone_failed = True
766 raise VCSException("Git svn clone failed", p.output)
770 # Discard any working tree changes
771 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException("Git reset failed", p.output)
774 # Remove untracked files now, in case they're tracked in the target
775 # revision (it happens!)
776 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
777 if p.returncode != 0:
778 raise VCSException("Git clean failed", p.output)
779 if not self.refreshed:
780 # Get new commits, branches and tags from repo
781 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
782 if p.returncode != 0:
783 raise VCSException("Git svn fetch failed")
784 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
785 if p.returncode != 0:
786 raise VCSException("Git svn rebase failed", p.output)
787 self.refreshed = True
789 rev = rev or 'master'
791 nospaces_rev = rev.replace(' ', '%20')
792 # Try finding a svn tag
793 for treeish in ['origin/', '']:
794 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
795 if p.returncode == 0:
797 if p.returncode != 0:
798 # No tag found, normal svn rev translation
799 # Translate svn rev into git format
800 rev_split = rev.split('/')
803 for treeish in ['origin/', '']:
804 if len(rev_split) > 1:
805 treeish += rev_split[0]
806 svn_rev = rev_split[1]
809 # if no branch is specified, then assume trunk (i.e. 'master' branch):
813 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
815 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
816 git_rev = p.output.rstrip()
818 if p.returncode == 0 and git_rev:
821 if p.returncode != 0 or not git_rev:
822 # Try a plain git checkout as a last resort
823 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
824 if p.returncode != 0:
825 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
827 # Check out the git rev equivalent to the svn rev
828 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
829 if p.returncode != 0:
830 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
832 # Get rid of any uncontrolled files left behind
833 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
834 if p.returncode != 0:
835 raise VCSException("Git clean failed", p.output)
839 for treeish in ['origin/', '']:
840 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
846 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
847 if p.returncode != 0:
849 return p.output.strip()
857 def gotorevisionx(self, rev):
858 if not os.path.exists(self.local):
859 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
860 if p.returncode != 0:
861 self.clone_failed = True
862 raise VCSException("Hg clone failed", p.output)
864 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
865 if p.returncode != 0:
866 raise VCSException("Hg status failed", p.output)
867 for line in p.output.splitlines():
868 if not line.startswith('? '):
869 raise VCSException("Unexpected output from hg status -uS: " + line)
870 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
871 if not self.refreshed:
872 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
873 if p.returncode != 0:
874 raise VCSException("Hg pull failed", p.output)
875 self.refreshed = True
877 rev = rev or 'default'
880 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
881 if p.returncode != 0:
882 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
883 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
884 # Also delete untracked files, we have to enable purge extension for that:
885 if "'purge' is provided by the following extension" in p.output:
886 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
887 myfile.write("\n[extensions]\nhgext.purge=\n")
888 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
889 if p.returncode != 0:
890 raise VCSException("HG purge failed", p.output)
891 elif p.returncode != 0:
892 raise VCSException("HG purge failed", p.output)
895 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
896 return p.output.splitlines()[1:]
904 def gotorevisionx(self, rev):
905 if not os.path.exists(self.local):
906 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
907 if p.returncode != 0:
908 self.clone_failed = True
909 raise VCSException("Bzr branch failed", p.output)
911 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
912 if p.returncode != 0:
913 raise VCSException("Bzr revert failed", p.output)
914 if not self.refreshed:
915 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
916 if p.returncode != 0:
917 raise VCSException("Bzr update failed", p.output)
918 self.refreshed = True
920 revargs = list(['-r', rev] if rev else [])
921 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
922 if p.returncode != 0:
923 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
926 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
927 return [tag.split(' ')[0].strip() for tag in
928 p.output.splitlines()]
931 def unescape_string(string):
934 if string[0] == '"' and string[-1] == '"':
937 return string.replace("\\'", "'")
940 def retrieve_string(app_dir, string, xmlfiles=None):
942 if not string.startswith('@string/'):
943 return unescape_string(string)
948 os.path.join(app_dir, 'res'),
949 os.path.join(app_dir, 'src', 'main', 'res'),
951 for r, d, f in os.walk(res_dir):
952 if os.path.basename(r) == 'values':
953 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
955 name = string[len('@string/'):]
957 def element_content(element):
958 if element.text is None:
960 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
961 return s.decode('utf-8').strip()
963 for path in xmlfiles:
964 if not os.path.isfile(path):
966 xml = parse_xml(path)
967 element = xml.find('string[@name="' + name + '"]')
968 if element is not None:
969 content = element_content(element)
970 return retrieve_string(app_dir, content, xmlfiles)
975 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
976 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
979 # Return list of existing files that will be used to find the highest vercode
980 def manifest_paths(app_dir, flavours):
982 possible_manifests = \
983 [os.path.join(app_dir, 'AndroidManifest.xml'),
984 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
985 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
986 os.path.join(app_dir, 'build.gradle')]
988 for flavour in flavours:
991 possible_manifests.append(
992 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
994 return [path for path in possible_manifests if os.path.isfile(path)]
997 # Retrieve the package name. Returns the name, or None if not found.
998 def fetch_real_name(app_dir, flavours):
999 for path in manifest_paths(app_dir, flavours):
1000 if not has_extension(path, 'xml') or not os.path.isfile(path):
1002 logging.debug("fetch_real_name: Checking manifest at " + path)
1003 xml = parse_xml(path)
1004 app = xml.find('application')
1007 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1009 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1010 result = retrieve_string_singleline(app_dir, label)
1012 result = result.strip()
1017 def get_library_references(root_dir):
1019 proppath = os.path.join(root_dir, 'project.properties')
1020 if not os.path.isfile(proppath):
1022 with open(proppath, 'r', encoding='iso-8859-1') as f:
1024 if not line.startswith('android.library.reference.'):
1026 path = line.split('=')[1].strip()
1027 relpath = os.path.join(root_dir, path)
1028 if not os.path.isdir(relpath):
1030 logging.debug("Found subproject at %s" % path)
1031 libraries.append(path)
1035 def ant_subprojects(root_dir):
1036 subprojects = get_library_references(root_dir)
1037 for subpath in subprojects:
1038 subrelpath = os.path.join(root_dir, subpath)
1039 for p in get_library_references(subrelpath):
1040 relp = os.path.normpath(os.path.join(subpath, p))
1041 if relp not in subprojects:
1042 subprojects.insert(0, relp)
1046 def remove_debuggable_flags(root_dir):
1047 # Remove forced debuggable flags
1048 logging.debug("Removing debuggable flags from %s" % root_dir)
1049 for root, dirs, files in os.walk(root_dir):
1050 if 'AndroidManifest.xml' in files:
1051 regsub_file(r'android:debuggable="[^"]*"',
1053 os.path.join(root, 'AndroidManifest.xml'))
1056 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1057 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1058 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1061 def app_matches_packagename(app, package):
1064 appid = app.UpdateCheckName or app.id
1065 if appid is None or appid == "Ignore":
1067 return appid == package
1070 # Extract some information from the AndroidManifest.xml at the given path.
1071 # Returns (version, vercode, package), any or all of which might be None.
1072 # All values returned are strings.
1073 def parse_androidmanifests(paths, app):
1075 ignoreversions = app.UpdateCheckIgnore
1076 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1079 return (None, None, None)
1087 if not os.path.isfile(path):
1090 logging.debug("Parsing manifest at {0}".format(path))
1091 gradle = has_extension(path, 'gradle')
1097 with open(path, 'r') as f:
1099 if gradle_comment.match(line):
1101 # Grab first occurence of each to avoid running into
1102 # alternative flavours and builds.
1104 matches = psearch_g(line)
1106 s = matches.group(2)
1107 if app_matches_packagename(app, s):
1110 matches = vnsearch_g(line)
1112 version = matches.group(2)
1114 matches = vcsearch_g(line)
1116 vercode = matches.group(1)
1119 xml = parse_xml(path)
1120 if "package" in xml.attrib:
1121 s = xml.attrib["package"]
1122 if app_matches_packagename(app, s):
1124 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1125 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1126 base_dir = os.path.dirname(path)
1127 version = retrieve_string_singleline(base_dir, version)
1128 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1129 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1130 if string_is_integer(a):
1133 logging.warning("Problem with xml at {0}".format(path))
1135 # Remember package name, may be defined separately from version+vercode
1137 package = max_package
1139 logging.debug("..got package={0}, version={1}, vercode={2}"
1140 .format(package, version, vercode))
1142 # Always grab the package name and version name in case they are not
1143 # together with the highest version code
1144 if max_package is None and package is not None:
1145 max_package = package
1146 if max_version is None and version is not None:
1147 max_version = version
1149 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1150 if not ignoresearch or not ignoresearch(version):
1151 if version is not None:
1152 max_version = version
1153 if vercode is not None:
1154 max_vercode = vercode
1155 if package is not None:
1156 max_package = package
1158 max_version = "Ignore"
1160 if max_version is None:
1161 max_version = "Unknown"
1163 if max_package and not is_valid_package_name(max_package):
1164 raise FDroidException("Invalid package name {0}".format(max_package))
1166 return (max_version, max_vercode, max_package)
1169 def is_valid_package_name(name):
1170 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1173 class FDroidException(Exception):
1175 def __init__(self, value, detail=None):
1177 self.detail = detail
1179 def shortened_detail(self):
1180 if len(self.detail) < 16000:
1182 return '[...]\n' + self.detail[-16000:]
1184 def get_wikitext(self):
1185 ret = repr(self.value) + "\n"
1188 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1194 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1198 class VCSException(FDroidException):
1202 class BuildException(FDroidException):
1206 # Get the specified source library.
1207 # Returns the path to it. Normally this is the path to be used when referencing
1208 # it, which may be a subdirectory of the actual project. If you want the base
1209 # directory of the project, pass 'basepath=True'.
1210 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1211 raw=False, prepare=True, preponly=False, refresh=True,
1220 name, ref = spec.split('@')
1222 number, name = name.split(':', 1)
1224 name, subdir = name.split('/', 1)
1226 if name not in fdroidserver.metadata.srclibs:
1227 raise VCSException('srclib ' + name + ' not found.')
1229 srclib = fdroidserver.metadata.srclibs[name]
1231 sdir = os.path.join(srclib_dir, name)
1234 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1235 vcs.srclib = (name, number, sdir)
1237 vcs.gotorevision(ref, refresh)
1244 libdir = os.path.join(sdir, subdir)
1245 elif srclib["Subdir"]:
1246 for subdir in srclib["Subdir"]:
1247 libdir_candidate = os.path.join(sdir, subdir)
1248 if os.path.exists(libdir_candidate):
1249 libdir = libdir_candidate
1255 remove_signing_keys(sdir)
1256 remove_debuggable_flags(sdir)
1260 if srclib["Prepare"]:
1261 cmd = replace_config_vars(srclib["Prepare"], build)
1263 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1264 if p.returncode != 0:
1265 raise BuildException("Error running prepare command for srclib %s"
1271 return (name, number, libdir)
1273 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1276 # Prepare the source code for a particular build
1277 # 'vcs' - the appropriate vcs object for the application
1278 # 'app' - the application details from the metadata
1279 # 'build' - the build details from the metadata
1280 # 'build_dir' - the path to the build directory, usually
1282 # 'srclib_dir' - the path to the source libraries directory, usually
1284 # 'extlib_dir' - the path to the external libraries directory, usually
1286 # Returns the (root, srclibpaths) where:
1287 # 'root' is the root directory, which may be the same as 'build_dir' or may
1288 # be a subdirectory of it.
1289 # 'srclibpaths' is information on the srclibs being used
1290 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1292 # Optionally, the actual app source can be in a subdirectory
1294 root_dir = os.path.join(build_dir, build.subdir)
1296 root_dir = build_dir
1298 # Get a working copy of the right revision
1299 logging.info("Getting source for revision " + build.commit)
1300 vcs.gotorevision(build.commit, refresh)
1302 # Initialise submodules if required
1303 if build.submodules:
1304 logging.info("Initialising submodules")
1305 vcs.initsubmodules()
1307 # Check that a subdir (if we're using one) exists. This has to happen
1308 # after the checkout, since it might not exist elsewhere
1309 if not os.path.exists(root_dir):
1310 raise BuildException('Missing subdir ' + root_dir)
1312 # Run an init command if one is required
1314 cmd = replace_config_vars(build.init, build)
1315 logging.info("Running 'init' commands in %s" % root_dir)
1317 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1318 if p.returncode != 0:
1319 raise BuildException("Error running init command for %s:%s" %
1320 (app.id, build.version), p.output)
1322 # Apply patches if any
1324 logging.info("Applying patches")
1325 for patch in build.patch:
1326 patch = patch.strip()
1327 logging.info("Applying " + patch)
1328 patch_path = os.path.join('metadata', app.id, patch)
1329 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1330 if p.returncode != 0:
1331 raise BuildException("Failed to apply patch %s" % patch_path)
1333 # Get required source libraries
1336 logging.info("Collecting source libraries")
1337 for lib in build.srclibs:
1338 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1339 refresh=refresh, build=build))
1341 for name, number, libpath in srclibpaths:
1342 place_srclib(root_dir, int(number) if number else None, libpath)
1344 basesrclib = vcs.getsrclib()
1345 # If one was used for the main source, add that too.
1347 srclibpaths.append(basesrclib)
1349 # Update the local.properties file
1350 localprops = [os.path.join(build_dir, 'local.properties')]
1352 parts = build.subdir.split(os.sep)
1355 cur = os.path.join(cur, d)
1356 localprops += [os.path.join(cur, 'local.properties')]
1357 for path in localprops:
1359 if os.path.isfile(path):
1360 logging.info("Updating local.properties file at %s" % path)
1361 with open(path, 'r', encoding='iso-8859-1') as f:
1365 logging.info("Creating local.properties file at %s" % path)
1366 # Fix old-fashioned 'sdk-location' by copying
1367 # from sdk.dir, if necessary
1369 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1370 re.S | re.M).group(1)
1371 props += "sdk-location=%s\n" % sdkloc
1373 props += "sdk.dir=%s\n" % config['sdk_path']
1374 props += "sdk-location=%s\n" % config['sdk_path']
1375 ndk_path = build.ndk_path()
1376 # if it wasn't expanded correctly (because the NDK is not
1377 # installed or $ANDROID_NDK not set properly), don't insert it.
1378 # even if not actually used, Gradle will error with a cryptic
1380 # https://gitlab.com/fdroid/fdroidserver/issues/171
1381 if ndk_path and ndk_path[0] != '$':
1383 props += "ndk.dir=%s\n" % ndk_path
1384 props += "ndk-location=%s\n" % ndk_path
1385 # Add java.encoding if necessary
1387 props += "java.encoding=%s\n" % build.encoding
1388 with open(path, 'w', encoding='iso-8859-1') as f:
1392 if build.build_method() == 'gradle':
1393 flavours = build.gradle
1396 n = build.target.split('-')[1]
1397 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1398 r'compileSdkVersion %s' % n,
1399 os.path.join(root_dir, 'build.gradle'))
1401 # Remove forced debuggable flags
1402 remove_debuggable_flags(root_dir)
1404 # Insert version code and number into the manifest if necessary
1405 if build.forceversion:
1406 logging.info("Changing the version name")
1407 for path in manifest_paths(root_dir, flavours):
1408 if not os.path.isfile(path):
1410 if has_extension(path, 'xml'):
1411 regsub_file(r'android:versionName="[^"]*"',
1412 r'android:versionName="%s"' % build.version,
1414 elif has_extension(path, 'gradle'):
1415 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1416 r"""\1versionName '%s'""" % build.version,
1419 if build.forcevercode:
1420 logging.info("Changing the version code")
1421 for path in manifest_paths(root_dir, flavours):
1422 if not os.path.isfile(path):
1424 if has_extension(path, 'xml'):
1425 regsub_file(r'android:versionCode="[^"]*"',
1426 r'android:versionCode="%s"' % build.vercode,
1428 elif has_extension(path, 'gradle'):
1429 regsub_file(r'versionCode[ =]+[0-9]+',
1430 r'versionCode %s' % build.vercode,
1433 # Delete unwanted files
1435 logging.info("Removing specified files")
1436 for part in getpaths(build_dir, build.rm):
1437 dest = os.path.join(build_dir, part)
1438 logging.info("Removing {0}".format(part))
1439 if os.path.lexists(dest):
1440 if os.path.islink(dest):
1441 FDroidPopen(['unlink', dest], output=False)
1443 FDroidPopen(['rm', '-rf', dest], output=False)
1445 logging.info("...but it didn't exist")
1447 remove_signing_keys(build_dir)
1449 # Add required external libraries
1451 logging.info("Collecting prebuilt libraries")
1452 libsdir = os.path.join(root_dir, 'libs')
1453 if not os.path.exists(libsdir):
1455 for lib in build.extlibs:
1457 logging.info("...installing extlib {0}".format(lib))
1458 libf = os.path.basename(lib)
1459 libsrc = os.path.join(extlib_dir, lib)
1460 if not os.path.exists(libsrc):
1461 raise BuildException("Missing extlib file {0}".format(libsrc))
1462 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1464 # Run a pre-build command if one is required
1466 logging.info("Running 'prebuild' commands in %s" % root_dir)
1468 cmd = replace_config_vars(build.prebuild, build)
1470 # Substitute source library paths into prebuild commands
1471 for name, number, libpath in srclibpaths:
1472 libpath = os.path.relpath(libpath, root_dir)
1473 cmd = cmd.replace('$$' + name + '$$', libpath)
1475 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1476 if p.returncode != 0:
1477 raise BuildException("Error running prebuild command for %s:%s" %
1478 (app.id, build.version), p.output)
1480 # Generate (or update) the ant build file, build.xml...
1481 if build.build_method() == 'ant' and build.update != ['no']:
1482 parms = ['android', 'update', 'lib-project']
1483 lparms = ['android', 'update', 'project']
1486 parms += ['-t', build.target]
1487 lparms += ['-t', build.target]
1489 update_dirs = build.update
1491 update_dirs = ant_subprojects(root_dir) + ['.']
1493 for d in update_dirs:
1494 subdir = os.path.join(root_dir, d)
1496 logging.debug("Updating main project")
1497 cmd = parms + ['-p', d]
1499 logging.debug("Updating subproject %s" % d)
1500 cmd = lparms + ['-p', d]
1501 p = SdkToolsPopen(cmd, cwd=root_dir)
1502 # Check to see whether an error was returned without a proper exit
1503 # code (this is the case for the 'no target set or target invalid'
1505 if p.returncode != 0 or p.output.startswith("Error: "):
1506 raise BuildException("Failed to update project at %s" % d, p.output)
1507 # Clean update dirs via ant
1509 logging.info("Cleaning subproject %s" % d)
1510 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1512 return (root_dir, srclibpaths)
1515 # Extend via globbing the paths from a field and return them as a map from
1516 # original path to resulting paths
1517 def getpaths_map(build_dir, globpaths):
1521 full_path = os.path.join(build_dir, p)
1522 full_path = os.path.normpath(full_path)
1523 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1525 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1529 # Extend via globbing the paths from a field and return them as a set
1530 def getpaths(build_dir, globpaths):
1531 paths_map = getpaths_map(build_dir, globpaths)
1533 for k, v in paths_map.items():
1540 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1546 self.path = os.path.join('stats', 'known_apks.txt')
1548 if os.path.isfile(self.path):
1549 with open(self.path, 'r', encoding='utf8') as f:
1551 t = line.rstrip().split(' ')
1553 self.apks[t[0]] = (t[1], None)
1555 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1556 self.changed = False
1558 def writeifchanged(self):
1559 if not self.changed:
1562 if not os.path.exists('stats'):
1566 for apk, app in self.apks.items():
1568 line = apk + ' ' + appid
1570 line += ' ' + time.strftime('%Y-%m-%d', added)
1573 with open(self.path, 'w', encoding='utf8') as f:
1574 for line in sorted(lst, key=natural_key):
1575 f.write(line + '\n')
1577 # Record an apk (if it's new, otherwise does nothing)
1578 # Returns the date it was added.
1579 def recordapk(self, apk, app, default_date=None):
1580 if apk not in self.apks:
1581 if default_date is None:
1582 default_date = time.gmtime(time.time())
1583 self.apks[apk] = (app, default_date)
1585 _, added = self.apks[apk]
1588 # Look up information - given the 'apkname', returns (app id, date added/None).
1589 # Or returns None for an unknown apk.
1590 def getapp(self, apkname):
1591 if apkname in self.apks:
1592 return self.apks[apkname]
1595 # Get the most recent 'num' apps added to the repo, as a list of package ids
1596 # with the most recent first.
1597 def getlatest(self, num):
1599 for apk, app in self.apks.items():
1603 if apps[appid] > added:
1607 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1608 lst = [app for app, _ in sortedapps]
1613 def isApkDebuggable(apkfile, config):
1614 """Returns True if the given apk file is debuggable
1616 :param apkfile: full path to the apk to check"""
1618 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1620 if p.returncode != 0:
1621 logging.critical("Failed to get apk manifest information")
1623 for line in p.output.splitlines():
1624 if 'android:debuggable' in line and not line.endswith('0x0'):
1631 self.returncode = None
1635 def SdkToolsPopen(commands, cwd=None, output=True):
1637 if cmd not in config:
1638 config[cmd] = find_sdk_tools_cmd(commands[0])
1639 abscmd = config[cmd]
1641 logging.critical("Could not find '%s' on your system" % cmd)
1643 return FDroidPopen([abscmd] + commands[1:],
1644 cwd=cwd, output=output)
1647 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1649 Run a command and capture the possibly huge output as bytes.
1651 :param commands: command and argument list like in subprocess.Popen
1652 :param cwd: optionally specifies a working directory
1653 :returns: A PopenResult.
1658 set_FDroidPopen_env()
1661 cwd = os.path.normpath(cwd)
1662 logging.debug("Directory: %s" % cwd)
1663 logging.debug("> %s" % ' '.join(commands))
1665 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1666 result = PopenResult()
1669 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1670 stdout=subprocess.PIPE, stderr=stderr_param)
1671 except OSError as e:
1672 raise BuildException("OSError while trying to execute " +
1673 ' '.join(commands) + ': ' + str(e))
1675 if not stderr_to_stdout and options.verbose:
1676 stderr_queue = Queue()
1677 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1679 while not stderr_reader.eof():
1680 while not stderr_queue.empty():
1681 line = stderr_queue.get()
1682 sys.stderr.buffer.write(line)
1687 stdout_queue = Queue()
1688 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1691 # Check the queue for output (until there is no more to get)
1692 while not stdout_reader.eof():
1693 while not stdout_queue.empty():
1694 line = stdout_queue.get()
1695 if output and options.verbose:
1696 # Output directly to console
1697 sys.stderr.buffer.write(line)
1703 result.returncode = p.wait()
1704 result.output = buf.getvalue()
1709 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1711 Run a command and capture the possibly huge output as a str.
1713 :param commands: command and argument list like in subprocess.Popen
1714 :param cwd: optionally specifies a working directory
1715 :returns: A PopenResult.
1717 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1718 result.output = result.output.decode('utf-8')
1722 gradle_comment = re.compile(r'[ ]*//')
1723 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1724 gradle_line_matches = [
1725 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1726 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1727 re.compile(r'.*\.readLine\(.*'),
1731 def remove_signing_keys(build_dir):
1732 for root, dirs, files in os.walk(build_dir):
1733 if 'build.gradle' in files:
1734 path = os.path.join(root, 'build.gradle')
1736 with open(path, "r", encoding='utf8') as o:
1737 lines = o.readlines()
1743 with open(path, "w", encoding='utf8') as o:
1744 while i < len(lines):
1747 while line.endswith('\\\n'):
1748 line = line.rstrip('\\\n') + lines[i]
1751 if gradle_comment.match(line):
1756 opened += line.count('{')
1757 opened -= line.count('}')
1760 if gradle_signing_configs.match(line):
1765 if any(s.match(line) for s in gradle_line_matches):
1773 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1776 'project.properties',
1778 'default.properties',
1779 'ant.properties', ]:
1780 if propfile in files:
1781 path = os.path.join(root, propfile)
1783 with open(path, "r", encoding='iso-8859-1') as o:
1784 lines = o.readlines()
1788 with open(path, "w", encoding='iso-8859-1') as o:
1790 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1797 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1800 def set_FDroidPopen_env(build=None):
1802 set up the environment variables for the build environment
1804 There is only a weak standard, the variables used by gradle, so also set
1805 up the most commonly used environment variables for SDK and NDK. Also, if
1806 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1808 global env, orig_path
1812 orig_path = env['PATH']
1813 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1814 env[n] = config['sdk_path']
1815 for k, v in config['java_paths'].items():
1816 env['JAVA%s_HOME' % k] = v
1818 missinglocale = True
1819 for k, v in env.items():
1820 if k == 'LANG' and v != 'C':
1821 missinglocale = False
1823 missinglocale = False
1825 env['LANG'] = 'en_US.UTF-8'
1827 if build is not None:
1828 path = build.ndk_path()
1829 paths = orig_path.split(os.pathsep)
1830 if path not in paths:
1831 paths = [path] + paths
1832 env['PATH'] = os.pathsep.join(paths)
1833 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1834 env[n] = build.ndk_path()
1837 def replace_config_vars(cmd, build):
1838 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1839 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1840 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1841 if build is not None:
1842 cmd = cmd.replace('$$COMMIT$$', build.commit)
1843 cmd = cmd.replace('$$VERSION$$', build.version)
1844 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1848 def place_srclib(root_dir, number, libpath):
1851 relpath = os.path.relpath(libpath, root_dir)
1852 proppath = os.path.join(root_dir, 'project.properties')
1855 if os.path.isfile(proppath):
1856 with open(proppath, "r", encoding='iso-8859-1') as o:
1857 lines = o.readlines()
1859 with open(proppath, "w", encoding='iso-8859-1') as o:
1862 if line.startswith('android.library.reference.%d=' % number):
1863 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1868 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1870 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1873 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1874 """Verify that two apks are the same
1876 One of the inputs is signed, the other is unsigned. The signature metadata
1877 is transferred from the signed to the unsigned apk, and then jarsigner is
1878 used to verify that the signature from the signed apk is also varlid for
1880 :param signed_apk: Path to a signed apk file
1881 :param unsigned_apk: Path to an unsigned apk file expected to match it
1882 :param tmp_dir: Path to directory for temporary files
1883 :returns: None if the verification is successful, otherwise a string
1884 describing what went wrong.
1886 with ZipFile(signed_apk) as signed_apk_as_zip:
1887 meta_inf_files = ['META-INF/MANIFEST.MF']
1888 for f in signed_apk_as_zip.namelist():
1889 if apk_sigfile.match(f):
1890 meta_inf_files.append(f)
1891 if len(meta_inf_files) < 3:
1892 return "Signature files missing from {0}".format(signed_apk)
1893 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1894 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1895 for meta_inf_file in meta_inf_files:
1896 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1898 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1899 logging.info("...NOT verified - {0}".format(signed_apk))
1900 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1901 logging.info("...successfully verified")
1904 apk_badchars = re.compile('''[/ :;'"]''')
1907 def compare_apks(apk1, apk2, tmp_dir):
1910 Returns None if the apk content is the same (apart from the signing key),
1911 otherwise a string describing what's different, or what went wrong when
1912 trying to do the comparison.
1915 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1916 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1917 for d in [apk1dir, apk2dir]:
1918 if os.path.exists(d):
1921 os.mkdir(os.path.join(d, 'jar-xf'))
1923 if subprocess.call(['jar', 'xf',
1924 os.path.abspath(apk1)],
1925 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1926 return("Failed to unpack " + apk1)
1927 if subprocess.call(['jar', 'xf',
1928 os.path.abspath(apk2)],
1929 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1930 return("Failed to unpack " + apk2)
1932 # try to find apktool in the path, if it hasn't been manually configed
1933 if 'apktool' not in config:
1934 tmp = find_command('apktool')
1936 config['apktool'] = tmp
1937 if 'apktool' in config:
1938 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1940 return("Failed to unpack " + apk1)
1941 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1943 return("Failed to unpack " + apk2)
1945 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1946 lines = p.output.splitlines()
1947 if len(lines) != 1 or 'META-INF' not in lines[0]:
1948 meld = find_command('meld')
1949 if meld is not None:
1950 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1951 return("Unexpected diff output - " + p.output)
1953 # since everything verifies, delete the comparison to keep cruft down
1954 shutil.rmtree(apk1dir)
1955 shutil.rmtree(apk2dir)
1957 # If we get here, it seems like they're the same!
1961 def find_command(command):
1962 '''find the full path of a command, or None if it can't be found in the PATH'''
1965 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1967 fpath, fname = os.path.split(command)
1972 for path in os.environ["PATH"].split(os.pathsep):
1973 path = path.strip('"')
1974 exe_file = os.path.join(path, command)
1975 if is_exe(exe_file):
1982 '''generate a random password for when generating keys'''
1983 h = hashlib.sha256()
1984 h.update(os.urandom(16)) # salt
1985 h.update(socket.getfqdn().encode('utf-8'))
1986 passwd = base64.b64encode(h.digest()).strip()
1987 return passwd.decode('utf-8')
1990 def genkeystore(localconfig):
1991 '''Generate a new key with random passwords and add it to new keystore'''
1992 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1993 keystoredir = os.path.dirname(localconfig['keystore'])
1994 if keystoredir is None or keystoredir == '':
1995 keystoredir = os.path.join(os.getcwd(), keystoredir)
1996 if not os.path.exists(keystoredir):
1997 os.makedirs(keystoredir, mode=0o700)
1999 write_password_file("keystorepass", localconfig['keystorepass'])
2000 write_password_file("keypass", localconfig['keypass'])
2001 p = FDroidPopen([config['keytool'], '-genkey',
2002 '-keystore', localconfig['keystore'],
2003 '-alias', localconfig['repo_keyalias'],
2004 '-keyalg', 'RSA', '-keysize', '4096',
2005 '-sigalg', 'SHA256withRSA',
2006 '-validity', '10000',
2007 '-storepass:file', config['keystorepassfile'],
2008 '-keypass:file', config['keypassfile'],
2009 '-dname', localconfig['keydname']])
2010 # TODO keypass should be sent via stdin
2011 if p.returncode != 0:
2012 raise BuildException("Failed to generate key", p.output)
2013 os.chmod(localconfig['keystore'], 0o0600)
2014 # now show the lovely key that was just generated
2015 p = FDroidPopen([config['keytool'], '-list', '-v',
2016 '-keystore', localconfig['keystore'],
2017 '-alias', localconfig['repo_keyalias'],
2018 '-storepass:file', config['keystorepassfile']])
2019 logging.info(p.output.strip() + '\n\n')
2022 def write_to_config(thisconfig, key, value=None):
2023 '''write a key/value to the local config.py'''
2025 origkey = key + '_orig'
2026 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2027 with open('config.py', 'r', encoding='utf8') as f:
2029 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2030 repl = '\n' + key + ' = "' + value + '"'
2031 data = re.sub(pattern, repl, data)
2032 # if this key is not in the file, append it
2033 if not re.match('\s*' + key + '\s*=\s*"', data):
2035 # make sure the file ends with a carraige return
2036 if not re.match('\n$', data):
2038 with open('config.py', 'w', encoding='utf8') as f:
2042 def parse_xml(path):
2043 return XMLElementTree.parse(path).getroot()
2046 def string_is_integer(string):
2054 def get_per_app_repos():
2055 '''per-app repos are dirs named with the packageName of a single app'''
2057 # Android packageNames are Java packages, they may contain uppercase or
2058 # lowercase letters ('A' through 'Z'), numbers, and underscores
2059 # ('_'). However, individual package name parts may only start with
2060 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2061 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2064 for root, dirs, files in os.walk(os.getcwd()):
2066 print('checking', root, 'for', d)
2067 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2068 # standard parts of an fdroid repo, so never packageNames
2071 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):