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 get_icon_bytes(apkzip, iconsrc):
415 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
417 return apkzip.read(iconsrc)
419 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
422 def scan_apks(apps, apkcache, repodir, knownapks, use_date_from_apk=False):
423 """Scan the apks in the given repo directory.
425 This also extracts the icons.
427 :param apps: list of all applications, as per metadata.read_metadata
428 :param apkcache: current apk cache information
429 :param repodir: repo directory to scan
430 :param knownapks: known apks info
431 :param use_date_from_apk: use date from APK (instead of current date)
433 :returns: (apks, cachechanged) where apks is a list of apk information,
434 and cachechanged is True if the apkcache got changed.
439 for icon_dir in get_all_icon_dirs(repodir):
440 if os.path.exists(icon_dir):
442 shutil.rmtree(icon_dir)
443 os.makedirs(icon_dir)
445 os.makedirs(icon_dir)
448 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
449 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
450 vername_pat = re.compile(".*versionName='([^']*)'.*")
451 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
452 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
453 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
454 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
455 string_pat = re.compile(".* name='([^']*)'.*")
456 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
458 apkfilename = apkfile[len(repodir) + 1:]
459 if ' ' in apkfilename:
460 logging.critical("Spaces in filenames are not allowed.")
463 # Calculate the sha256...
464 sha = hashlib.sha256()
465 with open(apkfile, 'rb') as f:
471 shasum = sha.hexdigest()
474 if apkfilename in apkcache:
475 apk = apkcache[apkfilename]
476 if apk['sha256'] == shasum:
477 logging.debug("Reading " + apkfilename + " from cache")
480 logging.debug("Ignoring stale cache data for " + apkfilename)
483 logging.debug("Processing " + apkfilename)
485 apk['apkname'] = apkfilename
486 apk['sha256'] = shasum
487 srcfilename = apkfilename[:-4] + "_src.tar.gz"
488 if os.path.exists(os.path.join(repodir, srcfilename)):
489 apk['srcname'] = srcfilename
490 apk['size'] = os.path.getsize(apkfile)
491 apk['permissions'] = set()
492 apk['features'] = set()
493 apk['icons_src'] = {}
495 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
496 if p.returncode != 0:
497 if options.delete_unknown:
498 if os.path.exists(apkfile):
499 logging.error("Failed to get apk information, deleting " + apkfile)
502 logging.error("Could not find {0} to remove it".format(apkfile))
504 logging.error("Failed to get apk information, skipping " + apkfile)
506 for line in p.output.splitlines():
507 if line.startswith("package:"):
509 apk['id'] = re.match(name_pat, line).group(1)
510 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
511 apk['version'] = re.match(vername_pat, line).group(1)
512 except Exception as e:
513 logging.error("Package matching failed: " + str(e))
514 logging.info("Line was: " + line)
516 elif line.startswith("application:"):
517 apk['name'] = re.match(label_pat, line).group(1)
518 # Keep path to non-dpi icon in case we need it
519 match = re.match(icon_pat_nodpi, line)
521 apk['icons_src']['-1'] = match.group(1)
522 elif line.startswith("launchable-activity:"):
523 # Only use launchable-activity as fallback to application
525 apk['name'] = re.match(label_pat, line).group(1)
526 if '-1' not in apk['icons_src']:
527 match = re.match(icon_pat_nodpi, line)
529 apk['icons_src']['-1'] = match.group(1)
530 elif line.startswith("application-icon-"):
531 match = re.match(icon_pat, line)
533 density = match.group(1)
534 path = match.group(2)
535 apk['icons_src'][density] = path
536 elif line.startswith("sdkVersion:"):
537 m = re.match(sdkversion_pat, line)
539 logging.error(line.replace('sdkVersion:', '')
540 + ' is not a valid minSdkVersion!')
542 apk['minSdkVersion'] = m.group(1)
543 # if target not set, default to min
544 if 'targetSdkVersion' not in apk:
545 apk['targetSdkVersion'] = m.group(1)
546 elif line.startswith("targetSdkVersion:"):
547 m = re.match(sdkversion_pat, line)
549 logging.error(line.replace('targetSdkVersion:', '')
550 + ' is not a valid targetSdkVersion!')
552 apk['targetSdkVersion'] = m.group(1)
553 elif line.startswith("maxSdkVersion:"):
554 apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
555 elif line.startswith("native-code:"):
556 apk['nativecode'] = []
557 for arch in line[13:].split(' '):
558 apk['nativecode'].append(arch[1:-1])
559 elif line.startswith("uses-permission:"):
560 perm = re.match(string_pat, line).group(1)
561 if perm.startswith("android.permission."):
563 apk['permissions'].add(perm)
564 elif line.startswith("uses-feature:"):
565 perm = re.match(string_pat, line).group(1)
566 # Filter out this, it's only added with the latest SDK tools and
567 # causes problems for lots of apps.
568 if perm != "android.hardware.screen.portrait" \
569 and perm != "android.hardware.screen.landscape":
570 if perm.startswith("android.feature."):
572 apk['features'].add(perm)
574 if 'minSdkVersion' not in apk:
575 logging.warn("No SDK version information found in {0}".format(apkfile))
576 apk['minSdkVersion'] = 1
578 # Check for debuggable apks...
579 if common.isApkDebuggable(apkfile, config):
580 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
582 # Get the signature (or md5 of, to be precise)...
583 logging.debug('Getting signature of {0}'.format(apkfile))
584 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
586 logging.critical("Failed to get apk signature")
589 apkzip = zipfile.ZipFile(apkfile, 'r')
591 # if an APK has files newer than the system time, suggest updating
592 # the system clock. This is useful for offline systems, used for
593 # signing, which do not have another source of clock sync info. It
594 # has to be more than 24 hours newer because ZIP/APK files do not
595 # store timezone info
596 manifest = apkzip.getinfo('AndroidManifest.xml')
597 if manifest.date_time[1] == 0: # month can't be zero
598 logging.debug('AndroidManifest.xml has no date')
600 dt_obj = datetime(*manifest.date_time)
601 checkdt = dt_obj - timedelta(1)
602 if datetime.today() < checkdt:
603 logging.warn('System clock is older than manifest in: '
605 + '\nSet clock to that time using:\n'
606 + 'sudo date -s "' + str(dt_obj) + '"')
608 iconfilename = "%s.%s.png" % (
612 # Extract the icon file...
614 for density in screen_densities:
615 if density not in apk['icons_src']:
616 empty_densities.append(density)
618 iconsrc = apk['icons_src'][density]
619 icon_dir = get_icon_dir(repodir, density)
620 icondest = os.path.join(icon_dir, iconfilename)
623 with open(icondest, 'wb') as f:
624 f.write(get_icon_bytes(apkzip, iconsrc))
625 apk['icons'][density] = iconfilename
628 logging.warn("Error retrieving icon file")
629 del apk['icons'][density]
630 del apk['icons_src'][density]
631 empty_densities.append(density)
633 if '-1' in apk['icons_src']:
634 iconsrc = apk['icons_src']['-1']
635 iconpath = os.path.join(
636 get_icon_dir(repodir, '0'), iconfilename)
637 with open(iconpath, 'wb') as f:
638 f.write(get_icon_bytes(apkzip, iconsrc))
640 im = Image.open(iconpath)
641 dpi = px_to_dpi(im.size[0])
642 for density in screen_densities:
643 if density in apk['icons']:
645 if density == screen_densities[-1] or dpi >= int(density):
646 apk['icons'][density] = iconfilename
647 shutil.move(iconpath,
648 os.path.join(get_icon_dir(repodir, density), iconfilename))
649 empty_densities.remove(density)
651 except Exception as e:
652 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
655 apk['icon'] = iconfilename
659 # First try resizing down to not lose quality
661 for density in screen_densities:
662 if density not in empty_densities:
663 last_density = density
665 if last_density is None:
667 logging.debug("Density %s not available, resizing down from %s"
668 % (density, last_density))
670 last_iconpath = os.path.join(
671 get_icon_dir(repodir, last_density), iconfilename)
672 iconpath = os.path.join(
673 get_icon_dir(repodir, density), iconfilename)
676 fp = open(last_iconpath, 'rb')
679 size = dpi_to_px(density)
681 im.thumbnail((size, size), Image.ANTIALIAS)
682 im.save(iconpath, "PNG")
683 empty_densities.remove(density)
685 logging.warning("Invalid image file at %s" % last_iconpath)
690 # Then just copy from the highest resolution available
692 for density in reversed(screen_densities):
693 if density not in empty_densities:
694 last_density = density
696 if last_density is None:
698 logging.debug("Density %s not available, copying from lower density %s"
699 % (density, last_density))
702 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
703 os.path.join(get_icon_dir(repodir, density), iconfilename))
705 empty_densities.remove(density)
707 for density in screen_densities:
708 icon_dir = get_icon_dir(repodir, density)
709 icondest = os.path.join(icon_dir, iconfilename)
710 resize_icon(icondest, density)
712 # Copy from icons-mdpi to icons since mdpi is the baseline density
713 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
714 if os.path.isfile(baseline):
715 apk['icons']['0'] = iconfilename
716 shutil.copyfile(baseline,
717 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
719 # Record in known apks, getting the added date at the same time..
720 added = knownapks.recordapk(apk['apkname'], apk['id'])
722 if use_date_from_apk and manifest.date_time[1] != 0:
723 added = datetime(*manifest.date_time).timetuple()
724 logging.debug("Using date from APK")
728 apkcache[apkfilename] = apk
733 return apks, cachechanged
736 repo_pubkey_fingerprint = None
739 # Generate a certificate fingerprint the same way keytool does it
740 # (but with slightly different formatting)
741 def cert_fingerprint(data):
742 digest = hashlib.sha256(data).digest()
744 ret.append(' '.join("%02X" % b for b in bytearray(digest)))
748 def extract_pubkey():
749 global repo_pubkey_fingerprint
750 if 'repo_pubkey' in config:
751 pubkey = unhexlify(config['repo_pubkey'])
753 p = FDroidPopenBytes([config['keytool'], '-exportcert',
754 '-alias', config['repo_keyalias'],
755 '-keystore', config['keystore'],
756 '-storepass:file', config['keystorepassfile']]
757 + config['smartcardoptions'],
758 output=False, stderr_to_stdout=False)
759 if p.returncode != 0 or len(p.output) < 20:
760 msg = "Failed to get repo pubkey!"
761 if config['keystore'] == 'NONE':
762 msg += ' Is your crypto smartcard plugged in?'
763 logging.critical(msg)
766 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
767 return hexlify(pubkey)
770 def make_index(apps, sortedids, apks, repodir, archive, categories):
771 """Make a repo index.
773 :param apps: fully populated apps list
774 :param apks: full populated apks list
775 :param repodir: the repo directory
776 :param archive: True if this is the archive repo, False if it's the
778 :param categories: list of categories
783 def addElement(name, value, doc, parent):
784 el = doc.createElement(name)
785 el.appendChild(doc.createTextNode(value))
786 parent.appendChild(el)
788 def addElementNonEmpty(name, value, doc, parent):
791 addElement(name, value, doc, parent)
793 def addElementCDATA(name, value, doc, parent):
794 el = doc.createElement(name)
795 el.appendChild(doc.createCDATASection(value))
796 parent.appendChild(el)
798 root = doc.createElement("fdroid")
799 doc.appendChild(root)
801 repoel = doc.createElement("repo")
803 mirrorcheckfailed = False
804 for mirror in config.get('mirrors', []):
805 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
806 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
807 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
808 mirrorcheckfailed = True
809 if mirrorcheckfailed:
813 repoel.setAttribute("name", config['archive_name'])
814 if config['repo_maxage'] != 0:
815 repoel.setAttribute("maxage", str(config['repo_maxage']))
816 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
817 repoel.setAttribute("url", config['archive_url'])
818 addElement('description', config['archive_description'], doc, repoel)
819 urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
820 for mirror in config.get('mirrors', []):
821 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
824 repoel.setAttribute("name", config['repo_name'])
825 if config['repo_maxage'] != 0:
826 repoel.setAttribute("maxage", str(config['repo_maxage']))
827 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
828 repoel.setAttribute("url", config['repo_url'])
829 addElement('description', config['repo_description'], doc, repoel)
830 urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
831 for mirror in config.get('mirrors', []):
832 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
834 repoel.setAttribute("version", str(METADATA_VERSION))
835 repoel.setAttribute("timestamp", str(int(time.time())))
838 if not options.nosign:
839 if 'repo_keyalias' not in config:
841 logging.critical("'repo_keyalias' not found in config.py!")
842 if 'keystore' not in config:
844 logging.critical("'keystore' not found in config.py!")
845 if 'keystorepass' not in config and 'keystorepassfile' not in config:
847 logging.critical("'keystorepass' not found in config.py!")
848 if 'keypass' not in config and 'keypassfile' not in config:
850 logging.critical("'keypass' not found in config.py!")
851 if not os.path.exists(config['keystore']):
853 logging.critical("'" + config['keystore'] + "' does not exist!")
855 logging.warning("`fdroid update` requires a signing key, you can create one using:")
856 logging.warning("\tfdroid update --create-key")
859 repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
860 root.appendChild(repoel)
862 for appid in sortedids:
865 if app.Disabled is not None:
868 # Get a list of the apks for this app...
871 if apk['id'] == appid:
874 if len(apklist) == 0:
877 apel = doc.createElement("application")
878 apel.setAttribute("id", app.id)
879 root.appendChild(apel)
881 addElement('id', app.id, doc, apel)
883 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
885 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
886 addElement('name', app.Name, doc, apel)
887 addElement('summary', app.Summary, doc, apel)
889 addElement('icon', app.icon, doc, apel)
893 return ("fdroid.app:" + appid, apps[appid].Name)
894 raise MetaDataException("Cannot resolve app id " + appid)
897 metadata.description_html(app.Description, linkres),
899 addElement('license', app.License, doc, apel)
901 addElement('categories', ','.join(app.Categories), doc, apel)
902 # We put the first (primary) category in LAST, which will have
903 # the desired effect of making clients that only understand one
904 # category see that one.
905 addElement('category', app.Categories[0], doc, apel)
906 addElement('web', app.WebSite, doc, apel)
907 addElement('source', app.SourceCode, doc, apel)
908 addElement('tracker', app.IssueTracker, doc, apel)
909 addElementNonEmpty('changelog', app.Changelog, doc, apel)
910 addElementNonEmpty('author', app.AuthorName, doc, apel)
911 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
912 addElementNonEmpty('donate', app.Donate, doc, apel)
913 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
914 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
915 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
917 # These elements actually refer to the current version (i.e. which
918 # one is recommended. They are historically mis-named, and need
919 # changing, but stay like this for now to support existing clients.
920 addElement('marketversion', app.CurrentVersion, doc, apel)
921 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
924 af = app.AntiFeatures
926 addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
928 pv = app.Provides.split(',')
929 addElementNonEmpty('provides', ','.join(pv), doc, apel)
931 addElement('requirements', 'root', doc, apel)
933 # Sort the apk list into version order, just so the web site
934 # doesn't have to do any work by default...
935 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
937 # Check for duplicates - they will make the client unhappy...
938 for i in range(len(apklist) - 1):
939 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
940 logging.critical("duplicate versions: '%s' - '%s'" % (
941 apklist[i]['apkname'], apklist[i + 1]['apkname']))
944 current_version_code = 0
945 current_version_file = None
947 # find the APK for the "Current Version"
948 if current_version_code < apk['versioncode']:
949 current_version_code = apk['versioncode']
950 if current_version_code < int(app.CurrentVersionCode):
951 current_version_file = apk['apkname']
953 apkel = doc.createElement("package")
954 apel.appendChild(apkel)
955 addElement('version', apk['version'], doc, apkel)
956 addElement('versioncode', str(apk['versioncode']), doc, apkel)
957 addElement('apkname', apk['apkname'], doc, apkel)
959 addElement('srcname', apk['srcname'], doc, apkel)
960 for hash_type in ['sha256']:
961 if hash_type not in apk:
963 hashel = doc.createElement("hash")
964 hashel.setAttribute("type", hash_type)
965 hashel.appendChild(doc.createTextNode(apk[hash_type]))
966 apkel.appendChild(hashel)
967 addElement('sig', apk['sig'], doc, apkel)
968 addElement('size', str(apk['size']), doc, apkel)
969 addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
970 if 'targetSdkVersion' in apk:
971 addElement('targetSdkVersion', str(apk['targetSdkVersion']), doc, apkel)
972 if 'maxSdkVersion' in apk:
973 addElement('maxsdkver', str(apk['maxSdkVersion']), doc, apkel)
975 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
976 addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
977 if 'nativecode' in apk:
978 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
979 addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
981 if current_version_file is not None \
982 and config['make_current_version_link'] \
983 and repodir == 'repo': # only create these
984 namefield = config['current_version_name_source']
985 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
986 apklinkname = sanitized_name + '.apk'
987 current_version_path = os.path.join(repodir, current_version_file)
988 if os.path.islink(apklinkname):
989 os.remove(apklinkname)
990 os.symlink(current_version_path, apklinkname)
991 # also symlink gpg signature, if it exists
992 for extension in ('.asc', '.sig'):
993 sigfile_path = current_version_path + extension
994 if os.path.exists(sigfile_path):
995 siglinkname = apklinkname + extension
996 if os.path.islink(siglinkname):
997 os.remove(siglinkname)
998 os.symlink(sigfile_path, siglinkname)
1001 output = doc.toprettyxml(encoding='utf-8')
1003 output = doc.toxml(encoding='utf-8')
1005 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
1008 if 'repo_keyalias' in config:
1011 logging.info("Creating unsigned index in preparation for signing")
1013 logging.info("Creating signed index with this key (SHA256):")
1014 logging.info("%s" % repo_pubkey_fingerprint)
1016 # Create a jar of the index...
1017 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
1018 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
1019 if p.returncode != 0:
1020 logging.critical("Failed to create {0}".format(jar_output))
1024 signed = os.path.join(repodir, 'index.jar')
1026 # Remove old signed index if not signing
1027 if os.path.exists(signed):
1030 args = [config['jarsigner'], '-keystore', config['keystore'],
1031 '-storepass:file', config['keystorepassfile'],
1032 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
1033 signed, config['repo_keyalias']]
1034 if config['keystore'] == 'NONE':
1035 args += config['smartcardoptions']
1036 else: # smardcards never use -keypass
1037 args += ['-keypass:file', config['keypassfile']]
1038 p = FDroidPopen(args)
1039 if p.returncode != 0:
1040 logging.critical("Failed to sign index")
1043 # Copy the repo icon into the repo directory...
1044 icon_dir = os.path.join(repodir, 'icons')
1045 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1046 shutil.copyfile(config['repo_icon'], iconfilename)
1048 # Write a category list in the repo to allow quick access...
1050 for cat in categories:
1051 catdata += cat + '\n'
1052 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1056 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1058 for appid, app in apps.items():
1060 if app.ArchivePolicy:
1061 keepversions = int(app.ArchivePolicy[:-9])
1063 keepversions = defaultkeepversions
1065 def filter_apk_list_sorted(apk_list):
1067 for apk in apk_list:
1068 if apk['id'] == appid:
1071 # Sort the apk list by version code. First is highest/newest.
1072 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1074 def move_file(from_dir, to_dir, filename, ignore_missing):
1075 from_path = os.path.join(from_dir, filename)
1076 if ignore_missing and not os.path.exists(from_path):
1078 to_path = os.path.join(to_dir, filename)
1079 shutil.move(from_path, to_path)
1081 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1082 .format(appid, len(apks), keepversions, len(archapks)))
1084 if len(apks) > keepversions:
1085 apklist = filter_apk_list_sorted(apks)
1086 # Move back the ones we don't want.
1087 for apk in apklist[keepversions:]:
1088 logging.info("Moving " + apk['apkname'] + " to archive")
1089 move_file(repodir, archivedir, apk['apkname'], False)
1090 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1091 for density in all_screen_densities:
1092 repo_icon_dir = get_icon_dir(repodir, density)
1093 archive_icon_dir = get_icon_dir(archivedir, density)
1094 if density not in apk['icons']:
1096 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1097 if 'srcname' in apk:
1098 move_file(repodir, archivedir, apk['srcname'], False)
1099 archapks.append(apk)
1101 elif len(apks) < keepversions and len(archapks) > 0:
1102 required = keepversions - len(apks)
1103 archapklist = filter_apk_list_sorted(archapks)
1104 # Move forward the ones we want again.
1105 for apk in archapklist[:required]:
1106 logging.info("Moving " + apk['apkname'] + " from archive")
1107 move_file(archivedir, repodir, apk['apkname'], False)
1108 move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
1109 for density in all_screen_densities:
1110 repo_icon_dir = get_icon_dir(repodir, density)
1111 archive_icon_dir = get_icon_dir(archivedir, density)
1112 if density not in apk['icons']:
1114 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1115 if 'srcname' in apk:
1116 move_file(archivedir, repodir, apk['srcname'], False)
1117 archapks.remove(apk)
1121 def add_apks_to_per_app_repos(repodir, apks):
1122 apks_per_app = dict()
1124 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1125 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1126 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1127 apks_per_app[apk['id']] = apk
1129 if not os.path.exists(apk['per_app_icons']):
1130 logging.info('Adding new repo for only ' + apk['id'])
1131 os.makedirs(apk['per_app_icons'])
1133 apkpath = os.path.join(repodir, apk['apkname'])
1134 shutil.copy(apkpath, apk['per_app_repo'])
1135 apksigpath = apkpath + '.sig'
1136 if os.path.exists(apksigpath):
1137 shutil.copy(apksigpath, apk['per_app_repo'])
1138 apkascpath = apkpath + '.asc'
1139 if os.path.exists(apkascpath):
1140 shutil.copy(apkascpath, apk['per_app_repo'])
1149 global config, options
1151 # Parse command line...
1152 parser = ArgumentParser()
1153 common.setup_global_opts(parser)
1154 parser.add_argument("--create-key", action="store_true", default=False,
1155 help="Create a repo signing key in a keystore")
1156 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1157 help="Create skeleton metadata files that are missing")
1158 parser.add_argument("--delete-unknown", action="store_true", default=False,
1159 help="Delete APKs without metadata from the repo")
1160 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1161 help="Report on build data status")
1162 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1163 help="Interactively ask about things that need updating.")
1164 parser.add_argument("-I", "--icons", action="store_true", default=False,
1165 help="Resize all the icons exceeding the max pixel size and exit")
1166 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1167 help="Specify editor to use in interactive mode. Default " +
1168 "is /etc/alternatives/editor")
1169 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1170 help="Update the wiki")
1171 parser.add_argument("--pretty", action="store_true", default=False,
1172 help="Produce human-readable index.xml")
1173 parser.add_argument("--clean", action="store_true", default=False,
1174 help="Clean update - don't uses caches, reprocess all apks")
1175 parser.add_argument("--nosign", action="store_true", default=False,
1176 help="When configured for signed indexes, create only unsigned indexes at this stage")
1177 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1178 help="Use date from apk instead of current time for newly added apks")
1179 options = parser.parse_args()
1181 config = common.read_config(options)
1183 if not ('jarsigner' in config and 'keytool' in config):
1184 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1188 if config['archive_older'] != 0:
1189 repodirs.append('archive')
1190 if not os.path.exists('archive'):
1194 resize_all_icons(repodirs)
1197 # check that icons exist now, rather than fail at the end of `fdroid update`
1198 for k in ['repo_icon', 'archive_icon']:
1200 if not os.path.exists(config[k]):
1201 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1204 # if the user asks to create a keystore, do it now, reusing whatever it can
1205 if options.create_key:
1206 if os.path.exists(config['keystore']):
1207 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1208 logging.critical("\t'" + config['keystore'] + "'")
1211 if 'repo_keyalias' not in config:
1212 config['repo_keyalias'] = socket.getfqdn()
1213 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1214 if 'keydname' not in config:
1215 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1216 common.write_to_config(config, 'keydname', config['keydname'])
1217 if 'keystore' not in config:
1218 config['keystore'] = common.default_config.keystore
1219 common.write_to_config(config, 'keystore', config['keystore'])
1221 password = common.genpassword()
1222 if 'keystorepass' not in config:
1223 config['keystorepass'] = password
1224 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1225 if 'keypass' not in config:
1226 config['keypass'] = password
1227 common.write_to_config(config, 'keypass', config['keypass'])
1228 common.genkeystore(config)
1231 apps = metadata.read_metadata()
1233 # Generate a list of categories...
1235 for app in apps.values():
1236 categories.update(app.Categories)
1238 # Read known apks data (will be updated and written back when we've finished)
1239 knownapks = common.KnownApks()
1241 # Gather information about all the apk files in the repo directory, using
1242 # cached data if possible.
1243 apkcachefile = os.path.join('tmp', 'apkcache')
1244 if not options.clean and os.path.exists(apkcachefile):
1245 with open(apkcachefile, 'rb') as cf:
1246 apkcache = pickle.load(cf, encoding='utf-8')
1247 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
1252 delete_disabled_builds(apps, apkcache, repodirs)
1254 # Scan all apks in the main repo
1255 apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1257 # Generate warnings for apk's with no metadata (or create skeleton
1258 # metadata files, if requested on the command line)
1261 if apk['id'] not in apps:
1262 if options.create_metadata:
1263 if 'name' not in apk:
1264 logging.error(apk['id'] + ' does not have a name! Skipping...')
1266 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
1267 f.write("License:Unknown\n")
1268 f.write("Web Site:\n")
1269 f.write("Source Code:\n")
1270 f.write("Issue Tracker:\n")
1271 f.write("Changelog:\n")
1272 f.write("Summary:" + apk['name'] + "\n")
1273 f.write("Description:\n")
1274 f.write(apk['name'] + "\n")
1277 logging.info("Generated skeleton metadata for " + apk['id'])
1280 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1281 if options.delete_unknown:
1282 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1283 rmf = os.path.join(repodirs[0], apk['apkname'])
1284 if not os.path.exists(rmf):
1285 logging.error("Could not find {0} to remove it".format(rmf))
1289 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1291 # update the metadata with the newly created ones included
1293 apps = metadata.read_metadata()
1295 # Scan the archive repo for apks as well
1296 if len(repodirs) > 1:
1297 archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1303 # Some information from the apks needs to be applied up to the application
1304 # level. When doing this, we use the info from the most recent version's apk.
1305 # We deal with figuring out when the app was added and last updated at the
1307 for appid, app in apps.items():
1309 for apk in apks + archapks:
1310 if apk['id'] == appid:
1311 if apk['versioncode'] > bestver:
1312 bestver = apk['versioncode']
1316 if not app.added or apk['added'] < app.added:
1317 app.added = apk['added']
1318 if not app.lastupdated or apk['added'] > app.lastupdated:
1319 app.lastupdated = apk['added']
1322 logging.debug("Don't know when " + appid + " was added")
1323 if not app.lastupdated:
1324 logging.debug("Don't know when " + appid + " was last updated")
1327 if app.Name is None:
1328 app.Name = app.AutoName or appid
1330 logging.debug("Application " + appid + " has no packages")
1332 if app.Name is None:
1333 app.Name = bestapk['name']
1334 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1335 if app.CurrentVersionCode is None:
1336 app.CurrentVersionCode = str(bestver)
1338 # Sort the app list by name, then the web site doesn't have to by default.
1339 # (we had to wait until we'd scanned the apks to do this, because mostly the
1340 # name comes from there!)
1341 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1343 # APKs are placed into multiple repos based on the app package, providing
1344 # per-app subscription feeds for nightly builds and things like it
1345 if config['per_app_repos']:
1346 add_apks_to_per_app_repos(repodirs[0], apks)
1347 for appid, app in apps.items():
1348 repodir = os.path.join(appid, 'fdroid', 'repo')
1350 appdict[appid] = app
1351 if os.path.isdir(repodir):
1352 make_index(appdict, [appid], apks, repodir, False, categories)
1354 logging.info('Skipping index generation for ' + appid)
1357 if len(repodirs) > 1:
1358 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1360 # Make the index for the main repo...
1361 make_index(apps, sortedids, apks, repodirs[0], False, categories)
1363 # If there's an archive repo, make the index for it. We already scanned it
1365 if len(repodirs) > 1:
1366 make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1368 if config['update_stats']:
1370 # Update known apks info...
1371 knownapks.writeifchanged()
1373 # Generate latest apps data for widget
1374 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1376 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1378 appid = line.rstrip()
1379 data += appid + "\t"
1381 data += app.Name + "\t"
1382 if app.icon is not None:
1383 data += app.icon + "\t"
1384 data += app.License + "\n"
1385 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1389 apkcache["METADATA_VERSION"] = METADATA_VERSION
1390 with open(apkcachefile, 'wb') as cf:
1391 pickle.dump(apkcache, cf)
1393 # Update the wiki...
1395 update_wiki(apps, sortedids, apks + archapks)
1397 logging.info("Finished.")
1399 if __name__ == "__main__":