3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2016, Blue Jay Wireless
5 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
6 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
7 # Copyright (C) 2013-2014, Daniel Martà <mvdan@mvdan.cc>
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU Affero General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU Affero General Public License for more details.
19 # You should have received a copy of the GNU Affero General Public License
20 # along with this program. If not, see <http://www.gnu.org/licenses/>.
33 from datetime import datetime, timedelta
34 from argparse import ArgumentParser
37 from pyasn1.error import PyAsn1Error
38 from pyasn1.codec.der import decoder, encoder
39 from pyasn1_modules import rfc2315
40 from binascii import hexlify
47 from . import metadata
48 from .common import FDroidPopen, SdkToolsPopen
52 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
53 UNSET_VERSION_CODE = -0x100000000
55 APK_NAME_PAT = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
56 APK_VERCODE_PAT = re.compile(".*versionCode='([0-9]*)'.*")
57 APK_VERNAME_PAT = re.compile(".*versionName='([^']*)'.*")
58 APK_LABEL_PAT = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
59 APK_ICON_PAT = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
60 APK_ICON_PAT_NODPI = re.compile(".*icon='([^']+?)'.*")
61 APK_SDK_VERSION_PAT = re.compile(".*'([0-9]*)'.*")
62 APK_PERMISSION_PAT = \
63 re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
64 APK_FEATURE_PAT = re.compile(".*name='([^']*)'.*")
66 screen_densities = ['640', '480', '320', '240', '160', '120']
68 all_screen_densities = ['0'] + screen_densities
70 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
71 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
74 def dpi_to_px(density):
75 return (int(density) * 48) / 160
79 return (int(px) * 160) / 48
82 def get_icon_dir(repodir, density):
84 return os.path.join(repodir, "icons")
85 return os.path.join(repodir, "icons-%s" % density)
88 def get_icon_dirs(repodir):
89 for density in screen_densities:
90 yield get_icon_dir(repodir, density)
93 def get_all_icon_dirs(repodir):
94 for density in all_screen_densities:
95 yield get_icon_dir(repodir, density)
98 def update_wiki(apps, sortedids, apks):
101 :param apps: fully populated list of all applications
102 :param apks: all apks, except...
104 logging.info("Updating wiki")
106 wikiredircat = 'App Redirects'
108 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
109 path=config['wiki_path'])
110 site.login(config['wiki_user'], config['wiki_password'])
112 generated_redirects = {}
114 for appid in sortedids:
115 app = metadata.App(apps[appid])
119 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
121 for af in app.AntiFeatures:
122 wikidata += '{{AntiFeature|' + af + '}}\n'
127 wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|changelog=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|license=%s|root=%s|author=%s|email=%s}}\n' % (
130 app.added.strftime('%Y-%m-%d') if app.added else '',
131 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
146 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
148 wikidata += app.Summary
149 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
151 wikidata += "=Description=\n"
152 wikidata += metadata.description_wiki(app.Description) + "\n"
154 wikidata += "=Maintainer Notes=\n"
155 if app.MaintainerNotes:
156 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
157 wikidata += "\nMetadata: [https://gitlab.com/fdroid/fdroiddata/blob/master/metadata/{0}.txt current] [https://gitlab.com/fdroid/fdroiddata/commits/master/metadata/{0}.txt history]\n".format(appid)
159 # Get a list of all packages for this application...
161 gotcurrentver = False
165 if apk['packageName'] == appid:
166 if str(apk['versionCode']) == app.CurrentVersionCode:
169 # Include ones we can't build, as a special case...
170 for build in app.builds:
172 if build.versionCode == app.CurrentVersionCode:
174 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
175 apklist.append({'versionCode': int(build.versionCode),
176 'versionName': build.versionName,
177 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
182 if apk['versionCode'] == int(build.versionCode):
187 apklist.append({'versionCode': int(build.versionCode),
188 'versionName': build.versionName,
189 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
191 if app.CurrentVersionCode == '0':
193 # Sort with most recent first...
194 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
196 wikidata += "=Versions=\n"
197 if len(apklist) == 0:
198 wikidata += "We currently have no versions of this app available."
199 elif not gotcurrentver:
200 wikidata += "We don't have the current version of this app."
202 wikidata += "We have the current version of this app."
203 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
204 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
205 if len(app.NoSourceSince) > 0:
206 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
207 if len(app.CurrentVersion) > 0:
208 wikidata += "The current (recommended) version is " + app.CurrentVersion
209 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
212 wikidata += "==" + apk['versionName'] + "==\n"
214 if 'buildproblem' in apk:
215 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
218 wikidata += "This version is built and signed by "
220 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
222 wikidata += "the original developer.\n\n"
223 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
225 wikidata += '\n[[Category:' + wikicat + ']]\n'
226 if len(app.NoSourceSince) > 0:
227 wikidata += '\n[[Category:Apps missing source code]]\n'
228 if validapks == 0 and not app.Disabled:
229 wikidata += '\n[[Category:Apps with no packages]]\n'
230 if cantupdate and not app.Disabled:
231 wikidata += "\n[[Category:Apps we cannot update]]\n"
232 if buildfails and not app.Disabled:
233 wikidata += "\n[[Category:Apps with failing builds]]\n"
234 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
235 wikidata += '\n[[Category:Apps to Update]]\n'
237 wikidata += '\n[[Category:Apps that are disabled]]\n'
238 if app.UpdateCheckMode == 'None' and not app.Disabled:
239 wikidata += '\n[[Category:Apps with no update check]]\n'
240 for appcat in app.Categories:
241 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
243 # We can't have underscores in the page name, even if they're in
244 # the package ID, because MediaWiki messes with them...
245 pagename = appid.replace('_', ' ')
247 # Drop a trailing newline, because mediawiki is going to drop it anyway
248 # and it we don't we'll think the page has changed when it hasn't...
249 if wikidata.endswith('\n'):
250 wikidata = wikidata[:-1]
252 generated_pages[pagename] = wikidata
254 # Make a redirect from the name to the ID too, unless there's
255 # already an existing page with the name and it isn't a redirect.
257 apppagename = app.Name.replace('_', ' ')
258 apppagename = apppagename.replace('{', '')
259 apppagename = apppagename.replace('}', ' ')
260 apppagename = apppagename.replace(':', ' ')
261 apppagename = apppagename.replace('[', ' ')
262 apppagename = apppagename.replace(']', ' ')
263 # Drop double spaces caused mostly by replacing ':' above
264 apppagename = apppagename.replace(' ', ' ')
265 for expagename in site.allpages(prefix=apppagename,
266 filterredir='nonredirects',
268 if expagename == apppagename:
270 # Another reason not to make the redirect page is if the app name
271 # is the same as it's ID, because that will overwrite the real page
272 # with an redirect to itself! (Although it seems like an odd
273 # scenario this happens a lot, e.g. where there is metadata but no
274 # builds or binaries to extract a name from.
275 if apppagename == pagename:
278 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
280 for tcat, genp in [(wikicat, generated_pages),
281 (wikiredircat, generated_redirects)]:
282 catpages = site.Pages['Category:' + tcat]
284 for page in catpages:
285 existingpages.append(page.name)
286 if page.name in genp:
287 pagetxt = page.edit()
288 if pagetxt != genp[page.name]:
289 logging.debug("Updating modified page " + page.name)
290 page.save(genp[page.name], summary='Auto-updated')
292 logging.debug("Page " + page.name + " is unchanged")
294 logging.warn("Deleting page " + page.name)
295 page.delete('No longer published')
296 for pagename, text in genp.items():
297 logging.debug("Checking " + pagename)
298 if pagename not in existingpages:
299 logging.debug("Creating page " + pagename)
301 newpage = site.Pages[pagename]
302 newpage.save(text, summary='Auto-created')
303 except Exception as e:
304 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
306 # Purge server cache to ensure counts are up to date
307 site.pages['Repository Maintenance'].purge()
310 def delete_disabled_builds(apps, apkcache, repodirs):
311 """Delete disabled build outputs.
313 :param apps: list of all applications, as per metadata.read_metadata
314 :param apkcache: current apk cache information
315 :param repodirs: the repo directories to process
317 for appid, app in apps.items():
318 for build in app['builds']:
319 if not build.disable:
321 apkfilename = appid + '_' + str(build.versionCode) + '.apk'
322 iconfilename = "%s.%s.png" % (
325 for repodir in repodirs:
327 os.path.join(repodir, apkfilename),
328 os.path.join(repodir, apkfilename + '.asc'),
329 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
331 for density in all_screen_densities:
332 repo_dir = get_icon_dir(repodir, density)
333 files.append(os.path.join(repo_dir, iconfilename))
336 if os.path.exists(f):
337 logging.info("Deleting disabled build output " + f)
339 if apkfilename in apkcache:
340 del apkcache[apkfilename]
343 def resize_icon(iconpath, density):
345 if not os.path.isfile(iconpath):
350 fp = open(iconpath, 'rb')
352 size = dpi_to_px(density)
354 if any(length > size for length in im.size):
356 im.thumbnail((size, size), Image.ANTIALIAS)
357 logging.debug("%s was too large at %s - new size is %s" % (
358 iconpath, oldsize, im.size))
359 im.save(iconpath, "PNG")
361 except Exception as e:
362 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
369 def resize_all_icons(repodirs):
370 """Resize all icons that exceed the max size
372 :param repodirs: the repo directories to process
374 for repodir in repodirs:
375 for density in screen_densities:
376 icon_dir = get_icon_dir(repodir, density)
377 icon_glob = os.path.join(icon_dir, '*.png')
378 for iconpath in glob.glob(icon_glob):
379 resize_icon(iconpath, density)
382 # A signature block file with a .DSA, .RSA, or .EC extension
383 cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
387 """ Get the signing certificate of an apk. To get the same md5 has that
388 Android gets, we encode the .RSA certificate in a specific format and pass
389 it hex-encoded to the md5 digest algorithm.
391 :param apkpath: path to the apk
392 :returns: A string containing the md5 of the signature of the apk or None
393 if an error occurred.
398 # verify the jar signature is correct
399 args = [config['jarsigner'], '-verify', apkpath]
400 p = FDroidPopen(args)
401 if p.returncode != 0:
402 logging.critical(apkpath + " has a bad signature!")
405 with zipfile.ZipFile(apkpath, 'r') as apk:
407 certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
410 logging.error("Found no signing certificates on %s" % apkpath)
413 logging.error("Found multiple signing certificates on %s" % apkpath)
416 cert = apk.read(certs[0])
418 content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
419 if content.getComponentByName('contentType') != rfc2315.signedData:
420 logging.error("Unexpected format.")
423 content = decoder.decode(content.getComponentByName('content'),
424 asn1Spec=rfc2315.SignedData())[0]
426 certificates = content.getComponentByName('certificates')
428 logging.error("Certificates not found.")
431 cert_encoded = encoder.encode(certificates)[4:]
433 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
436 def get_cache_file():
437 return os.path.join('tmp', 'apkcache')
442 Gather information about all the apk files in the repo directory,
443 using cached data if possible.
446 apkcachefile = get_cache_file()
447 if not options.clean and os.path.exists(apkcachefile):
448 with open(apkcachefile, 'rb') as cf:
449 apkcache = pickle.load(cf, encoding='utf-8')
450 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
458 def write_cache(apkcache):
459 apkcachefile = get_cache_file()
460 cache_path = os.path.dirname(apkcachefile)
461 if not os.path.exists(cache_path):
462 os.makedirs(cache_path)
463 apkcache["METADATA_VERSION"] = METADATA_VERSION
464 with open(apkcachefile, 'wb') as cf:
465 pickle.dump(apkcache, cf)
468 def get_icon_bytes(apkzip, iconsrc):
469 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
471 return apkzip.read(iconsrc)
473 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
476 def sha256sum(filename):
477 '''Calculate the sha256 of the given file'''
478 sha = hashlib.sha256()
479 with open(filename, 'rb') as f:
485 return sha.hexdigest()
488 def has_old_openssl(filename):
489 '''checks for known vulnerable openssl versions in the APK'''
491 # statically load this pattern
492 if not hasattr(has_old_openssl, "pattern"):
493 has_old_openssl.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
495 with zipfile.ZipFile(filename) as zf:
496 for name in zf.namelist():
497 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
500 chunk = lib.read(4096)
503 m = has_old_openssl.pattern.search(chunk)
505 version = m.group(1).decode('ascii')
506 if version.startswith('1.0.1') and version[5] >= 'r' \
507 or version.startswith('1.0.2') and version[5] >= 'f':
508 logging.debug('"%s" contains recent %s (%s)', filename, name, version)
510 logging.warning('"%s" contains outdated %s (%s)', filename, name, version)
516 def insert_obbs(repodir, apps, apks):
517 """Scans the .obb files in a given repo directory and adds them to the
518 relevant APK instances. OBB files have versionCodes like APK
519 files, and they are loosely associated. If there is an OBB file
520 present, then any APK with the same or higher versionCode will use
521 that OBB file. There are two OBB types: main and patch, each APK
522 can only have only have one of each.
524 https://developer.android.com/google/play/expansion-files.html
526 :param repodir: repo directory to scan
527 :param apps: list of current, valid apps
528 :param apks: current information on all APKs
532 def obbWarnDelete(f, msg):
533 logging.warning(msg + f)
534 if options.delete_unknown:
535 logging.error("Deleting unknown file: " + f)
539 java_Integer_MIN_VALUE = -pow(2, 31)
540 currentPackageNames = apps.keys()
541 for f in glob.glob(os.path.join(repodir, '*.obb')):
542 obbfile = os.path.basename(f)
543 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
544 chunks = obbfile.split('.')
545 if chunks[0] != 'main' and chunks[0] != 'patch':
546 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
548 if not re.match(r'^-?[0-9]+$', chunks[1]):
549 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
551 versionCode = int(chunks[1])
552 packagename = ".".join(chunks[2:-1])
554 highestVersionCode = java_Integer_MIN_VALUE
555 if packagename not in currentPackageNames:
556 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
559 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
560 highestVersionCode = apk['versionCode']
561 if versionCode > highestVersionCode:
562 obbWarnDelete(f, 'OBB file has newer versionCode(' + str(versionCode)
563 + ') than any APK: ')
565 obbsha256 = sha256sum(f)
566 obbs.append((packagename, versionCode, obbfile, obbsha256))
569 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
570 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
571 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
572 apk['obbMainFile'] = obbfile
573 apk['obbMainFileSha256'] = obbsha256
574 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
575 apk['obbPatchFile'] = obbfile
576 apk['obbPatchFileSha256'] = obbsha256
577 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
581 def insert_graphics(repodir, apps):
582 """Scans for screenshot PNG files in statically defined screenshots
583 directory and adds them to the app metadata. The screenshots and
584 graphic must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
585 and must be in the following layout:
587 repo/packageName/locale/featureGraphic.png
588 repo/packageName/locale/phoneScreenshots/1.png
589 repo/packageName/locale/phoneScreenshots/2.png
591 Where "packageName" is the app's packageName and "locale" is the locale
592 of the graphics, e.g. what language they are in, using the IETF RFC5646
593 format (en-US, fr-CA, es-MX, etc). This is following this pattern:
594 https://github.com/fastlane/fastlane/blob/1.109.0/supply/README.md#images-and-screenshots
596 This will also scan the metadata/ folder and the apps' source repos
597 for standard locations of graphic and screenshot files. If it finds
598 them, it will copy them into the repo.
600 :param repodir: repo directory to scan
604 allowed_extensions = ('png', 'jpg', 'jpeg')
605 graphicnames = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
606 screenshotdirs = ('phoneScreenshots', 'sevenInchScreenshots',
607 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
609 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z][A-Z-.@]*'))
610 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*'))
612 for d in sorted(sourcedirs):
613 if not os.path.isdir(d):
615 for root, dirs, files in os.walk(d):
616 segments = root.split('/')
617 destdir = os.path.join('repo', segments[1], segments[-1]) # repo/packageName/locale
619 base, extension = common.get_extension(f)
620 if base in graphicnames and extension in allowed_extensions:
621 os.makedirs(destdir, mode=0o755, exist_ok=True)
622 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
623 shutil.copy(os.path.join(root, f), destdir)
625 if d in screenshotdirs:
626 for f in glob.glob(os.path.join(root, d, '*.*')):
627 _, extension = common.get_extension(f)
628 if extension in allowed_extensions:
629 screenshotdestdir = os.path.join(destdir, d)
630 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
631 logging.debug('copying ' + f + ' ' + screenshotdestdir)
632 shutil.copy(f, screenshotdestdir)
634 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
636 if not os.path.isdir(d):
638 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
639 if not os.path.isfile(f):
641 segments = f.split('/')
642 packageName = segments[1]
644 screenshotdir = segments[3]
645 filename = os.path.basename(f)
646 base, extension = common.get_extension(filename)
648 if packageName not in apps:
649 logging.warning('Found "%s" graphic without metadata for app "%s"!'
650 % (filename, packageName))
652 if 'localized' not in apps[packageName]:
653 apps[packageName]['localized'] = collections.OrderedDict()
654 if locale not in apps[packageName]['localized']:
655 apps[packageName]['localized'][locale] = collections.OrderedDict()
656 graphics = apps[packageName]['localized'][locale]
658 if extension not in allowed_extensions:
659 logging.warning('Only PNG and JPEG are supported for graphics, found: ' + f)
660 elif base in graphicnames:
661 # there can only be zero or one of these per locale
662 graphics[base] = filename
663 elif screenshotdir in screenshotdirs:
664 # there can any number of these per locale
665 logging.debug('adding ' + base + ':' + f)
666 if screenshotdir not in graphics:
667 graphics[screenshotdir] = []
668 graphics[screenshotdir].append(filename)
670 logging.warning('Unsupported graphics file found: ' + f)
673 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
674 """Scan a repo for all files with an extension except APK/OBB
676 :param apkcache: current cached info about all repo files
677 :param repodir: repo directory to scan
678 :param knownapks: list of all known files, as per metadata.read_metadata
679 :param use_date_from_file: use date from file (instead of current date)
680 for newly added files
685 for name in os.listdir(repodir):
686 file_extension = common.get_file_extension(name)
687 if file_extension == 'apk' or file_extension == 'obb':
689 filename = os.path.join(repodir, name)
690 if filename.endswith('_src.tar.gz'):
691 logging.debug('skipping source tarball: ' + filename)
693 if not common.is_repo_file(filename):
695 stat = os.stat(filename)
696 if stat.st_size == 0:
697 logging.error(filename + ' is zero size!')
700 shasum = sha256sum(filename)
703 repo_file = apkcache[name]
704 # added time is cached as tuple but used here as datetime instance
705 if 'added' in repo_file:
706 a = repo_file['added']
707 if isinstance(a, datetime):
708 repo_file['added'] = a
710 repo_file['added'] = datetime(*a[:6])
711 if repo_file['hash'] == shasum:
712 logging.debug("Reading " + name + " from cache")
715 logging.debug("Ignoring stale cache data for " + name)
718 logging.debug("Processing " + name)
720 # TODO rename apkname globally to something more generic
721 repo_file['name'] = name
722 repo_file['apkName'] = name
723 repo_file['hash'] = shasum
724 repo_file['hashType'] = 'sha256'
725 repo_file['versionCode'] = 0
726 repo_file['versionName'] = shasum
727 # the static ID is the SHA256 unless it is set in the metadata
728 repo_file['packageName'] = shasum
732 versionCode = n[1].split('.')[0]
733 if re.match(r'^-?[0-9]+$', versionCode) \
734 and common.is_valid_package_name(name.split('_')[0]):
735 repo_file['packageName'] = packageName
736 repo_file['versionCode'] = int(versionCode)
737 srcfilename = name + "_src.tar.gz"
738 if os.path.exists(os.path.join(repodir, srcfilename)):
739 repo_file['srcname'] = srcfilename
740 repo_file['size'] = stat.st_size
742 apkcache[name] = repo_file
745 if use_date_from_file:
746 timestamp = stat.st_ctime
747 default_date_param = datetime.fromtimestamp(timestamp).utctimetuple()
749 default_date_param = None
751 # Record in knownapks, getting the added date at the same time..
752 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
753 default_date=default_date_param)
755 repo_file['added'] = added
757 repo_files.append(repo_file)
759 return repo_files, cachechanged
762 def scan_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk):
763 """Scan the apk with the given filename in the given repo directory.
765 This also extracts the icons.
767 :param apkcache: current apk cache information
768 :param apkfilename: the filename of the apk to scan
769 :param repodir: repo directory to scan
770 :param knownapks: known apks info
771 :param use_date_from_apk: use date from APK (instead of current date)
773 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
774 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
777 if ' ' in apkfilename:
778 logging.critical("Spaces in filenames are not allowed.")
781 apkfile = os.path.join(repodir, apkfilename)
782 shasum = sha256sum(apkfile)
786 if apkfilename in apkcache:
787 apk = apkcache[apkfilename]
788 if apk['hash'] == shasum:
789 logging.debug("Reading " + apkfilename + " from cache")
792 logging.debug("Ignoring stale cache data for " + apkfilename)
795 logging.debug("Processing " + apkfilename)
797 apk['apkName'] = apkfilename
799 apk['hashType'] = 'sha256'
800 srcfilename = apkfilename[:-4] + "_src.tar.gz"
801 if os.path.exists(os.path.join(repodir, srcfilename)):
802 apk['srcname'] = srcfilename
803 apk['size'] = os.path.getsize(apkfile)
804 apk['uses-permission'] = set()
805 apk['uses-permission-sdk-23'] = set()
806 apk['features'] = set()
807 apk['icons_src'] = {}
809 apk['antiFeatures'] = set()
810 if has_old_openssl(apkfile):
811 apk['antiFeatures'].add('KnownVuln')
812 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
813 if p.returncode != 0:
814 if options.delete_unknown:
815 if os.path.exists(apkfile):
816 logging.error("Failed to get apk information, deleting " + apkfile)
819 logging.error("Could not find {0} to remove it".format(apkfile))
821 logging.error("Failed to get apk information, skipping " + apkfile)
823 for line in p.output.splitlines():
824 if line.startswith("package:"):
826 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
827 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
828 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
829 except Exception as e:
830 logging.error("Package matching failed: " + str(e))
831 logging.info("Line was: " + line)
833 elif line.startswith("application:"):
834 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
835 # Keep path to non-dpi icon in case we need it
836 match = re.match(APK_ICON_PAT_NODPI, line)
838 apk['icons_src']['-1'] = match.group(1)
839 elif line.startswith("launchable-activity:"):
840 # Only use launchable-activity as fallback to application
842 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
843 if '-1' not in apk['icons_src']:
844 match = re.match(APK_ICON_PAT_NODPI, line)
846 apk['icons_src']['-1'] = match.group(1)
847 elif line.startswith("application-icon-"):
848 match = re.match(APK_ICON_PAT, line)
850 density = match.group(1)
851 path = match.group(2)
852 apk['icons_src'][density] = path
853 elif line.startswith("sdkVersion:"):
854 m = re.match(APK_SDK_VERSION_PAT, line)
856 logging.error(line.replace('sdkVersion:', '')
857 + ' is not a valid minSdkVersion!')
859 apk['minSdkVersion'] = m.group(1)
860 # if target not set, default to min
861 if 'targetSdkVersion' not in apk:
862 apk['targetSdkVersion'] = m.group(1)
863 elif line.startswith("targetSdkVersion:"):
864 m = re.match(APK_SDK_VERSION_PAT, line)
866 logging.error(line.replace('targetSdkVersion:', '')
867 + ' is not a valid targetSdkVersion!')
869 apk['targetSdkVersion'] = m.group(1)
870 elif line.startswith("maxSdkVersion:"):
871 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
872 elif line.startswith("native-code:"):
873 apk['nativecode'] = []
874 for arch in line[13:].split(' '):
875 apk['nativecode'].append(arch[1:-1])
876 elif line.startswith('uses-permission:'):
877 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
878 if perm_match['maxSdkVersion']:
879 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
880 permission = UsesPermission(
882 perm_match['maxSdkVersion']
885 apk['uses-permission'].add(permission)
886 elif line.startswith('uses-permission-sdk-23:'):
887 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
888 if perm_match['maxSdkVersion']:
889 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
890 permission_sdk_23 = UsesPermissionSdk23(
892 perm_match['maxSdkVersion']
895 apk['uses-permission-sdk-23'].add(permission_sdk_23)
897 elif line.startswith('uses-feature:'):
898 feature = re.match(APK_FEATURE_PAT, line).group(1)
899 # Filter out this, it's only added with the latest SDK tools and
900 # causes problems for lots of apps.
901 if feature != "android.hardware.screen.portrait" \
902 and feature != "android.hardware.screen.landscape":
903 if feature.startswith("android.feature."):
904 feature = feature[16:]
905 apk['features'].add(feature)
907 if 'minSdkVersion' not in apk:
908 logging.warn("No SDK version information found in {0}".format(apkfile))
909 apk['minSdkVersion'] = 1
911 # Check for debuggable apks...
912 if common.isApkAndDebuggable(apkfile, config):
913 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
915 # Get the signature (or md5 of, to be precise)...
916 logging.debug('Getting signature of {0}'.format(apkfile))
917 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
919 logging.critical("Failed to get apk signature")
922 apkzip = zipfile.ZipFile(apkfile, 'r')
924 # if an APK has files newer than the system time, suggest updating
925 # the system clock. This is useful for offline systems, used for
926 # signing, which do not have another source of clock sync info. It
927 # has to be more than 24 hours newer because ZIP/APK files do not
928 # store timezone info
929 manifest = apkzip.getinfo('AndroidManifest.xml')
930 if manifest.date_time[1] == 0: # month can't be zero
931 logging.debug('AndroidManifest.xml has no date')
933 dt_obj = datetime(*manifest.date_time)
934 checkdt = dt_obj - timedelta(1)
935 if datetime.today() < checkdt:
936 logging.warn('System clock is older than manifest in: '
938 + '\nSet clock to that time using:\n'
939 + 'sudo date -s "' + str(dt_obj) + '"')
941 iconfilename = "%s.%s.png" % (
945 # Extract the icon file...
947 for density in screen_densities:
948 if density not in apk['icons_src']:
949 empty_densities.append(density)
951 iconsrc = apk['icons_src'][density]
952 icon_dir = get_icon_dir(repodir, density)
953 icondest = os.path.join(icon_dir, iconfilename)
956 with open(icondest, 'wb') as f:
957 f.write(get_icon_bytes(apkzip, iconsrc))
958 apk['icons'][density] = iconfilename
960 except Exception as e:
961 logging.warn("Error retrieving icon file: %s" % (e))
962 del apk['icons'][density]
963 del apk['icons_src'][density]
964 empty_densities.append(density)
966 if '-1' in apk['icons_src']:
967 iconsrc = apk['icons_src']['-1']
968 iconpath = os.path.join(
969 get_icon_dir(repodir, '0'), iconfilename)
970 with open(iconpath, 'wb') as f:
971 f.write(get_icon_bytes(apkzip, iconsrc))
973 im = Image.open(iconpath)
974 dpi = px_to_dpi(im.size[0])
975 for density in screen_densities:
976 if density in apk['icons']:
978 if density == screen_densities[-1] or dpi >= int(density):
979 apk['icons'][density] = iconfilename
980 shutil.move(iconpath,
981 os.path.join(get_icon_dir(repodir, density), iconfilename))
982 empty_densities.remove(density)
984 except Exception as e:
985 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
988 apk['icon'] = iconfilename
992 # First try resizing down to not lose quality
994 for density in screen_densities:
995 if density not in empty_densities:
996 last_density = density
998 if last_density is None:
1000 logging.debug("Density %s not available, resizing down from %s"
1001 % (density, last_density))
1003 last_iconpath = os.path.join(
1004 get_icon_dir(repodir, last_density), iconfilename)
1005 iconpath = os.path.join(
1006 get_icon_dir(repodir, density), iconfilename)
1009 fp = open(last_iconpath, 'rb')
1012 size = dpi_to_px(density)
1014 im.thumbnail((size, size), Image.ANTIALIAS)
1015 im.save(iconpath, "PNG")
1016 empty_densities.remove(density)
1017 except Exception as e:
1018 logging.warning("Invalid image file at %s: %s" % (last_iconpath, e))
1023 # Then just copy from the highest resolution available
1025 for density in reversed(screen_densities):
1026 if density not in empty_densities:
1027 last_density = density
1029 if last_density is None:
1031 logging.debug("Density %s not available, copying from lower density %s"
1032 % (density, last_density))
1035 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
1036 os.path.join(get_icon_dir(repodir, density), iconfilename))
1038 empty_densities.remove(density)
1040 for density in screen_densities:
1041 icon_dir = get_icon_dir(repodir, density)
1042 icondest = os.path.join(icon_dir, iconfilename)
1043 resize_icon(icondest, density)
1045 # Copy from icons-mdpi to icons since mdpi is the baseline density
1046 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
1047 if os.path.isfile(baseline):
1048 apk['icons']['0'] = iconfilename
1049 shutil.copyfile(baseline,
1050 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
1052 if use_date_from_apk and manifest.date_time[1] != 0:
1053 default_date_param = datetime(*manifest.date_time)
1055 default_date_param = None
1057 # Record in known apks, getting the added date at the same time..
1058 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1059 default_date=default_date_param)
1061 apk['added'] = added
1063 apkcache[apkfilename] = apk
1066 return False, apk, cachechanged
1069 def scan_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1070 """Scan the apks in the given repo directory.
1072 This also extracts the icons.
1074 :param apkcache: current apk cache information
1075 :param repodir: repo directory to scan
1076 :param knownapks: known apks info
1077 :param use_date_from_apk: use date from APK (instead of current date)
1078 for newly added APKs
1079 :returns: (apks, cachechanged) where apks is a list of apk information,
1080 and cachechanged is True if the apkcache got changed.
1083 cachechanged = False
1085 for icon_dir in get_all_icon_dirs(repodir):
1086 if os.path.exists(icon_dir):
1088 shutil.rmtree(icon_dir)
1089 os.makedirs(icon_dir)
1091 os.makedirs(icon_dir)
1094 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
1095 apkfilename = apkfile[len(repodir) + 1:]
1096 (skip, apk, cachechanged) = scan_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk)
1101 return apks, cachechanged
1104 def apply_info_from_latest_apk(apps, apks):
1106 Some information from the apks needs to be applied up to the application level.
1107 When doing this, we use the info from the most recent version's apk.
1108 We deal with figuring out when the app was added and last updated at the same time.
1110 for appid, app in apps.items():
1111 bestver = UNSET_VERSION_CODE
1113 if apk['packageName'] == appid:
1114 if apk['versionCode'] > bestver:
1115 bestver = apk['versionCode']
1119 if not app.added or apk['added'] < app.added:
1120 app.added = apk['added']
1121 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1122 app.lastUpdated = apk['added']
1125 logging.debug("Don't know when " + appid + " was added")
1126 if not app.lastUpdated:
1127 logging.debug("Don't know when " + appid + " was last updated")
1129 if bestver == UNSET_VERSION_CODE:
1131 if app.Name is None:
1132 app.Name = app.AutoName or appid
1134 logging.debug("Application " + appid + " has no packages")
1136 if app.Name is None:
1137 app.Name = bestapk['name']
1138 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1139 if app.CurrentVersionCode is None:
1140 app.CurrentVersionCode = str(bestver)
1143 def make_categories_txt(repodir, categories):
1144 '''Write a category list in the repo to allow quick access'''
1146 for cat in sorted(categories):
1147 catdata += cat + '\n'
1148 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1152 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1154 for appid, app in apps.items():
1156 if app.ArchivePolicy:
1157 keepversions = int(app.ArchivePolicy[:-9])
1159 keepversions = defaultkeepversions
1161 def filter_apk_list_sorted(apk_list):
1163 for apk in apk_list:
1164 if apk['packageName'] == appid:
1167 # Sort the apk list by version code. First is highest/newest.
1168 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1170 def move_file(from_dir, to_dir, filename, ignore_missing):
1171 from_path = os.path.join(from_dir, filename)
1172 if ignore_missing and not os.path.exists(from_path):
1174 to_path = os.path.join(to_dir, filename)
1175 shutil.move(from_path, to_path)
1177 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1178 .format(appid, len(apks), keepversions, len(archapks)))
1180 if len(apks) > keepversions:
1181 apklist = filter_apk_list_sorted(apks)
1182 # Move back the ones we don't want.
1183 for apk in apklist[keepversions:]:
1184 logging.info("Moving " + apk['apkName'] + " to archive")
1185 move_file(repodir, archivedir, apk['apkName'], False)
1186 move_file(repodir, archivedir, apk['apkName'] + '.asc', True)
1187 for density in all_screen_densities:
1188 repo_icon_dir = get_icon_dir(repodir, density)
1189 archive_icon_dir = get_icon_dir(archivedir, density)
1190 if density not in apk['icons']:
1192 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1193 if 'srcname' in apk:
1194 move_file(repodir, archivedir, apk['srcname'], False)
1195 archapks.append(apk)
1197 elif len(apks) < keepversions and len(archapks) > 0:
1198 required = keepversions - len(apks)
1199 archapklist = filter_apk_list_sorted(archapks)
1200 # Move forward the ones we want again.
1201 for apk in archapklist[:required]:
1202 logging.info("Moving " + apk['apkName'] + " from archive")
1203 move_file(archivedir, repodir, apk['apkName'], False)
1204 move_file(archivedir, repodir, apk['apkName'] + '.asc', True)
1205 for density in all_screen_densities:
1206 repo_icon_dir = get_icon_dir(repodir, density)
1207 archive_icon_dir = get_icon_dir(archivedir, density)
1208 if density not in apk['icons']:
1210 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1211 if 'srcname' in apk:
1212 move_file(archivedir, repodir, apk['srcname'], False)
1213 archapks.remove(apk)
1217 def add_apks_to_per_app_repos(repodir, apks):
1218 apks_per_app = dict()
1220 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1221 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1222 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1223 apks_per_app[apk['packageName']] = apk
1225 if not os.path.exists(apk['per_app_icons']):
1226 logging.info('Adding new repo for only ' + apk['packageName'])
1227 os.makedirs(apk['per_app_icons'])
1229 apkpath = os.path.join(repodir, apk['apkName'])
1230 shutil.copy(apkpath, apk['per_app_repo'])
1231 apksigpath = apkpath + '.sig'
1232 if os.path.exists(apksigpath):
1233 shutil.copy(apksigpath, apk['per_app_repo'])
1234 apkascpath = apkpath + '.asc'
1235 if os.path.exists(apkascpath):
1236 shutil.copy(apkascpath, apk['per_app_repo'])
1239 def make_binary_transparency_log(repodirs):
1240 '''Log the indexes in a standalone git repo to serve as a "binary
1243 see: https://www.eff.org/deeplinks/2014/02/open-letter-to-tech-companies
1248 btrepo = 'binary_transparency'
1249 if os.path.exists(os.path.join(btrepo, '.git')):
1250 gitrepo = git.Repo(btrepo)
1252 if not os.path.exists(btrepo):
1254 gitrepo = git.Repo.init(btrepo)
1256 gitconfig = gitrepo.config_writer()
1257 gitconfig.set_value('user', 'name', 'fdroid update')
1258 gitconfig.set_value('user', 'email', 'fdroid@' + platform.node())
1260 url = config['repo_url'].rstrip('/')
1261 with open(os.path.join(btrepo, 'README.md'), 'w') as fp:
1263 # Binary Transparency Log for %s
1265 """ % url[:url.rindex('/')]) # strip '/repo'
1266 gitrepo.index.add(['README.md', ])
1267 gitrepo.index.commit('add README')
1269 for repodir in repodirs:
1270 cpdir = os.path.join(btrepo, repodir)
1271 if not os.path.exists(cpdir):
1273 for f in ('index.xml', 'index-v1.json'):
1274 dest = os.path.join(cpdir, f)
1275 shutil.copyfile(os.path.join(repodir, f), dest)
1276 gitrepo.index.add([os.path.join(repodir, f), ])
1277 for f in ('index.jar', 'index-v1.jar'):
1278 repof = os.path.join(repodir, f)
1279 dest = os.path.join(cpdir, f)
1280 jarin = zipfile.ZipFile(repof, 'r')
1281 jarout = zipfile.ZipFile(dest, 'w')
1282 for info in jarin.infolist():
1283 if info.filename.startswith('META-INF/'):
1284 jarout.writestr(info, jarin.read(info.filename))
1287 gitrepo.index.add([repof, ])
1290 for root, dirs, filenames in os.walk(repodir):
1292 files.append(os.path.relpath(os.path.join(root, f), repodir))
1293 output = collections.OrderedDict()
1294 for f in sorted(files):
1295 repofile = os.path.join(repodir, f)
1296 stat = os.stat(repofile)
1305 fslogfile = os.path.join(cpdir, 'filesystemlog.json')
1306 with open(fslogfile, 'w') as fp:
1307 json.dump(output, fp, indent=2)
1308 gitrepo.index.add([os.path.join(repodir, 'filesystemlog.json'), ])
1310 gitrepo.index.commit('fdroid update')
1319 global config, options
1321 # Parse command line...
1322 parser = ArgumentParser()
1323 common.setup_global_opts(parser)
1324 parser.add_argument("--create-key", action="store_true", default=False,
1325 help="Create a repo signing key in a keystore")
1326 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1327 help="Create skeleton metadata files that are missing")
1328 parser.add_argument("--delete-unknown", action="store_true", default=False,
1329 help="Delete APKs and/or OBBs without metadata from the repo")
1330 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1331 help="Report on build data status")
1332 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1333 help="Interactively ask about things that need updating.")
1334 parser.add_argument("-I", "--icons", action="store_true", default=False,
1335 help="Resize all the icons exceeding the max pixel size and exit")
1336 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1337 help="Specify editor to use in interactive mode. Default " +
1338 "is /etc/alternatives/editor")
1339 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1340 help="Update the wiki")
1341 parser.add_argument("--pretty", action="store_true", default=False,
1342 help="Produce human-readable index.xml")
1343 parser.add_argument("--clean", action="store_true", default=False,
1344 help="Clean update - don't uses caches, reprocess all apks")
1345 parser.add_argument("--nosign", action="store_true", default=False,
1346 help="When configured for signed indexes, create only unsigned indexes at this stage")
1347 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1348 help="Use date from apk instead of current time for newly added apks")
1349 metadata.add_metadata_arguments(parser)
1350 options = parser.parse_args()
1351 metadata.warnings_action = options.W
1353 config = common.read_config(options)
1355 if not ('jarsigner' in config and 'keytool' in config):
1356 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1360 if config['archive_older'] != 0:
1361 repodirs.append('archive')
1362 if not os.path.exists('archive'):
1366 resize_all_icons(repodirs)
1369 # check that icons exist now, rather than fail at the end of `fdroid update`
1370 for k in ['repo_icon', 'archive_icon']:
1372 if not os.path.exists(config[k]):
1373 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1376 # if the user asks to create a keystore, do it now, reusing whatever it can
1377 if options.create_key:
1378 if os.path.exists(config['keystore']):
1379 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1380 logging.critical("\t'" + config['keystore'] + "'")
1383 if 'repo_keyalias' not in config:
1384 config['repo_keyalias'] = socket.getfqdn()
1385 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1386 if 'keydname' not in config:
1387 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1388 common.write_to_config(config, 'keydname', config['keydname'])
1389 if 'keystore' not in config:
1390 config['keystore'] = common.default_config.keystore
1391 common.write_to_config(config, 'keystore', config['keystore'])
1393 password = common.genpassword()
1394 if 'keystorepass' not in config:
1395 config['keystorepass'] = password
1396 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1397 if 'keypass' not in config:
1398 config['keypass'] = password
1399 common.write_to_config(config, 'keypass', config['keypass'])
1400 common.genkeystore(config)
1403 apps = metadata.read_metadata()
1405 # Generate a list of categories...
1407 for app in apps.values():
1408 categories.update(app.Categories)
1410 # Read known apks data (will be updated and written back when we've finished)
1411 knownapks = common.KnownApks()
1414 apkcache = get_cache()
1416 # Delete builds for disabled apps
1417 delete_disabled_builds(apps, apkcache, repodirs)
1419 # Scan all apks in the main repo
1420 apks, cachechanged = scan_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1422 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1423 options.use_date_from_apk)
1424 cachechanged = cachechanged or fcachechanged
1426 # Generate warnings for apk's with no metadata (or create skeleton
1427 # metadata files, if requested on the command line)
1430 if apk['packageName'] not in apps:
1431 if options.create_metadata:
1432 if 'name' not in apk:
1433 logging.error(apk['packageName'] + ' does not have a name! Skipping...')
1435 f = open(os.path.join('metadata', apk['packageName'] + '.txt'), 'w', encoding='utf8')
1436 f.write("License:Unknown\n")
1437 f.write("Web Site:\n")
1438 f.write("Source Code:\n")
1439 f.write("Issue Tracker:\n")
1440 f.write("Changelog:\n")
1441 f.write("Summary:" + apk['name'] + "\n")
1442 f.write("Description:\n")
1443 f.write(apk['name'] + "\n")
1445 f.write("Name:" + apk['name'] + "\n")
1447 logging.info("Generated skeleton metadata for " + apk['packageName'])
1450 msg = apk['apkName'] + " (" + apk['packageName'] + ") has no metadata!"
1451 if options.delete_unknown:
1452 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkName'])
1453 rmf = os.path.join(repodirs[0], apk['apkName'])
1454 if not os.path.exists(rmf):
1455 logging.error("Could not find {0} to remove it".format(rmf))
1459 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1461 # update the metadata with the newly created ones included
1463 apps = metadata.read_metadata()
1465 insert_obbs(repodirs[0], apps, apks)
1466 insert_graphics(repodirs[0], apps)
1468 # Scan the archive repo for apks as well
1469 if len(repodirs) > 1:
1470 archapks, cc = scan_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1476 # Apply information from latest apks to the application and update dates
1477 apply_info_from_latest_apk(apps, apks + archapks)
1479 # Sort the app list by name, then the web site doesn't have to by default.
1480 # (we had to wait until we'd scanned the apks to do this, because mostly the
1481 # name comes from there!)
1482 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1484 # APKs are placed into multiple repos based on the app package, providing
1485 # per-app subscription feeds for nightly builds and things like it
1486 if config['per_app_repos']:
1487 add_apks_to_per_app_repos(repodirs[0], apks)
1488 for appid, app in apps.items():
1489 repodir = os.path.join(appid, 'fdroid', 'repo')
1491 appdict[appid] = app
1492 if os.path.isdir(repodir):
1493 index.make(appdict, [appid], apks, repodir, False)
1495 logging.info('Skipping index generation for ' + appid)
1498 if len(repodirs) > 1:
1499 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1501 # Make the index for the main repo...
1502 index.make(apps, sortedids, apks, repodirs[0], False)
1503 make_categories_txt(repodirs[0], categories)
1505 # If there's an archive repo, make the index for it. We already scanned it
1507 if len(repodirs) > 1:
1508 index.make(apps, sortedids, archapks, repodirs[1], True)
1510 if config.get('binary_transparency_remote'):
1511 make_binary_transparency_log(repodirs)
1513 if config['update_stats']:
1514 # Update known apks info...
1515 knownapks.writeifchanged()
1517 # Generate latest apps data for widget
1518 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1520 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1522 appid = line.rstrip()
1523 data += appid + "\t"
1525 data += app.Name + "\t"
1526 if app.icon is not None:
1527 data += app.icon + "\t"
1528 data += app.License + "\n"
1529 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1533 write_cache(apkcache)
1535 # Update the wiki...
1537 update_wiki(apps, sortedids, apks + archapks)
1539 logging.info("Finished.")
1542 if __name__ == "__main__":