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 apppagename = apppagename.replace('[', ' ')
242 apppagename = apppagename.replace(']', ' ')
243 # Drop double spaces caused mostly by replacing ':' above
244 apppagename = apppagename.replace(' ', ' ')
245 for expagename in site.allpages(prefix=apppagename,
246 filterredir='nonredirects',
248 if expagename == apppagename:
250 # Another reason not to make the redirect page is if the app name
251 # is the same as it's ID, because that will overwrite the real page
252 # with an redirect to itself! (Although it seems like an odd
253 # scenario this happens a lot, e.g. where there is metadata but no
254 # builds or binaries to extract a name from.
255 if apppagename == pagename:
258 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
260 for tcat, genp in [(wikicat, generated_pages),
261 (wikiredircat, generated_redirects)]:
262 catpages = site.Pages['Category:' + tcat]
264 for page in catpages:
265 existingpages.append(page.name)
266 if page.name in genp:
267 pagetxt = page.edit()
268 if pagetxt != genp[page.name]:
269 logging.debug("Updating modified page " + page.name)
270 page.save(genp[page.name], summary='Auto-updated')
272 logging.debug("Page " + page.name + " is unchanged")
274 logging.warn("Deleting page " + page.name)
275 page.delete('No longer published')
276 for pagename, text in genp.items():
277 logging.debug("Checking " + pagename)
278 if pagename not in existingpages:
279 logging.debug("Creating page " + pagename)
281 newpage = site.Pages[pagename]
282 newpage.save(text, summary='Auto-created')
284 logging.error("...FAILED to create page '{0}'".format(pagename))
286 # Purge server cache to ensure counts are up to date
287 site.pages['Repository Maintenance'].purge()
290 def delete_disabled_builds(apps, apkcache, repodirs):
291 """Delete disabled build outputs.
293 :param apps: list of all applications, as per metadata.read_metadata
294 :param apkcache: current apk cache information
295 :param repodirs: the repo directories to process
297 for appid, app in apps.items():
298 for build in app.builds:
299 if not build.disable:
301 apkfilename = appid + '_' + str(build.vercode) + '.apk'
302 iconfilename = "%s.%s.png" % (
305 for repodir in repodirs:
307 os.path.join(repodir, apkfilename),
308 os.path.join(repodir, apkfilename + '.asc'),
309 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
311 for density in all_screen_densities:
312 repo_dir = get_icon_dir(repodir, density)
313 files.append(os.path.join(repo_dir, iconfilename))
316 if os.path.exists(f):
317 logging.info("Deleting disabled build output " + f)
319 if apkfilename in apkcache:
320 del apkcache[apkfilename]
323 def resize_icon(iconpath, density):
325 if not os.path.isfile(iconpath):
330 fp = open(iconpath, 'rb')
332 size = dpi_to_px(density)
334 if any(length > size for length in im.size):
336 im.thumbnail((size, size), Image.ANTIALIAS)
337 logging.debug("%s was too large at %s - new size is %s" % (
338 iconpath, oldsize, im.size))
339 im.save(iconpath, "PNG")
341 except Exception as e:
342 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
349 def resize_all_icons(repodirs):
350 """Resize all icons that exceed the max size
352 :param repodirs: the repo directories to process
354 for repodir in repodirs:
355 for density in screen_densities:
356 icon_dir = get_icon_dir(repodir, density)
357 icon_glob = os.path.join(icon_dir, '*.png')
358 for iconpath in glob.glob(icon_glob):
359 resize_icon(iconpath, density)
362 # A signature block file with a .DSA, .RSA, or .EC extension
363 cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
367 """ Get the signing certificate of an apk. To get the same md5 has that
368 Android gets, we encode the .RSA certificate in a specific format and pass
369 it hex-encoded to the md5 digest algorithm.
371 :param apkpath: path to the apk
372 :returns: A string containing the md5 of the signature of the apk or None
373 if an error occurred.
378 # verify the jar signature is correct
379 args = [config['jarsigner'], '-verify', apkpath]
380 p = FDroidPopen(args)
381 if p.returncode != 0:
382 logging.critical(apkpath + " has a bad signature!")
385 with zipfile.ZipFile(apkpath, 'r') as apk:
387 certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
390 logging.error("Found no signing certificates on %s" % apkpath)
393 logging.error("Found multiple signing certificates on %s" % apkpath)
396 cert = apk.read(certs[0])
398 content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
399 if content.getComponentByName('contentType') != rfc2315.signedData:
400 logging.error("Unexpected format.")
403 content = decoder.decode(content.getComponentByName('content'),
404 asn1Spec=rfc2315.SignedData())[0]
406 certificates = content.getComponentByName('certificates')
408 logging.error("Certificates not found.")
411 cert_encoded = encoder.encode(certificates)[4:]
413 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
416 def get_icon_bytes(apkzip, iconsrc):
417 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
419 return apkzip.read(iconsrc)
421 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
424 def sha256sum(filename):
425 '''Calculate the sha256 of the given file'''
426 sha = hashlib.sha256()
427 with open(filename, 'rb') as f:
433 return sha.hexdigest()
436 def insert_obbs(repodir, apps, apks):
437 """Scans the .obb files in a given repo directory and adds them to the
438 relevant APK instances. OBB files have versionCodes like APK
439 files, and they are loosely associated. If there is an OBB file
440 present, then any APK with the same or higher versionCode will use
441 that OBB file. There are two OBB types: main and patch, each APK
442 can only have only have one of each.
444 https://developer.android.com/google/play/expansion-files.html
446 :param repodir: repo directory to scan
447 :param apps: list of current, valid apps
448 :param apks: current information on all APKs
452 def obbWarnDelete(f, msg):
453 logging.warning(msg + f)
454 if options.delete_unknown:
455 logging.error("Deleting unknown file: " + f)
459 java_Integer_MIN_VALUE = -pow(2, 31)
460 for f in glob.glob(os.path.join(repodir, '*.obb')):
461 obbfile = os.path.basename(f)
462 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
463 chunks = obbfile.split('.')
464 if chunks[0] != 'main' and chunks[0] != 'patch':
465 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
467 if not re.match(r'^-?[0-9]+$', chunks[1]):
468 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
470 versioncode = int(chunks[1])
471 packagename = ".".join(chunks[2:-1])
473 highestVersionCode = java_Integer_MIN_VALUE
474 if packagename not in apps.keys():
475 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
478 if packagename == apk['id'] and apk['versioncode'] > highestVersionCode:
479 highestVersionCode = apk['versioncode']
480 if versioncode > highestVersionCode:
481 obbWarnDelete(f, 'OBB file has newer versioncode(' + str(versioncode)
482 + ') than any APK: ')
484 obbsha256 = sha256sum(f)
485 obbs.append((packagename, versioncode, obbfile, obbsha256))
488 for (packagename, versioncode, obbfile, obbsha256) in sorted(obbs, reverse=True):
489 if versioncode <= apk['versioncode'] and packagename == apk['id']:
490 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
491 apk['obbMainFile'] = obbfile
492 apk['obbMainFileSha256'] = obbsha256
493 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
494 apk['obbPatchFile'] = obbfile
495 apk['obbPatchFileSha256'] = obbsha256
496 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
500 def scan_apks(apps, apkcache, repodir, knownapks, use_date_from_apk=False):
501 """Scan the apks in the given repo directory.
503 This also extracts the icons.
505 :param apps: list of all applications, as per metadata.read_metadata
506 :param apkcache: current apk cache information
507 :param repodir: repo directory to scan
508 :param knownapks: known apks info
509 :param use_date_from_apk: use date from APK (instead of current date)
511 :returns: (apks, cachechanged) where apks is a list of apk information,
512 and cachechanged is True if the apkcache got changed.
517 for icon_dir in get_all_icon_dirs(repodir):
518 if os.path.exists(icon_dir):
520 shutil.rmtree(icon_dir)
521 os.makedirs(icon_dir)
523 os.makedirs(icon_dir)
526 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
527 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
528 vername_pat = re.compile(".*versionName='([^']*)'.*")
529 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
530 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
531 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
532 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
533 string_pat = re.compile(".* name='([^']*)'.*")
534 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
536 apkfilename = apkfile[len(repodir) + 1:]
537 if ' ' in apkfilename:
538 logging.critical("Spaces in filenames are not allowed.")
541 shasum = sha256sum(apkfile)
544 if apkfilename in apkcache:
545 apk = apkcache[apkfilename]
546 if apk['sha256'] == shasum:
547 logging.debug("Reading " + apkfilename + " from cache")
550 logging.debug("Ignoring stale cache data for " + apkfilename)
553 logging.debug("Processing " + apkfilename)
555 apk['apkname'] = apkfilename
556 apk['sha256'] = shasum
557 srcfilename = apkfilename[:-4] + "_src.tar.gz"
558 if os.path.exists(os.path.join(repodir, srcfilename)):
559 apk['srcname'] = srcfilename
560 apk['size'] = os.path.getsize(apkfile)
561 apk['permissions'] = set()
562 apk['features'] = set()
563 apk['icons_src'] = {}
565 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
566 if p.returncode != 0:
567 if options.delete_unknown:
568 if os.path.exists(apkfile):
569 logging.error("Failed to get apk information, deleting " + apkfile)
572 logging.error("Could not find {0} to remove it".format(apkfile))
574 logging.error("Failed to get apk information, skipping " + apkfile)
576 for line in p.output.splitlines():
577 if line.startswith("package:"):
579 apk['id'] = re.match(name_pat, line).group(1)
580 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
581 apk['version'] = re.match(vername_pat, line).group(1)
582 except Exception as e:
583 logging.error("Package matching failed: " + str(e))
584 logging.info("Line was: " + line)
586 elif line.startswith("application:"):
587 apk['name'] = re.match(label_pat, line).group(1)
588 # Keep path to non-dpi icon in case we need it
589 match = re.match(icon_pat_nodpi, line)
591 apk['icons_src']['-1'] = match.group(1)
592 elif line.startswith("launchable-activity:"):
593 # Only use launchable-activity as fallback to application
595 apk['name'] = re.match(label_pat, line).group(1)
596 if '-1' not in apk['icons_src']:
597 match = re.match(icon_pat_nodpi, line)
599 apk['icons_src']['-1'] = match.group(1)
600 elif line.startswith("application-icon-"):
601 match = re.match(icon_pat, line)
603 density = match.group(1)
604 path = match.group(2)
605 apk['icons_src'][density] = path
606 elif line.startswith("sdkVersion:"):
607 m = re.match(sdkversion_pat, line)
609 logging.error(line.replace('sdkVersion:', '')
610 + ' is not a valid minSdkVersion!')
612 apk['minSdkVersion'] = m.group(1)
613 # if target not set, default to min
614 if 'targetSdkVersion' not in apk:
615 apk['targetSdkVersion'] = m.group(1)
616 elif line.startswith("targetSdkVersion:"):
617 m = re.match(sdkversion_pat, line)
619 logging.error(line.replace('targetSdkVersion:', '')
620 + ' is not a valid targetSdkVersion!')
622 apk['targetSdkVersion'] = m.group(1)
623 elif line.startswith("maxSdkVersion:"):
624 apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
625 elif line.startswith("native-code:"):
626 apk['nativecode'] = []
627 for arch in line[13:].split(' '):
628 apk['nativecode'].append(arch[1:-1])
629 elif line.startswith("uses-permission:"):
630 perm = re.match(string_pat, line).group(1)
631 if perm.startswith("android.permission."):
633 apk['permissions'].add(perm)
634 elif line.startswith("uses-feature:"):
635 perm = re.match(string_pat, line).group(1)
636 # Filter out this, it's only added with the latest SDK tools and
637 # causes problems for lots of apps.
638 if perm != "android.hardware.screen.portrait" \
639 and perm != "android.hardware.screen.landscape":
640 if perm.startswith("android.feature."):
642 apk['features'].add(perm)
644 if 'minSdkVersion' not in apk:
645 logging.warn("No SDK version information found in {0}".format(apkfile))
646 apk['minSdkVersion'] = 1
648 # Check for debuggable apks...
649 if common.isApkDebuggable(apkfile, config):
650 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
652 # Get the signature (or md5 of, to be precise)...
653 logging.debug('Getting signature of {0}'.format(apkfile))
654 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
656 logging.critical("Failed to get apk signature")
659 apkzip = zipfile.ZipFile(apkfile, 'r')
661 # if an APK has files newer than the system time, suggest updating
662 # the system clock. This is useful for offline systems, used for
663 # signing, which do not have another source of clock sync info. It
664 # has to be more than 24 hours newer because ZIP/APK files do not
665 # store timezone info
666 manifest = apkzip.getinfo('AndroidManifest.xml')
667 if manifest.date_time[1] == 0: # month can't be zero
668 logging.debug('AndroidManifest.xml has no date')
670 dt_obj = datetime(*manifest.date_time)
671 checkdt = dt_obj - timedelta(1)
672 if datetime.today() < checkdt:
673 logging.warn('System clock is older than manifest in: '
675 + '\nSet clock to that time using:\n'
676 + 'sudo date -s "' + str(dt_obj) + '"')
678 iconfilename = "%s.%s.png" % (
682 # Extract the icon file...
684 for density in screen_densities:
685 if density not in apk['icons_src']:
686 empty_densities.append(density)
688 iconsrc = apk['icons_src'][density]
689 icon_dir = get_icon_dir(repodir, density)
690 icondest = os.path.join(icon_dir, iconfilename)
693 with open(icondest, 'wb') as f:
694 f.write(get_icon_bytes(apkzip, iconsrc))
695 apk['icons'][density] = iconfilename
698 logging.warn("Error retrieving icon file")
699 del apk['icons'][density]
700 del apk['icons_src'][density]
701 empty_densities.append(density)
703 if '-1' in apk['icons_src']:
704 iconsrc = apk['icons_src']['-1']
705 iconpath = os.path.join(
706 get_icon_dir(repodir, '0'), iconfilename)
707 with open(iconpath, 'wb') as f:
708 f.write(get_icon_bytes(apkzip, iconsrc))
710 im = Image.open(iconpath)
711 dpi = px_to_dpi(im.size[0])
712 for density in screen_densities:
713 if density in apk['icons']:
715 if density == screen_densities[-1] or dpi >= int(density):
716 apk['icons'][density] = iconfilename
717 shutil.move(iconpath,
718 os.path.join(get_icon_dir(repodir, density), iconfilename))
719 empty_densities.remove(density)
721 except Exception as e:
722 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
725 apk['icon'] = iconfilename
729 # First try resizing down to not lose quality
731 for density in screen_densities:
732 if density not in empty_densities:
733 last_density = density
735 if last_density is None:
737 logging.debug("Density %s not available, resizing down from %s"
738 % (density, last_density))
740 last_iconpath = os.path.join(
741 get_icon_dir(repodir, last_density), iconfilename)
742 iconpath = os.path.join(
743 get_icon_dir(repodir, density), iconfilename)
746 fp = open(last_iconpath, 'rb')
749 size = dpi_to_px(density)
751 im.thumbnail((size, size), Image.ANTIALIAS)
752 im.save(iconpath, "PNG")
753 empty_densities.remove(density)
755 logging.warning("Invalid image file at %s" % last_iconpath)
760 # Then just copy from the highest resolution available
762 for density in reversed(screen_densities):
763 if density not in empty_densities:
764 last_density = density
766 if last_density is None:
768 logging.debug("Density %s not available, copying from lower density %s"
769 % (density, last_density))
772 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
773 os.path.join(get_icon_dir(repodir, density), iconfilename))
775 empty_densities.remove(density)
777 for density in screen_densities:
778 icon_dir = get_icon_dir(repodir, density)
779 icondest = os.path.join(icon_dir, iconfilename)
780 resize_icon(icondest, density)
782 # Copy from icons-mdpi to icons since mdpi is the baseline density
783 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
784 if os.path.isfile(baseline):
785 apk['icons']['0'] = iconfilename
786 shutil.copyfile(baseline,
787 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
789 if use_date_from_apk and manifest.date_time[1] != 0:
790 default_date_param = datetime(*manifest.date_time).utctimetuple()
792 default_date_param = None
794 # Record in known apks, getting the added date at the same time..
795 added = knownapks.recordapk(apk['apkname'], apk['id'], default_date=default_date_param)
799 apkcache[apkfilename] = apk
804 return apks, cachechanged
807 repo_pubkey_fingerprint = None
810 # Generate a certificate fingerprint the same way keytool does it
811 # (but with slightly different formatting)
812 def cert_fingerprint(data):
813 digest = hashlib.sha256(data).digest()
815 ret.append(' '.join("%02X" % b for b in bytearray(digest)))
819 def extract_pubkey():
820 global repo_pubkey_fingerprint
821 if 'repo_pubkey' in config:
822 pubkey = unhexlify(config['repo_pubkey'])
824 p = FDroidPopenBytes([config['keytool'], '-exportcert',
825 '-alias', config['repo_keyalias'],
826 '-keystore', config['keystore'],
827 '-storepass:file', config['keystorepassfile']]
828 + config['smartcardoptions'],
829 output=False, stderr_to_stdout=False)
830 if p.returncode != 0 or len(p.output) < 20:
831 msg = "Failed to get repo pubkey!"
832 if config['keystore'] == 'NONE':
833 msg += ' Is your crypto smartcard plugged in?'
834 logging.critical(msg)
837 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
838 return hexlify(pubkey)
841 def make_index(apps, sortedids, apks, repodir, archive, categories):
842 """Make a repo index.
844 :param apps: fully populated apps list
845 :param apks: full populated apks list
846 :param repodir: the repo directory
847 :param archive: True if this is the archive repo, False if it's the
849 :param categories: list of categories
854 def addElement(name, value, doc, parent):
855 el = doc.createElement(name)
856 el.appendChild(doc.createTextNode(value))
857 parent.appendChild(el)
859 def addElementNonEmpty(name, value, doc, parent):
862 addElement(name, value, doc, parent)
864 def addElementCDATA(name, value, doc, parent):
865 el = doc.createElement(name)
866 el.appendChild(doc.createCDATASection(value))
867 parent.appendChild(el)
869 root = doc.createElement("fdroid")
870 doc.appendChild(root)
872 repoel = doc.createElement("repo")
874 mirrorcheckfailed = False
875 for mirror in config.get('mirrors', []):
876 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
877 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
878 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
879 mirrorcheckfailed = True
880 if mirrorcheckfailed:
884 repoel.setAttribute("name", config['archive_name'])
885 if config['repo_maxage'] != 0:
886 repoel.setAttribute("maxage", str(config['repo_maxage']))
887 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
888 repoel.setAttribute("url", config['archive_url'])
889 addElement('description', config['archive_description'], doc, repoel)
890 urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
891 for mirror in config.get('mirrors', []):
892 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
895 repoel.setAttribute("name", config['repo_name'])
896 if config['repo_maxage'] != 0:
897 repoel.setAttribute("maxage", str(config['repo_maxage']))
898 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
899 repoel.setAttribute("url", config['repo_url'])
900 addElement('description', config['repo_description'], doc, repoel)
901 urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
902 for mirror in config.get('mirrors', []):
903 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
905 repoel.setAttribute("version", str(METADATA_VERSION))
906 repoel.setAttribute("timestamp", str(int(time.time())))
909 if not options.nosign:
910 if 'repo_keyalias' not in config:
912 logging.critical("'repo_keyalias' not found in config.py!")
913 if 'keystore' not in config:
915 logging.critical("'keystore' not found in config.py!")
916 if 'keystorepass' not in config and 'keystorepassfile' not in config:
918 logging.critical("'keystorepass' not found in config.py!")
919 if 'keypass' not in config and 'keypassfile' not in config:
921 logging.critical("'keypass' not found in config.py!")
922 if not os.path.exists(config['keystore']):
924 logging.critical("'" + config['keystore'] + "' does not exist!")
926 logging.warning("`fdroid update` requires a signing key, you can create one using:")
927 logging.warning("\tfdroid update --create-key")
930 repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
931 root.appendChild(repoel)
933 for appid in sortedids:
936 if app.Disabled is not None:
939 # Get a list of the apks for this app...
942 if apk['id'] == appid:
945 if len(apklist) == 0:
948 apel = doc.createElement("application")
949 apel.setAttribute("id", app.id)
950 root.appendChild(apel)
952 addElement('id', app.id, doc, apel)
954 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
956 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
957 addElement('name', app.Name, doc, apel)
958 addElement('summary', app.Summary, doc, apel)
960 addElement('icon', app.icon, doc, apel)
964 return ("fdroid.app:" + appid, apps[appid].Name)
965 raise MetaDataException("Cannot resolve app id " + appid)
968 metadata.description_html(app.Description, linkres),
970 addElement('license', app.License, doc, apel)
972 addElement('categories', ','.join(app.Categories), doc, apel)
973 # We put the first (primary) category in LAST, which will have
974 # the desired effect of making clients that only understand one
975 # category see that one.
976 addElement('category', app.Categories[0], doc, apel)
977 addElement('web', app.WebSite, doc, apel)
978 addElement('source', app.SourceCode, doc, apel)
979 addElement('tracker', app.IssueTracker, doc, apel)
980 addElementNonEmpty('changelog', app.Changelog, doc, apel)
981 addElementNonEmpty('author', app.AuthorName, doc, apel)
982 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
983 addElementNonEmpty('donate', app.Donate, doc, apel)
984 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
985 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
986 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
988 # These elements actually refer to the current version (i.e. which
989 # one is recommended. They are historically mis-named, and need
990 # changing, but stay like this for now to support existing clients.
991 addElement('marketversion', app.CurrentVersion, doc, apel)
992 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
995 af = app.AntiFeatures
997 addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
999 pv = app.Provides.split(',')
1000 addElementNonEmpty('provides', ','.join(pv), doc, apel)
1001 if app.RequiresRoot:
1002 addElement('requirements', 'root', doc, apel)
1004 # Sort the apk list into version order, just so the web site
1005 # doesn't have to do any work by default...
1006 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
1008 # Check for duplicates - they will make the client unhappy...
1009 for i in range(len(apklist) - 1):
1010 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
1011 logging.critical("duplicate versions: '%s' - '%s'" % (
1012 apklist[i]['apkname'], apklist[i + 1]['apkname']))
1015 current_version_code = 0
1016 current_version_file = None
1018 # find the APK for the "Current Version"
1019 if current_version_code < apk['versioncode']:
1020 current_version_code = apk['versioncode']
1021 if current_version_code < int(app.CurrentVersionCode):
1022 current_version_file = apk['apkname']
1024 apkel = doc.createElement("package")
1025 apel.appendChild(apkel)
1026 addElement('version', apk['version'], doc, apkel)
1027 addElement('versioncode', str(apk['versioncode']), doc, apkel)
1028 addElement('apkname', apk['apkname'], doc, apkel)
1029 if 'srcname' in apk:
1030 addElement('srcname', apk['srcname'], doc, apkel)
1031 for hash_type in ['sha256']:
1032 if hash_type not in apk:
1034 hashel = doc.createElement("hash")
1035 hashel.setAttribute("type", hash_type)
1036 hashel.appendChild(doc.createTextNode(apk[hash_type]))
1037 apkel.appendChild(hashel)
1038 addElement('sig', apk['sig'], doc, apkel)
1039 addElement('size', str(apk['size']), doc, apkel)
1040 addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
1041 if 'targetSdkVersion' in apk:
1042 addElement('targetSdkVersion', str(apk['targetSdkVersion']), doc, apkel)
1043 if 'maxSdkVersion' in apk:
1044 addElement('maxsdkver', str(apk['maxSdkVersion']), doc, apkel)
1045 addElementNonEmpty('obbMainFile', apk.get('obbMainFile'), doc, apkel)
1046 addElementNonEmpty('obbMainFileSha256', apk.get('obbMainFileSha256'), doc, apkel)
1047 addElementNonEmpty('obbPatchFile', apk.get('obbPatchFile'), doc, apkel)
1048 addElementNonEmpty('obbPatchFileSha256', apk.get('obbPatchFileSha256'), doc, apkel)
1050 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
1051 addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
1052 if 'nativecode' in apk:
1053 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
1054 addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
1056 if current_version_file is not None \
1057 and config['make_current_version_link'] \
1058 and repodir == 'repo': # only create these
1059 namefield = config['current_version_name_source']
1060 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
1061 apklinkname = sanitized_name + '.apk'
1062 current_version_path = os.path.join(repodir, current_version_file)
1063 if os.path.islink(apklinkname):
1064 os.remove(apklinkname)
1065 os.symlink(current_version_path, apklinkname)
1066 # also symlink gpg signature, if it exists
1067 for extension in ('.asc', '.sig'):
1068 sigfile_path = current_version_path + extension
1069 if os.path.exists(sigfile_path):
1070 siglinkname = apklinkname + extension
1071 if os.path.islink(siglinkname):
1072 os.remove(siglinkname)
1073 os.symlink(sigfile_path, siglinkname)
1076 output = doc.toprettyxml(encoding='utf-8')
1078 output = doc.toxml(encoding='utf-8')
1080 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
1083 if 'repo_keyalias' in config:
1086 logging.info("Creating unsigned index in preparation for signing")
1088 logging.info("Creating signed index with this key (SHA256):")
1089 logging.info("%s" % repo_pubkey_fingerprint)
1091 # Create a jar of the index...
1092 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
1093 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
1094 if p.returncode != 0:
1095 logging.critical("Failed to create {0}".format(jar_output))
1099 signed = os.path.join(repodir, 'index.jar')
1101 # Remove old signed index if not signing
1102 if os.path.exists(signed):
1105 args = [config['jarsigner'], '-keystore', config['keystore'],
1106 '-storepass:file', config['keystorepassfile'],
1107 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
1108 signed, config['repo_keyalias']]
1109 if config['keystore'] == 'NONE':
1110 args += config['smartcardoptions']
1111 else: # smardcards never use -keypass
1112 args += ['-keypass:file', config['keypassfile']]
1113 p = FDroidPopen(args)
1114 if p.returncode != 0:
1115 logging.critical("Failed to sign index")
1118 # Copy the repo icon into the repo directory...
1119 icon_dir = os.path.join(repodir, 'icons')
1120 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1121 shutil.copyfile(config['repo_icon'], iconfilename)
1123 # Write a category list in the repo to allow quick access...
1125 for cat in categories:
1126 catdata += cat + '\n'
1127 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1131 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1133 for appid, app in apps.items():
1135 if app.ArchivePolicy:
1136 keepversions = int(app.ArchivePolicy[:-9])
1138 keepversions = defaultkeepversions
1140 def filter_apk_list_sorted(apk_list):
1142 for apk in apk_list:
1143 if apk['id'] == appid:
1146 # Sort the apk list by version code. First is highest/newest.
1147 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1149 def move_file(from_dir, to_dir, filename, ignore_missing):
1150 from_path = os.path.join(from_dir, filename)
1151 if ignore_missing and not os.path.exists(from_path):
1153 to_path = os.path.join(to_dir, filename)
1154 shutil.move(from_path, to_path)
1156 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1157 .format(appid, len(apks), keepversions, len(archapks)))
1159 if len(apks) > keepversions:
1160 apklist = filter_apk_list_sorted(apks)
1161 # Move back the ones we don't want.
1162 for apk in apklist[keepversions:]:
1163 logging.info("Moving " + apk['apkname'] + " to archive")
1164 move_file(repodir, archivedir, apk['apkname'], False)
1165 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1166 for density in all_screen_densities:
1167 repo_icon_dir = get_icon_dir(repodir, density)
1168 archive_icon_dir = get_icon_dir(archivedir, density)
1169 if density not in apk['icons']:
1171 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1172 if 'srcname' in apk:
1173 move_file(repodir, archivedir, apk['srcname'], False)
1174 archapks.append(apk)
1176 elif len(apks) < keepversions and len(archapks) > 0:
1177 required = keepversions - len(apks)
1178 archapklist = filter_apk_list_sorted(archapks)
1179 # Move forward the ones we want again.
1180 for apk in archapklist[:required]:
1181 logging.info("Moving " + apk['apkname'] + " from archive")
1182 move_file(archivedir, repodir, apk['apkname'], False)
1183 move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
1184 for density in all_screen_densities:
1185 repo_icon_dir = get_icon_dir(repodir, density)
1186 archive_icon_dir = get_icon_dir(archivedir, density)
1187 if density not in apk['icons']:
1189 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1190 if 'srcname' in apk:
1191 move_file(archivedir, repodir, apk['srcname'], False)
1192 archapks.remove(apk)
1196 def add_apks_to_per_app_repos(repodir, apks):
1197 apks_per_app = dict()
1199 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1200 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1201 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1202 apks_per_app[apk['id']] = apk
1204 if not os.path.exists(apk['per_app_icons']):
1205 logging.info('Adding new repo for only ' + apk['id'])
1206 os.makedirs(apk['per_app_icons'])
1208 apkpath = os.path.join(repodir, apk['apkname'])
1209 shutil.copy(apkpath, apk['per_app_repo'])
1210 apksigpath = apkpath + '.sig'
1211 if os.path.exists(apksigpath):
1212 shutil.copy(apksigpath, apk['per_app_repo'])
1213 apkascpath = apkpath + '.asc'
1214 if os.path.exists(apkascpath):
1215 shutil.copy(apkascpath, apk['per_app_repo'])
1224 global config, options
1226 # Parse command line...
1227 parser = ArgumentParser()
1228 common.setup_global_opts(parser)
1229 parser.add_argument("--create-key", action="store_true", default=False,
1230 help="Create a repo signing key in a keystore")
1231 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1232 help="Create skeleton metadata files that are missing")
1233 parser.add_argument("--delete-unknown", action="store_true", default=False,
1234 help="Delete APKs and/or OBBs without metadata from the repo")
1235 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1236 help="Report on build data status")
1237 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1238 help="Interactively ask about things that need updating.")
1239 parser.add_argument("-I", "--icons", action="store_true", default=False,
1240 help="Resize all the icons exceeding the max pixel size and exit")
1241 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1242 help="Specify editor to use in interactive mode. Default " +
1243 "is /etc/alternatives/editor")
1244 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1245 help="Update the wiki")
1246 parser.add_argument("--pretty", action="store_true", default=False,
1247 help="Produce human-readable index.xml")
1248 parser.add_argument("--clean", action="store_true", default=False,
1249 help="Clean update - don't uses caches, reprocess all apks")
1250 parser.add_argument("--nosign", action="store_true", default=False,
1251 help="When configured for signed indexes, create only unsigned indexes at this stage")
1252 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1253 help="Use date from apk instead of current time for newly added apks")
1254 options = parser.parse_args()
1256 config = common.read_config(options)
1258 if not ('jarsigner' in config and 'keytool' in config):
1259 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1263 if config['archive_older'] != 0:
1264 repodirs.append('archive')
1265 if not os.path.exists('archive'):
1269 resize_all_icons(repodirs)
1272 # check that icons exist now, rather than fail at the end of `fdroid update`
1273 for k in ['repo_icon', 'archive_icon']:
1275 if not os.path.exists(config[k]):
1276 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1279 # if the user asks to create a keystore, do it now, reusing whatever it can
1280 if options.create_key:
1281 if os.path.exists(config['keystore']):
1282 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1283 logging.critical("\t'" + config['keystore'] + "'")
1286 if 'repo_keyalias' not in config:
1287 config['repo_keyalias'] = socket.getfqdn()
1288 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1289 if 'keydname' not in config:
1290 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1291 common.write_to_config(config, 'keydname', config['keydname'])
1292 if 'keystore' not in config:
1293 config['keystore'] = common.default_config.keystore
1294 common.write_to_config(config, 'keystore', config['keystore'])
1296 password = common.genpassword()
1297 if 'keystorepass' not in config:
1298 config['keystorepass'] = password
1299 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1300 if 'keypass' not in config:
1301 config['keypass'] = password
1302 common.write_to_config(config, 'keypass', config['keypass'])
1303 common.genkeystore(config)
1306 apps = metadata.read_metadata()
1308 # Generate a list of categories...
1310 for app in apps.values():
1311 categories.update(app.Categories)
1313 # Read known apks data (will be updated and written back when we've finished)
1314 knownapks = common.KnownApks()
1316 # Gather information about all the apk files in the repo directory, using
1317 # cached data if possible.
1318 apkcachefile = os.path.join('tmp', 'apkcache')
1319 if not options.clean and os.path.exists(apkcachefile):
1320 with open(apkcachefile, 'rb') as cf:
1321 apkcache = pickle.load(cf, encoding='utf-8')
1322 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
1327 delete_disabled_builds(apps, apkcache, repodirs)
1329 # Scan all apks in the main repo
1330 apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1332 # Generate warnings for apk's with no metadata (or create skeleton
1333 # metadata files, if requested on the command line)
1336 if apk['id'] not in apps:
1337 if options.create_metadata:
1338 if 'name' not in apk:
1339 logging.error(apk['id'] + ' does not have a name! Skipping...')
1341 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
1342 f.write("License:Unknown\n")
1343 f.write("Web Site:\n")
1344 f.write("Source Code:\n")
1345 f.write("Issue Tracker:\n")
1346 f.write("Changelog:\n")
1347 f.write("Summary:" + apk['name'] + "\n")
1348 f.write("Description:\n")
1349 f.write(apk['name'] + "\n")
1352 logging.info("Generated skeleton metadata for " + apk['id'])
1355 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1356 if options.delete_unknown:
1357 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1358 rmf = os.path.join(repodirs[0], apk['apkname'])
1359 if not os.path.exists(rmf):
1360 logging.error("Could not find {0} to remove it".format(rmf))
1364 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1366 # update the metadata with the newly created ones included
1368 apps = metadata.read_metadata()
1370 insert_obbs(repodirs[0], apps, apks)
1372 # Scan the archive repo for apks as well
1373 if len(repodirs) > 1:
1374 archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1380 # Some information from the apks needs to be applied up to the application
1381 # level. When doing this, we use the info from the most recent version's apk.
1382 # We deal with figuring out when the app was added and last updated at the
1384 for appid, app in apps.items():
1386 for apk in apks + archapks:
1387 if apk['id'] == appid:
1388 if apk['versioncode'] > bestver:
1389 bestver = apk['versioncode']
1393 if not app.added or apk['added'] < app.added:
1394 app.added = apk['added']
1395 if not app.lastupdated or apk['added'] > app.lastupdated:
1396 app.lastupdated = apk['added']
1399 logging.debug("Don't know when " + appid + " was added")
1400 if not app.lastupdated:
1401 logging.debug("Don't know when " + appid + " was last updated")
1404 if app.Name is None:
1405 app.Name = app.AutoName or appid
1407 logging.debug("Application " + appid + " has no packages")
1409 if app.Name is None:
1410 app.Name = bestapk['name']
1411 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1412 if app.CurrentVersionCode is None:
1413 app.CurrentVersionCode = str(bestver)
1415 # Sort the app list by name, then the web site doesn't have to by default.
1416 # (we had to wait until we'd scanned the apks to do this, because mostly the
1417 # name comes from there!)
1418 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1420 # APKs are placed into multiple repos based on the app package, providing
1421 # per-app subscription feeds for nightly builds and things like it
1422 if config['per_app_repos']:
1423 add_apks_to_per_app_repos(repodirs[0], apks)
1424 for appid, app in apps.items():
1425 repodir = os.path.join(appid, 'fdroid', 'repo')
1427 appdict[appid] = app
1428 if os.path.isdir(repodir):
1429 make_index(appdict, [appid], apks, repodir, False, categories)
1431 logging.info('Skipping index generation for ' + appid)
1434 if len(repodirs) > 1:
1435 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1437 # Make the index for the main repo...
1438 make_index(apps, sortedids, apks, repodirs[0], False, categories)
1440 # If there's an archive repo, make the index for it. We already scanned it
1442 if len(repodirs) > 1:
1443 make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1445 if config['update_stats']:
1447 # Update known apks info...
1448 knownapks.writeifchanged()
1450 # Generate latest apps data for widget
1451 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1453 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1455 appid = line.rstrip()
1456 data += appid + "\t"
1458 data += app.Name + "\t"
1459 if app.icon is not None:
1460 data += app.icon + "\t"
1461 data += app.License + "\n"
1462 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1466 apkcache["METADATA_VERSION"] = METADATA_VERSION
1467 with open(apkcachefile, 'wb') as cf:
1468 pickle.dump(apkcache, cf)
1470 # Update the wiki...
1472 update_wiki(apps, sortedids, apks + archapks)
1474 logging.info("Finished.")
1476 if __name__ == "__main__":