1 # -*- coding: utf-8 -*-
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.
36 import xml.etree.ElementTree as XMLElementTree
38 from queue import Queue
40 from zipfile import ZipFile
42 import fdroidserver.metadata
43 from .asynchronousfilereader import AsynchronousFileReader
46 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
55 'sdk_path': "$ANDROID_HOME",
58 'r10e': "$ANDROID_NDK",
60 'build_tools': "23.0.2",
65 'accepted_formats': ['txt', 'yaml'],
66 'sync_from_local_copy_dir': False,
67 'per_app_repos': False,
68 'make_current_version_link': True,
69 'current_version_name_source': 'Name',
70 'update_stats': False,
74 'stats_to_carbon': False,
76 'build_server_always': False,
77 'keystore': 'keystore.jks',
78 'smartcardoptions': [],
84 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
85 'repo_name': "My First FDroid Repo Demo",
86 'repo_icon': "fdroid-icon.png",
87 'repo_description': '''
88 This is a repository of apps to be used with FDroid. Applications in this
89 repository are either official binaries built by the original application
90 developers, or are binaries built from source by the admin of f-droid.org
91 using the tools on https://gitlab.com/u/fdroid.
97 def setup_global_opts(parser):
98 parser.add_argument("-v", "--verbose", action="store_true", default=False,
99 help="Spew out even more information than normal")
100 parser.add_argument("-q", "--quiet", action="store_true", default=False,
101 help="Restrict output to warnings and errors")
104 def fill_config_defaults(thisconfig):
105 for k, v in default_config.items():
106 if k not in thisconfig:
109 # Expand paths (~users and $vars)
110 def expand_path(path):
114 path = os.path.expanduser(path)
115 path = os.path.expandvars(path)
120 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
125 thisconfig[k + '_orig'] = v
127 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
128 if thisconfig['java_paths'] is None:
129 thisconfig['java_paths'] = dict()
130 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
131 + glob.glob('/usr/java/jdk1.[6-9]*')
132 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
133 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
134 if os.path.islink(d):
136 j = os.path.basename(d)
137 # the last one found will be the canonical one, so order appropriately
139 r'^1\.([6-9])\.0\.jdk$', # OSX
140 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
141 r'^jdk([6-9])-openjdk$', # Arch
142 r'^java-([6-9])-openjdk$', # Arch
143 r'^java-([6-9])-jdk$', # Arch (oracle)
144 r'^java-1\.([6-9])\.0-.*$', # RedHat
145 r'^java-([6-9])-oracle$', # Debian WebUpd8
146 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
147 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
149 m = re.match(regex, j)
152 osxhome = os.path.join(d, 'Contents', 'Home')
153 if os.path.exists(osxhome):
154 thisconfig['java_paths'][m.group(1)] = osxhome
156 thisconfig['java_paths'][m.group(1)] = d
158 for java_version in ('7', '8', '9'):
159 if java_version not in thisconfig['java_paths']:
161 java_home = thisconfig['java_paths'][java_version]
162 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
163 if os.path.exists(jarsigner):
164 thisconfig['jarsigner'] = jarsigner
165 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
166 break # Java7 is preferred, so quit if found
168 for k in ['ndk_paths', 'java_paths']:
174 thisconfig[k][k2] = exp
175 thisconfig[k][k2 + '_orig'] = v
178 def regsub_file(pattern, repl, path):
179 with open(path, 'r') as f:
181 text = re.sub(pattern, repl, text)
182 with open(path, 'w') as f:
186 def read_config(opts, config_file='config.py'):
187 """Read the repository config
189 The config is read from config_file, which is in the current directory when
190 any of the repo management commands are used.
192 global config, options, env, orig_path
194 if config is not None:
196 if not os.path.isfile(config_file):
197 logging.critical("Missing config file - is this a repo directory?")
204 logging.debug("Reading %s" % config_file)
205 with io.open(config_file, "rb") as f:
206 code = compile(f.read(), config_file, 'exec')
207 exec(code, None, config)
209 # smartcardoptions must be a list since its command line args for Popen
210 if 'smartcardoptions' in config:
211 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
212 elif 'keystore' in config and config['keystore'] == 'NONE':
213 # keystore='NONE' means use smartcard, these are required defaults
214 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
215 'SunPKCS11-OpenSC', '-providerClass',
216 'sun.security.pkcs11.SunPKCS11',
217 '-providerArg', 'opensc-fdroid.cfg']
219 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
220 st = os.stat(config_file)
221 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
222 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
224 fill_config_defaults(config)
226 # There is no standard, so just set up the most common environment
229 orig_path = env['PATH']
230 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
231 env[n] = config['sdk_path']
233 for k, v in config['java_paths'].items():
234 env['JAVA%s_HOME' % k] = v
236 for k in ["keystorepass", "keypass"]:
238 write_password_file(k)
240 for k in ["repo_description", "archive_description"]:
242 config[k] = clean_description(config[k])
244 if 'serverwebroot' in config:
245 if isinstance(config['serverwebroot'], str):
246 roots = [config['serverwebroot']]
247 elif all(isinstance(item, str) for item in config['serverwebroot']):
248 roots = config['serverwebroot']
250 raise TypeError('only accepts strings, lists, and tuples')
252 for rootstr in roots:
253 # since this is used with rsync, where trailing slashes have
254 # meaning, ensure there is always a trailing slash
255 if rootstr[-1] != '/':
257 rootlist.append(rootstr.replace('//', '/'))
258 config['serverwebroot'] = rootlist
263 def find_sdk_tools_cmd(cmd):
264 '''find a working path to a tool from the Android SDK'''
267 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
268 # try to find a working path to this command, in all the recent possible paths
269 if 'build_tools' in config:
270 build_tools = os.path.join(config['sdk_path'], 'build-tools')
271 # if 'build_tools' was manually set and exists, check only that one
272 configed_build_tools = os.path.join(build_tools, config['build_tools'])
273 if os.path.exists(configed_build_tools):
274 tooldirs.append(configed_build_tools)
276 # no configed version, so hunt known paths for it
277 for f in sorted(os.listdir(build_tools), reverse=True):
278 if os.path.isdir(os.path.join(build_tools, f)):
279 tooldirs.append(os.path.join(build_tools, f))
280 tooldirs.append(build_tools)
281 sdk_tools = os.path.join(config['sdk_path'], 'tools')
282 if os.path.exists(sdk_tools):
283 tooldirs.append(sdk_tools)
284 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
285 if os.path.exists(sdk_platform_tools):
286 tooldirs.append(sdk_platform_tools)
287 tooldirs.append('/usr/bin')
289 if os.path.isfile(os.path.join(d, cmd)):
290 return os.path.join(d, cmd)
291 # did not find the command, exit with error message
292 ensure_build_tools_exists(config)
295 def test_sdk_exists(thisconfig):
296 if 'sdk_path' not in thisconfig:
297 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
300 logging.error("'sdk_path' not set in config.py!")
302 if thisconfig['sdk_path'] == default_config['sdk_path']:
303 logging.error('No Android SDK found!')
304 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
305 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
307 if not os.path.exists(thisconfig['sdk_path']):
308 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
310 if not os.path.isdir(thisconfig['sdk_path']):
311 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
313 for d in ['build-tools', 'platform-tools', 'tools']:
314 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
315 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
316 thisconfig['sdk_path'], d))
321 def ensure_build_tools_exists(thisconfig):
322 if not test_sdk_exists(thisconfig):
324 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
325 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
326 if not os.path.isdir(versioned_build_tools):
327 logging.critical('Android Build Tools path "'
328 + versioned_build_tools + '" does not exist!')
332 def write_password_file(pwtype, password=None):
334 writes out passwords to a protected file instead of passing passwords as
335 command line argments
337 filename = '.fdroid.' + pwtype + '.txt'
338 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
340 os.write(fd, config[pwtype])
342 os.write(fd, password)
344 config[pwtype + 'file'] = filename
347 # Given the arguments in the form of multiple appid:[vc] strings, this returns
348 # a dictionary with the set of vercodes specified for each package.
349 def read_pkg_args(args, allow_vercodes=False):
356 if allow_vercodes and ':' in p:
357 package, vercode = p.split(':')
359 package, vercode = p, None
360 if package not in vercodes:
361 vercodes[package] = [vercode] if vercode else []
363 elif vercode and vercode not in vercodes[package]:
364 vercodes[package] += [vercode] if vercode else []
369 # On top of what read_pkg_args does, this returns the whole app metadata, but
370 # limiting the builds list to the builds matching the vercodes specified.
371 def read_app_args(args, allapps, allow_vercodes=False):
373 vercodes = read_pkg_args(args, allow_vercodes)
379 for appid, app in allapps.items():
380 if appid in vercodes:
383 if len(apps) != len(vercodes):
386 logging.critical("No such package: %s" % p)
387 raise FDroidException("Found invalid app ids in arguments")
389 raise FDroidException("No packages specified")
392 for appid, app in apps.items():
396 app.builds = [b for b in app.builds if b.vercode in vc]
397 if len(app.builds) != len(vercodes[appid]):
399 allvcs = [b.vercode for b in app.builds]
400 for v in vercodes[appid]:
402 logging.critical("No such vercode %s for app %s" % (v, appid))
405 raise FDroidException("Found invalid vercodes for some apps")
410 def get_extension(filename):
411 base, ext = os.path.splitext(filename)
414 return base, ext.lower()[1:]
417 def has_extension(filename, ext):
418 _, f_ext = get_extension(filename)
422 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
425 def clean_description(description):
426 'Remove unneeded newlines and spaces from a block of description text'
428 # this is split up by paragraph to make removing the newlines easier
429 for paragraph in re.split(r'\n\n', description):
430 paragraph = re.sub('\r', '', paragraph)
431 paragraph = re.sub('\n', ' ', paragraph)
432 paragraph = re.sub(' {2,}', ' ', paragraph)
433 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
434 returnstring += paragraph + '\n\n'
435 return returnstring.rstrip('\n')
438 def apknameinfo(filename):
439 filename = os.path.basename(filename)
440 m = apk_regex.match(filename)
442 result = (m.group(1), m.group(2))
443 except AttributeError:
444 raise FDroidException("Invalid apk name: %s" % filename)
448 def getapkname(app, build):
449 return "%s_%s.apk" % (app.id, build.vercode)
452 def getsrcname(app, build):
453 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
465 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
468 def getvcs(vcstype, remote, local):
470 return vcs_git(remote, local)
471 if vcstype == 'git-svn':
472 return vcs_gitsvn(remote, local)
474 return vcs_hg(remote, local)
476 return vcs_bzr(remote, local)
477 if vcstype == 'srclib':
478 if local != os.path.join('build', 'srclib', remote):
479 raise VCSException("Error: srclib paths are hard-coded!")
480 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
482 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
483 raise VCSException("Invalid vcs type " + vcstype)
486 def getsrclibvcs(name):
487 if name not in fdroidserver.metadata.srclibs:
488 raise VCSException("Missing srclib " + name)
489 return fdroidserver.metadata.srclibs[name]['Repo Type']
494 def __init__(self, remote, local):
496 # svn, git-svn and bzr may require auth
498 if self.repotype() in ('git-svn', 'bzr'):
500 if self.repotype == 'git-svn':
501 raise VCSException("Authentication is not supported for git-svn")
502 self.username, remote = remote.split('@')
503 if ':' not in self.username:
504 raise VCSException("Password required with username")
505 self.username, self.password = self.username.split(':')
509 self.clone_failed = False
510 self.refreshed = False
516 # Take the local repository to a clean version of the given revision, which
517 # is specificed in the VCS's native format. Beforehand, the repository can
518 # be dirty, or even non-existent. If the repository does already exist
519 # locally, it will be updated from the origin, but only once in the
520 # lifetime of the vcs object.
521 # None is acceptable for 'rev' if you know you are cloning a clean copy of
522 # the repo - otherwise it must specify a valid revision.
523 def gotorevision(self, rev, refresh=True):
525 if self.clone_failed:
526 raise VCSException("Downloading the repository already failed once, not trying again.")
528 # The .fdroidvcs-id file for a repo tells us what VCS type
529 # and remote that directory was created from, allowing us to drop it
530 # automatically if either of those things changes.
531 fdpath = os.path.join(self.local, '..',
532 '.fdroidvcs-' + os.path.basename(self.local))
533 cdata = self.repotype() + ' ' + self.remote
536 if os.path.exists(self.local):
537 if os.path.exists(fdpath):
538 with open(fdpath, 'r') as f:
539 fsdata = f.read().strip()
544 logging.info("Repository details for %s changed - deleting" % (
548 logging.info("Repository details for %s missing - deleting" % (
551 shutil.rmtree(self.local)
555 self.refreshed = True
558 self.gotorevisionx(rev)
559 except FDroidException as e:
562 # If necessary, write the .fdroidvcs file.
563 if writeback and not self.clone_failed:
564 with open(fdpath, 'w') as f:
570 # Derived classes need to implement this. It's called once basic checking
571 # has been performend.
572 def gotorevisionx(self, rev):
573 raise VCSException("This VCS type doesn't define gotorevisionx")
575 # Initialise and update submodules
576 def initsubmodules(self):
577 raise VCSException('Submodules not supported for this vcs type')
579 # Get a list of all known tags
581 if not self._gettags:
582 raise VCSException('gettags not supported for this vcs type')
584 for tag in self._gettags():
585 if re.match('[-A-Za-z0-9_. /]+$', tag):
589 def latesttags(self, tags, number):
590 """Get the most recent tags in a given list.
592 :param tags: a list of tags
593 :param number: the number to return
594 :returns: A list containing the most recent tags in the provided
595 list, up to the maximum number given.
597 raise VCSException('latesttags not supported for this vcs type')
599 # Get current commit reference (hash, revision, etc)
601 raise VCSException('getref not supported for this vcs type')
603 # Returns the srclib (name, path) used in setting up the current
614 # If the local directory exists, but is somehow not a git repository, git
615 # will traverse up the directory tree until it finds one that is (i.e.
616 # fdroidserver) and then we'll proceed to destroy it! This is called as
619 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
620 result = p.output.rstrip()
621 if not result.endswith(self.local):
622 raise VCSException('Repository mismatch')
624 def gotorevisionx(self, rev):
625 if not os.path.exists(self.local):
627 p = FDroidPopen(['git', 'clone', self.remote, self.local])
628 if p.returncode != 0:
629 self.clone_failed = True
630 raise VCSException("Git clone failed", p.output)
634 # Discard any working tree changes
635 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
636 'git', 'reset', '--hard'], cwd=self.local, output=False)
637 if p.returncode != 0:
638 raise VCSException("Git reset failed", p.output)
639 # Remove untracked files now, in case they're tracked in the target
640 # revision (it happens!)
641 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
642 'git', 'clean', '-dffx'], cwd=self.local, output=False)
643 if p.returncode != 0:
644 raise VCSException("Git clean failed", p.output)
645 if not self.refreshed:
646 # Get latest commits and tags from remote
647 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
648 if p.returncode != 0:
649 raise VCSException("Git fetch failed", p.output)
650 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
651 if p.returncode != 0:
652 raise VCSException("Git fetch failed", p.output)
653 # Recreate origin/HEAD as git clone would do it, in case it disappeared
654 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
655 if p.returncode != 0:
656 lines = p.output.splitlines()
657 if 'Multiple remote HEAD branches' not in lines[0]:
658 raise VCSException("Git remote set-head failed", p.output)
659 branch = lines[1].split(' ')[-1]
660 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
661 if p2.returncode != 0:
662 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
663 self.refreshed = True
664 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
665 # a github repo. Most of the time this is the same as origin/master.
666 rev = rev or 'origin/HEAD'
667 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
668 if p.returncode != 0:
669 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
670 # Get rid of any uncontrolled files left behind
671 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
672 if p.returncode != 0:
673 raise VCSException("Git clean failed", p.output)
675 def initsubmodules(self):
677 submfile = os.path.join(self.local, '.gitmodules')
678 if not os.path.isfile(submfile):
679 raise VCSException("No git submodules available")
681 # fix submodules not accessible without an account and public key auth
682 with open(submfile, 'r') as f:
683 lines = f.readlines()
684 with open(submfile, 'w') as f:
686 if 'git@github.com' in line:
687 line = line.replace('git@github.com:', 'https://github.com/')
688 if 'git@gitlab.com' in line:
689 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
692 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
693 if p.returncode != 0:
694 raise VCSException("Git submodule sync failed", p.output)
695 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
696 if p.returncode != 0:
697 raise VCSException("Git submodule update failed", p.output)
701 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
702 return p.output.splitlines()
704 def latesttags(self, tags, number):
709 ['git', 'show', '--format=format:%ct', '-s', tag],
710 cwd=self.local, output=False)
711 # Timestamp is on the last line. For a normal tag, it's the only
712 # line, but for annotated tags, the rest of the info precedes it.
713 ts = int(p.output.splitlines()[-1])
716 for _, t in sorted(tl)[-number:]:
721 class vcs_gitsvn(vcs):
726 # If the local directory exists, but is somehow not a git repository, git
727 # will traverse up the directory tree until it finds one that is (i.e.
728 # fdroidserver) and then we'll proceed to destory it! This is called as
731 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
732 result = p.output.rstrip()
733 if not result.endswith(self.local):
734 raise VCSException('Repository mismatch')
736 def gotorevisionx(self, rev):
737 if not os.path.exists(self.local):
739 gitsvn_args = ['git', 'svn', 'clone']
740 if ';' in self.remote:
741 remote_split = self.remote.split(';')
742 for i in remote_split[1:]:
743 if i.startswith('trunk='):
744 gitsvn_args.extend(['-T', i[6:]])
745 elif i.startswith('tags='):
746 gitsvn_args.extend(['-t', i[5:]])
747 elif i.startswith('branches='):
748 gitsvn_args.extend(['-b', i[9:]])
749 gitsvn_args.extend([remote_split[0], self.local])
750 p = FDroidPopen(gitsvn_args, output=False)
751 if p.returncode != 0:
752 self.clone_failed = True
753 raise VCSException("Git svn clone failed", p.output)
755 gitsvn_args.extend([self.remote, self.local])
756 p = FDroidPopen(gitsvn_args, output=False)
757 if p.returncode != 0:
758 self.clone_failed = True
759 raise VCSException("Git svn clone failed", p.output)
763 # Discard any working tree changes
764 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git reset failed", p.output)
767 # Remove untracked files now, in case they're tracked in the target
768 # revision (it happens!)
769 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
770 if p.returncode != 0:
771 raise VCSException("Git clean failed", p.output)
772 if not self.refreshed:
773 # Get new commits, branches and tags from repo
774 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
775 if p.returncode != 0:
776 raise VCSException("Git svn fetch failed")
777 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
778 if p.returncode != 0:
779 raise VCSException("Git svn rebase failed", p.output)
780 self.refreshed = True
782 rev = rev or 'master'
784 nospaces_rev = rev.replace(' ', '%20')
785 # Try finding a svn tag
786 for treeish in ['origin/', '']:
787 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
788 if p.returncode == 0:
790 if p.returncode != 0:
791 # No tag found, normal svn rev translation
792 # Translate svn rev into git format
793 rev_split = rev.split('/')
796 for treeish in ['origin/', '']:
797 if len(rev_split) > 1:
798 treeish += rev_split[0]
799 svn_rev = rev_split[1]
802 # if no branch is specified, then assume trunk (i.e. 'master' branch):
806 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
808 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
809 git_rev = p.output.rstrip()
811 if p.returncode == 0 and git_rev:
814 if p.returncode != 0 or not git_rev:
815 # Try a plain git checkout as a last resort
816 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
817 if p.returncode != 0:
818 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
820 # Check out the git rev equivalent to the svn rev
821 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
822 if p.returncode != 0:
823 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
825 # Get rid of any uncontrolled files left behind
826 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
827 if p.returncode != 0:
828 raise VCSException("Git clean failed", p.output)
832 for treeish in ['origin/', '']:
833 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
839 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
840 if p.returncode != 0:
842 return p.output.strip()
850 def gotorevisionx(self, rev):
851 if not os.path.exists(self.local):
852 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
853 if p.returncode != 0:
854 self.clone_failed = True
855 raise VCSException("Hg clone failed", p.output)
857 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
858 if p.returncode != 0:
859 raise VCSException("Hg status failed", p.output)
860 for line in p.output.splitlines():
861 if not line.startswith('? '):
862 raise VCSException("Unexpected output from hg status -uS: " + line)
863 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
864 if not self.refreshed:
865 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
866 if p.returncode != 0:
867 raise VCSException("Hg pull failed", p.output)
868 self.refreshed = True
870 rev = rev or 'default'
873 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
874 if p.returncode != 0:
875 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
876 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
877 # Also delete untracked files, we have to enable purge extension for that:
878 if "'purge' is provided by the following extension" in p.output:
879 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
880 myfile.write("\n[extensions]\nhgext.purge=\n")
881 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
882 if p.returncode != 0:
883 raise VCSException("HG purge failed", p.output)
884 elif p.returncode != 0:
885 raise VCSException("HG purge failed", p.output)
888 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
889 return p.output.splitlines()[1:]
897 def gotorevisionx(self, rev):
898 if not os.path.exists(self.local):
899 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
900 if p.returncode != 0:
901 self.clone_failed = True
902 raise VCSException("Bzr branch failed", p.output)
904 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
905 if p.returncode != 0:
906 raise VCSException("Bzr revert failed", p.output)
907 if not self.refreshed:
908 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
909 if p.returncode != 0:
910 raise VCSException("Bzr update failed", p.output)
911 self.refreshed = True
913 revargs = list(['-r', rev] if rev else [])
914 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
915 if p.returncode != 0:
916 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
919 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
920 return [tag.split(' ')[0].strip() for tag in
921 p.output.splitlines()]
924 def unescape_string(string):
927 if string[0] == '"' and string[-1] == '"':
930 return string.replace("\\'", "'")
933 def retrieve_string(app_dir, string, xmlfiles=None):
935 if not string.startswith('@string/'):
936 return unescape_string(string)
941 os.path.join(app_dir, 'res'),
942 os.path.join(app_dir, 'src', 'main', 'res'),
944 for r, d, f in os.walk(res_dir):
945 if os.path.basename(r) == 'values':
946 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
948 name = string[len('@string/'):]
950 def element_content(element):
951 if element.text is None:
953 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
956 for path in xmlfiles:
957 if not os.path.isfile(path):
959 xml = parse_xml(path)
960 element = xml.find('string[@name="' + name + '"]')
961 if element is not None:
962 content = element_content(element)
963 return retrieve_string(app_dir, content, xmlfiles)
968 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
969 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
972 # Return list of existing files that will be used to find the highest vercode
973 def manifest_paths(app_dir, flavours):
975 possible_manifests = \
976 [os.path.join(app_dir, 'AndroidManifest.xml'),
977 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
978 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
979 os.path.join(app_dir, 'build.gradle')]
981 for flavour in flavours:
984 possible_manifests.append(
985 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
987 return [path for path in possible_manifests if os.path.isfile(path)]
990 # Retrieve the package name. Returns the name, or None if not found.
991 def fetch_real_name(app_dir, flavours):
992 for path in manifest_paths(app_dir, flavours):
993 if not has_extension(path, 'xml') or not os.path.isfile(path):
995 logging.debug("fetch_real_name: Checking manifest at " + path)
996 xml = parse_xml(path)
997 app = xml.find('application')
1000 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1002 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
1003 result = retrieve_string_singleline(app_dir, label)
1005 result = result.strip()
1010 def get_library_references(root_dir):
1012 proppath = os.path.join(root_dir, 'project.properties')
1013 if not os.path.isfile(proppath):
1015 for line in file(proppath):
1016 if not line.startswith('android.library.reference.'):
1018 path = line.split('=')[1].strip()
1019 relpath = os.path.join(root_dir, path)
1020 if not os.path.isdir(relpath):
1022 logging.debug("Found subproject at %s" % path)
1023 libraries.append(path)
1027 def ant_subprojects(root_dir):
1028 subprojects = get_library_references(root_dir)
1029 for subpath in subprojects:
1030 subrelpath = os.path.join(root_dir, subpath)
1031 for p in get_library_references(subrelpath):
1032 relp = os.path.normpath(os.path.join(subpath, p))
1033 if relp not in subprojects:
1034 subprojects.insert(0, relp)
1038 def remove_debuggable_flags(root_dir):
1039 # Remove forced debuggable flags
1040 logging.debug("Removing debuggable flags from %s" % root_dir)
1041 for root, dirs, files in os.walk(root_dir):
1042 if 'AndroidManifest.xml' in files:
1043 regsub_file(r'android:debuggable="[^"]*"',
1045 os.path.join(root, 'AndroidManifest.xml'))
1048 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1049 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1050 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1053 def app_matches_packagename(app, package):
1056 appid = app.UpdateCheckName or app.id
1057 if appid is None or appid == "Ignore":
1059 return appid == package
1062 # Extract some information from the AndroidManifest.xml at the given path.
1063 # Returns (version, vercode, package), any or all of which might be None.
1064 # All values returned are strings.
1065 def parse_androidmanifests(paths, app):
1067 ignoreversions = app.UpdateCheckIgnore
1068 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1071 return (None, None, None)
1079 if not os.path.isfile(path):
1082 logging.debug("Parsing manifest at {0}".format(path))
1083 gradle = has_extension(path, 'gradle')
1089 for line in file(path):
1090 if gradle_comment.match(line):
1092 # Grab first occurence of each to avoid running into
1093 # alternative flavours and builds.
1095 matches = psearch_g(line)
1097 s = matches.group(2)
1098 if app_matches_packagename(app, s):
1101 matches = vnsearch_g(line)
1103 version = matches.group(2)
1105 matches = vcsearch_g(line)
1107 vercode = matches.group(1)
1110 xml = parse_xml(path)
1111 if "package" in xml.attrib:
1112 s = xml.attrib["package"].encode('utf-8')
1113 if app_matches_packagename(app, s):
1115 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1116 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1117 base_dir = os.path.dirname(path)
1118 version = retrieve_string_singleline(base_dir, version)
1119 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1120 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1121 if string_is_integer(a):
1124 logging.warning("Problem with xml at {0}".format(path))
1126 # Remember package name, may be defined separately from version+vercode
1128 package = max_package
1130 logging.debug("..got package={0}, version={1}, vercode={2}"
1131 .format(package, version, vercode))
1133 # Always grab the package name and version name in case they are not
1134 # together with the highest version code
1135 if max_package is None and package is not None:
1136 max_package = package
1137 if max_version is None and version is not None:
1138 max_version = version
1140 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1141 if not ignoresearch or not ignoresearch(version):
1142 if version is not None:
1143 max_version = version
1144 if vercode is not None:
1145 max_vercode = vercode
1146 if package is not None:
1147 max_package = package
1149 max_version = "Ignore"
1151 if max_version is None:
1152 max_version = "Unknown"
1154 if max_package and not is_valid_package_name(max_package):
1155 raise FDroidException("Invalid package name {0}".format(max_package))
1157 return (max_version, max_vercode, max_package)
1160 def is_valid_package_name(name):
1161 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1164 class FDroidException(Exception):
1166 def __init__(self, value, detail=None):
1168 self.detail = detail
1170 def shortened_detail(self):
1171 if len(self.detail) < 16000:
1173 return '[...]\n' + self.detail[-16000:]
1175 def get_wikitext(self):
1176 ret = repr(self.value) + "\n"
1179 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1185 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1189 class VCSException(FDroidException):
1193 class BuildException(FDroidException):
1197 # Get the specified source library.
1198 # Returns the path to it. Normally this is the path to be used when referencing
1199 # it, which may be a subdirectory of the actual project. If you want the base
1200 # directory of the project, pass 'basepath=True'.
1201 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1202 raw=False, prepare=True, preponly=False, refresh=True):
1210 name, ref = spec.split('@')
1212 number, name = name.split(':', 1)
1214 name, subdir = name.split('/', 1)
1216 if name not in fdroidserver.metadata.srclibs:
1217 raise VCSException('srclib ' + name + ' not found.')
1219 srclib = fdroidserver.metadata.srclibs[name]
1221 sdir = os.path.join(srclib_dir, name)
1224 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1225 vcs.srclib = (name, number, sdir)
1227 vcs.gotorevision(ref, refresh)
1234 libdir = os.path.join(sdir, subdir)
1235 elif srclib["Subdir"]:
1236 for subdir in srclib["Subdir"]:
1237 libdir_candidate = os.path.join(sdir, subdir)
1238 if os.path.exists(libdir_candidate):
1239 libdir = libdir_candidate
1245 remove_signing_keys(sdir)
1246 remove_debuggable_flags(sdir)
1250 if srclib["Prepare"]:
1251 cmd = replace_config_vars(srclib["Prepare"], None)
1253 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1254 if p.returncode != 0:
1255 raise BuildException("Error running prepare command for srclib %s"
1261 return (name, number, libdir)
1263 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1266 # Prepare the source code for a particular build
1267 # 'vcs' - the appropriate vcs object for the application
1268 # 'app' - the application details from the metadata
1269 # 'build' - the build details from the metadata
1270 # 'build_dir' - the path to the build directory, usually
1272 # 'srclib_dir' - the path to the source libraries directory, usually
1274 # 'extlib_dir' - the path to the external libraries directory, usually
1276 # Returns the (root, srclibpaths) where:
1277 # 'root' is the root directory, which may be the same as 'build_dir' or may
1278 # be a subdirectory of it.
1279 # 'srclibpaths' is information on the srclibs being used
1280 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1282 # Optionally, the actual app source can be in a subdirectory
1284 root_dir = os.path.join(build_dir, build.subdir)
1286 root_dir = build_dir
1288 # Get a working copy of the right revision
1289 logging.info("Getting source for revision " + build.commit)
1290 vcs.gotorevision(build.commit, refresh)
1292 # Initialise submodules if required
1293 if build.submodules:
1294 logging.info("Initialising submodules")
1295 vcs.initsubmodules()
1297 # Check that a subdir (if we're using one) exists. This has to happen
1298 # after the checkout, since it might not exist elsewhere
1299 if not os.path.exists(root_dir):
1300 raise BuildException('Missing subdir ' + root_dir)
1302 # Run an init command if one is required
1304 cmd = replace_config_vars(build.init, build)
1305 logging.info("Running 'init' commands in %s" % root_dir)
1307 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1308 if p.returncode != 0:
1309 raise BuildException("Error running init command for %s:%s" %
1310 (app.id, build.version), p.output)
1312 # Apply patches if any
1314 logging.info("Applying patches")
1315 for patch in build.patch:
1316 patch = patch.strip()
1317 logging.info("Applying " + patch)
1318 patch_path = os.path.join('metadata', app.id, patch)
1319 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1320 if p.returncode != 0:
1321 raise BuildException("Failed to apply patch %s" % patch_path)
1323 # Get required source libraries
1326 logging.info("Collecting source libraries")
1327 for lib in build.srclibs:
1328 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1330 for name, number, libpath in srclibpaths:
1331 place_srclib(root_dir, int(number) if number else None, libpath)
1333 basesrclib = vcs.getsrclib()
1334 # If one was used for the main source, add that too.
1336 srclibpaths.append(basesrclib)
1338 # Update the local.properties file
1339 localprops = [os.path.join(build_dir, 'local.properties')]
1341 parts = build.subdir.split(os.sep)
1344 cur = os.path.join(cur, d)
1345 localprops += [os.path.join(cur, 'local.properties')]
1346 for path in localprops:
1348 if os.path.isfile(path):
1349 logging.info("Updating local.properties file at %s" % path)
1350 with open(path, 'r') as f:
1354 logging.info("Creating local.properties file at %s" % path)
1355 # Fix old-fashioned 'sdk-location' by copying
1356 # from sdk.dir, if necessary
1358 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1359 re.S | re.M).group(1)
1360 props += "sdk-location=%s\n" % sdkloc
1362 props += "sdk.dir=%s\n" % config['sdk_path']
1363 props += "sdk-location=%s\n" % config['sdk_path']
1364 ndk_path = build.ndk_path()
1367 props += "ndk.dir=%s\n" % ndk_path
1368 props += "ndk-location=%s\n" % ndk_path
1369 # Add java.encoding if necessary
1371 props += "java.encoding=%s\n" % build.encoding
1372 with open(path, 'w') as f:
1376 if build.build_method() == 'gradle':
1377 flavours = build.gradle
1380 n = build.target.split('-')[1]
1381 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1382 r'compileSdkVersion %s' % n,
1383 os.path.join(root_dir, 'build.gradle'))
1385 # Remove forced debuggable flags
1386 remove_debuggable_flags(root_dir)
1388 # Insert version code and number into the manifest if necessary
1389 if build.forceversion:
1390 logging.info("Changing the version name")
1391 for path in manifest_paths(root_dir, flavours):
1392 if not os.path.isfile(path):
1394 if has_extension(path, 'xml'):
1395 regsub_file(r'android:versionName="[^"]*"',
1396 r'android:versionName="%s"' % build.version,
1398 elif has_extension(path, 'gradle'):
1399 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1400 r"""\1versionName '%s'""" % build.version,
1403 if build.forcevercode:
1404 logging.info("Changing the version code")
1405 for path in manifest_paths(root_dir, flavours):
1406 if not os.path.isfile(path):
1408 if has_extension(path, 'xml'):
1409 regsub_file(r'android:versionCode="[^"]*"',
1410 r'android:versionCode="%s"' % build.vercode,
1412 elif has_extension(path, 'gradle'):
1413 regsub_file(r'versionCode[ =]+[0-9]+',
1414 r'versionCode %s' % build.vercode,
1417 # Delete unwanted files
1419 logging.info("Removing specified files")
1420 for part in getpaths(build_dir, build.rm):
1421 dest = os.path.join(build_dir, part)
1422 logging.info("Removing {0}".format(part))
1423 if os.path.lexists(dest):
1424 if os.path.islink(dest):
1425 FDroidPopen(['unlink', dest], output=False)
1427 FDroidPopen(['rm', '-rf', dest], output=False)
1429 logging.info("...but it didn't exist")
1431 remove_signing_keys(build_dir)
1433 # Add required external libraries
1435 logging.info("Collecting prebuilt libraries")
1436 libsdir = os.path.join(root_dir, 'libs')
1437 if not os.path.exists(libsdir):
1439 for lib in build.extlibs:
1441 logging.info("...installing extlib {0}".format(lib))
1442 libf = os.path.basename(lib)
1443 libsrc = os.path.join(extlib_dir, lib)
1444 if not os.path.exists(libsrc):
1445 raise BuildException("Missing extlib file {0}".format(libsrc))
1446 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1448 # Run a pre-build command if one is required
1450 logging.info("Running 'prebuild' commands in %s" % root_dir)
1452 cmd = replace_config_vars(build.prebuild, build)
1454 # Substitute source library paths into prebuild commands
1455 for name, number, libpath in srclibpaths:
1456 libpath = os.path.relpath(libpath, root_dir)
1457 cmd = cmd.replace('$$' + name + '$$', libpath)
1459 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1460 if p.returncode != 0:
1461 raise BuildException("Error running prebuild command for %s:%s" %
1462 (app.id, build.version), p.output)
1464 # Generate (or update) the ant build file, build.xml...
1465 if build.build_method() == 'ant' and build.update != ['no']:
1466 parms = ['android', 'update', 'lib-project']
1467 lparms = ['android', 'update', 'project']
1470 parms += ['-t', build.target]
1471 lparms += ['-t', build.target]
1473 update_dirs = build.update
1475 update_dirs = ant_subprojects(root_dir) + ['.']
1477 for d in update_dirs:
1478 subdir = os.path.join(root_dir, d)
1480 logging.debug("Updating main project")
1481 cmd = parms + ['-p', d]
1483 logging.debug("Updating subproject %s" % d)
1484 cmd = lparms + ['-p', d]
1485 p = SdkToolsPopen(cmd, cwd=root_dir)
1486 # Check to see whether an error was returned without a proper exit
1487 # code (this is the case for the 'no target set or target invalid'
1489 if p.returncode != 0 or p.output.startswith("Error: "):
1490 raise BuildException("Failed to update project at %s" % d, p.output)
1491 # Clean update dirs via ant
1493 logging.info("Cleaning subproject %s" % d)
1494 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1496 return (root_dir, srclibpaths)
1499 # Extend via globbing the paths from a field and return them as a map from
1500 # original path to resulting paths
1501 def getpaths_map(build_dir, globpaths):
1505 full_path = os.path.join(build_dir, p)
1506 full_path = os.path.normpath(full_path)
1507 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1509 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1513 # Extend via globbing the paths from a field and return them as a set
1514 def getpaths(build_dir, globpaths):
1515 paths_map = getpaths_map(build_dir, globpaths)
1517 for k, v in paths_map.items():
1524 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1530 self.path = os.path.join('stats', 'known_apks.txt')
1532 if os.path.isfile(self.path):
1533 for line in file(self.path):
1534 t = line.rstrip().split(' ')
1536 self.apks[t[0]] = (t[1], None)
1538 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1539 self.changed = False
1541 def writeifchanged(self):
1542 if not self.changed:
1545 if not os.path.exists('stats'):
1549 for apk, app in self.apks.items():
1551 line = apk + ' ' + appid
1553 line += ' ' + time.strftime('%Y-%m-%d', added)
1556 with open(self.path, 'w') as f:
1557 for line in sorted(lst, key=natural_key):
1558 f.write(line + '\n')
1560 # Record an apk (if it's new, otherwise does nothing)
1561 # Returns the date it was added.
1562 def recordapk(self, apk, app):
1563 if apk not in self.apks:
1564 self.apks[apk] = (app, time.gmtime(time.time()))
1566 _, added = self.apks[apk]
1569 # Look up information - given the 'apkname', returns (app id, date added/None).
1570 # Or returns None for an unknown apk.
1571 def getapp(self, apkname):
1572 if apkname in self.apks:
1573 return self.apks[apkname]
1576 # Get the most recent 'num' apps added to the repo, as a list of package ids
1577 # with the most recent first.
1578 def getlatest(self, num):
1580 for apk, app in self.apks.items():
1584 if apps[appid] > added:
1588 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1589 lst = [app for app, _ in sortedapps]
1594 def isApkDebuggable(apkfile, config):
1595 """Returns True if the given apk file is debuggable
1597 :param apkfile: full path to the apk to check"""
1599 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1601 if p.returncode != 0:
1602 logging.critical("Failed to get apk manifest information")
1604 for line in p.output.splitlines():
1605 if 'android:debuggable' in line and not line.endswith('0x0'):
1615 def SdkToolsPopen(commands, cwd=None, output=True):
1617 if cmd not in config:
1618 config[cmd] = find_sdk_tools_cmd(commands[0])
1619 abscmd = config[cmd]
1621 logging.critical("Could not find '%s' on your system" % cmd)
1623 return FDroidPopen([abscmd] + commands[1:],
1624 cwd=cwd, output=output)
1627 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1629 Run a command and capture the possibly huge output.
1631 :param commands: command and argument list like in subprocess.Popen
1632 :param cwd: optionally specifies a working directory
1633 :returns: A PopenResult.
1639 cwd = os.path.normpath(cwd)
1640 logging.debug("Directory: %s" % cwd)
1641 logging.debug("> %s" % ' '.join(commands))
1643 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1644 result = PopenResult()
1647 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1648 stdout=subprocess.PIPE, stderr=stderr_param)
1649 except OSError as e:
1650 raise BuildException("OSError while trying to execute " +
1651 ' '.join(commands) + ': ' + str(e))
1653 if not stderr_to_stdout and options.verbose:
1654 stderr_queue = Queue()
1655 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1657 while not stderr_reader.eof():
1658 while not stderr_queue.empty():
1659 line = stderr_queue.get()
1660 sys.stderr.write(line)
1665 stdout_queue = Queue()
1666 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1668 # Check the queue for output (until there is no more to get)
1669 while not stdout_reader.eof():
1670 while not stdout_queue.empty():
1671 line = stdout_queue.get()
1672 if output and options.verbose:
1673 # Output directly to console
1674 sys.stderr.write(line)
1676 result.output += line
1680 result.returncode = p.wait()
1684 gradle_comment = re.compile(r'[ ]*//')
1685 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1686 gradle_line_matches = [
1687 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1688 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1689 re.compile(r'.*\.readLine\(.*'),
1693 def remove_signing_keys(build_dir):
1694 for root, dirs, files in os.walk(build_dir):
1695 if 'build.gradle' in files:
1696 path = os.path.join(root, 'build.gradle')
1698 with open(path, "r") as o:
1699 lines = o.readlines()
1705 with open(path, "w") as o:
1706 while i < len(lines):
1709 while line.endswith('\\\n'):
1710 line = line.rstrip('\\\n') + lines[i]
1713 if gradle_comment.match(line):
1718 opened += line.count('{')
1719 opened -= line.count('}')
1722 if gradle_signing_configs.match(line):
1727 if any(s.match(line) for s in gradle_line_matches):
1735 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1738 'project.properties',
1740 'default.properties',
1741 'ant.properties', ]:
1742 if propfile in files:
1743 path = os.path.join(root, propfile)
1745 with open(path, "r") as o:
1746 lines = o.readlines()
1750 with open(path, "w") as o:
1752 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1759 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1762 def reset_env_path():
1763 global env, orig_path
1764 env['PATH'] = orig_path
1767 def add_to_env_path(path):
1769 paths = env['PATH'].split(os.pathsep)
1773 env['PATH'] = os.pathsep.join(paths)
1776 def replace_config_vars(cmd, build):
1778 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1779 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1780 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1781 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1782 if build is not None:
1783 cmd = cmd.replace('$$COMMIT$$', build.commit)
1784 cmd = cmd.replace('$$VERSION$$', build.version)
1785 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1789 def place_srclib(root_dir, number, libpath):
1792 relpath = os.path.relpath(libpath, root_dir)
1793 proppath = os.path.join(root_dir, 'project.properties')
1796 if os.path.isfile(proppath):
1797 with open(proppath, "r") as o:
1798 lines = o.readlines()
1800 with open(proppath, "w") as o:
1803 if line.startswith('android.library.reference.%d=' % number):
1804 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1809 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1811 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1814 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1815 """Verify that two apks are the same
1817 One of the inputs is signed, the other is unsigned. The signature metadata
1818 is transferred from the signed to the unsigned apk, and then jarsigner is
1819 used to verify that the signature from the signed apk is also varlid for
1821 :param signed_apk: Path to a signed apk file
1822 :param unsigned_apk: Path to an unsigned apk file expected to match it
1823 :param tmp_dir: Path to directory for temporary files
1824 :returns: None if the verification is successful, otherwise a string
1825 describing what went wrong.
1827 with ZipFile(signed_apk) as signed_apk_as_zip:
1828 meta_inf_files = ['META-INF/MANIFEST.MF']
1829 for f in signed_apk_as_zip.namelist():
1830 if apk_sigfile.match(f):
1831 meta_inf_files.append(f)
1832 if len(meta_inf_files) < 3:
1833 return "Signature files missing from {0}".format(signed_apk)
1834 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1835 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1836 for meta_inf_file in meta_inf_files:
1837 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1839 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1840 logging.info("...NOT verified - {0}".format(signed_apk))
1841 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1842 logging.info("...successfully verified")
1845 apk_badchars = re.compile('''[/ :;'"]''')
1848 def compare_apks(apk1, apk2, tmp_dir):
1851 Returns None if the apk content is the same (apart from the signing key),
1852 otherwise a string describing what's different, or what went wrong when
1853 trying to do the comparison.
1856 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1857 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1858 for d in [apk1dir, apk2dir]:
1859 if os.path.exists(d):
1862 os.mkdir(os.path.join(d, 'jar-xf'))
1864 if subprocess.call(['jar', 'xf',
1865 os.path.abspath(apk1)],
1866 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1867 return("Failed to unpack " + apk1)
1868 if subprocess.call(['jar', 'xf',
1869 os.path.abspath(apk2)],
1870 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1871 return("Failed to unpack " + apk2)
1873 # try to find apktool in the path, if it hasn't been manually configed
1874 if 'apktool' not in config:
1875 tmp = find_command('apktool')
1877 config['apktool'] = tmp
1878 if 'apktool' in config:
1879 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1881 return("Failed to unpack " + apk1)
1882 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1884 return("Failed to unpack " + apk2)
1886 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1887 lines = p.output.splitlines()
1888 if len(lines) != 1 or 'META-INF' not in lines[0]:
1889 meld = find_command('meld')
1890 if meld is not None:
1891 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1892 return("Unexpected diff output - " + p.output)
1894 # since everything verifies, delete the comparison to keep cruft down
1895 shutil.rmtree(apk1dir)
1896 shutil.rmtree(apk2dir)
1898 # If we get here, it seems like they're the same!
1902 def find_command(command):
1903 '''find the full path of a command, or None if it can't be found in the PATH'''
1906 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1908 fpath, fname = os.path.split(command)
1913 for path in os.environ["PATH"].split(os.pathsep):
1914 path = path.strip('"')
1915 exe_file = os.path.join(path, command)
1916 if is_exe(exe_file):
1923 '''generate a random password for when generating keys'''
1924 h = hashlib.sha256()
1925 h.update(os.urandom(16)) # salt
1926 h.update(bytes(socket.getfqdn()))
1927 return h.digest().encode('base64').strip()
1930 def genkeystore(localconfig):
1931 '''Generate a new key with random passwords and add it to new keystore'''
1932 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1933 keystoredir = os.path.dirname(localconfig['keystore'])
1934 if keystoredir is None or keystoredir == '':
1935 keystoredir = os.path.join(os.getcwd(), keystoredir)
1936 if not os.path.exists(keystoredir):
1937 os.makedirs(keystoredir, mode=0o700)
1939 write_password_file("keystorepass", localconfig['keystorepass'])
1940 write_password_file("keypass", localconfig['keypass'])
1941 p = FDroidPopen([config['keytool'], '-genkey',
1942 '-keystore', localconfig['keystore'],
1943 '-alias', localconfig['repo_keyalias'],
1944 '-keyalg', 'RSA', '-keysize', '4096',
1945 '-sigalg', 'SHA256withRSA',
1946 '-validity', '10000',
1947 '-storepass:file', config['keystorepassfile'],
1948 '-keypass:file', config['keypassfile'],
1949 '-dname', localconfig['keydname']])
1950 # TODO keypass should be sent via stdin
1951 if p.returncode != 0:
1952 raise BuildException("Failed to generate key", p.output)
1953 os.chmod(localconfig['keystore'], 0o0600)
1954 # now show the lovely key that was just generated
1955 p = FDroidPopen([config['keytool'], '-list', '-v',
1956 '-keystore', localconfig['keystore'],
1957 '-alias', localconfig['repo_keyalias'],
1958 '-storepass:file', config['keystorepassfile']])
1959 logging.info(p.output.strip() + '\n\n')
1962 def write_to_config(thisconfig, key, value=None):
1963 '''write a key/value to the local config.py'''
1965 origkey = key + '_orig'
1966 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1967 with open('config.py', 'r') as f:
1969 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1970 repl = '\n' + key + ' = "' + value + '"'
1971 data = re.sub(pattern, repl, data)
1972 # if this key is not in the file, append it
1973 if not re.match('\s*' + key + '\s*=\s*"', data):
1975 # make sure the file ends with a carraige return
1976 if not re.match('\n$', data):
1978 with open('config.py', 'w') as f:
1982 def parse_xml(path):
1983 return XMLElementTree.parse(path).getroot()
1986 def string_is_integer(string):
1994 def get_per_app_repos():
1995 '''per-app repos are dirs named with the packageName of a single app'''
1997 # Android packageNames are Java packages, they may contain uppercase or
1998 # lowercase letters ('A' through 'Z'), numbers, and underscores
1999 # ('_'). However, individual package name parts may only start with
2000 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2001 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2004 for root, dirs, files in os.walk(os.getcwd()):
2006 print('checking', root, 'for', d)
2007 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2008 # standard parts of an fdroid repo, so never packageNames
2011 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):