3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2010-2015, 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/>.
30 from datetime import datetime, timedelta
31 from xml.dom.minidom import Document
32 from argparse import ArgumentParser
34 from pyasn1.error import PyAsn1Error
35 from pyasn1.codec.der import decoder, encoder
36 from pyasn1_modules import rfc2315
37 from binascii import hexlify, unhexlify
43 from . import metadata
44 from .common import FDroidPopen, FDroidPopenBytes, SdkToolsPopen
45 from .metadata import MetaDataException
49 screen_densities = ['640', '480', '320', '240', '160', '120']
51 all_screen_densities = ['0'] + screen_densities
54 def dpi_to_px(density):
55 return (int(density) * 48) / 160
59 return (int(px) * 160) / 48
62 def get_icon_dir(repodir, density):
64 return os.path.join(repodir, "icons")
65 return os.path.join(repodir, "icons-%s" % density)
68 def get_icon_dirs(repodir):
69 for density in screen_densities:
70 yield get_icon_dir(repodir, density)
73 def get_all_icon_dirs(repodir):
74 for density in all_screen_densities:
75 yield get_icon_dir(repodir, density)
78 def update_wiki(apps, sortedids, apks):
81 :param apps: fully populated list of all applications
82 :param apks: all apks, except...
84 logging.info("Updating wiki")
86 wikiredircat = 'App Redirects'
88 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
89 path=config['wiki_path'])
90 site.login(config['wiki_user'], config['wiki_password'])
92 generated_redirects = {}
94 for appid in sortedids:
99 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
101 for af in app.AntiFeatures:
102 wikidata += '{{AntiFeature|' + af + '}}\n'
107 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' % (
110 time.strftime('%Y-%m-%d', app.added) if app.added else '',
111 time.strftime('%Y-%m-%d', app.lastupdated) if app.lastupdated else '',
126 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
128 wikidata += app.Summary
129 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
131 wikidata += "=Description=\n"
132 wikidata += metadata.description_wiki(app.Description) + "\n"
134 wikidata += "=Maintainer Notes=\n"
135 if app.MaintainerNotes:
136 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
137 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)
139 # Get a list of all packages for this application...
141 gotcurrentver = False
145 if apk['id'] == appid:
146 if str(apk['versioncode']) == app.CurrentVersionCode:
149 # Include ones we can't build, as a special case...
150 for build in app.builds:
152 if build.vercode == app.CurrentVersionCode:
154 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
155 apklist.append({'versioncode': int(build.vercode),
156 'version': build.version,
157 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
162 if apk['versioncode'] == int(build.vercode):
167 apklist.append({'versioncode': int(build.vercode),
168 'version': build.version,
169 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.vercode),
171 if app.CurrentVersionCode == '0':
173 # Sort with most recent first...
174 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
176 wikidata += "=Versions=\n"
177 if len(apklist) == 0:
178 wikidata += "We currently have no versions of this app available."
179 elif not gotcurrentver:
180 wikidata += "We don't have the current version of this app."
182 wikidata += "We have the current version of this app."
183 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
184 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
185 if len(app.NoSourceSince) > 0:
186 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
187 if len(app.CurrentVersion) > 0:
188 wikidata += "The current (recommended) version is " + app.CurrentVersion
189 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
192 wikidata += "==" + apk['version'] + "==\n"
194 if 'buildproblem' in apk:
195 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
198 wikidata += "This version is built and signed by "
200 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
202 wikidata += "the original developer.\n\n"
203 wikidata += "Version code: " + str(apk['versioncode']) + '\n'
205 wikidata += '\n[[Category:' + wikicat + ']]\n'
206 if len(app.NoSourceSince) > 0:
207 wikidata += '\n[[Category:Apps missing source code]]\n'
208 if validapks == 0 and not app.Disabled:
209 wikidata += '\n[[Category:Apps with no packages]]\n'
210 if cantupdate and not app.Disabled:
211 wikidata += "\n[[Category:Apps we cannot update]]\n"
212 if buildfails and not app.Disabled:
213 wikidata += "\n[[Category:Apps with failing builds]]\n"
214 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
215 wikidata += '\n[[Category:Apps to Update]]\n'
217 wikidata += '\n[[Category:Apps that are disabled]]\n'
218 if app.UpdateCheckMode == 'None' and not app.Disabled:
219 wikidata += '\n[[Category:Apps with no update check]]\n'
220 for appcat in app.Categories:
221 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
223 # We can't have underscores in the page name, even if they're in
224 # the package ID, because MediaWiki messes with them...
225 pagename = appid.replace('_', ' ')
227 # Drop a trailing newline, because mediawiki is going to drop it anyway
228 # and it we don't we'll think the page has changed when it hasn't...
229 if wikidata.endswith('\n'):
230 wikidata = wikidata[:-1]
232 generated_pages[pagename] = wikidata
234 # Make a redirect from the name to the ID too, unless there's
235 # already an existing page with the name and it isn't a redirect.
237 apppagename = app.Name.replace('_', ' ')
238 apppagename = apppagename.replace('{', '')
239 apppagename = apppagename.replace('}', ' ')
240 apppagename = apppagename.replace(':', ' ')
241 # Drop double spaces caused mostly by replacing ':' above
242 apppagename = apppagename.replace(' ', ' ')
243 for expagename in site.allpages(prefix=apppagename,
244 filterredir='nonredirects',
246 if expagename == apppagename:
248 # Another reason not to make the redirect page is if the app name
249 # is the same as it's ID, because that will overwrite the real page
250 # with an redirect to itself! (Although it seems like an odd
251 # scenario this happens a lot, e.g. where there is metadata but no
252 # builds or binaries to extract a name from.
253 if apppagename == pagename:
256 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
258 for tcat, genp in [(wikicat, generated_pages),
259 (wikiredircat, generated_redirects)]:
260 catpages = site.Pages['Category:' + tcat]
262 for page in catpages:
263 existingpages.append(page.name)
264 if page.name in genp:
265 pagetxt = page.edit()
266 if pagetxt != genp[page.name]:
267 logging.debug("Updating modified page " + page.name)
268 page.save(genp[page.name], summary='Auto-updated')
270 logging.debug("Page " + page.name + " is unchanged")
272 logging.warn("Deleting page " + page.name)
273 page.delete('No longer published')
274 for pagename, text in genp.items():
275 logging.debug("Checking " + pagename)
276 if pagename not in existingpages:
277 logging.debug("Creating page " + pagename)
279 newpage = site.Pages[pagename]
280 newpage.save(text, summary='Auto-created')
282 logging.error("...FAILED to create page '{0}'".format(pagename))
284 # Purge server cache to ensure counts are up to date
285 site.pages['Repository Maintenance'].purge()
288 def delete_disabled_builds(apps, apkcache, repodirs):
289 """Delete disabled build outputs.
291 :param apps: list of all applications, as per metadata.read_metadata
292 :param apkcache: current apk cache information
293 :param repodirs: the repo directories to process
295 for appid, app in apps.items():
296 for build in app.builds:
297 if not build.disable:
299 apkfilename = appid + '_' + str(build.vercode) + '.apk'
300 iconfilename = "%s.%s.png" % (
303 for repodir in repodirs:
305 os.path.join(repodir, apkfilename),
306 os.path.join(repodir, apkfilename + '.asc'),
307 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
309 for density in all_screen_densities:
310 repo_dir = get_icon_dir(repodir, density)
311 files.append(os.path.join(repo_dir, iconfilename))
314 if os.path.exists(f):
315 logging.info("Deleting disabled build output " + f)
317 if apkfilename in apkcache:
318 del apkcache[apkfilename]
321 def resize_icon(iconpath, density):
323 if not os.path.isfile(iconpath):
328 fp = open(iconpath, 'rb')
330 size = dpi_to_px(density)
332 if any(length > size for length in im.size):
334 im.thumbnail((size, size), Image.ANTIALIAS)
335 logging.debug("%s was too large at %s - new size is %s" % (
336 iconpath, oldsize, im.size))
337 im.save(iconpath, "PNG")
339 except Exception as e:
340 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
347 def resize_all_icons(repodirs):
348 """Resize all icons that exceed the max size
350 :param repodirs: the repo directories to process
352 for repodir in repodirs:
353 for density in screen_densities:
354 icon_dir = get_icon_dir(repodir, density)
355 icon_glob = os.path.join(icon_dir, '*.png')
356 for iconpath in glob.glob(icon_glob):
357 resize_icon(iconpath, density)
360 # A signature block file with a .DSA, .RSA, or .EC extension
361 cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
365 """ Get the signing certificate of an apk. To get the same md5 has that
366 Android gets, we encode the .RSA certificate in a specific format and pass
367 it hex-encoded to the md5 digest algorithm.
369 :param apkpath: path to the apk
370 :returns: A string containing the md5 of the signature of the apk or None
371 if an error occurred.
376 # verify the jar signature is correct
377 args = [config['jarsigner'], '-verify', apkpath]
378 p = FDroidPopen(args)
379 if p.returncode != 0:
380 logging.critical(apkpath + " has a bad signature!")
383 with zipfile.ZipFile(apkpath, 'r') as apk:
385 certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
388 logging.error("Found no signing certificates on %s" % apkpath)
391 logging.error("Found multiple signing certificates on %s" % apkpath)
394 cert = apk.read(certs[0])
396 content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
397 if content.getComponentByName('contentType') != rfc2315.signedData:
398 logging.error("Unexpected format.")
401 content = decoder.decode(content.getComponentByName('content'),
402 asn1Spec=rfc2315.SignedData())[0]
404 certificates = content.getComponentByName('certificates')
406 logging.error("Certificates not found.")
409 cert_encoded = encoder.encode(certificates)[4:]
411 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
414 def scan_apks(apps, apkcache, repodir, knownapks, use_date_from_apk=False):
415 """Scan the apks in the given repo directory.
417 This also extracts the icons.
419 :param apps: list of all applications, as per metadata.read_metadata
420 :param apkcache: current apk cache information
421 :param repodir: repo directory to scan
422 :param knownapks: known apks info
423 :param use_date_from_apk: use date from APK (instead of current date)
425 :returns: (apks, cachechanged) where apks is a list of apk information,
426 and cachechanged is True if the apkcache got changed.
431 for icon_dir in get_all_icon_dirs(repodir):
432 if os.path.exists(icon_dir):
434 shutil.rmtree(icon_dir)
435 os.makedirs(icon_dir)
437 os.makedirs(icon_dir)
440 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
441 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
442 vername_pat = re.compile(".*versionName='([^']*)'.*")
443 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
444 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
445 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
446 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
447 string_pat = re.compile(".* name='([^']*)'.*")
448 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
450 apkfilename = apkfile[len(repodir) + 1:]
451 if ' ' in apkfilename:
452 logging.critical("Spaces in filenames are not allowed.")
455 # Calculate the sha256...
456 sha = hashlib.sha256()
457 with open(apkfile, 'rb') as f:
463 shasum = sha.hexdigest()
466 if apkfilename in apkcache:
467 apk = apkcache[apkfilename]
468 if apk['sha256'] == shasum:
469 logging.debug("Reading " + apkfilename + " from cache")
472 logging.debug("Ignoring stale cache data for " + apkfilename)
475 logging.debug("Processing " + apkfilename)
477 apk['apkname'] = apkfilename
478 apk['sha256'] = shasum
479 srcfilename = apkfilename[:-4] + "_src.tar.gz"
480 if os.path.exists(os.path.join(repodir, srcfilename)):
481 apk['srcname'] = srcfilename
482 apk['size'] = os.path.getsize(apkfile)
483 apk['permissions'] = set()
484 apk['features'] = set()
485 apk['icons_src'] = {}
487 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
488 if p.returncode != 0:
489 if options.delete_unknown:
490 if os.path.exists(apkfile):
491 logging.error("Failed to get apk information, deleting " + apkfile)
494 logging.error("Could not find {0} to remove it".format(apkfile))
496 logging.error("Failed to get apk information, skipping " + apkfile)
498 for line in p.output.splitlines():
499 if line.startswith("package:"):
501 apk['id'] = re.match(name_pat, line).group(1)
502 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
503 apk['version'] = re.match(vername_pat, line).group(1)
504 except Exception as e:
505 logging.error("Package matching failed: " + str(e))
506 logging.info("Line was: " + line)
508 elif line.startswith("application:"):
509 apk['name'] = re.match(label_pat, line).group(1)
510 # Keep path to non-dpi icon in case we need it
511 match = re.match(icon_pat_nodpi, line)
513 apk['icons_src']['-1'] = match.group(1)
514 elif line.startswith("launchable-activity:"):
515 # Only use launchable-activity as fallback to application
517 apk['name'] = re.match(label_pat, line).group(1)
518 if '-1' not in apk['icons_src']:
519 match = re.match(icon_pat_nodpi, line)
521 apk['icons_src']['-1'] = match.group(1)
522 elif line.startswith("application-icon-"):
523 match = re.match(icon_pat, line)
525 density = match.group(1)
526 path = match.group(2)
527 apk['icons_src'][density] = path
528 elif line.startswith("sdkVersion:"):
529 m = re.match(sdkversion_pat, line)
531 logging.error(line.replace('sdkVersion:', '')
532 + ' is not a valid minSdkVersion!')
534 apk['minSdkVersion'] = m.group(1)
535 # if target not set, default to min
536 if 'targetSdkVersion' not in apk:
537 apk['targetSdkVersion'] = m.group(1)
538 elif line.startswith("targetSdkVersion:"):
539 m = re.match(sdkversion_pat, line)
541 logging.error(line.replace('targetSdkVersion:', '')
542 + ' is not a valid targetSdkVersion!')
544 apk['targetSdkVersion'] = m.group(1)
545 elif line.startswith("maxSdkVersion:"):
546 apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
547 elif line.startswith("native-code:"):
548 apk['nativecode'] = []
549 for arch in line[13:].split(' '):
550 apk['nativecode'].append(arch[1:-1])
551 elif line.startswith("uses-permission:"):
552 perm = re.match(string_pat, line).group(1)
553 if perm.startswith("android.permission."):
555 apk['permissions'].add(perm)
556 elif line.startswith("uses-feature:"):
557 perm = re.match(string_pat, line).group(1)
558 # Filter out this, it's only added with the latest SDK tools and
559 # causes problems for lots of apps.
560 if perm != "android.hardware.screen.portrait" \
561 and perm != "android.hardware.screen.landscape":
562 if perm.startswith("android.feature."):
564 apk['features'].add(perm)
566 if 'minSdkVersion' not in apk:
567 logging.warn("No SDK version information found in {0}".format(apkfile))
568 apk['minSdkVersion'] = 1
570 # Check for debuggable apks...
571 if common.isApkDebuggable(apkfile, config):
572 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
574 # Get the signature (or md5 of, to be precise)...
575 logging.debug('Getting signature of {0}'.format(apkfile))
576 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
578 logging.critical("Failed to get apk signature")
581 apkzip = zipfile.ZipFile(apkfile, 'r')
583 # if an APK has files newer than the system time, suggest updating
584 # the system clock. This is useful for offline systems, used for
585 # signing, which do not have another source of clock sync info. It
586 # has to be more than 24 hours newer because ZIP/APK files do not
587 # store timezone info
588 manifest = apkzip.getinfo('AndroidManifest.xml')
589 if manifest.date_time[1] == 0: # month can't be zero
590 logging.debug('AndroidManifest.xml has no date')
592 dt_obj = datetime(*manifest.date_time)
593 checkdt = dt_obj - timedelta(1)
594 if datetime.today() < checkdt:
595 logging.warn('System clock is older than manifest in: '
597 + '\nSet clock to that time using:\n'
598 + 'sudo date -s "' + str(dt_obj) + '"')
600 iconfilename = "%s.%s.png" % (
604 # Extract the icon file...
606 for density in screen_densities:
607 if density not in apk['icons_src']:
608 empty_densities.append(density)
610 iconsrc = apk['icons_src'][density]
611 icon_dir = get_icon_dir(repodir, density)
612 icondest = os.path.join(icon_dir, iconfilename)
615 with open(icondest, 'wb') as f:
616 f.write(apkzip.read(iconsrc))
617 apk['icons'][density] = iconfilename
620 logging.warn("Error retrieving icon file")
621 del apk['icons'][density]
622 del apk['icons_src'][density]
623 empty_densities.append(density)
625 if '-1' in apk['icons_src']:
626 iconsrc = apk['icons_src']['-1']
627 iconpath = os.path.join(
628 get_icon_dir(repodir, '0'), iconfilename)
629 with open(iconpath, 'wb') as f:
630 f.write(apkzip.read(iconsrc))
632 im = Image.open(iconpath)
633 dpi = px_to_dpi(im.size[0])
634 for density in screen_densities:
635 if density in apk['icons']:
637 if density == screen_densities[-1] or dpi >= int(density):
638 apk['icons'][density] = iconfilename
639 shutil.move(iconpath,
640 os.path.join(get_icon_dir(repodir, density), iconfilename))
641 empty_densities.remove(density)
643 except Exception as e:
644 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
647 apk['icon'] = iconfilename
651 # First try resizing down to not lose quality
653 for density in screen_densities:
654 if density not in empty_densities:
655 last_density = density
657 if last_density is None:
659 logging.debug("Density %s not available, resizing down from %s"
660 % (density, last_density))
662 last_iconpath = os.path.join(
663 get_icon_dir(repodir, last_density), iconfilename)
664 iconpath = os.path.join(
665 get_icon_dir(repodir, density), iconfilename)
668 fp = open(last_iconpath, 'rb')
671 size = dpi_to_px(density)
673 im.thumbnail((size, size), Image.ANTIALIAS)
674 im.save(iconpath, "PNG")
675 empty_densities.remove(density)
677 logging.warning("Invalid image file at %s" % last_iconpath)
682 # Then just copy from the highest resolution available
684 for density in reversed(screen_densities):
685 if density not in empty_densities:
686 last_density = density
688 if last_density is None:
690 logging.debug("Density %s not available, copying from lower density %s"
691 % (density, last_density))
694 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
695 os.path.join(get_icon_dir(repodir, density), iconfilename))
697 empty_densities.remove(density)
699 for density in screen_densities:
700 icon_dir = get_icon_dir(repodir, density)
701 icondest = os.path.join(icon_dir, iconfilename)
702 resize_icon(icondest, density)
704 # Copy from icons-mdpi to icons since mdpi is the baseline density
705 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
706 if os.path.isfile(baseline):
707 apk['icons']['0'] = iconfilename
708 shutil.copyfile(baseline,
709 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
711 # Record in known apks, getting the added date at the same time..
712 added = knownapks.recordapk(apk['apkname'], apk['id'])
714 if use_date_from_apk and manifest.date_time[1] != 0:
715 added = datetime(*manifest.date_time).timetuple()
716 logging.debug("Using date from APK")
720 apkcache[apkfilename] = apk
725 return apks, cachechanged
728 repo_pubkey_fingerprint = None
731 # Generate a certificate fingerprint the same way keytool does it
732 # (but with slightly different formatting)
733 def cert_fingerprint(data):
734 digest = hashlib.sha256(data).digest()
736 ret.append(' '.join("%02X" % b for b in bytearray(digest)))
740 def extract_pubkey():
741 global repo_pubkey_fingerprint
742 if 'repo_pubkey' in config:
743 pubkey = unhexlify(config['repo_pubkey'])
745 p = FDroidPopenBytes([config['keytool'], '-exportcert',
746 '-alias', config['repo_keyalias'],
747 '-keystore', config['keystore'],
748 '-storepass:file', config['keystorepassfile']]
749 + config['smartcardoptions'],
750 output=False, stderr_to_stdout=False)
751 if p.returncode != 0 or len(p.output) < 20:
752 msg = "Failed to get repo pubkey!"
753 if config['keystore'] == 'NONE':
754 msg += ' Is your crypto smartcard plugged in?'
755 logging.critical(msg)
758 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
759 return hexlify(pubkey)
762 def make_index(apps, sortedids, apks, repodir, archive, categories):
763 """Make a repo index.
765 :param apps: fully populated apps list
766 :param apks: full populated apks list
767 :param repodir: the repo directory
768 :param archive: True if this is the archive repo, False if it's the
770 :param categories: list of categories
775 def addElement(name, value, doc, parent):
776 el = doc.createElement(name)
777 el.appendChild(doc.createTextNode(value))
778 parent.appendChild(el)
780 def addElementNonEmpty(name, value, doc, parent):
783 addElement(name, value, doc, parent)
785 def addElementCDATA(name, value, doc, parent):
786 el = doc.createElement(name)
787 el.appendChild(doc.createCDATASection(value))
788 parent.appendChild(el)
790 root = doc.createElement("fdroid")
791 doc.appendChild(root)
793 repoel = doc.createElement("repo")
795 mirrorcheckfailed = False
796 for mirror in config.get('mirrors', []):
797 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
798 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
799 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
800 mirrorcheckfailed = True
801 if mirrorcheckfailed:
805 repoel.setAttribute("name", config['archive_name'])
806 if config['repo_maxage'] != 0:
807 repoel.setAttribute("maxage", str(config['repo_maxage']))
808 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
809 repoel.setAttribute("url", config['archive_url'])
810 addElement('description', config['archive_description'], doc, repoel)
811 urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
812 for mirror in config.get('mirrors', []):
813 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
816 repoel.setAttribute("name", config['repo_name'])
817 if config['repo_maxage'] != 0:
818 repoel.setAttribute("maxage", str(config['repo_maxage']))
819 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
820 repoel.setAttribute("url", config['repo_url'])
821 addElement('description', config['repo_description'], doc, repoel)
822 urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
823 for mirror in config.get('mirrors', []):
824 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
826 repoel.setAttribute("version", str(METADATA_VERSION))
827 repoel.setAttribute("timestamp", str(int(time.time())))
830 if not options.nosign:
831 if 'repo_keyalias' not in config:
833 logging.critical("'repo_keyalias' not found in config.py!")
834 if 'keystore' not in config:
836 logging.critical("'keystore' not found in config.py!")
837 if 'keystorepass' not in config and 'keystorepassfile' not in config:
839 logging.critical("'keystorepass' not found in config.py!")
840 if 'keypass' not in config and 'keypassfile' not in config:
842 logging.critical("'keypass' not found in config.py!")
843 if not os.path.exists(config['keystore']):
845 logging.critical("'" + config['keystore'] + "' does not exist!")
847 logging.warning("`fdroid update` requires a signing key, you can create one using:")
848 logging.warning("\tfdroid update --create-key")
851 repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
852 root.appendChild(repoel)
854 for appid in sortedids:
857 if app.Disabled is not None:
860 # Get a list of the apks for this app...
863 if apk['id'] == appid:
866 if len(apklist) == 0:
869 apel = doc.createElement("application")
870 apel.setAttribute("id", app.id)
871 root.appendChild(apel)
873 addElement('id', app.id, doc, apel)
875 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
877 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
878 addElement('name', app.Name, doc, apel)
879 addElement('summary', app.Summary, doc, apel)
881 addElement('icon', app.icon, doc, apel)
885 return ("fdroid.app:" + appid, apps[appid].Name)
886 raise MetaDataException("Cannot resolve app id " + appid)
889 metadata.description_html(app.Description, linkres),
891 addElement('license', app.License, doc, apel)
893 addElement('categories', ','.join(app.Categories), doc, apel)
894 # We put the first (primary) category in LAST, which will have
895 # the desired effect of making clients that only understand one
896 # category see that one.
897 addElement('category', app.Categories[0], doc, apel)
898 addElement('web', app.WebSite, doc, apel)
899 addElement('source', app.SourceCode, doc, apel)
900 addElement('tracker', app.IssueTracker, doc, apel)
901 addElementNonEmpty('changelog', app.Changelog, doc, apel)
902 addElementNonEmpty('author', app.AuthorName, doc, apel)
903 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
904 addElementNonEmpty('donate', app.Donate, doc, apel)
905 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
906 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
907 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
909 # These elements actually refer to the current version (i.e. which
910 # one is recommended. They are historically mis-named, and need
911 # changing, but stay like this for now to support existing clients.
912 addElement('marketversion', app.CurrentVersion, doc, apel)
913 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
916 af = app.AntiFeatures
918 addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
920 pv = app.Provides.split(',')
921 addElementNonEmpty('provides', ','.join(pv), doc, apel)
923 addElement('requirements', 'root', doc, apel)
925 # Sort the apk list into version order, just so the web site
926 # doesn't have to do any work by default...
927 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
929 # Check for duplicates - they will make the client unhappy...
930 for i in range(len(apklist) - 1):
931 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
932 logging.critical("duplicate versions: '%s' - '%s'" % (
933 apklist[i]['apkname'], apklist[i + 1]['apkname']))
936 current_version_code = 0
937 current_version_file = None
939 # find the APK for the "Current Version"
940 if current_version_code < apk['versioncode']:
941 current_version_code = apk['versioncode']
942 if current_version_code < int(app.CurrentVersionCode):
943 current_version_file = apk['apkname']
945 apkel = doc.createElement("package")
946 apel.appendChild(apkel)
947 addElement('version', apk['version'], doc, apkel)
948 addElement('versioncode', str(apk['versioncode']), doc, apkel)
949 addElement('apkname', apk['apkname'], doc, apkel)
951 addElement('srcname', apk['srcname'], doc, apkel)
952 for hash_type in ['sha256']:
953 if hash_type not in apk:
955 hashel = doc.createElement("hash")
956 hashel.setAttribute("type", hash_type)
957 hashel.appendChild(doc.createTextNode(apk[hash_type]))
958 apkel.appendChild(hashel)
959 addElement('sig', apk['sig'], doc, apkel)
960 addElement('size', str(apk['size']), doc, apkel)
961 addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
962 if 'targetSdkVersion' in apk:
963 addElement('targetSdkVersion', str(apk['targetSdkVersion']), doc, apkel)
964 if 'maxSdkVersion' in apk:
965 addElement('maxsdkver', str(apk['maxSdkVersion']), doc, apkel)
967 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
968 addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
969 if 'nativecode' in apk:
970 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
971 addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
973 if current_version_file is not None \
974 and config['make_current_version_link'] \
975 and repodir == 'repo': # only create these
976 namefield = config['current_version_name_source']
977 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
978 apklinkname = sanitized_name + '.apk'
979 current_version_path = os.path.join(repodir, current_version_file)
980 if os.path.islink(apklinkname):
981 os.remove(apklinkname)
982 os.symlink(current_version_path, apklinkname)
983 # also symlink gpg signature, if it exists
984 for extension in ('.asc', '.sig'):
985 sigfile_path = current_version_path + extension
986 if os.path.exists(sigfile_path):
987 siglinkname = apklinkname + extension
988 if os.path.islink(siglinkname):
989 os.remove(siglinkname)
990 os.symlink(sigfile_path, siglinkname)
993 output = doc.toprettyxml(encoding='utf-8')
995 output = doc.toxml(encoding='utf-8')
997 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
1000 if 'repo_keyalias' in config:
1003 logging.info("Creating unsigned index in preparation for signing")
1005 logging.info("Creating signed index with this key (SHA256):")
1006 logging.info("%s" % repo_pubkey_fingerprint)
1008 # Create a jar of the index...
1009 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
1010 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
1011 if p.returncode != 0:
1012 logging.critical("Failed to create {0}".format(jar_output))
1016 signed = os.path.join(repodir, 'index.jar')
1018 # Remove old signed index if not signing
1019 if os.path.exists(signed):
1022 args = [config['jarsigner'], '-keystore', config['keystore'],
1023 '-storepass:file', config['keystorepassfile'],
1024 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
1025 signed, config['repo_keyalias']]
1026 if config['keystore'] == 'NONE':
1027 args += config['smartcardoptions']
1028 else: # smardcards never use -keypass
1029 args += ['-keypass:file', config['keypassfile']]
1030 p = FDroidPopen(args)
1031 if p.returncode != 0:
1032 logging.critical("Failed to sign index")
1035 # Copy the repo icon into the repo directory...
1036 icon_dir = os.path.join(repodir, 'icons')
1037 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1038 shutil.copyfile(config['repo_icon'], iconfilename)
1040 # Write a category list in the repo to allow quick access...
1042 for cat in categories:
1043 catdata += cat + '\n'
1044 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1048 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1050 for appid, app in apps.items():
1052 if app.ArchivePolicy:
1053 keepversions = int(app.ArchivePolicy[:-9])
1055 keepversions = defaultkeepversions
1057 def filter_apk_list_sorted(apk_list):
1059 for apk in apk_list:
1060 if apk['id'] == appid:
1063 # Sort the apk list by version code. First is highest/newest.
1064 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1066 def move_file(from_dir, to_dir, filename, ignore_missing):
1067 from_path = os.path.join(from_dir, filename)
1068 if ignore_missing and not os.path.exists(from_path):
1070 to_path = os.path.join(to_dir, filename)
1071 shutil.move(from_path, to_path)
1073 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1074 .format(appid, len(apks), keepversions, len(archapks)))
1076 if len(apks) > keepversions:
1077 apklist = filter_apk_list_sorted(apks)
1078 # Move back the ones we don't want.
1079 for apk in apklist[keepversions:]:
1080 logging.info("Moving " + apk['apkname'] + " to archive")
1081 move_file(repodir, archivedir, apk['apkname'], False)
1082 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1083 for density in all_screen_densities:
1084 repo_icon_dir = get_icon_dir(repodir, density)
1085 archive_icon_dir = get_icon_dir(archivedir, density)
1086 if density not in apk['icons']:
1088 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1089 if 'srcname' in apk:
1090 move_file(repodir, archivedir, apk['srcname'], False)
1091 archapks.append(apk)
1093 elif len(apks) < keepversions and len(archapks) > 0:
1094 required = keepversions - len(apks)
1095 archapklist = filter_apk_list_sorted(archapks)
1096 # Move forward the ones we want again.
1097 for apk in archapklist[:required]:
1098 logging.info("Moving " + apk['apkname'] + " from archive")
1099 move_file(archivedir, repodir, apk['apkname'], False)
1100 move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
1101 for density in all_screen_densities:
1102 repo_icon_dir = get_icon_dir(repodir, density)
1103 archive_icon_dir = get_icon_dir(archivedir, density)
1104 if density not in apk['icons']:
1106 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1107 if 'srcname' in apk:
1108 move_file(archivedir, repodir, apk['srcname'], False)
1109 archapks.remove(apk)
1113 def add_apks_to_per_app_repos(repodir, apks):
1114 apks_per_app = dict()
1116 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1117 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1118 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1119 apks_per_app[apk['id']] = apk
1121 if not os.path.exists(apk['per_app_icons']):
1122 logging.info('Adding new repo for only ' + apk['id'])
1123 os.makedirs(apk['per_app_icons'])
1125 apkpath = os.path.join(repodir, apk['apkname'])
1126 shutil.copy(apkpath, apk['per_app_repo'])
1127 apksigpath = apkpath + '.sig'
1128 if os.path.exists(apksigpath):
1129 shutil.copy(apksigpath, apk['per_app_repo'])
1130 apkascpath = apkpath + '.asc'
1131 if os.path.exists(apkascpath):
1132 shutil.copy(apkascpath, apk['per_app_repo'])
1141 global config, options
1143 # Parse command line...
1144 parser = ArgumentParser()
1145 common.setup_global_opts(parser)
1146 parser.add_argument("--create-key", action="store_true", default=False,
1147 help="Create a repo signing key in a keystore")
1148 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1149 help="Create skeleton metadata files that are missing")
1150 parser.add_argument("--delete-unknown", action="store_true", default=False,
1151 help="Delete APKs without metadata from the repo")
1152 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1153 help="Report on build data status")
1154 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1155 help="Interactively ask about things that need updating.")
1156 parser.add_argument("-I", "--icons", action="store_true", default=False,
1157 help="Resize all the icons exceeding the max pixel size and exit")
1158 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1159 help="Specify editor to use in interactive mode. Default " +
1160 "is /etc/alternatives/editor")
1161 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1162 help="Update the wiki")
1163 parser.add_argument("--pretty", action="store_true", default=False,
1164 help="Produce human-readable index.xml")
1165 parser.add_argument("--clean", action="store_true", default=False,
1166 help="Clean update - don't uses caches, reprocess all apks")
1167 parser.add_argument("--nosign", action="store_true", default=False,
1168 help="When configured for signed indexes, create only unsigned indexes at this stage")
1169 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1170 help="Use date from apk instead of current time for newly added apks")
1171 options = parser.parse_args()
1173 config = common.read_config(options)
1175 if not ('jarsigner' in config and 'keytool' in config):
1176 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1180 if config['archive_older'] != 0:
1181 repodirs.append('archive')
1182 if not os.path.exists('archive'):
1186 resize_all_icons(repodirs)
1189 # check that icons exist now, rather than fail at the end of `fdroid update`
1190 for k in ['repo_icon', 'archive_icon']:
1192 if not os.path.exists(config[k]):
1193 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1196 # if the user asks to create a keystore, do it now, reusing whatever it can
1197 if options.create_key:
1198 if os.path.exists(config['keystore']):
1199 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1200 logging.critical("\t'" + config['keystore'] + "'")
1203 if 'repo_keyalias' not in config:
1204 config['repo_keyalias'] = socket.getfqdn()
1205 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1206 if 'keydname' not in config:
1207 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1208 common.write_to_config(config, 'keydname', config['keydname'])
1209 if 'keystore' not in config:
1210 config['keystore'] = common.default_config.keystore
1211 common.write_to_config(config, 'keystore', config['keystore'])
1213 password = common.genpassword()
1214 if 'keystorepass' not in config:
1215 config['keystorepass'] = password
1216 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1217 if 'keypass' not in config:
1218 config['keypass'] = password
1219 common.write_to_config(config, 'keypass', config['keypass'])
1220 common.genkeystore(config)
1223 apps = metadata.read_metadata()
1225 # Generate a list of categories...
1227 for app in apps.values():
1228 categories.update(app.Categories)
1230 # Read known apks data (will be updated and written back when we've finished)
1231 knownapks = common.KnownApks()
1233 # Gather information about all the apk files in the repo directory, using
1234 # cached data if possible.
1235 apkcachefile = os.path.join('tmp', 'apkcache')
1236 if not options.clean and os.path.exists(apkcachefile):
1237 with open(apkcachefile, 'rb') as cf:
1238 apkcache = pickle.load(cf, encoding='utf-8')
1239 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
1244 delete_disabled_builds(apps, apkcache, repodirs)
1246 # Scan all apks in the main repo
1247 apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1249 # Generate warnings for apk's with no metadata (or create skeleton
1250 # metadata files, if requested on the command line)
1253 if apk['id'] not in apps:
1254 if options.create_metadata:
1255 if 'name' not in apk:
1256 logging.error(apk['id'] + ' does not have a name! Skipping...')
1258 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
1259 f.write("License:Unknown\n")
1260 f.write("Web Site:\n")
1261 f.write("Source Code:\n")
1262 f.write("Issue Tracker:\n")
1263 f.write("Changelog:\n")
1264 f.write("Summary:" + apk['name'] + "\n")
1265 f.write("Description:\n")
1266 f.write(apk['name'] + "\n")
1269 logging.info("Generated skeleton metadata for " + apk['id'])
1272 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1273 if options.delete_unknown:
1274 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1275 rmf = os.path.join(repodirs[0], apk['apkname'])
1276 if not os.path.exists(rmf):
1277 logging.error("Could not find {0} to remove it".format(rmf))
1281 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1283 # update the metadata with the newly created ones included
1285 apps = metadata.read_metadata()
1287 # Scan the archive repo for apks as well
1288 if len(repodirs) > 1:
1289 archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1295 # Some information from the apks needs to be applied up to the application
1296 # level. When doing this, we use the info from the most recent version's apk.
1297 # We deal with figuring out when the app was added and last updated at the
1299 for appid, app in apps.items():
1301 for apk in apks + archapks:
1302 if apk['id'] == appid:
1303 if apk['versioncode'] > bestver:
1304 bestver = apk['versioncode']
1308 if not app.added or apk['added'] < app.added:
1309 app.added = apk['added']
1310 if not app.lastupdated or apk['added'] > app.lastupdated:
1311 app.lastupdated = apk['added']
1314 logging.debug("Don't know when " + appid + " was added")
1315 if not app.lastupdated:
1316 logging.debug("Don't know when " + appid + " was last updated")
1319 if app.Name is None:
1320 app.Name = app.AutoName or appid
1322 logging.debug("Application " + appid + " has no packages")
1324 if app.Name is None:
1325 app.Name = bestapk['name']
1326 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1327 if app.CurrentVersionCode is None:
1328 app.CurrentVersionCode = str(bestver)
1330 # Sort the app list by name, then the web site doesn't have to by default.
1331 # (we had to wait until we'd scanned the apks to do this, because mostly the
1332 # name comes from there!)
1333 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1335 # APKs are placed into multiple repos based on the app package, providing
1336 # per-app subscription feeds for nightly builds and things like it
1337 if config['per_app_repos']:
1338 add_apks_to_per_app_repos(repodirs[0], apks)
1339 for appid, app in apps.items():
1340 repodir = os.path.join(appid, 'fdroid', 'repo')
1342 appdict[appid] = app
1343 if os.path.isdir(repodir):
1344 make_index(appdict, [appid], apks, repodir, False, categories)
1346 logging.info('Skipping index generation for ' + appid)
1349 if len(repodirs) > 1:
1350 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1352 # Make the index for the main repo...
1353 make_index(apps, sortedids, apks, repodirs[0], False, categories)
1355 # If there's an archive repo, make the index for it. We already scanned it
1357 if len(repodirs) > 1:
1358 make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1360 if config['update_stats']:
1362 # Update known apks info...
1363 knownapks.writeifchanged()
1365 # Generate latest apps data for widget
1366 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1368 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1370 appid = line.rstrip()
1371 data += appid + "\t"
1373 data += app.Name + "\t"
1374 if app.icon is not None:
1375 data += app.icon + "\t"
1376 data += app.License + "\n"
1377 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1381 apkcache["METADATA_VERSION"] = METADATA_VERSION
1382 with open(apkcachefile, 'wb') as cf:
1383 pickle.dump(apkcache, cf)
1385 # Update the wiki...
1387 update_wiki(apps, sortedids, apks + archapks)
1389 logging.info("Finished.")
1391 if __name__ == "__main__":