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 Given the arguments in the form of multiple appid:[vc] strings, this returns
401 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 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
480 def clean_description(description):
481 'Remove unneeded newlines and spaces from a block of description text'
483 # this is split up by paragraph to make removing the newlines easier
484 for paragraph in re.split(r'\n\n', description):
485 paragraph = re.sub('\r', '', paragraph)
486 paragraph = re.sub('\n', ' ', paragraph)
487 paragraph = re.sub(' {2,}', ' ', paragraph)
488 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
489 returnstring += paragraph + '\n\n'
490 return returnstring.rstrip('\n')
493 def publishednameinfo(filename):
494 filename = os.path.basename(filename)
495 m = publish_name_regex.match(filename)
497 result = (m.group(1), m.group(2))
498 except AttributeError:
499 raise FDroidException("Invalid name for published file: %s" % filename)
503 def get_release_filename(app, build):
505 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
507 return "%s_%s.apk" % (app.id, build.versionCode)
510 def get_toolsversion_logname(app, build):
511 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
514 def getsrcname(app, build):
515 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
527 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
530 def get_build_dir(app):
531 '''get the dir that this app will be built in'''
533 if app.RepoType == 'srclib':
534 return os.path.join('build', 'srclib', app.Repo)
536 return os.path.join('build', app.id)
540 '''checkout code from VCS and return instance of vcs and the build dir'''
541 build_dir = get_build_dir(app)
543 # Set up vcs interface and make sure we have the latest code...
544 logging.debug("Getting {0} vcs interface for {1}"
545 .format(app.RepoType, app.Repo))
546 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
550 vcs = getvcs(app.RepoType, remote, build_dir)
552 return vcs, build_dir
555 def getvcs(vcstype, remote, local):
557 return vcs_git(remote, local)
558 if vcstype == 'git-svn':
559 return vcs_gitsvn(remote, local)
561 return vcs_hg(remote, local)
563 return vcs_bzr(remote, local)
564 if vcstype == 'srclib':
565 if local != os.path.join('build', 'srclib', remote):
566 raise VCSException("Error: srclib paths are hard-coded!")
567 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
569 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
570 raise VCSException("Invalid vcs type " + vcstype)
573 def getsrclibvcs(name):
574 if name not in fdroidserver.metadata.srclibs:
575 raise VCSException("Missing srclib " + name)
576 return fdroidserver.metadata.srclibs[name]['Repo Type']
581 def __init__(self, remote, local):
583 # svn, git-svn and bzr may require auth
585 if self.repotype() in ('git-svn', 'bzr'):
587 if self.repotype == 'git-svn':
588 raise VCSException("Authentication is not supported for git-svn")
589 self.username, remote = remote.split('@')
590 if ':' not in self.username:
591 raise VCSException("Password required with username")
592 self.username, self.password = self.username.split(':')
596 self.clone_failed = False
597 self.refreshed = False
603 # Take the local repository to a clean version of the given revision, which
604 # is specificed in the VCS's native format. Beforehand, the repository can
605 # be dirty, or even non-existent. If the repository does already exist
606 # locally, it will be updated from the origin, but only once in the
607 # lifetime of the vcs object.
608 # None is acceptable for 'rev' if you know you are cloning a clean copy of
609 # the repo - otherwise it must specify a valid revision.
610 def gotorevision(self, rev, refresh=True):
612 if self.clone_failed:
613 raise VCSException("Downloading the repository already failed once, not trying again.")
615 # The .fdroidvcs-id file for a repo tells us what VCS type
616 # and remote that directory was created from, allowing us to drop it
617 # automatically if either of those things changes.
618 fdpath = os.path.join(self.local, '..',
619 '.fdroidvcs-' + os.path.basename(self.local))
620 fdpath = os.path.normpath(fdpath)
621 cdata = self.repotype() + ' ' + self.remote
624 if os.path.exists(self.local):
625 if os.path.exists(fdpath):
626 with open(fdpath, 'r') as f:
627 fsdata = f.read().strip()
632 logging.info("Repository details for %s changed - deleting" % (
636 logging.info("Repository details for %s missing - deleting" % (
639 shutil.rmtree(self.local)
643 self.refreshed = True
646 self.gotorevisionx(rev)
647 except FDroidException as e:
650 # If necessary, write the .fdroidvcs file.
651 if writeback and not self.clone_failed:
652 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
653 with open(fdpath, 'w+') as f:
659 # Derived classes need to implement this. It's called once basic checking
660 # has been performend.
661 def gotorevisionx(self, rev): # pylint: disable=unused-argument
662 raise VCSException("This VCS type doesn't define gotorevisionx")
664 # Initialise and update submodules
665 def initsubmodules(self):
666 raise VCSException('Submodules not supported for this vcs type')
668 # Get a list of all known tags
670 if not self._gettags:
671 raise VCSException('gettags not supported for this vcs type')
673 for tag in self._gettags():
674 if re.match('[-A-Za-z0-9_. /]+$', tag):
678 # Get a list of all the known tags, sorted from newest to oldest
679 def latesttags(self):
680 raise VCSException('latesttags not supported for this vcs type')
682 # Get current commit reference (hash, revision, etc)
684 raise VCSException('getref not supported for this vcs type')
686 # Returns the srclib (name, path) used in setting up the current
697 # If the local directory exists, but is somehow not a git repository, git
698 # will traverse up the directory tree until it finds one that is (i.e.
699 # fdroidserver) and then we'll proceed to destroy it! This is called as
702 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
703 result = p.output.rstrip()
704 if not result.endswith(self.local):
705 raise VCSException('Repository mismatch')
707 def gotorevisionx(self, rev):
708 if not os.path.exists(self.local):
710 p = FDroidPopen(['git', 'clone', self.remote, self.local])
711 if p.returncode != 0:
712 self.clone_failed = True
713 raise VCSException("Git clone failed", p.output)
717 # Discard any working tree changes
718 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
719 'git', 'reset', '--hard'], cwd=self.local, output=False)
720 if p.returncode != 0:
721 raise VCSException("Git reset failed", p.output)
722 # Remove untracked files now, in case they're tracked in the target
723 # revision (it happens!)
724 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
725 'git', 'clean', '-dffx'], cwd=self.local, output=False)
726 if p.returncode != 0:
727 raise VCSException("Git clean failed", p.output)
728 if not self.refreshed:
729 # Get latest commits and tags from remote
730 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
731 if p.returncode != 0:
732 raise VCSException("Git fetch failed", p.output)
733 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
734 if p.returncode != 0:
735 raise VCSException("Git fetch failed", p.output)
736 # Recreate origin/HEAD as git clone would do it, in case it disappeared
737 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
738 if p.returncode != 0:
739 lines = p.output.splitlines()
740 if 'Multiple remote HEAD branches' not in lines[0]:
741 raise VCSException("Git remote set-head failed", p.output)
742 branch = lines[1].split(' ')[-1]
743 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
744 if p2.returncode != 0:
745 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
746 self.refreshed = True
747 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
748 # a github repo. Most of the time this is the same as origin/master.
749 rev = rev or 'origin/HEAD'
750 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
751 if p.returncode != 0:
752 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
753 # Get rid of any uncontrolled files left behind
754 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
755 if p.returncode != 0:
756 raise VCSException("Git clean failed", p.output)
758 def initsubmodules(self):
760 submfile = os.path.join(self.local, '.gitmodules')
761 if not os.path.isfile(submfile):
762 raise VCSException("No git submodules available")
764 # fix submodules not accessible without an account and public key auth
765 with open(submfile, 'r') as f:
766 lines = f.readlines()
767 with open(submfile, 'w') as f:
769 if 'git@github.com' in line:
770 line = line.replace('git@github.com:', 'https://github.com/')
771 if 'git@gitlab.com' in line:
772 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
775 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
776 if p.returncode != 0:
777 raise VCSException("Git submodule sync failed", p.output)
778 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
779 if p.returncode != 0:
780 raise VCSException("Git submodule update failed", p.output)
784 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
785 return p.output.splitlines()
787 tag_format = re.compile(r'tag: ([^),]*)')
789 def latesttags(self):
791 p = FDroidPopen(['git', 'log', '--tags',
792 '--simplify-by-decoration', '--pretty=format:%d'],
793 cwd=self.local, output=False)
795 for line in p.output.splitlines():
796 for tag in self.tag_format.findall(line):
801 class vcs_gitsvn(vcs):
806 # If the local directory exists, but is somehow not a git repository, git
807 # will traverse up the directory tree until it finds one that is (i.e.
808 # fdroidserver) and then we'll proceed to destory it! This is called as
811 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
812 result = p.output.rstrip()
813 if not result.endswith(self.local):
814 raise VCSException('Repository mismatch')
816 def gotorevisionx(self, rev):
817 if not os.path.exists(self.local):
819 gitsvn_args = ['git', 'svn', 'clone']
820 if ';' in self.remote:
821 remote_split = self.remote.split(';')
822 for i in remote_split[1:]:
823 if i.startswith('trunk='):
824 gitsvn_args.extend(['-T', i[6:]])
825 elif i.startswith('tags='):
826 gitsvn_args.extend(['-t', i[5:]])
827 elif i.startswith('branches='):
828 gitsvn_args.extend(['-b', i[9:]])
829 gitsvn_args.extend([remote_split[0], self.local])
830 p = FDroidPopen(gitsvn_args, output=False)
831 if p.returncode != 0:
832 self.clone_failed = True
833 raise VCSException("Git svn clone failed", p.output)
835 gitsvn_args.extend([self.remote, self.local])
836 p = FDroidPopen(gitsvn_args, output=False)
837 if p.returncode != 0:
838 self.clone_failed = True
839 raise VCSException("Git svn clone failed", p.output)
843 # Discard any working tree changes
844 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
845 if p.returncode != 0:
846 raise VCSException("Git reset failed", p.output)
847 # Remove untracked files now, in case they're tracked in the target
848 # revision (it happens!)
849 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
850 if p.returncode != 0:
851 raise VCSException("Git clean failed", p.output)
852 if not self.refreshed:
853 # Get new commits, branches and tags from repo
854 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Git svn fetch failed")
857 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
858 if p.returncode != 0:
859 raise VCSException("Git svn rebase failed", p.output)
860 self.refreshed = True
862 rev = rev or 'master'
864 nospaces_rev = rev.replace(' ', '%20')
865 # Try finding a svn tag
866 for treeish in ['origin/', '']:
867 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
868 if p.returncode == 0:
870 if p.returncode != 0:
871 # No tag found, normal svn rev translation
872 # Translate svn rev into git format
873 rev_split = rev.split('/')
876 for treeish in ['origin/', '']:
877 if len(rev_split) > 1:
878 treeish += rev_split[0]
879 svn_rev = rev_split[1]
882 # if no branch is specified, then assume trunk (i.e. 'master' branch):
886 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
888 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
889 git_rev = p.output.rstrip()
891 if p.returncode == 0 and git_rev:
894 if p.returncode != 0 or not git_rev:
895 # Try a plain git checkout as a last resort
896 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
897 if p.returncode != 0:
898 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
900 # Check out the git rev equivalent to the svn rev
901 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
902 if p.returncode != 0:
903 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
905 # Get rid of any uncontrolled files left behind
906 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
907 if p.returncode != 0:
908 raise VCSException("Git clean failed", p.output)
912 for treeish in ['origin/', '']:
913 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
919 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
920 if p.returncode != 0:
922 return p.output.strip()
930 def gotorevisionx(self, rev):
931 if not os.path.exists(self.local):
932 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
933 if p.returncode != 0:
934 self.clone_failed = True
935 raise VCSException("Hg clone failed", p.output)
937 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
938 if p.returncode != 0:
939 raise VCSException("Hg status failed", p.output)
940 for line in p.output.splitlines():
941 if not line.startswith('? '):
942 raise VCSException("Unexpected output from hg status -uS: " + line)
943 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
944 if not self.refreshed:
945 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
946 if p.returncode != 0:
947 raise VCSException("Hg pull failed", p.output)
948 self.refreshed = True
950 rev = rev or 'default'
953 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
954 if p.returncode != 0:
955 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
956 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
957 # Also delete untracked files, we have to enable purge extension for that:
958 if "'purge' is provided by the following extension" in p.output:
959 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
960 myfile.write("\n[extensions]\nhgext.purge=\n")
961 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
962 if p.returncode != 0:
963 raise VCSException("HG purge failed", p.output)
964 elif p.returncode != 0:
965 raise VCSException("HG purge failed", p.output)
968 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
969 return p.output.splitlines()[1:]
977 def gotorevisionx(self, rev):
978 if not os.path.exists(self.local):
979 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
980 if p.returncode != 0:
981 self.clone_failed = True
982 raise VCSException("Bzr branch failed", p.output)
984 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
985 if p.returncode != 0:
986 raise VCSException("Bzr revert failed", p.output)
987 if not self.refreshed:
988 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
989 if p.returncode != 0:
990 raise VCSException("Bzr update failed", p.output)
991 self.refreshed = True
993 revargs = list(['-r', rev] if rev else [])
994 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
995 if p.returncode != 0:
996 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
999 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1000 return [tag.split(' ')[0].strip() for tag in
1001 p.output.splitlines()]
1004 def unescape_string(string):
1007 if string[0] == '"' and string[-1] == '"':
1010 return string.replace("\\'", "'")
1013 def retrieve_string(app_dir, string, xmlfiles=None):
1015 if not string.startswith('@string/'):
1016 return unescape_string(string)
1018 if xmlfiles is None:
1021 os.path.join(app_dir, 'res'),
1022 os.path.join(app_dir, 'src', 'main', 'res'),
1024 for r, d, f in os.walk(res_dir):
1025 if os.path.basename(r) == 'values':
1026 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1028 name = string[len('@string/'):]
1030 def element_content(element):
1031 if element.text is None:
1033 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1034 return s.decode('utf-8').strip()
1036 for path in xmlfiles:
1037 if not os.path.isfile(path):
1039 xml = parse_xml(path)
1040 element = xml.find('string[@name="' + name + '"]')
1041 if element is not None:
1042 content = element_content(element)
1043 return retrieve_string(app_dir, content, xmlfiles)
1048 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1049 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1052 def manifest_paths(app_dir, flavours):
1053 '''Return list of existing files that will be used to find the highest vercode'''
1055 possible_manifests = \
1056 [os.path.join(app_dir, 'AndroidManifest.xml'),
1057 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1058 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1059 os.path.join(app_dir, 'build.gradle')]
1061 for flavour in flavours:
1062 if flavour == 'yes':
1064 possible_manifests.append(
1065 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1067 return [path for path in possible_manifests if os.path.isfile(path)]
1070 def fetch_real_name(app_dir, flavours):
1071 '''Retrieve the package name. Returns the name, or None if not found.'''
1072 for path in manifest_paths(app_dir, flavours):
1073 if not has_extension(path, 'xml') or not os.path.isfile(path):
1075 logging.debug("fetch_real_name: Checking manifest at " + path)
1076 xml = parse_xml(path)
1077 app = xml.find('application')
1080 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1082 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1083 result = retrieve_string_singleline(app_dir, label)
1085 result = result.strip()
1090 def get_library_references(root_dir):
1092 proppath = os.path.join(root_dir, 'project.properties')
1093 if not os.path.isfile(proppath):
1095 with open(proppath, 'r', encoding='iso-8859-1') as f:
1097 if not line.startswith('android.library.reference.'):
1099 path = line.split('=')[1].strip()
1100 relpath = os.path.join(root_dir, path)
1101 if not os.path.isdir(relpath):
1103 logging.debug("Found subproject at %s" % path)
1104 libraries.append(path)
1108 def ant_subprojects(root_dir):
1109 subprojects = get_library_references(root_dir)
1110 for subpath in subprojects:
1111 subrelpath = os.path.join(root_dir, subpath)
1112 for p in get_library_references(subrelpath):
1113 relp = os.path.normpath(os.path.join(subpath, p))
1114 if relp not in subprojects:
1115 subprojects.insert(0, relp)
1119 def remove_debuggable_flags(root_dir):
1120 # Remove forced debuggable flags
1121 logging.debug("Removing debuggable flags from %s" % root_dir)
1122 for root, dirs, files in os.walk(root_dir):
1123 if 'AndroidManifest.xml' in files:
1124 regsub_file(r'android:debuggable="[^"]*"',
1126 os.path.join(root, 'AndroidManifest.xml'))
1129 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1130 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1131 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1134 def app_matches_packagename(app, package):
1137 appid = app.UpdateCheckName or app.id
1138 if appid is None or appid == "Ignore":
1140 return appid == package
1143 def parse_androidmanifests(paths, app):
1145 Extract some information from the AndroidManifest.xml at the given path.
1146 Returns (version, vercode, package), any or all of which might be None.
1147 All values returned are strings.
1150 ignoreversions = app.UpdateCheckIgnore
1151 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1154 return (None, None, None)
1162 if not os.path.isfile(path):
1165 logging.debug("Parsing manifest at {0}".format(path))
1170 if has_extension(path, 'gradle'):
1171 with open(path, 'r') as f:
1173 if gradle_comment.match(line):
1175 # Grab first occurence of each to avoid running into
1176 # alternative flavours and builds.
1178 matches = psearch_g(line)
1180 s = matches.group(2)
1181 if app_matches_packagename(app, s):
1184 matches = vnsearch_g(line)
1186 version = matches.group(2)
1188 matches = vcsearch_g(line)
1190 vercode = matches.group(1)
1193 xml = parse_xml(path)
1194 if "package" in xml.attrib:
1195 s = xml.attrib["package"]
1196 if app_matches_packagename(app, s):
1198 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1199 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1200 base_dir = os.path.dirname(path)
1201 version = retrieve_string_singleline(base_dir, version)
1202 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1203 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1204 if string_is_integer(a):
1207 logging.warning("Problem with xml at {0}".format(path))
1209 # Remember package name, may be defined separately from version+vercode
1211 package = max_package
1213 logging.debug("..got package={0}, version={1}, vercode={2}"
1214 .format(package, version, vercode))
1216 # Always grab the package name and version name in case they are not
1217 # together with the highest version code
1218 if max_package is None and package is not None:
1219 max_package = package
1220 if max_version is None and version is not None:
1221 max_version = version
1223 if vercode is not None \
1224 and (max_vercode is None or vercode > max_vercode):
1225 if not ignoresearch or not ignoresearch(version):
1226 if version is not None:
1227 max_version = version
1228 if vercode is not None:
1229 max_vercode = vercode
1230 if package is not None:
1231 max_package = package
1233 max_version = "Ignore"
1235 if max_version is None:
1236 max_version = "Unknown"
1238 if max_package and not is_valid_package_name(max_package):
1239 raise FDroidException("Invalid package name {0}".format(max_package))
1241 return (max_version, max_vercode, max_package)
1244 def is_valid_package_name(name):
1245 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1248 # Get the specified source library.
1249 # Returns the path to it. Normally this is the path to be used when referencing
1250 # it, which may be a subdirectory of the actual project. If you want the base
1251 # directory of the project, pass 'basepath=True'.
1252 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1253 raw=False, prepare=True, preponly=False, refresh=True,
1262 name, ref = spec.split('@')
1264 number, name = name.split(':', 1)
1266 name, subdir = name.split('/', 1)
1268 if name not in fdroidserver.metadata.srclibs:
1269 raise VCSException('srclib ' + name + ' not found.')
1271 srclib = fdroidserver.metadata.srclibs[name]
1273 sdir = os.path.join(srclib_dir, name)
1276 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1277 vcs.srclib = (name, number, sdir)
1279 vcs.gotorevision(ref, refresh)
1286 libdir = os.path.join(sdir, subdir)
1287 elif srclib["Subdir"]:
1288 for subdir in srclib["Subdir"]:
1289 libdir_candidate = os.path.join(sdir, subdir)
1290 if os.path.exists(libdir_candidate):
1291 libdir = libdir_candidate
1297 remove_signing_keys(sdir)
1298 remove_debuggable_flags(sdir)
1302 if srclib["Prepare"]:
1303 cmd = replace_config_vars(srclib["Prepare"], build)
1305 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1306 if p.returncode != 0:
1307 raise BuildException("Error running prepare command for srclib %s"
1313 return (name, number, libdir)
1316 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1319 # Prepare the source code for a particular build
1320 # 'vcs' - the appropriate vcs object for the application
1321 # 'app' - the application details from the metadata
1322 # 'build' - the build details from the metadata
1323 # 'build_dir' - the path to the build directory, usually
1325 # 'srclib_dir' - the path to the source libraries directory, usually
1327 # 'extlib_dir' - the path to the external libraries directory, usually
1329 # Returns the (root, srclibpaths) where:
1330 # 'root' is the root directory, which may be the same as 'build_dir' or may
1331 # be a subdirectory of it.
1332 # 'srclibpaths' is information on the srclibs being used
1333 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1335 # Optionally, the actual app source can be in a subdirectory
1337 root_dir = os.path.join(build_dir, build.subdir)
1339 root_dir = build_dir
1341 # Get a working copy of the right revision
1342 logging.info("Getting source for revision " + build.commit)
1343 vcs.gotorevision(build.commit, refresh)
1345 # Initialise submodules if required
1346 if build.submodules:
1347 logging.info("Initialising submodules")
1348 vcs.initsubmodules()
1350 # Check that a subdir (if we're using one) exists. This has to happen
1351 # after the checkout, since it might not exist elsewhere
1352 if not os.path.exists(root_dir):
1353 raise BuildException('Missing subdir ' + root_dir)
1355 # Run an init command if one is required
1357 cmd = replace_config_vars(build.init, build)
1358 logging.info("Running 'init' commands in %s" % root_dir)
1360 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1361 if p.returncode != 0:
1362 raise BuildException("Error running init command for %s:%s" %
1363 (app.id, build.versionName), p.output)
1365 # Apply patches if any
1367 logging.info("Applying patches")
1368 for patch in build.patch:
1369 patch = patch.strip()
1370 logging.info("Applying " + patch)
1371 patch_path = os.path.join('metadata', app.id, patch)
1372 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1373 if p.returncode != 0:
1374 raise BuildException("Failed to apply patch %s" % patch_path)
1376 # Get required source libraries
1379 logging.info("Collecting source libraries")
1380 for lib in build.srclibs:
1381 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1382 refresh=refresh, build=build))
1384 for name, number, libpath in srclibpaths:
1385 place_srclib(root_dir, int(number) if number else None, libpath)
1387 basesrclib = vcs.getsrclib()
1388 # If one was used for the main source, add that too.
1390 srclibpaths.append(basesrclib)
1392 # Update the local.properties file
1393 localprops = [os.path.join(build_dir, 'local.properties')]
1395 parts = build.subdir.split(os.sep)
1398 cur = os.path.join(cur, d)
1399 localprops += [os.path.join(cur, 'local.properties')]
1400 for path in localprops:
1402 if os.path.isfile(path):
1403 logging.info("Updating local.properties file at %s" % path)
1404 with open(path, 'r', encoding='iso-8859-1') as f:
1408 logging.info("Creating local.properties file at %s" % path)
1409 # Fix old-fashioned 'sdk-location' by copying
1410 # from sdk.dir, if necessary
1412 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1413 re.S | re.M).group(1)
1414 props += "sdk-location=%s\n" % sdkloc
1416 props += "sdk.dir=%s\n" % config['sdk_path']
1417 props += "sdk-location=%s\n" % config['sdk_path']
1418 ndk_path = build.ndk_path()
1419 # if for any reason the path isn't valid or the directory
1420 # doesn't exist, some versions of Gradle will error with a
1421 # cryptic message (even if the NDK is not even necessary).
1422 # https://gitlab.com/fdroid/fdroidserver/issues/171
1423 if ndk_path and os.path.exists(ndk_path):
1425 props += "ndk.dir=%s\n" % ndk_path
1426 props += "ndk-location=%s\n" % ndk_path
1427 # Add java.encoding if necessary
1429 props += "java.encoding=%s\n" % build.encoding
1430 with open(path, 'w', encoding='iso-8859-1') as f:
1434 if build.build_method() == 'gradle':
1435 flavours = build.gradle
1438 n = build.target.split('-')[1]
1439 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1440 r'compileSdkVersion %s' % n,
1441 os.path.join(root_dir, 'build.gradle'))
1443 # Remove forced debuggable flags
1444 remove_debuggable_flags(root_dir)
1446 # Insert version code and number into the manifest if necessary
1447 if build.forceversion:
1448 logging.info("Changing the version name")
1449 for path in manifest_paths(root_dir, flavours):
1450 if not os.path.isfile(path):
1452 if has_extension(path, 'xml'):
1453 regsub_file(r'android:versionName="[^"]*"',
1454 r'android:versionName="%s"' % build.versionName,
1456 elif has_extension(path, 'gradle'):
1457 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1458 r"""\1versionName '%s'""" % build.versionName,
1461 if build.forcevercode:
1462 logging.info("Changing the version code")
1463 for path in manifest_paths(root_dir, flavours):
1464 if not os.path.isfile(path):
1466 if has_extension(path, 'xml'):
1467 regsub_file(r'android:versionCode="[^"]*"',
1468 r'android:versionCode="%s"' % build.versionCode,
1470 elif has_extension(path, 'gradle'):
1471 regsub_file(r'versionCode[ =]+[0-9]+',
1472 r'versionCode %s' % build.versionCode,
1475 # Delete unwanted files
1477 logging.info("Removing specified files")
1478 for part in getpaths(build_dir, build.rm):
1479 dest = os.path.join(build_dir, part)
1480 logging.info("Removing {0}".format(part))
1481 if os.path.lexists(dest):
1482 if os.path.islink(dest):
1483 FDroidPopen(['unlink', dest], output=False)
1485 FDroidPopen(['rm', '-rf', dest], output=False)
1487 logging.info("...but it didn't exist")
1489 remove_signing_keys(build_dir)
1491 # Add required external libraries
1493 logging.info("Collecting prebuilt libraries")
1494 libsdir = os.path.join(root_dir, 'libs')
1495 if not os.path.exists(libsdir):
1497 for lib in build.extlibs:
1499 logging.info("...installing extlib {0}".format(lib))
1500 libf = os.path.basename(lib)
1501 libsrc = os.path.join(extlib_dir, lib)
1502 if not os.path.exists(libsrc):
1503 raise BuildException("Missing extlib file {0}".format(libsrc))
1504 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1506 # Run a pre-build command if one is required
1508 logging.info("Running 'prebuild' commands in %s" % root_dir)
1510 cmd = replace_config_vars(build.prebuild, build)
1512 # Substitute source library paths into prebuild commands
1513 for name, number, libpath in srclibpaths:
1514 libpath = os.path.relpath(libpath, root_dir)
1515 cmd = cmd.replace('$$' + name + '$$', libpath)
1517 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1518 if p.returncode != 0:
1519 raise BuildException("Error running prebuild command for %s:%s" %
1520 (app.id, build.versionName), p.output)
1522 # Generate (or update) the ant build file, build.xml...
1523 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1524 parms = ['android', 'update', 'lib-project']
1525 lparms = ['android', 'update', 'project']
1528 parms += ['-t', build.target]
1529 lparms += ['-t', build.target]
1530 if build.androidupdate:
1531 update_dirs = build.androidupdate
1533 update_dirs = ant_subprojects(root_dir) + ['.']
1535 for d in update_dirs:
1536 subdir = os.path.join(root_dir, d)
1538 logging.debug("Updating main project")
1539 cmd = parms + ['-p', d]
1541 logging.debug("Updating subproject %s" % d)
1542 cmd = lparms + ['-p', d]
1543 p = SdkToolsPopen(cmd, cwd=root_dir)
1544 # Check to see whether an error was returned without a proper exit
1545 # code (this is the case for the 'no target set or target invalid'
1547 if p.returncode != 0 or p.output.startswith("Error: "):
1548 raise BuildException("Failed to update project at %s" % d, p.output)
1549 # Clean update dirs via ant
1551 logging.info("Cleaning subproject %s" % d)
1552 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1554 return (root_dir, srclibpaths)
1557 # Extend via globbing the paths from a field and return them as a map from
1558 # original path to resulting paths
1559 def getpaths_map(build_dir, globpaths):
1563 full_path = os.path.join(build_dir, p)
1564 full_path = os.path.normpath(full_path)
1565 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1567 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1571 # Extend via globbing the paths from a field and return them as a set
1572 def getpaths(build_dir, globpaths):
1573 paths_map = getpaths_map(build_dir, globpaths)
1575 for k, v in paths_map.items():
1582 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1586 """permanent store of existing APKs with the date they were added
1588 This is currently the only way to permanently store the "updated"
1593 self.path = os.path.join('stats', 'known_apks.txt')
1595 if os.path.isfile(self.path):
1596 with open(self.path, 'r', encoding='utf8') as f:
1598 t = line.rstrip().split(' ')
1600 self.apks[t[0]] = (t[1], None)
1602 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1603 self.changed = False
1605 def writeifchanged(self):
1606 if not self.changed:
1609 if not os.path.exists('stats'):
1613 for apk, app in self.apks.items():
1615 line = apk + ' ' + appid
1617 line += ' ' + added.strftime('%Y-%m-%d')
1620 with open(self.path, 'w', encoding='utf8') as f:
1621 for line in sorted(lst, key=natural_key):
1622 f.write(line + '\n')
1624 def recordapk(self, apkName, app, default_date=None):
1626 Record an apk (if it's new, otherwise does nothing)
1627 Returns the date it was added as a datetime instance
1629 if apkName not in self.apks:
1630 if default_date is None:
1631 default_date = datetime.utcnow()
1632 self.apks[apkName] = (app, default_date)
1634 _, added = self.apks[apkName]
1637 # Look up information - given the 'apkname', returns (app id, date added/None).
1638 # Or returns None for an unknown apk.
1639 def getapp(self, apkname):
1640 if apkname in self.apks:
1641 return self.apks[apkname]
1644 # Get the most recent 'num' apps added to the repo, as a list of package ids
1645 # with the most recent first.
1646 def getlatest(self, num):
1648 for apk, app in self.apks.items():
1652 if apps[appid] > added:
1656 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1657 lst = [app for app, _ in sortedapps]
1662 def get_file_extension(filename):
1663 """get the normalized file extension, can be blank string but never None"""
1664 if isinstance(filename, bytes):
1665 filename = filename.decode('utf-8')
1666 return os.path.splitext(filename)[1].lower()[1:]
1669 def get_apk_debuggable_aapt(apkfile):
1670 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1672 if p.returncode != 0:
1673 raise FDroidException("Failed to get apk manifest information")
1674 for line in p.output.splitlines():
1675 if 'android:debuggable' in line and not line.endswith('0x0'):
1680 def get_apk_debuggable_androguard(apkfile):
1682 from androguard.core.bytecodes.apk import APK
1684 raise FDroidException("androguard library is not installed and aapt not present")
1686 apkobject = APK(apkfile)
1687 if apkobject.is_valid_APK():
1688 debuggable = apkobject.get_element("application", "debuggable")
1689 if debuggable is not None:
1690 return bool(strtobool(debuggable))
1694 def isApkAndDebuggable(apkfile):
1695 """Returns True if the given file is an APK and is debuggable
1697 :param apkfile: full path to the apk to check"""
1699 if get_file_extension(apkfile) != 'apk':
1702 if SdkToolsPopen(['aapt', 'version'], output=False):
1703 return get_apk_debuggable_aapt(apkfile)
1705 return get_apk_debuggable_androguard(apkfile)
1710 self.returncode = None
1714 def SdkToolsPopen(commands, cwd=None, output=True):
1716 if cmd not in config:
1717 config[cmd] = find_sdk_tools_cmd(commands[0])
1718 abscmd = config[cmd]
1720 raise FDroidException("Could not find '%s' on your system" % cmd)
1722 test_aapt_version(config['aapt'])
1723 return FDroidPopen([abscmd] + commands[1:],
1724 cwd=cwd, output=output)
1727 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1729 Run a command and capture the possibly huge output as bytes.
1731 :param commands: command and argument list like in subprocess.Popen
1732 :param cwd: optionally specifies a working directory
1733 :param envs: a optional dictionary of environment variables and their values
1734 :returns: A PopenResult.
1739 set_FDroidPopen_env()
1741 process_env = env.copy()
1742 if envs is not None and len(envs) > 0:
1743 process_env.update(envs)
1746 cwd = os.path.normpath(cwd)
1747 logging.debug("Directory: %s" % cwd)
1748 logging.debug("> %s" % ' '.join(commands))
1750 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1751 result = PopenResult()
1754 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1755 stdout=subprocess.PIPE, stderr=stderr_param)
1756 except OSError as e:
1757 raise BuildException("OSError while trying to execute " +
1758 ' '.join(commands) + ': ' + str(e))
1760 if not stderr_to_stdout and options.verbose:
1761 stderr_queue = Queue()
1762 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1764 while not stderr_reader.eof():
1765 while not stderr_queue.empty():
1766 line = stderr_queue.get()
1767 sys.stderr.buffer.write(line)
1772 stdout_queue = Queue()
1773 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1776 # Check the queue for output (until there is no more to get)
1777 while not stdout_reader.eof():
1778 while not stdout_queue.empty():
1779 line = stdout_queue.get()
1780 if output and options.verbose:
1781 # Output directly to console
1782 sys.stderr.buffer.write(line)
1788 result.returncode = p.wait()
1789 result.output = buf.getvalue()
1794 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1796 Run a command and capture the possibly huge output as a str.
1798 :param commands: command and argument list like in subprocess.Popen
1799 :param cwd: optionally specifies a working directory
1800 :param envs: a optional dictionary of environment variables and their values
1801 :returns: A PopenResult.
1803 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1804 result.output = result.output.decode('utf-8', 'ignore')
1808 gradle_comment = re.compile(r'[ ]*//')
1809 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1810 gradle_line_matches = [
1811 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1812 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1813 re.compile(r'.*\.readLine\(.*'),
1817 def remove_signing_keys(build_dir):
1818 for root, dirs, files in os.walk(build_dir):
1819 if 'build.gradle' in files:
1820 path = os.path.join(root, 'build.gradle')
1822 with open(path, "r", encoding='utf8') as o:
1823 lines = o.readlines()
1829 with open(path, "w", encoding='utf8') as o:
1830 while i < len(lines):
1833 while line.endswith('\\\n'):
1834 line = line.rstrip('\\\n') + lines[i]
1837 if gradle_comment.match(line):
1842 opened += line.count('{')
1843 opened -= line.count('}')
1846 if gradle_signing_configs.match(line):
1851 if any(s.match(line) for s in gradle_line_matches):
1859 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1862 'project.properties',
1864 'default.properties',
1865 'ant.properties', ]:
1866 if propfile in files:
1867 path = os.path.join(root, propfile)
1869 with open(path, "r", encoding='iso-8859-1') as o:
1870 lines = o.readlines()
1874 with open(path, "w", encoding='iso-8859-1') as o:
1876 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1883 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1886 def set_FDroidPopen_env(build=None):
1888 set up the environment variables for the build environment
1890 There is only a weak standard, the variables used by gradle, so also set
1891 up the most commonly used environment variables for SDK and NDK. Also, if
1892 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1894 global env, orig_path
1898 orig_path = env['PATH']
1899 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1900 env[n] = config['sdk_path']
1901 for k, v in config['java_paths'].items():
1902 env['JAVA%s_HOME' % k] = v
1904 missinglocale = True
1905 for k, v in env.items():
1906 if k == 'LANG' and v != 'C':
1907 missinglocale = False
1909 missinglocale = False
1911 env['LANG'] = 'en_US.UTF-8'
1913 if build is not None:
1914 path = build.ndk_path()
1915 paths = orig_path.split(os.pathsep)
1916 if path not in paths:
1917 paths = [path] + paths
1918 env['PATH'] = os.pathsep.join(paths)
1919 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1920 env[n] = build.ndk_path()
1923 def replace_build_vars(cmd, build):
1924 cmd = cmd.replace('$$COMMIT$$', build.commit)
1925 cmd = cmd.replace('$$VERSION$$', build.versionName)
1926 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1930 def replace_config_vars(cmd, build):
1931 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1932 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1933 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1934 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1935 if build is not None:
1936 cmd = replace_build_vars(cmd, build)
1940 def place_srclib(root_dir, number, libpath):
1943 relpath = os.path.relpath(libpath, root_dir)
1944 proppath = os.path.join(root_dir, 'project.properties')
1947 if os.path.isfile(proppath):
1948 with open(proppath, "r", encoding='iso-8859-1') as o:
1949 lines = o.readlines()
1951 with open(proppath, "w", encoding='iso-8859-1') as o:
1954 if line.startswith('android.library.reference.%d=' % number):
1955 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1960 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1963 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1966 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1967 """Verify that two apks are the same
1969 One of the inputs is signed, the other is unsigned. The signature metadata
1970 is transferred from the signed to the unsigned apk, and then jarsigner is
1971 used to verify that the signature from the signed apk is also varlid for
1972 the unsigned one. If the APK given as unsigned actually does have a
1973 signature, it will be stripped out and ignored.
1975 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1976 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1977 into AndroidManifest.xml, but that makes the build not reproducible. So
1978 instead they are included as separate files in the APK's META-INF/ folder.
1979 If those files exist in the signed APK, they will be part of the signature
1980 and need to also be included in the unsigned APK for it to validate.
1982 :param signed_apk: Path to a signed apk file
1983 :param unsigned_apk: Path to an unsigned apk file expected to match it
1984 :param tmp_dir: Path to directory for temporary files
1985 :returns: None if the verification is successful, otherwise a string
1986 describing what went wrong.
1989 signed = ZipFile(signed_apk, 'r')
1990 meta_inf_files = ['META-INF/MANIFEST.MF']
1991 for f in signed.namelist():
1992 if apk_sigfile.match(f) \
1993 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
1994 meta_inf_files.append(f)
1995 if len(meta_inf_files) < 3:
1996 return "Signature files missing from {0}".format(signed_apk)
1998 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
1999 unsigned = ZipFile(unsigned_apk, 'r')
2000 # only read the signature from the signed APK, everything else from unsigned
2001 with ZipFile(tmp_apk, 'w') as tmp:
2002 for filename in meta_inf_files:
2003 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2004 for info in unsigned.infolist():
2005 if info.filename in meta_inf_files:
2006 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2008 if info.filename in tmp.namelist():
2009 return "duplicate filename found: " + info.filename
2010 tmp.writestr(info, unsigned.read(info.filename))
2014 verified = verify_apk_signature(tmp_apk)
2017 logging.info("...NOT verified - {0}".format(tmp_apk))
2018 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2019 os.path.dirname(unsigned_apk))
2021 logging.info("...successfully verified")
2025 def verify_apk_signature(apk, jar=False):
2026 """verify the signature on an APK
2028 Try to use apksigner whenever possible since jarsigner is very
2029 shitty: unsigned APKs pass as "verified"! So this has to turn on
2030 -strict then check for result 4.
2032 You can set :param: jar to True if you want to use this method
2033 to verify jar signatures.
2035 if set_command_in_config('apksigner'):
2036 args = [config['apksigner'], 'verify']
2038 args += ['--min-sdk-version=1']
2039 return subprocess.call(args + [apk]) == 0
2041 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2042 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2045 def verify_old_apk_signature(apk):
2046 """verify the signature on an archived APK, supporting deprecated algorithms
2048 F-Droid aims to keep every single binary that it ever published. Therefore,
2049 it needs to be able to verify APK signatures that include deprecated/removed
2050 algorithms. For example, jarsigner treats an MD5 signature as unsigned.
2052 jarsigner passes unsigned APKs as "verified"! So this has to turn
2053 on -strict then check for result 4.
2057 _java_security = os.path.join(os.getcwd(), '.java.security')
2058 with open(_java_security, 'w') as fp:
2059 fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2061 return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2062 '-strict', '-verify', apk]) == 4
2065 apk_badchars = re.compile('''[/ :;'"]''')
2068 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2071 Returns None if the apk content is the same (apart from the signing key),
2072 otherwise a string describing what's different, or what went wrong when
2073 trying to do the comparison.
2079 absapk1 = os.path.abspath(apk1)
2080 absapk2 = os.path.abspath(apk2)
2082 if set_command_in_config('diffoscope'):
2083 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2084 htmlfile = logfilename + '.diffoscope.html'
2085 textfile = logfilename + '.diffoscope.txt'
2086 if subprocess.call([config['diffoscope'],
2087 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2088 '--html', htmlfile, '--text', textfile,
2089 absapk1, absapk2]) != 0:
2090 return("Failed to unpack " + apk1)
2092 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2093 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2094 for d in [apk1dir, apk2dir]:
2095 if os.path.exists(d):
2098 os.mkdir(os.path.join(d, 'jar-xf'))
2100 if subprocess.call(['jar', 'xf',
2101 os.path.abspath(apk1)],
2102 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2103 return("Failed to unpack " + apk1)
2104 if subprocess.call(['jar', 'xf',
2105 os.path.abspath(apk2)],
2106 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2107 return("Failed to unpack " + apk2)
2109 if set_command_in_config('apktool'):
2110 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2112 return("Failed to unpack " + apk1)
2113 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2115 return("Failed to unpack " + apk2)
2117 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2118 lines = p.output.splitlines()
2119 if len(lines) != 1 or 'META-INF' not in lines[0]:
2120 if set_command_in_config('meld'):
2121 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2122 return("Unexpected diff output - " + p.output)
2124 # since everything verifies, delete the comparison to keep cruft down
2125 shutil.rmtree(apk1dir)
2126 shutil.rmtree(apk2dir)
2128 # If we get here, it seems like they're the same!
2132 def set_command_in_config(command):
2133 '''Try to find specified command in the path, if it hasn't been
2134 manually set in config.py. If found, it is added to the config
2135 dict. The return value says whether the command is available.
2138 if command in config:
2141 tmp = find_command(command)
2143 config[command] = tmp
2148 def find_command(command):
2149 '''find the full path of a command, or None if it can't be found in the PATH'''
2152 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2154 fpath, fname = os.path.split(command)
2159 for path in os.environ["PATH"].split(os.pathsep):
2160 path = path.strip('"')
2161 exe_file = os.path.join(path, command)
2162 if is_exe(exe_file):
2169 '''generate a random password for when generating keys'''
2170 h = hashlib.sha256()
2171 h.update(os.urandom(16)) # salt
2172 h.update(socket.getfqdn().encode('utf-8'))
2173 passwd = base64.b64encode(h.digest()).strip()
2174 return passwd.decode('utf-8')
2177 def genkeystore(localconfig):
2179 Generate a new key with password provided in :param localconfig and add it to new keystore
2180 :return: hexed public key, public key fingerprint
2182 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2183 keystoredir = os.path.dirname(localconfig['keystore'])
2184 if keystoredir is None or keystoredir == '':
2185 keystoredir = os.path.join(os.getcwd(), keystoredir)
2186 if not os.path.exists(keystoredir):
2187 os.makedirs(keystoredir, mode=0o700)
2190 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2191 'FDROID_KEY_PASS': localconfig['keypass'],
2193 p = FDroidPopen([config['keytool'], '-genkey',
2194 '-keystore', localconfig['keystore'],
2195 '-alias', localconfig['repo_keyalias'],
2196 '-keyalg', 'RSA', '-keysize', '4096',
2197 '-sigalg', 'SHA256withRSA',
2198 '-validity', '10000',
2199 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2200 '-keypass:env', 'FDROID_KEY_PASS',
2201 '-dname', localconfig['keydname']], envs=env_vars)
2202 if p.returncode != 0:
2203 raise BuildException("Failed to generate key", p.output)
2204 os.chmod(localconfig['keystore'], 0o0600)
2205 if not options.quiet:
2206 # now show the lovely key that was just generated
2207 p = FDroidPopen([config['keytool'], '-list', '-v',
2208 '-keystore', localconfig['keystore'],
2209 '-alias', localconfig['repo_keyalias'],
2210 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2211 logging.info(p.output.strip() + '\n\n')
2212 # get the public key
2213 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2214 '-keystore', localconfig['keystore'],
2215 '-alias', localconfig['repo_keyalias'],
2216 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2217 + config['smartcardoptions'],
2218 envs=env_vars, output=False, stderr_to_stdout=False)
2219 if p.returncode != 0 or len(p.output) < 20:
2220 raise BuildException("Failed to get public key", p.output)
2222 fingerprint = get_cert_fingerprint(pubkey)
2223 return hexlify(pubkey), fingerprint
2226 def get_cert_fingerprint(pubkey):
2228 Generate a certificate fingerprint the same way keytool does it
2229 (but with slightly different formatting)
2231 digest = hashlib.sha256(pubkey).digest()
2232 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2233 return " ".join(ret)
2236 def get_certificate(certificate_file):
2238 Extracts a certificate from the given file.
2239 :param certificate_file: file bytes (as string) representing the certificate
2240 :return: A binary representation of the certificate's public key, or None in case of error
2242 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2243 if content.getComponentByName('contentType') != rfc2315.signedData:
2245 content = decoder.decode(content.getComponentByName('content'),
2246 asn1Spec=rfc2315.SignedData())[0]
2248 certificates = content.getComponentByName('certificates')
2249 cert = certificates[0].getComponentByName('certificate')
2251 logging.error("Certificates not found.")
2253 return encoder.encode(cert)
2256 def write_to_config(thisconfig, key, value=None, config_file=None):
2257 '''write a key/value to the local config.py
2259 NOTE: only supports writing string variables.
2261 :param thisconfig: config dictionary
2262 :param key: variable name in config.py to be overwritten/added
2263 :param value: optional value to be written, instead of fetched
2264 from 'thisconfig' dictionary.
2267 origkey = key + '_orig'
2268 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2269 cfg = config_file if config_file else 'config.py'
2272 with open(cfg, 'r', encoding="utf-8") as f:
2273 lines = f.readlines()
2275 # make sure the file ends with a carraige return
2277 if not lines[-1].endswith('\n'):
2280 # regex for finding and replacing python string variable
2281 # definitions/initializations
2282 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2283 repl = key + ' = "' + value + '"'
2284 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2285 repl2 = key + " = '" + value + "'"
2287 # If we replaced this line once, we make sure won't be a
2288 # second instance of this line for this key in the document.
2291 with open(cfg, 'w', encoding="utf-8") as f:
2293 if pattern.match(line) or pattern2.match(line):
2295 line = pattern.sub(repl, line)
2296 line = pattern2.sub(repl2, line)
2307 def parse_xml(path):
2308 return XMLElementTree.parse(path).getroot()
2311 def string_is_integer(string):
2319 def get_per_app_repos():
2320 '''per-app repos are dirs named with the packageName of a single app'''
2322 # Android packageNames are Java packages, they may contain uppercase or
2323 # lowercase letters ('A' through 'Z'), numbers, and underscores
2324 # ('_'). However, individual package name parts may only start with
2325 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2326 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2329 for root, dirs, files in os.walk(os.getcwd()):
2331 print('checking', root, 'for', d)
2332 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2333 # standard parts of an fdroid repo, so never packageNames
2336 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2342 def is_repo_file(filename):
2343 '''Whether the file in a repo is a build product to be delivered to users'''
2344 if isinstance(filename, str):
2345 filename = filename.encode('utf-8', errors="surrogateescape")
2346 return os.path.isfile(filename) \
2347 and not filename.endswith(b'.asc') \
2348 and not filename.endswith(b'.sig') \
2349 and os.path.basename(filename) not in [
2351 b'index_unsigned.jar',