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 binascii import hexlify
40 from datetime import datetime
41 from distutils.version import LooseVersion
42 from queue import Queue
43 from zipfile import ZipFile
45 from pyasn1.codec.der import decoder, encoder
46 from pyasn1_modules import rfc2315
47 from pyasn1.error import PyAsn1Error
49 from distutils.util import strtobool
51 import fdroidserver.metadata
52 from fdroidserver.exception import FDroidException, VCSException, BuildException
53 from .asynchronousfilereader import AsynchronousFileReader
56 # A signature block file with a .DSA, .RSA, or .EC extension
57 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
58 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
59 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
61 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
70 'sdk_path': "$ANDROID_HOME",
75 'r12b': "$ANDROID_NDK",
80 'build_tools': "25.0.2",
81 'force_build_tools': False,
86 'accepted_formats': ['txt', 'yml'],
87 'sync_from_local_copy_dir': False,
88 'allow_disabled_algorithms': False,
89 'per_app_repos': False,
90 'make_current_version_link': True,
91 'current_version_name_source': 'Name',
92 'update_stats': False,
96 'stats_to_carbon': False,
98 'build_server_always': False,
99 'keystore': 'keystore.jks',
100 'smartcardoptions': [],
110 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
111 'repo_name': "My First FDroid Repo Demo",
112 'repo_icon': "fdroid-icon.png",
113 'repo_description': '''
114 This is a repository of apps to be used with FDroid. Applications in this
115 repository are either official binaries built by the original application
116 developers, or are binaries built from source by the admin of f-droid.org
117 using the tools on https://gitlab.com/u/fdroid.
123 def setup_global_opts(parser):
124 parser.add_argument("-v", "--verbose", action="store_true", default=False,
125 help="Spew out even more information than normal")
126 parser.add_argument("-q", "--quiet", action="store_true", default=False,
127 help="Restrict output to warnings and errors")
130 def fill_config_defaults(thisconfig):
131 for k, v in default_config.items():
132 if k not in thisconfig:
135 # Expand paths (~users and $vars)
136 def expand_path(path):
140 path = os.path.expanduser(path)
141 path = os.path.expandvars(path)
146 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
151 thisconfig[k + '_orig'] = v
153 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
154 if thisconfig['java_paths'] is None:
155 thisconfig['java_paths'] = dict()
157 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
158 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
159 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
160 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
161 if os.getenv('JAVA_HOME') is not None:
162 pathlist.append(os.getenv('JAVA_HOME'))
163 if os.getenv('PROGRAMFILES') is not None:
164 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
165 for d in sorted(pathlist):
166 if os.path.islink(d):
168 j = os.path.basename(d)
169 # the last one found will be the canonical one, so order appropriately
171 r'^1\.([6-9])\.0\.jdk$', # OSX
172 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
173 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
174 r'^jdk([6-9])-openjdk$', # Arch
175 r'^java-([6-9])-openjdk$', # Arch
176 r'^java-([6-9])-jdk$', # Arch (oracle)
177 r'^java-1\.([6-9])\.0-.*$', # RedHat
178 r'^java-([6-9])-oracle$', # Debian WebUpd8
179 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
180 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
182 m = re.match(regex, j)
185 for p in [d, os.path.join(d, 'Contents', 'Home')]:
186 if os.path.exists(os.path.join(p, 'bin', 'javac')):
187 thisconfig['java_paths'][m.group(1)] = p
189 for java_version in ('7', '8', '9'):
190 if java_version not in thisconfig['java_paths']:
192 java_home = thisconfig['java_paths'][java_version]
193 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
194 if os.path.exists(jarsigner):
195 thisconfig['jarsigner'] = jarsigner
196 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
197 break # Java7 is preferred, so quit if found
199 for k in ['ndk_paths', 'java_paths']:
205 thisconfig[k][k2] = exp
206 thisconfig[k][k2 + '_orig'] = v
209 def regsub_file(pattern, repl, path):
210 with open(path, 'rb') as f:
212 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
213 with open(path, 'wb') as f:
217 def read_config(opts, config_file='config.py'):
218 """Read the repository config
220 The config is read from config_file, which is in the current
221 directory when any of the repo management commands are used. If
222 there is a local metadata file in the git repo, then config.py is
223 not required, just use defaults.
226 global config, options
228 if config is not None:
235 if os.path.isfile(config_file):
236 logging.debug("Reading %s" % config_file)
237 with io.open(config_file, "rb") as f:
238 code = compile(f.read(), config_file, 'exec')
239 exec(code, None, config)
240 elif len(get_local_metadata_files()) == 0:
241 raise FDroidException("Missing config file - is this a repo directory?")
243 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
245 if not type(config[k]) in (str, list, tuple):
246 logging.warn('"' + k + '" will be in random order!'
247 + ' Use () or [] brackets if order is important!')
249 # smartcardoptions must be a list since its command line args for Popen
250 if 'smartcardoptions' in config:
251 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
252 elif 'keystore' in config and config['keystore'] == 'NONE':
253 # keystore='NONE' means use smartcard, these are required defaults
254 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
255 'SunPKCS11-OpenSC', '-providerClass',
256 'sun.security.pkcs11.SunPKCS11',
257 '-providerArg', 'opensc-fdroid.cfg']
259 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
260 st = os.stat(config_file)
261 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
262 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
264 fill_config_defaults(config)
266 for k in ["repo_description", "archive_description"]:
268 config[k] = clean_description(config[k])
270 if 'serverwebroot' in config:
271 if isinstance(config['serverwebroot'], str):
272 roots = [config['serverwebroot']]
273 elif all(isinstance(item, str) for item in config['serverwebroot']):
274 roots = config['serverwebroot']
276 raise TypeError('only accepts strings, lists, and tuples')
278 for rootstr in roots:
279 # since this is used with rsync, where trailing slashes have
280 # meaning, ensure there is always a trailing slash
281 if rootstr[-1] != '/':
283 rootlist.append(rootstr.replace('//', '/'))
284 config['serverwebroot'] = rootlist
286 if 'servergitmirrors' in config:
287 if isinstance(config['servergitmirrors'], str):
288 roots = [config['servergitmirrors']]
289 elif all(isinstance(item, str) for item in config['servergitmirrors']):
290 roots = config['servergitmirrors']
292 raise TypeError('only accepts strings, lists, and tuples')
293 config['servergitmirrors'] = roots
298 def find_sdk_tools_cmd(cmd):
299 '''find a working path to a tool from the Android SDK'''
302 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
303 # try to find a working path to this command, in all the recent possible paths
304 if 'build_tools' in config:
305 build_tools = os.path.join(config['sdk_path'], 'build-tools')
306 # if 'build_tools' was manually set and exists, check only that one
307 configed_build_tools = os.path.join(build_tools, config['build_tools'])
308 if os.path.exists(configed_build_tools):
309 tooldirs.append(configed_build_tools)
311 # no configed version, so hunt known paths for it
312 for f in sorted(os.listdir(build_tools), reverse=True):
313 if os.path.isdir(os.path.join(build_tools, f)):
314 tooldirs.append(os.path.join(build_tools, f))
315 tooldirs.append(build_tools)
316 sdk_tools = os.path.join(config['sdk_path'], 'tools')
317 if os.path.exists(sdk_tools):
318 tooldirs.append(sdk_tools)
319 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
320 if os.path.exists(sdk_platform_tools):
321 tooldirs.append(sdk_platform_tools)
322 tooldirs.append('/usr/bin')
324 path = os.path.join(d, cmd)
325 if os.path.isfile(path):
327 test_aapt_version(path)
329 # did not find the command, exit with error message
330 ensure_build_tools_exists(config)
333 def test_aapt_version(aapt):
334 '''Check whether the version of aapt is new enough'''
335 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
336 if output is None or output == '':
337 logging.error(aapt + ' failed to execute!')
339 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
344 # the Debian package has the version string like "v0.2-23.0.2"
345 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
346 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
348 logging.warning('Unknown version of aapt, might cause problems: ' + output)
351 def test_sdk_exists(thisconfig):
352 if 'sdk_path' not in thisconfig:
353 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
354 test_aapt_version(thisconfig['aapt'])
357 logging.error("'sdk_path' not set in config.py!")
359 if thisconfig['sdk_path'] == default_config['sdk_path']:
360 logging.error('No Android SDK found!')
361 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
362 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
364 if not os.path.exists(thisconfig['sdk_path']):
365 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
367 if not os.path.isdir(thisconfig['sdk_path']):
368 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
370 for d in ['build-tools', 'platform-tools', 'tools']:
371 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
372 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
373 thisconfig['sdk_path'], d))
378 def ensure_build_tools_exists(thisconfig):
379 if not test_sdk_exists(thisconfig):
380 raise FDroidException("Android SDK not found.")
381 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
382 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
383 if not os.path.isdir(versioned_build_tools):
384 raise FDroidException(
385 'Android Build Tools path "' + versioned_build_tools + '" does not exist!')
388 def get_local_metadata_files():
389 '''get any metadata files local to an app's source repo
391 This tries to ignore anything that does not count as app metdata,
392 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
395 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
398 def read_pkg_args(args, allow_vercodes=False):
400 :param args: arguments in the form of multiple appid:[vc] strings
401 :returns: a dictionary with the set of vercodes specified for each package
409 if allow_vercodes and ':' in p:
410 package, vercode = p.split(':')
412 package, vercode = p, None
413 if package not in vercodes:
414 vercodes[package] = [vercode] if vercode else []
416 elif vercode and vercode not in vercodes[package]:
417 vercodes[package] += [vercode] if vercode else []
422 def read_app_args(args, allapps, allow_vercodes=False):
424 On top of what read_pkg_args does, this returns the whole app metadata, but
425 limiting the builds list to the builds matching the vercodes specified.
428 vercodes = read_pkg_args(args, allow_vercodes)
434 for appid, app in allapps.items():
435 if appid in vercodes:
438 if len(apps) != len(vercodes):
441 logging.critical("No such package: %s" % p)
442 raise FDroidException("Found invalid app ids in arguments")
444 raise FDroidException("No packages specified")
447 for appid, app in apps.items():
451 app.builds = [b for b in app.builds if b.versionCode in vc]
452 if len(app.builds) != len(vercodes[appid]):
454 allvcs = [b.versionCode for b in app.builds]
455 for v in vercodes[appid]:
457 logging.critical("No such vercode %s for app %s" % (v, appid))
460 raise FDroidException("Found invalid vercodes for some apps")
465 def get_extension(filename):
466 base, ext = os.path.splitext(filename)
469 return base, ext.lower()[1:]
472 def has_extension(filename, ext):
473 _, f_ext = get_extension(filename)
477 def metadata_srclib_relpath(name):
479 :param name: name of the src lib. (eg. 'HttpClient')
480 :returns: relative path for requested srclib (eg. 'srclib/HttpClient.txt')
483 for ext in config['accepted_formats']:
484 pth = os.path.join('srclibs', name + '.' + ext)
485 if os.path.isfile(pth):
487 raise FDroidException("could not find srclib metadata file for '{}'".format(name))
490 def metadata_relpath(appid):
492 :param appid: an appid. (eg. 'org.fdroid.fdroid')
493 :returns: relative path for requested srclib (eg. 'metadata/org.fdroid.fdroid.txt')
496 for ext in config['accepted_formats']:
497 pth = os.path.join('metadata', appid + '.' + ext)
498 if os.path.isfile(pth):
500 raise FDroidException("could not find metadata file for '{}'".format(appid))
503 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
506 def clean_description(description):
507 'Remove unneeded newlines and spaces from a block of description text'
509 # this is split up by paragraph to make removing the newlines easier
510 for paragraph in re.split(r'\n\n', description):
511 paragraph = re.sub('\r', '', paragraph)
512 paragraph = re.sub('\n', ' ', paragraph)
513 paragraph = re.sub(' {2,}', ' ', paragraph)
514 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
515 returnstring += paragraph + '\n\n'
516 return returnstring.rstrip('\n')
519 def publishednameinfo(filename):
520 filename = os.path.basename(filename)
521 m = publish_name_regex.match(filename)
523 result = (m.group(1), m.group(2))
524 except AttributeError:
525 raise FDroidException("Invalid name for published file: %s" % filename)
529 def get_release_filename(app, build):
531 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
533 return "%s_%s.apk" % (app.id, build.versionCode)
536 def get_toolsversion_logname(app, build):
537 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
540 def getsrcname(app, build):
541 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
553 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
556 def get_build_dir(app):
557 '''get the dir that this app will be built in'''
559 if app.RepoType == 'srclib':
560 return os.path.join('build', 'srclib', app.Repo)
562 return os.path.join('build', app.id)
566 '''checkout code from VCS and return instance of vcs and the build dir'''
567 build_dir = get_build_dir(app)
569 # Set up vcs interface and make sure we have the latest code...
570 logging.debug("Getting {0} vcs interface for {1}"
571 .format(app.RepoType, app.Repo))
572 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
576 vcs = getvcs(app.RepoType, remote, build_dir)
578 return vcs, build_dir
581 def getvcs(vcstype, remote, local):
583 return vcs_git(remote, local)
584 if vcstype == 'git-svn':
585 return vcs_gitsvn(remote, local)
587 return vcs_hg(remote, local)
589 return vcs_bzr(remote, local)
590 if vcstype == 'srclib':
591 if local != os.path.join('build', 'srclib', remote):
592 raise VCSException("Error: srclib paths are hard-coded!")
593 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
595 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
596 raise VCSException("Invalid vcs type " + vcstype)
599 def getsrclibvcs(name):
600 if name not in fdroidserver.metadata.srclibs:
601 raise VCSException("Missing srclib " + name)
602 return fdroidserver.metadata.srclibs[name]['Repo Type']
607 def __init__(self, remote, local):
609 # svn, git-svn and bzr may require auth
611 if self.repotype() in ('git-svn', 'bzr'):
613 if self.repotype == 'git-svn':
614 raise VCSException("Authentication is not supported for git-svn")
615 self.username, remote = remote.split('@')
616 if ':' not in self.username:
617 raise VCSException("Password required with username")
618 self.username, self.password = self.username.split(':')
622 self.clone_failed = False
623 self.refreshed = False
629 # Take the local repository to a clean version of the given revision, which
630 # is specificed in the VCS's native format. Beforehand, the repository can
631 # be dirty, or even non-existent. If the repository does already exist
632 # locally, it will be updated from the origin, but only once in the
633 # lifetime of the vcs object.
634 # None is acceptable for 'rev' if you know you are cloning a clean copy of
635 # the repo - otherwise it must specify a valid revision.
636 def gotorevision(self, rev, refresh=True):
638 if self.clone_failed:
639 raise VCSException("Downloading the repository already failed once, not trying again.")
641 # The .fdroidvcs-id file for a repo tells us what VCS type
642 # and remote that directory was created from, allowing us to drop it
643 # automatically if either of those things changes.
644 fdpath = os.path.join(self.local, '..',
645 '.fdroidvcs-' + os.path.basename(self.local))
646 fdpath = os.path.normpath(fdpath)
647 cdata = self.repotype() + ' ' + self.remote
650 if os.path.exists(self.local):
651 if os.path.exists(fdpath):
652 with open(fdpath, 'r') as f:
653 fsdata = f.read().strip()
658 logging.info("Repository details for %s changed - deleting" % (
662 logging.info("Repository details for %s missing - deleting" % (
665 shutil.rmtree(self.local)
669 self.refreshed = True
672 self.gotorevisionx(rev)
673 except FDroidException as e:
676 # If necessary, write the .fdroidvcs file.
677 if writeback and not self.clone_failed:
678 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
679 with open(fdpath, 'w+') as f:
685 # Derived classes need to implement this. It's called once basic checking
686 # has been performend.
687 def gotorevisionx(self, rev): # pylint: disable=unused-argument
688 raise VCSException("This VCS type doesn't define gotorevisionx")
690 # Initialise and update submodules
691 def initsubmodules(self):
692 raise VCSException('Submodules not supported for this vcs type')
694 # Get a list of all known tags
696 if not self._gettags:
697 raise VCSException('gettags not supported for this vcs type')
699 for tag in self._gettags():
700 if re.match('[-A-Za-z0-9_. /]+$', tag):
704 # Get a list of all the known tags, sorted from newest to oldest
705 def latesttags(self):
706 raise VCSException('latesttags not supported for this vcs type')
708 # Get current commit reference (hash, revision, etc)
710 raise VCSException('getref not supported for this vcs type')
712 # Returns the srclib (name, path) used in setting up the current
723 # If the local directory exists, but is somehow not a git repository, git
724 # will traverse up the directory tree until it finds one that is (i.e.
725 # fdroidserver) and then we'll proceed to destroy it! This is called as
728 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
729 result = p.output.rstrip()
730 if not result.endswith(self.local):
731 raise VCSException('Repository mismatch')
733 def gotorevisionx(self, rev):
734 if not os.path.exists(self.local):
736 p = FDroidPopen(['git', 'clone', self.remote, self.local])
737 if p.returncode != 0:
738 self.clone_failed = True
739 raise VCSException("Git clone failed", p.output)
743 # Discard any working tree changes
744 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
745 'git', 'reset', '--hard'], cwd=self.local, output=False)
746 if p.returncode != 0:
747 raise VCSException("Git reset failed", p.output)
748 # Remove untracked files now, in case they're tracked in the target
749 # revision (it happens!)
750 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
751 'git', 'clean', '-dffx'], cwd=self.local, output=False)
752 if p.returncode != 0:
753 raise VCSException("Git clean failed", p.output)
754 if not self.refreshed:
755 # Get latest commits and tags from remote
756 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
757 if p.returncode != 0:
758 raise VCSException("Git fetch failed", p.output)
759 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException("Git fetch failed", p.output)
762 # Recreate origin/HEAD as git clone would do it, in case it disappeared
763 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
764 if p.returncode != 0:
765 lines = p.output.splitlines()
766 if 'Multiple remote HEAD branches' not in lines[0]:
767 raise VCSException("Git remote set-head failed", p.output)
768 branch = lines[1].split(' ')[-1]
769 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
770 if p2.returncode != 0:
771 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
772 self.refreshed = True
773 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
774 # a github repo. Most of the time this is the same as origin/master.
775 rev = rev or 'origin/HEAD'
776 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
777 if p.returncode != 0:
778 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
779 # Get rid of any uncontrolled files left behind
780 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
781 if p.returncode != 0:
782 raise VCSException("Git clean failed", p.output)
784 def initsubmodules(self):
786 submfile = os.path.join(self.local, '.gitmodules')
787 if not os.path.isfile(submfile):
788 raise VCSException("No git submodules available")
790 # fix submodules not accessible without an account and public key auth
791 with open(submfile, 'r') as f:
792 lines = f.readlines()
793 with open(submfile, 'w') as f:
795 if 'git@github.com' in line:
796 line = line.replace('git@github.com:', 'https://github.com/')
797 if 'git@gitlab.com' in line:
798 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
801 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
802 if p.returncode != 0:
803 raise VCSException("Git submodule sync failed", p.output)
804 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
805 if p.returncode != 0:
806 raise VCSException("Git submodule update failed", p.output)
810 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
811 return p.output.splitlines()
813 tag_format = re.compile(r'tag: ([^),]*)')
815 def latesttags(self):
817 p = FDroidPopen(['git', 'log', '--tags',
818 '--simplify-by-decoration', '--pretty=format:%d'],
819 cwd=self.local, output=False)
821 for line in p.output.splitlines():
822 for tag in self.tag_format.findall(line):
827 class vcs_gitsvn(vcs):
832 # If the local directory exists, but is somehow not a git repository, git
833 # will traverse up the directory tree until it finds one that is (i.e.
834 # fdroidserver) and then we'll proceed to destory it! This is called as
837 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
838 result = p.output.rstrip()
839 if not result.endswith(self.local):
840 raise VCSException('Repository mismatch')
842 def gotorevisionx(self, rev):
843 if not os.path.exists(self.local):
845 gitsvn_args = ['git', 'svn', 'clone']
846 if ';' in self.remote:
847 remote_split = self.remote.split(';')
848 for i in remote_split[1:]:
849 if i.startswith('trunk='):
850 gitsvn_args.extend(['-T', i[6:]])
851 elif i.startswith('tags='):
852 gitsvn_args.extend(['-t', i[5:]])
853 elif i.startswith('branches='):
854 gitsvn_args.extend(['-b', i[9:]])
855 gitsvn_args.extend([remote_split[0], self.local])
856 p = FDroidPopen(gitsvn_args, output=False)
857 if p.returncode != 0:
858 self.clone_failed = True
859 raise VCSException("Git svn clone failed", p.output)
861 gitsvn_args.extend([self.remote, self.local])
862 p = FDroidPopen(gitsvn_args, output=False)
863 if p.returncode != 0:
864 self.clone_failed = True
865 raise VCSException("Git svn clone failed", p.output)
869 # Discard any working tree changes
870 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Git reset failed", p.output)
873 # Remove untracked files now, in case they're tracked in the target
874 # revision (it happens!)
875 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
876 if p.returncode != 0:
877 raise VCSException("Git clean failed", p.output)
878 if not self.refreshed:
879 # Get new commits, branches and tags from repo
880 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
881 if p.returncode != 0:
882 raise VCSException("Git svn fetch failed")
883 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
884 if p.returncode != 0:
885 raise VCSException("Git svn rebase failed", p.output)
886 self.refreshed = True
888 rev = rev or 'master'
890 nospaces_rev = rev.replace(' ', '%20')
891 # Try finding a svn tag
892 for treeish in ['origin/', '']:
893 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
894 if p.returncode == 0:
896 if p.returncode != 0:
897 # No tag found, normal svn rev translation
898 # Translate svn rev into git format
899 rev_split = rev.split('/')
902 for treeish in ['origin/', '']:
903 if len(rev_split) > 1:
904 treeish += rev_split[0]
905 svn_rev = rev_split[1]
908 # if no branch is specified, then assume trunk (i.e. 'master' branch):
912 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
914 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
915 git_rev = p.output.rstrip()
917 if p.returncode == 0 and git_rev:
920 if p.returncode != 0 or not git_rev:
921 # Try a plain git checkout as a last resort
922 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
923 if p.returncode != 0:
924 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
926 # Check out the git rev equivalent to the svn rev
927 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
928 if p.returncode != 0:
929 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
931 # Get rid of any uncontrolled files left behind
932 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
933 if p.returncode != 0:
934 raise VCSException("Git clean failed", p.output)
938 for treeish in ['origin/', '']:
939 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
945 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
946 if p.returncode != 0:
948 return p.output.strip()
956 def gotorevisionx(self, rev):
957 if not os.path.exists(self.local):
958 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
959 if p.returncode != 0:
960 self.clone_failed = True
961 raise VCSException("Hg clone failed", p.output)
963 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
964 if p.returncode != 0:
965 raise VCSException("Hg status failed", p.output)
966 for line in p.output.splitlines():
967 if not line.startswith('? '):
968 raise VCSException("Unexpected output from hg status -uS: " + line)
969 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
970 if not self.refreshed:
971 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
972 if p.returncode != 0:
973 raise VCSException("Hg pull failed", p.output)
974 self.refreshed = True
976 rev = rev or 'default'
979 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
980 if p.returncode != 0:
981 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
982 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
983 # Also delete untracked files, we have to enable purge extension for that:
984 if "'purge' is provided by the following extension" in p.output:
985 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
986 myfile.write("\n[extensions]\nhgext.purge=\n")
987 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
988 if p.returncode != 0:
989 raise VCSException("HG purge failed", p.output)
990 elif p.returncode != 0:
991 raise VCSException("HG purge failed", p.output)
994 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
995 return p.output.splitlines()[1:]
1003 def gotorevisionx(self, rev):
1004 if not os.path.exists(self.local):
1005 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1006 if p.returncode != 0:
1007 self.clone_failed = True
1008 raise VCSException("Bzr branch failed", p.output)
1010 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1011 if p.returncode != 0:
1012 raise VCSException("Bzr revert failed", p.output)
1013 if not self.refreshed:
1014 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1015 if p.returncode != 0:
1016 raise VCSException("Bzr update failed", p.output)
1017 self.refreshed = True
1019 revargs = list(['-r', rev] if rev else [])
1020 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1021 if p.returncode != 0:
1022 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1025 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1026 return [tag.split(' ')[0].strip() for tag in
1027 p.output.splitlines()]
1030 def unescape_string(string):
1033 if string[0] == '"' and string[-1] == '"':
1036 return string.replace("\\'", "'")
1039 def retrieve_string(app_dir, string, xmlfiles=None):
1041 if not string.startswith('@string/'):
1042 return unescape_string(string)
1044 if xmlfiles is None:
1047 os.path.join(app_dir, 'res'),
1048 os.path.join(app_dir, 'src', 'main', 'res'),
1050 for r, d, f in os.walk(res_dir):
1051 if os.path.basename(r) == 'values':
1052 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1054 name = string[len('@string/'):]
1056 def element_content(element):
1057 if element.text is None:
1059 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1060 return s.decode('utf-8').strip()
1062 for path in xmlfiles:
1063 if not os.path.isfile(path):
1065 xml = parse_xml(path)
1066 element = xml.find('string[@name="' + name + '"]')
1067 if element is not None:
1068 content = element_content(element)
1069 return retrieve_string(app_dir, content, xmlfiles)
1074 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1075 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1078 def manifest_paths(app_dir, flavours):
1079 '''Return list of existing files that will be used to find the highest vercode'''
1081 possible_manifests = \
1082 [os.path.join(app_dir, 'AndroidManifest.xml'),
1083 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1084 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1085 os.path.join(app_dir, 'build.gradle')]
1087 for flavour in flavours:
1088 if flavour == 'yes':
1090 possible_manifests.append(
1091 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1093 return [path for path in possible_manifests if os.path.isfile(path)]
1096 def fetch_real_name(app_dir, flavours):
1097 '''Retrieve the package name. Returns the name, or None if not found.'''
1098 for path in manifest_paths(app_dir, flavours):
1099 if not has_extension(path, 'xml') or not os.path.isfile(path):
1101 logging.debug("fetch_real_name: Checking manifest at " + path)
1102 xml = parse_xml(path)
1103 app = xml.find('application')
1106 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1108 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1109 result = retrieve_string_singleline(app_dir, label)
1111 result = result.strip()
1116 def get_library_references(root_dir):
1118 proppath = os.path.join(root_dir, 'project.properties')
1119 if not os.path.isfile(proppath):
1121 with open(proppath, 'r', encoding='iso-8859-1') as f:
1123 if not line.startswith('android.library.reference.'):
1125 path = line.split('=')[1].strip()
1126 relpath = os.path.join(root_dir, path)
1127 if not os.path.isdir(relpath):
1129 logging.debug("Found subproject at %s" % path)
1130 libraries.append(path)
1134 def ant_subprojects(root_dir):
1135 subprojects = get_library_references(root_dir)
1136 for subpath in subprojects:
1137 subrelpath = os.path.join(root_dir, subpath)
1138 for p in get_library_references(subrelpath):
1139 relp = os.path.normpath(os.path.join(subpath, p))
1140 if relp not in subprojects:
1141 subprojects.insert(0, relp)
1145 def remove_debuggable_flags(root_dir):
1146 # Remove forced debuggable flags
1147 logging.debug("Removing debuggable flags from %s" % root_dir)
1148 for root, dirs, files in os.walk(root_dir):
1149 if 'AndroidManifest.xml' in files:
1150 regsub_file(r'android:debuggable="[^"]*"',
1152 os.path.join(root, 'AndroidManifest.xml'))
1155 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1156 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1157 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1160 def app_matches_packagename(app, package):
1163 appid = app.UpdateCheckName or app.id
1164 if appid is None or appid == "Ignore":
1166 return appid == package
1169 def parse_androidmanifests(paths, app):
1171 Extract some information from the AndroidManifest.xml at the given path.
1172 Returns (version, vercode, package), any or all of which might be None.
1173 All values returned are strings.
1176 ignoreversions = app.UpdateCheckIgnore
1177 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1180 return (None, None, None)
1188 if not os.path.isfile(path):
1191 logging.debug("Parsing manifest at {0}".format(path))
1196 if has_extension(path, 'gradle'):
1197 with open(path, 'r') as f:
1199 if gradle_comment.match(line):
1201 # Grab first occurence of each to avoid running into
1202 # alternative flavours and builds.
1204 matches = psearch_g(line)
1206 s = matches.group(2)
1207 if app_matches_packagename(app, s):
1210 matches = vnsearch_g(line)
1212 version = matches.group(2)
1214 matches = vcsearch_g(line)
1216 vercode = matches.group(1)
1219 xml = parse_xml(path)
1220 if "package" in xml.attrib:
1221 s = xml.attrib["package"]
1222 if app_matches_packagename(app, s):
1224 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1225 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1226 base_dir = os.path.dirname(path)
1227 version = retrieve_string_singleline(base_dir, version)
1228 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1229 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1230 if string_is_integer(a):
1233 logging.warning("Problem with xml at {0}".format(path))
1235 # Remember package name, may be defined separately from version+vercode
1237 package = max_package
1239 logging.debug("..got package={0}, version={1}, vercode={2}"
1240 .format(package, version, vercode))
1242 # Always grab the package name and version name in case they are not
1243 # together with the highest version code
1244 if max_package is None and package is not None:
1245 max_package = package
1246 if max_version is None and version is not None:
1247 max_version = version
1249 if vercode is not None \
1250 and (max_vercode is None or vercode > max_vercode):
1251 if not ignoresearch or not ignoresearch(version):
1252 if version is not None:
1253 max_version = version
1254 if vercode is not None:
1255 max_vercode = vercode
1256 if package is not None:
1257 max_package = package
1259 max_version = "Ignore"
1261 if max_version is None:
1262 max_version = "Unknown"
1264 if max_package and not is_valid_package_name(max_package):
1265 raise FDroidException("Invalid package name {0}".format(max_package))
1267 return (max_version, max_vercode, max_package)
1270 def is_valid_package_name(name):
1271 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1274 # Get the specified source library.
1275 # Returns the path to it. Normally this is the path to be used when referencing
1276 # it, which may be a subdirectory of the actual project. If you want the base
1277 # directory of the project, pass 'basepath=True'.
1278 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1279 raw=False, prepare=True, preponly=False, refresh=True,
1288 name, ref = spec.split('@')
1290 number, name = name.split(':', 1)
1292 name, subdir = name.split('/', 1)
1294 if name not in fdroidserver.metadata.srclibs:
1295 raise VCSException('srclib ' + name + ' not found.')
1297 srclib = fdroidserver.metadata.srclibs[name]
1299 sdir = os.path.join(srclib_dir, name)
1302 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1303 vcs.srclib = (name, number, sdir)
1305 vcs.gotorevision(ref, refresh)
1312 libdir = os.path.join(sdir, subdir)
1313 elif srclib["Subdir"]:
1314 for subdir in srclib["Subdir"]:
1315 libdir_candidate = os.path.join(sdir, subdir)
1316 if os.path.exists(libdir_candidate):
1317 libdir = libdir_candidate
1323 remove_signing_keys(sdir)
1324 remove_debuggable_flags(sdir)
1328 if srclib["Prepare"]:
1329 cmd = replace_config_vars(srclib["Prepare"], build)
1331 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1332 if p.returncode != 0:
1333 raise BuildException("Error running prepare command for srclib %s"
1339 return (name, number, libdir)
1342 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1345 # Prepare the source code for a particular build
1346 # 'vcs' - the appropriate vcs object for the application
1347 # 'app' - the application details from the metadata
1348 # 'build' - the build details from the metadata
1349 # 'build_dir' - the path to the build directory, usually
1351 # 'srclib_dir' - the path to the source libraries directory, usually
1353 # 'extlib_dir' - the path to the external libraries directory, usually
1355 # Returns the (root, srclibpaths) where:
1356 # 'root' is the root directory, which may be the same as 'build_dir' or may
1357 # be a subdirectory of it.
1358 # 'srclibpaths' is information on the srclibs being used
1359 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1361 # Optionally, the actual app source can be in a subdirectory
1363 root_dir = os.path.join(build_dir, build.subdir)
1365 root_dir = build_dir
1367 # Get a working copy of the right revision
1368 logging.info("Getting source for revision " + build.commit)
1369 vcs.gotorevision(build.commit, refresh)
1371 # Initialise submodules if required
1372 if build.submodules:
1373 logging.info("Initialising submodules")
1374 vcs.initsubmodules()
1376 # Check that a subdir (if we're using one) exists. This has to happen
1377 # after the checkout, since it might not exist elsewhere
1378 if not os.path.exists(root_dir):
1379 raise BuildException('Missing subdir ' + root_dir)
1381 # Run an init command if one is required
1383 cmd = replace_config_vars(build.init, build)
1384 logging.info("Running 'init' commands in %s" % root_dir)
1386 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1387 if p.returncode != 0:
1388 raise BuildException("Error running init command for %s:%s" %
1389 (app.id, build.versionName), p.output)
1391 # Apply patches if any
1393 logging.info("Applying patches")
1394 for patch in build.patch:
1395 patch = patch.strip()
1396 logging.info("Applying " + patch)
1397 patch_path = os.path.join('metadata', app.id, patch)
1398 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1399 if p.returncode != 0:
1400 raise BuildException("Failed to apply patch %s" % patch_path)
1402 # Get required source libraries
1405 logging.info("Collecting source libraries")
1406 for lib in build.srclibs:
1407 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1408 refresh=refresh, build=build))
1410 for name, number, libpath in srclibpaths:
1411 place_srclib(root_dir, int(number) if number else None, libpath)
1413 basesrclib = vcs.getsrclib()
1414 # If one was used for the main source, add that too.
1416 srclibpaths.append(basesrclib)
1418 # Update the local.properties file
1419 localprops = [os.path.join(build_dir, 'local.properties')]
1421 parts = build.subdir.split(os.sep)
1424 cur = os.path.join(cur, d)
1425 localprops += [os.path.join(cur, 'local.properties')]
1426 for path in localprops:
1428 if os.path.isfile(path):
1429 logging.info("Updating local.properties file at %s" % path)
1430 with open(path, 'r', encoding='iso-8859-1') as f:
1434 logging.info("Creating local.properties file at %s" % path)
1435 # Fix old-fashioned 'sdk-location' by copying
1436 # from sdk.dir, if necessary
1438 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1439 re.S | re.M).group(1)
1440 props += "sdk-location=%s\n" % sdkloc
1442 props += "sdk.dir=%s\n" % config['sdk_path']
1443 props += "sdk-location=%s\n" % config['sdk_path']
1444 ndk_path = build.ndk_path()
1445 # if for any reason the path isn't valid or the directory
1446 # doesn't exist, some versions of Gradle will error with a
1447 # cryptic message (even if the NDK is not even necessary).
1448 # https://gitlab.com/fdroid/fdroidserver/issues/171
1449 if ndk_path and os.path.exists(ndk_path):
1451 props += "ndk.dir=%s\n" % ndk_path
1452 props += "ndk-location=%s\n" % ndk_path
1453 # Add java.encoding if necessary
1455 props += "java.encoding=%s\n" % build.encoding
1456 with open(path, 'w', encoding='iso-8859-1') as f:
1460 if build.build_method() == 'gradle':
1461 flavours = build.gradle
1464 n = build.target.split('-')[1]
1465 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1466 r'compileSdkVersion %s' % n,
1467 os.path.join(root_dir, 'build.gradle'))
1469 # Remove forced debuggable flags
1470 remove_debuggable_flags(root_dir)
1472 # Insert version code and number into the manifest if necessary
1473 if build.forceversion:
1474 logging.info("Changing the version name")
1475 for path in manifest_paths(root_dir, flavours):
1476 if not os.path.isfile(path):
1478 if has_extension(path, 'xml'):
1479 regsub_file(r'android:versionName="[^"]*"',
1480 r'android:versionName="%s"' % build.versionName,
1482 elif has_extension(path, 'gradle'):
1483 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1484 r"""\1versionName '%s'""" % build.versionName,
1487 if build.forcevercode:
1488 logging.info("Changing the version code")
1489 for path in manifest_paths(root_dir, flavours):
1490 if not os.path.isfile(path):
1492 if has_extension(path, 'xml'):
1493 regsub_file(r'android:versionCode="[^"]*"',
1494 r'android:versionCode="%s"' % build.versionCode,
1496 elif has_extension(path, 'gradle'):
1497 regsub_file(r'versionCode[ =]+[0-9]+',
1498 r'versionCode %s' % build.versionCode,
1501 # Delete unwanted files
1503 logging.info("Removing specified files")
1504 for part in getpaths(build_dir, build.rm):
1505 dest = os.path.join(build_dir, part)
1506 logging.info("Removing {0}".format(part))
1507 if os.path.lexists(dest):
1508 if os.path.islink(dest):
1509 FDroidPopen(['unlink', dest], output=False)
1511 FDroidPopen(['rm', '-rf', dest], output=False)
1513 logging.info("...but it didn't exist")
1515 remove_signing_keys(build_dir)
1517 # Add required external libraries
1519 logging.info("Collecting prebuilt libraries")
1520 libsdir = os.path.join(root_dir, 'libs')
1521 if not os.path.exists(libsdir):
1523 for lib in build.extlibs:
1525 logging.info("...installing extlib {0}".format(lib))
1526 libf = os.path.basename(lib)
1527 libsrc = os.path.join(extlib_dir, lib)
1528 if not os.path.exists(libsrc):
1529 raise BuildException("Missing extlib file {0}".format(libsrc))
1530 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1532 # Run a pre-build command if one is required
1534 logging.info("Running 'prebuild' commands in %s" % root_dir)
1536 cmd = replace_config_vars(build.prebuild, build)
1538 # Substitute source library paths into prebuild commands
1539 for name, number, libpath in srclibpaths:
1540 libpath = os.path.relpath(libpath, root_dir)
1541 cmd = cmd.replace('$$' + name + '$$', libpath)
1543 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1544 if p.returncode != 0:
1545 raise BuildException("Error running prebuild command for %s:%s" %
1546 (app.id, build.versionName), p.output)
1548 # Generate (or update) the ant build file, build.xml...
1549 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1550 parms = ['android', 'update', 'lib-project']
1551 lparms = ['android', 'update', 'project']
1554 parms += ['-t', build.target]
1555 lparms += ['-t', build.target]
1556 if build.androidupdate:
1557 update_dirs = build.androidupdate
1559 update_dirs = ant_subprojects(root_dir) + ['.']
1561 for d in update_dirs:
1562 subdir = os.path.join(root_dir, d)
1564 logging.debug("Updating main project")
1565 cmd = parms + ['-p', d]
1567 logging.debug("Updating subproject %s" % d)
1568 cmd = lparms + ['-p', d]
1569 p = SdkToolsPopen(cmd, cwd=root_dir)
1570 # Check to see whether an error was returned without a proper exit
1571 # code (this is the case for the 'no target set or target invalid'
1573 if p.returncode != 0 or p.output.startswith("Error: "):
1574 raise BuildException("Failed to update project at %s" % d, p.output)
1575 # Clean update dirs via ant
1577 logging.info("Cleaning subproject %s" % d)
1578 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1580 return (root_dir, srclibpaths)
1583 # Extend via globbing the paths from a field and return them as a map from
1584 # original path to resulting paths
1585 def getpaths_map(build_dir, globpaths):
1589 full_path = os.path.join(build_dir, p)
1590 full_path = os.path.normpath(full_path)
1591 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1593 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1597 # Extend via globbing the paths from a field and return them as a set
1598 def getpaths(build_dir, globpaths):
1599 paths_map = getpaths_map(build_dir, globpaths)
1601 for k, v in paths_map.items():
1608 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1612 """permanent store of existing APKs with the date they were added
1614 This is currently the only way to permanently store the "updated"
1619 self.path = os.path.join('stats', 'known_apks.txt')
1621 if os.path.isfile(self.path):
1622 with open(self.path, 'r', encoding='utf8') as f:
1624 t = line.rstrip().split(' ')
1626 self.apks[t[0]] = (t[1], None)
1628 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1629 self.changed = False
1631 def writeifchanged(self):
1632 if not self.changed:
1635 if not os.path.exists('stats'):
1639 for apk, app in self.apks.items():
1641 line = apk + ' ' + appid
1643 line += ' ' + added.strftime('%Y-%m-%d')
1646 with open(self.path, 'w', encoding='utf8') as f:
1647 for line in sorted(lst, key=natural_key):
1648 f.write(line + '\n')
1650 def recordapk(self, apkName, app, default_date=None):
1652 Record an apk (if it's new, otherwise does nothing)
1653 Returns the date it was added as a datetime instance
1655 if apkName not in self.apks:
1656 if default_date is None:
1657 default_date = datetime.utcnow()
1658 self.apks[apkName] = (app, default_date)
1660 _, added = self.apks[apkName]
1663 # Look up information - given the 'apkname', returns (app id, date added/None).
1664 # Or returns None for an unknown apk.
1665 def getapp(self, apkname):
1666 if apkname in self.apks:
1667 return self.apks[apkname]
1670 # Get the most recent 'num' apps added to the repo, as a list of package ids
1671 # with the most recent first.
1672 def getlatest(self, num):
1674 for apk, app in self.apks.items():
1678 if apps[appid] > added:
1682 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1683 lst = [app for app, _ in sortedapps]
1688 def get_file_extension(filename):
1689 """get the normalized file extension, can be blank string but never None"""
1690 if isinstance(filename, bytes):
1691 filename = filename.decode('utf-8')
1692 return os.path.splitext(filename)[1].lower()[1:]
1695 def get_apk_debuggable_aapt(apkfile):
1696 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1698 if p.returncode != 0:
1699 raise FDroidException("Failed to get apk manifest information")
1700 for line in p.output.splitlines():
1701 if 'android:debuggable' in line and not line.endswith('0x0'):
1706 def get_apk_debuggable_androguard(apkfile):
1708 from androguard.core.bytecodes.apk import APK
1710 raise FDroidException("androguard library is not installed and aapt not present")
1712 apkobject = APK(apkfile)
1713 if apkobject.is_valid_APK():
1714 debuggable = apkobject.get_element("application", "debuggable")
1715 if debuggable is not None:
1716 return bool(strtobool(debuggable))
1720 def isApkAndDebuggable(apkfile):
1721 """Returns True if the given file is an APK and is debuggable
1723 :param apkfile: full path to the apk to check"""
1725 if get_file_extension(apkfile) != 'apk':
1728 if SdkToolsPopen(['aapt', 'version'], output=False):
1729 return get_apk_debuggable_aapt(apkfile)
1731 return get_apk_debuggable_androguard(apkfile)
1736 self.returncode = None
1740 def SdkToolsPopen(commands, cwd=None, output=True):
1742 if cmd not in config:
1743 config[cmd] = find_sdk_tools_cmd(commands[0])
1744 abscmd = config[cmd]
1746 raise FDroidException("Could not find '%s' on your system" % cmd)
1748 test_aapt_version(config['aapt'])
1749 return FDroidPopen([abscmd] + commands[1:],
1750 cwd=cwd, output=output)
1753 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1755 Run a command and capture the possibly huge output as bytes.
1757 :param commands: command and argument list like in subprocess.Popen
1758 :param cwd: optionally specifies a working directory
1759 :param envs: a optional dictionary of environment variables and their values
1760 :returns: A PopenResult.
1765 set_FDroidPopen_env()
1767 process_env = env.copy()
1768 if envs is not None and len(envs) > 0:
1769 process_env.update(envs)
1772 cwd = os.path.normpath(cwd)
1773 logging.debug("Directory: %s" % cwd)
1774 logging.debug("> %s" % ' '.join(commands))
1776 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1777 result = PopenResult()
1780 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1781 stdout=subprocess.PIPE, stderr=stderr_param)
1782 except OSError as e:
1783 raise BuildException("OSError while trying to execute " +
1784 ' '.join(commands) + ': ' + str(e))
1786 if not stderr_to_stdout and options.verbose:
1787 stderr_queue = Queue()
1788 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1790 while not stderr_reader.eof():
1791 while not stderr_queue.empty():
1792 line = stderr_queue.get()
1793 sys.stderr.buffer.write(line)
1798 stdout_queue = Queue()
1799 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1802 # Check the queue for output (until there is no more to get)
1803 while not stdout_reader.eof():
1804 while not stdout_queue.empty():
1805 line = stdout_queue.get()
1806 if output and options.verbose:
1807 # Output directly to console
1808 sys.stderr.buffer.write(line)
1814 result.returncode = p.wait()
1815 result.output = buf.getvalue()
1820 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1822 Run a command and capture the possibly huge output as a str.
1824 :param commands: command and argument list like in subprocess.Popen
1825 :param cwd: optionally specifies a working directory
1826 :param envs: a optional dictionary of environment variables and their values
1827 :returns: A PopenResult.
1829 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1830 result.output = result.output.decode('utf-8', 'ignore')
1834 gradle_comment = re.compile(r'[ ]*//')
1835 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1836 gradle_line_matches = [
1837 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1838 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1839 re.compile(r'.*\.readLine\(.*'),
1843 def remove_signing_keys(build_dir):
1844 for root, dirs, files in os.walk(build_dir):
1845 if 'build.gradle' in files:
1846 path = os.path.join(root, 'build.gradle')
1848 with open(path, "r", encoding='utf8') as o:
1849 lines = o.readlines()
1855 with open(path, "w", encoding='utf8') as o:
1856 while i < len(lines):
1859 while line.endswith('\\\n'):
1860 line = line.rstrip('\\\n') + lines[i]
1863 if gradle_comment.match(line):
1868 opened += line.count('{')
1869 opened -= line.count('}')
1872 if gradle_signing_configs.match(line):
1877 if any(s.match(line) for s in gradle_line_matches):
1885 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1888 'project.properties',
1890 'default.properties',
1891 'ant.properties', ]:
1892 if propfile in files:
1893 path = os.path.join(root, propfile)
1895 with open(path, "r", encoding='iso-8859-1') as o:
1896 lines = o.readlines()
1900 with open(path, "w", encoding='iso-8859-1') as o:
1902 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1909 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1912 def set_FDroidPopen_env(build=None):
1914 set up the environment variables for the build environment
1916 There is only a weak standard, the variables used by gradle, so also set
1917 up the most commonly used environment variables for SDK and NDK. Also, if
1918 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1920 global env, orig_path
1924 orig_path = env['PATH']
1925 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1926 env[n] = config['sdk_path']
1927 for k, v in config['java_paths'].items():
1928 env['JAVA%s_HOME' % k] = v
1930 missinglocale = True
1931 for k, v in env.items():
1932 if k == 'LANG' and v != 'C':
1933 missinglocale = False
1935 missinglocale = False
1937 env['LANG'] = 'en_US.UTF-8'
1939 if build is not None:
1940 path = build.ndk_path()
1941 paths = orig_path.split(os.pathsep)
1942 if path not in paths:
1943 paths = [path] + paths
1944 env['PATH'] = os.pathsep.join(paths)
1945 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1946 env[n] = build.ndk_path()
1949 def replace_build_vars(cmd, build):
1950 cmd = cmd.replace('$$COMMIT$$', build.commit)
1951 cmd = cmd.replace('$$VERSION$$', build.versionName)
1952 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1956 def replace_config_vars(cmd, build):
1957 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1958 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1959 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1960 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1961 if build is not None:
1962 cmd = replace_build_vars(cmd, build)
1966 def place_srclib(root_dir, number, libpath):
1969 relpath = os.path.relpath(libpath, root_dir)
1970 proppath = os.path.join(root_dir, 'project.properties')
1973 if os.path.isfile(proppath):
1974 with open(proppath, "r", encoding='iso-8859-1') as o:
1975 lines = o.readlines()
1977 with open(proppath, "w", encoding='iso-8859-1') as o:
1980 if line.startswith('android.library.reference.%d=' % number):
1981 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1986 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1989 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1992 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1993 """Verify that two apks are the same
1995 One of the inputs is signed, the other is unsigned. The signature metadata
1996 is transferred from the signed to the unsigned apk, and then jarsigner is
1997 used to verify that the signature from the signed apk is also varlid for
1998 the unsigned one. If the APK given as unsigned actually does have a
1999 signature, it will be stripped out and ignored.
2001 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2002 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2003 into AndroidManifest.xml, but that makes the build not reproducible. So
2004 instead they are included as separate files in the APK's META-INF/ folder.
2005 If those files exist in the signed APK, they will be part of the signature
2006 and need to also be included in the unsigned APK for it to validate.
2008 :param signed_apk: Path to a signed apk file
2009 :param unsigned_apk: Path to an unsigned apk file expected to match it
2010 :param tmp_dir: Path to directory for temporary files
2011 :returns: None if the verification is successful, otherwise a string
2012 describing what went wrong.
2015 signed = ZipFile(signed_apk, 'r')
2016 meta_inf_files = ['META-INF/MANIFEST.MF']
2017 for f in signed.namelist():
2018 if apk_sigfile.match(f) \
2019 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2020 meta_inf_files.append(f)
2021 if len(meta_inf_files) < 3:
2022 return "Signature files missing from {0}".format(signed_apk)
2024 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2025 unsigned = ZipFile(unsigned_apk, 'r')
2026 # only read the signature from the signed APK, everything else from unsigned
2027 with ZipFile(tmp_apk, 'w') as tmp:
2028 for filename in meta_inf_files:
2029 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2030 for info in unsigned.infolist():
2031 if info.filename in meta_inf_files:
2032 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2034 if info.filename in tmp.namelist():
2035 return "duplicate filename found: " + info.filename
2036 tmp.writestr(info, unsigned.read(info.filename))
2040 verified = verify_apk_signature(tmp_apk)
2043 logging.info("...NOT verified - {0}".format(tmp_apk))
2044 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2045 os.path.dirname(unsigned_apk))
2047 logging.info("...successfully verified")
2051 def verify_apk_signature(apk, jar=False):
2052 """verify the signature on an APK
2054 Try to use apksigner whenever possible since jarsigner is very
2055 shitty: unsigned APKs pass as "verified"! So this has to turn on
2056 -strict then check for result 4.
2058 You can set :param: jar to True if you want to use this method
2059 to verify jar signatures.
2061 if set_command_in_config('apksigner'):
2062 args = [config['apksigner'], 'verify']
2064 args += ['--min-sdk-version=1']
2065 return subprocess.call(args + [apk]) == 0
2067 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2068 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2071 def verify_old_apk_signature(apk):
2072 """verify the signature on an archived APK, supporting deprecated algorithms
2074 F-Droid aims to keep every single binary that it ever published. Therefore,
2075 it needs to be able to verify APK signatures that include deprecated/removed
2076 algorithms. For example, jarsigner treats an MD5 signature as unsigned.
2078 jarsigner passes unsigned APKs as "verified"! So this has to turn
2079 on -strict then check for result 4.
2083 _java_security = os.path.join(os.getcwd(), '.java.security')
2084 with open(_java_security, 'w') as fp:
2085 fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2087 return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2088 '-strict', '-verify', apk]) == 4
2091 apk_badchars = re.compile('''[/ :;'"]''')
2094 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2097 Returns None if the apk content is the same (apart from the signing key),
2098 otherwise a string describing what's different, or what went wrong when
2099 trying to do the comparison.
2105 absapk1 = os.path.abspath(apk1)
2106 absapk2 = os.path.abspath(apk2)
2108 if set_command_in_config('diffoscope'):
2109 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2110 htmlfile = logfilename + '.diffoscope.html'
2111 textfile = logfilename + '.diffoscope.txt'
2112 if subprocess.call([config['diffoscope'],
2113 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2114 '--html', htmlfile, '--text', textfile,
2115 absapk1, absapk2]) != 0:
2116 return("Failed to unpack " + apk1)
2118 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2119 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2120 for d in [apk1dir, apk2dir]:
2121 if os.path.exists(d):
2124 os.mkdir(os.path.join(d, 'jar-xf'))
2126 if subprocess.call(['jar', 'xf',
2127 os.path.abspath(apk1)],
2128 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2129 return("Failed to unpack " + apk1)
2130 if subprocess.call(['jar', 'xf',
2131 os.path.abspath(apk2)],
2132 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2133 return("Failed to unpack " + apk2)
2135 if set_command_in_config('apktool'):
2136 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2138 return("Failed to unpack " + apk1)
2139 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2141 return("Failed to unpack " + apk2)
2143 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2144 lines = p.output.splitlines()
2145 if len(lines) != 1 or 'META-INF' not in lines[0]:
2146 if set_command_in_config('meld'):
2147 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2148 return("Unexpected diff output - " + p.output)
2150 # since everything verifies, delete the comparison to keep cruft down
2151 shutil.rmtree(apk1dir)
2152 shutil.rmtree(apk2dir)
2154 # If we get here, it seems like they're the same!
2158 def set_command_in_config(command):
2159 '''Try to find specified command in the path, if it hasn't been
2160 manually set in config.py. If found, it is added to the config
2161 dict. The return value says whether the command is available.
2164 if command in config:
2167 tmp = find_command(command)
2169 config[command] = tmp
2174 def find_command(command):
2175 '''find the full path of a command, or None if it can't be found in the PATH'''
2178 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2180 fpath, fname = os.path.split(command)
2185 for path in os.environ["PATH"].split(os.pathsep):
2186 path = path.strip('"')
2187 exe_file = os.path.join(path, command)
2188 if is_exe(exe_file):
2195 '''generate a random password for when generating keys'''
2196 h = hashlib.sha256()
2197 h.update(os.urandom(16)) # salt
2198 h.update(socket.getfqdn().encode('utf-8'))
2199 passwd = base64.b64encode(h.digest()).strip()
2200 return passwd.decode('utf-8')
2203 def genkeystore(localconfig):
2205 Generate a new key with password provided in :param localconfig and add it to new keystore
2206 :return: hexed public key, public key fingerprint
2208 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2209 keystoredir = os.path.dirname(localconfig['keystore'])
2210 if keystoredir is None or keystoredir == '':
2211 keystoredir = os.path.join(os.getcwd(), keystoredir)
2212 if not os.path.exists(keystoredir):
2213 os.makedirs(keystoredir, mode=0o700)
2216 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2217 'FDROID_KEY_PASS': localconfig['keypass'],
2219 p = FDroidPopen([config['keytool'], '-genkey',
2220 '-keystore', localconfig['keystore'],
2221 '-alias', localconfig['repo_keyalias'],
2222 '-keyalg', 'RSA', '-keysize', '4096',
2223 '-sigalg', 'SHA256withRSA',
2224 '-validity', '10000',
2225 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2226 '-keypass:env', 'FDROID_KEY_PASS',
2227 '-dname', localconfig['keydname']], envs=env_vars)
2228 if p.returncode != 0:
2229 raise BuildException("Failed to generate key", p.output)
2230 os.chmod(localconfig['keystore'], 0o0600)
2231 if not options.quiet:
2232 # now show the lovely key that was just generated
2233 p = FDroidPopen([config['keytool'], '-list', '-v',
2234 '-keystore', localconfig['keystore'],
2235 '-alias', localconfig['repo_keyalias'],
2236 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2237 logging.info(p.output.strip() + '\n\n')
2238 # get the public key
2239 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2240 '-keystore', localconfig['keystore'],
2241 '-alias', localconfig['repo_keyalias'],
2242 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2243 + config['smartcardoptions'],
2244 envs=env_vars, output=False, stderr_to_stdout=False)
2245 if p.returncode != 0 or len(p.output) < 20:
2246 raise BuildException("Failed to get public key", p.output)
2248 fingerprint = get_cert_fingerprint(pubkey)
2249 return hexlify(pubkey), fingerprint
2252 def get_cert_fingerprint(pubkey):
2254 Generate a certificate fingerprint the same way keytool does it
2255 (but with slightly different formatting)
2257 digest = hashlib.sha256(pubkey).digest()
2258 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2259 return " ".join(ret)
2262 def get_certificate(certificate_file):
2264 Extracts a certificate from the given file.
2265 :param certificate_file: file bytes (as string) representing the certificate
2266 :return: A binary representation of the certificate's public key, or None in case of error
2268 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2269 if content.getComponentByName('contentType') != rfc2315.signedData:
2271 content = decoder.decode(content.getComponentByName('content'),
2272 asn1Spec=rfc2315.SignedData())[0]
2274 certificates = content.getComponentByName('certificates')
2275 cert = certificates[0].getComponentByName('certificate')
2277 logging.error("Certificates not found.")
2279 return encoder.encode(cert)
2282 def write_to_config(thisconfig, key, value=None, config_file=None):
2283 '''write a key/value to the local config.py
2285 NOTE: only supports writing string variables.
2287 :param thisconfig: config dictionary
2288 :param key: variable name in config.py to be overwritten/added
2289 :param value: optional value to be written, instead of fetched
2290 from 'thisconfig' dictionary.
2293 origkey = key + '_orig'
2294 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2295 cfg = config_file if config_file else 'config.py'
2298 with open(cfg, 'r', encoding="utf-8") as f:
2299 lines = f.readlines()
2301 # make sure the file ends with a carraige return
2303 if not lines[-1].endswith('\n'):
2306 # regex for finding and replacing python string variable
2307 # definitions/initializations
2308 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2309 repl = key + ' = "' + value + '"'
2310 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2311 repl2 = key + " = '" + value + "'"
2313 # If we replaced this line once, we make sure won't be a
2314 # second instance of this line for this key in the document.
2317 with open(cfg, 'w', encoding="utf-8") as f:
2319 if pattern.match(line) or pattern2.match(line):
2321 line = pattern.sub(repl, line)
2322 line = pattern2.sub(repl2, line)
2333 def parse_xml(path):
2334 return XMLElementTree.parse(path).getroot()
2337 def string_is_integer(string):
2345 def get_per_app_repos():
2346 '''per-app repos are dirs named with the packageName of a single app'''
2348 # Android packageNames are Java packages, they may contain uppercase or
2349 # lowercase letters ('A' through 'Z'), numbers, and underscores
2350 # ('_'). However, individual package name parts may only start with
2351 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2352 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2355 for root, dirs, files in os.walk(os.getcwd()):
2357 print('checking', root, 'for', d)
2358 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2359 # standard parts of an fdroid repo, so never packageNames
2362 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2368 def is_repo_file(filename):
2369 '''Whether the file in a repo is a build product to be delivered to users'''
2370 if isinstance(filename, str):
2371 filename = filename.encode('utf-8', errors="surrogateescape")
2372 return os.path.isfile(filename) \
2373 and not filename.endswith(b'.asc') \
2374 and not filename.endswith(b'.sig') \
2375 and os.path.basename(filename) not in [
2377 b'index_unsigned.jar',