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 insert_obbs(repodir, apps, apks):
423 """Scans the .obb files in a given repo directory and adds them to the
424 relevant APK instances.
426 :param repodir: repo directory to scan
427 :param apps: list of current, valid apps
428 :param apks: current information on all APKs
431 def obbWarnDelete(f, msg):
432 logging.warning(msg + f)
433 if options.delete_unknown:
434 logging.error("Deleting unknown file: " + f)
438 java_Integer_MIN_VALUE = -pow(2, 31)
439 for f in glob.glob(os.path.join(repodir, '*.obb')):
440 obbfile = os.path.basename(f)
441 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
442 chunks = obbfile.split('.')
443 if chunks[0] != 'main' and chunks[0] != 'patch':
444 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
446 if not re.match(r'^-?[0-9]+$', chunks[1]):
447 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
449 versioncode = int(chunks[1])
450 packagename = ".".join(chunks[2:-1])
452 highestVersionCode = java_Integer_MIN_VALUE
453 if packagename not in apps.keys():
454 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
457 if packagename == apk['id'] and apk['versioncode'] > highestVersionCode:
458 highestVersionCode = apk['versioncode']
459 if versioncode > highestVersionCode:
460 obbWarnDelete(f, 'OBB file has newer versioncode(' + str(versioncode)
461 + ') than any APK: ')
464 obbs.append((packagename, versioncode, obbfile))
467 for (packagename, versioncode, obbfile) in sorted(obbs, reverse=True):
468 if versioncode <= apk['versioncode'] and packagename == apk['id']:
469 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
470 apk['obbMainFile'] = obbfile
471 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
472 apk['obbPatchFile'] = obbfile
473 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
477 def scan_apks(apps, apkcache, repodir, knownapks, use_date_from_apk=False):
478 """Scan the apks in the given repo directory.
480 This also extracts the icons.
482 :param apps: list of all applications, as per metadata.read_metadata
483 :param apkcache: current apk cache information
484 :param repodir: repo directory to scan
485 :param knownapks: known apks info
486 :param use_date_from_apk: use date from APK (instead of current date)
488 :returns: (apks, cachechanged) where apks is a list of apk information,
489 and cachechanged is True if the apkcache got changed.
494 for icon_dir in get_all_icon_dirs(repodir):
495 if os.path.exists(icon_dir):
497 shutil.rmtree(icon_dir)
498 os.makedirs(icon_dir)
500 os.makedirs(icon_dir)
503 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
504 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
505 vername_pat = re.compile(".*versionName='([^']*)'.*")
506 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
507 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
508 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
509 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
510 string_pat = re.compile(".* name='([^']*)'.*")
511 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
513 apkfilename = apkfile[len(repodir) + 1:]
514 if ' ' in apkfilename:
515 logging.critical("Spaces in filenames are not allowed.")
518 # Calculate the sha256...
519 sha = hashlib.sha256()
520 with open(apkfile, 'rb') as f:
526 shasum = sha.hexdigest()
529 if apkfilename in apkcache:
530 apk = apkcache[apkfilename]
531 if apk['sha256'] == shasum:
532 logging.debug("Reading " + apkfilename + " from cache")
535 logging.debug("Ignoring stale cache data for " + apkfilename)
538 logging.debug("Processing " + apkfilename)
540 apk['apkname'] = apkfilename
541 apk['sha256'] = shasum
542 srcfilename = apkfilename[:-4] + "_src.tar.gz"
543 if os.path.exists(os.path.join(repodir, srcfilename)):
544 apk['srcname'] = srcfilename
545 apk['size'] = os.path.getsize(apkfile)
546 apk['permissions'] = set()
547 apk['features'] = set()
548 apk['icons_src'] = {}
550 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
551 if p.returncode != 0:
552 if options.delete_unknown:
553 if os.path.exists(apkfile):
554 logging.error("Failed to get apk information, deleting " + apkfile)
557 logging.error("Could not find {0} to remove it".format(apkfile))
559 logging.error("Failed to get apk information, skipping " + apkfile)
561 for line in p.output.splitlines():
562 if line.startswith("package:"):
564 apk['id'] = re.match(name_pat, line).group(1)
565 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
566 apk['version'] = re.match(vername_pat, line).group(1)
567 except Exception as e:
568 logging.error("Package matching failed: " + str(e))
569 logging.info("Line was: " + line)
571 elif line.startswith("application:"):
572 apk['name'] = re.match(label_pat, line).group(1)
573 # Keep path to non-dpi icon in case we need it
574 match = re.match(icon_pat_nodpi, line)
576 apk['icons_src']['-1'] = match.group(1)
577 elif line.startswith("launchable-activity:"):
578 # Only use launchable-activity as fallback to application
580 apk['name'] = re.match(label_pat, line).group(1)
581 if '-1' not in apk['icons_src']:
582 match = re.match(icon_pat_nodpi, line)
584 apk['icons_src']['-1'] = match.group(1)
585 elif line.startswith("application-icon-"):
586 match = re.match(icon_pat, line)
588 density = match.group(1)
589 path = match.group(2)
590 apk['icons_src'][density] = path
591 elif line.startswith("sdkVersion:"):
592 m = re.match(sdkversion_pat, line)
594 logging.error(line.replace('sdkVersion:', '')
595 + ' is not a valid minSdkVersion!')
597 apk['minSdkVersion'] = m.group(1)
598 # if target not set, default to min
599 if 'targetSdkVersion' not in apk:
600 apk['targetSdkVersion'] = m.group(1)
601 elif line.startswith("targetSdkVersion:"):
602 m = re.match(sdkversion_pat, line)
604 logging.error(line.replace('targetSdkVersion:', '')
605 + ' is not a valid targetSdkVersion!')
607 apk['targetSdkVersion'] = m.group(1)
608 elif line.startswith("maxSdkVersion:"):
609 apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
610 elif line.startswith("native-code:"):
611 apk['nativecode'] = []
612 for arch in line[13:].split(' '):
613 apk['nativecode'].append(arch[1:-1])
614 elif line.startswith("uses-permission:"):
615 perm = re.match(string_pat, line).group(1)
616 if perm.startswith("android.permission."):
618 apk['permissions'].add(perm)
619 elif line.startswith("uses-feature:"):
620 perm = re.match(string_pat, line).group(1)
621 # Filter out this, it's only added with the latest SDK tools and
622 # causes problems for lots of apps.
623 if perm != "android.hardware.screen.portrait" \
624 and perm != "android.hardware.screen.landscape":
625 if perm.startswith("android.feature."):
627 apk['features'].add(perm)
629 if 'minSdkVersion' not in apk:
630 logging.warn("No SDK version information found in {0}".format(apkfile))
631 apk['minSdkVersion'] = 1
633 # Check for debuggable apks...
634 if common.isApkDebuggable(apkfile, config):
635 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
637 # Get the signature (or md5 of, to be precise)...
638 logging.debug('Getting signature of {0}'.format(apkfile))
639 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
641 logging.critical("Failed to get apk signature")
644 apkzip = zipfile.ZipFile(apkfile, 'r')
646 # if an APK has files newer than the system time, suggest updating
647 # the system clock. This is useful for offline systems, used for
648 # signing, which do not have another source of clock sync info. It
649 # has to be more than 24 hours newer because ZIP/APK files do not
650 # store timezone info
651 manifest = apkzip.getinfo('AndroidManifest.xml')
652 if manifest.date_time[1] == 0: # month can't be zero
653 logging.debug('AndroidManifest.xml has no date')
655 dt_obj = datetime(*manifest.date_time)
656 checkdt = dt_obj - timedelta(1)
657 if datetime.today() < checkdt:
658 logging.warn('System clock is older than manifest in: '
660 + '\nSet clock to that time using:\n'
661 + 'sudo date -s "' + str(dt_obj) + '"')
663 iconfilename = "%s.%s.png" % (
667 # Extract the icon file...
669 for density in screen_densities:
670 if density not in apk['icons_src']:
671 empty_densities.append(density)
673 iconsrc = apk['icons_src'][density]
674 icon_dir = get_icon_dir(repodir, density)
675 icondest = os.path.join(icon_dir, iconfilename)
678 with open(icondest, 'wb') as f:
679 f.write(get_icon_bytes(apkzip, iconsrc))
680 apk['icons'][density] = iconfilename
683 logging.warn("Error retrieving icon file")
684 del apk['icons'][density]
685 del apk['icons_src'][density]
686 empty_densities.append(density)
688 if '-1' in apk['icons_src']:
689 iconsrc = apk['icons_src']['-1']
690 iconpath = os.path.join(
691 get_icon_dir(repodir, '0'), iconfilename)
692 with open(iconpath, 'wb') as f:
693 f.write(get_icon_bytes(apkzip, iconsrc))
695 im = Image.open(iconpath)
696 dpi = px_to_dpi(im.size[0])
697 for density in screen_densities:
698 if density in apk['icons']:
700 if density == screen_densities[-1] or dpi >= int(density):
701 apk['icons'][density] = iconfilename
702 shutil.move(iconpath,
703 os.path.join(get_icon_dir(repodir, density), iconfilename))
704 empty_densities.remove(density)
706 except Exception as e:
707 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
710 apk['icon'] = iconfilename
714 # First try resizing down to not lose quality
716 for density in screen_densities:
717 if density not in empty_densities:
718 last_density = density
720 if last_density is None:
722 logging.debug("Density %s not available, resizing down from %s"
723 % (density, last_density))
725 last_iconpath = os.path.join(
726 get_icon_dir(repodir, last_density), iconfilename)
727 iconpath = os.path.join(
728 get_icon_dir(repodir, density), iconfilename)
731 fp = open(last_iconpath, 'rb')
734 size = dpi_to_px(density)
736 im.thumbnail((size, size), Image.ANTIALIAS)
737 im.save(iconpath, "PNG")
738 empty_densities.remove(density)
740 logging.warning("Invalid image file at %s" % last_iconpath)
745 # Then just copy from the highest resolution available
747 for density in reversed(screen_densities):
748 if density not in empty_densities:
749 last_density = density
751 if last_density is None:
753 logging.debug("Density %s not available, copying from lower density %s"
754 % (density, last_density))
757 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
758 os.path.join(get_icon_dir(repodir, density), iconfilename))
760 empty_densities.remove(density)
762 for density in screen_densities:
763 icon_dir = get_icon_dir(repodir, density)
764 icondest = os.path.join(icon_dir, iconfilename)
765 resize_icon(icondest, density)
767 # Copy from icons-mdpi to icons since mdpi is the baseline density
768 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
769 if os.path.isfile(baseline):
770 apk['icons']['0'] = iconfilename
771 shutil.copyfile(baseline,
772 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
774 # Record in known apks, getting the added date at the same time..
775 added = knownapks.recordapk(apk['apkname'], apk['id'])
777 if use_date_from_apk and manifest.date_time[1] != 0:
778 added = datetime(*manifest.date_time).timetuple()
779 logging.debug("Using date from APK")
783 apkcache[apkfilename] = apk
788 return apks, cachechanged
791 repo_pubkey_fingerprint = None
794 # Generate a certificate fingerprint the same way keytool does it
795 # (but with slightly different formatting)
796 def cert_fingerprint(data):
797 digest = hashlib.sha256(data).digest()
799 ret.append(' '.join("%02X" % b for b in bytearray(digest)))
803 def extract_pubkey():
804 global repo_pubkey_fingerprint
805 if 'repo_pubkey' in config:
806 pubkey = unhexlify(config['repo_pubkey'])
808 p = FDroidPopenBytes([config['keytool'], '-exportcert',
809 '-alias', config['repo_keyalias'],
810 '-keystore', config['keystore'],
811 '-storepass:file', config['keystorepassfile']]
812 + config['smartcardoptions'],
813 output=False, stderr_to_stdout=False)
814 if p.returncode != 0 or len(p.output) < 20:
815 msg = "Failed to get repo pubkey!"
816 if config['keystore'] == 'NONE':
817 msg += ' Is your crypto smartcard plugged in?'
818 logging.critical(msg)
821 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
822 return hexlify(pubkey)
825 def make_index(apps, sortedids, apks, repodir, archive, categories):
826 """Make a repo index.
828 :param apps: fully populated apps list
829 :param apks: full populated apks list
830 :param repodir: the repo directory
831 :param archive: True if this is the archive repo, False if it's the
833 :param categories: list of categories
838 def addElement(name, value, doc, parent):
839 el = doc.createElement(name)
840 el.appendChild(doc.createTextNode(value))
841 parent.appendChild(el)
843 def addElementNonEmpty(name, value, doc, parent):
846 addElement(name, value, doc, parent)
848 def addElementCDATA(name, value, doc, parent):
849 el = doc.createElement(name)
850 el.appendChild(doc.createCDATASection(value))
851 parent.appendChild(el)
853 root = doc.createElement("fdroid")
854 doc.appendChild(root)
856 repoel = doc.createElement("repo")
858 mirrorcheckfailed = False
859 for mirror in config.get('mirrors', []):
860 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
861 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
862 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
863 mirrorcheckfailed = True
864 if mirrorcheckfailed:
868 repoel.setAttribute("name", config['archive_name'])
869 if config['repo_maxage'] != 0:
870 repoel.setAttribute("maxage", str(config['repo_maxage']))
871 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
872 repoel.setAttribute("url", config['archive_url'])
873 addElement('description', config['archive_description'], doc, repoel)
874 urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
875 for mirror in config.get('mirrors', []):
876 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
879 repoel.setAttribute("name", config['repo_name'])
880 if config['repo_maxage'] != 0:
881 repoel.setAttribute("maxage", str(config['repo_maxage']))
882 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
883 repoel.setAttribute("url", config['repo_url'])
884 addElement('description', config['repo_description'], doc, repoel)
885 urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
886 for mirror in config.get('mirrors', []):
887 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
889 repoel.setAttribute("version", str(METADATA_VERSION))
890 repoel.setAttribute("timestamp", str(int(time.time())))
893 if not options.nosign:
894 if 'repo_keyalias' not in config:
896 logging.critical("'repo_keyalias' not found in config.py!")
897 if 'keystore' not in config:
899 logging.critical("'keystore' not found in config.py!")
900 if 'keystorepass' not in config and 'keystorepassfile' not in config:
902 logging.critical("'keystorepass' not found in config.py!")
903 if 'keypass' not in config and 'keypassfile' not in config:
905 logging.critical("'keypass' not found in config.py!")
906 if not os.path.exists(config['keystore']):
908 logging.critical("'" + config['keystore'] + "' does not exist!")
910 logging.warning("`fdroid update` requires a signing key, you can create one using:")
911 logging.warning("\tfdroid update --create-key")
914 repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
915 root.appendChild(repoel)
917 for appid in sortedids:
920 if app.Disabled is not None:
923 # Get a list of the apks for this app...
926 if apk['id'] == appid:
929 if len(apklist) == 0:
932 apel = doc.createElement("application")
933 apel.setAttribute("id", app.id)
934 root.appendChild(apel)
936 addElement('id', app.id, doc, apel)
938 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
940 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
941 addElement('name', app.Name, doc, apel)
942 addElement('summary', app.Summary, doc, apel)
944 addElement('icon', app.icon, doc, apel)
948 return ("fdroid.app:" + appid, apps[appid].Name)
949 raise MetaDataException("Cannot resolve app id " + appid)
952 metadata.description_html(app.Description, linkres),
954 addElement('license', app.License, doc, apel)
956 addElement('categories', ','.join(app.Categories), doc, apel)
957 # We put the first (primary) category in LAST, which will have
958 # the desired effect of making clients that only understand one
959 # category see that one.
960 addElement('category', app.Categories[0], doc, apel)
961 addElement('web', app.WebSite, doc, apel)
962 addElement('source', app.SourceCode, doc, apel)
963 addElement('tracker', app.IssueTracker, doc, apel)
964 addElementNonEmpty('changelog', app.Changelog, doc, apel)
965 addElementNonEmpty('author', app.AuthorName, doc, apel)
966 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
967 addElementNonEmpty('donate', app.Donate, doc, apel)
968 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
969 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
970 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
972 # These elements actually refer to the current version (i.e. which
973 # one is recommended. They are historically mis-named, and need
974 # changing, but stay like this for now to support existing clients.
975 addElement('marketversion', app.CurrentVersion, doc, apel)
976 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
979 af = app.AntiFeatures
981 addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
983 pv = app.Provides.split(',')
984 addElementNonEmpty('provides', ','.join(pv), doc, apel)
986 addElement('requirements', 'root', doc, apel)
988 # Sort the apk list into version order, just so the web site
989 # doesn't have to do any work by default...
990 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
992 # Check for duplicates - they will make the client unhappy...
993 for i in range(len(apklist) - 1):
994 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
995 logging.critical("duplicate versions: '%s' - '%s'" % (
996 apklist[i]['apkname'], apklist[i + 1]['apkname']))
999 current_version_code = 0
1000 current_version_file = None
1002 # find the APK for the "Current Version"
1003 if current_version_code < apk['versioncode']:
1004 current_version_code = apk['versioncode']
1005 if current_version_code < int(app.CurrentVersionCode):
1006 current_version_file = apk['apkname']
1008 apkel = doc.createElement("package")
1009 apel.appendChild(apkel)
1010 addElement('version', apk['version'], doc, apkel)
1011 addElement('versioncode', str(apk['versioncode']), doc, apkel)
1012 addElement('apkname', apk['apkname'], doc, apkel)
1013 if 'srcname' in apk:
1014 addElement('srcname', apk['srcname'], doc, apkel)
1015 for hash_type in ['sha256']:
1016 if hash_type not in apk:
1018 hashel = doc.createElement("hash")
1019 hashel.setAttribute("type", hash_type)
1020 hashel.appendChild(doc.createTextNode(apk[hash_type]))
1021 apkel.appendChild(hashel)
1022 addElement('sig', apk['sig'], doc, apkel)
1023 addElement('size', str(apk['size']), doc, apkel)
1024 addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
1025 if 'targetSdkVersion' in apk:
1026 addElement('targetSdkVersion', str(apk['targetSdkVersion']), doc, apkel)
1027 if 'maxSdkVersion' in apk:
1028 addElement('maxsdkver', str(apk['maxSdkVersion']), doc, apkel)
1029 addElementNonEmpty('obbMainFile', apk.get('obbMainFile'), doc, apkel)
1030 addElementNonEmpty('obbPatchFile', apk.get('obbPatchFile'), doc, apkel)
1032 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
1033 addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
1034 if 'nativecode' in apk:
1035 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
1036 addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
1038 if current_version_file is not None \
1039 and config['make_current_version_link'] \
1040 and repodir == 'repo': # only create these
1041 namefield = config['current_version_name_source']
1042 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
1043 apklinkname = sanitized_name + '.apk'
1044 current_version_path = os.path.join(repodir, current_version_file)
1045 if os.path.islink(apklinkname):
1046 os.remove(apklinkname)
1047 os.symlink(current_version_path, apklinkname)
1048 # also symlink gpg signature, if it exists
1049 for extension in ('.asc', '.sig'):
1050 sigfile_path = current_version_path + extension
1051 if os.path.exists(sigfile_path):
1052 siglinkname = apklinkname + extension
1053 if os.path.islink(siglinkname):
1054 os.remove(siglinkname)
1055 os.symlink(sigfile_path, siglinkname)
1058 output = doc.toprettyxml(encoding='utf-8')
1060 output = doc.toxml(encoding='utf-8')
1062 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
1065 if 'repo_keyalias' in config:
1068 logging.info("Creating unsigned index in preparation for signing")
1070 logging.info("Creating signed index with this key (SHA256):")
1071 logging.info("%s" % repo_pubkey_fingerprint)
1073 # Create a jar of the index...
1074 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
1075 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
1076 if p.returncode != 0:
1077 logging.critical("Failed to create {0}".format(jar_output))
1081 signed = os.path.join(repodir, 'index.jar')
1083 # Remove old signed index if not signing
1084 if os.path.exists(signed):
1087 args = [config['jarsigner'], '-keystore', config['keystore'],
1088 '-storepass:file', config['keystorepassfile'],
1089 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
1090 signed, config['repo_keyalias']]
1091 if config['keystore'] == 'NONE':
1092 args += config['smartcardoptions']
1093 else: # smardcards never use -keypass
1094 args += ['-keypass:file', config['keypassfile']]
1095 p = FDroidPopen(args)
1096 if p.returncode != 0:
1097 logging.critical("Failed to sign index")
1100 # Copy the repo icon into the repo directory...
1101 icon_dir = os.path.join(repodir, 'icons')
1102 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1103 shutil.copyfile(config['repo_icon'], iconfilename)
1105 # Write a category list in the repo to allow quick access...
1107 for cat in categories:
1108 catdata += cat + '\n'
1109 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1113 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1115 for appid, app in apps.items():
1117 if app.ArchivePolicy:
1118 keepversions = int(app.ArchivePolicy[:-9])
1120 keepversions = defaultkeepversions
1122 def filter_apk_list_sorted(apk_list):
1124 for apk in apk_list:
1125 if apk['id'] == appid:
1128 # Sort the apk list by version code. First is highest/newest.
1129 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1131 def move_file(from_dir, to_dir, filename, ignore_missing):
1132 from_path = os.path.join(from_dir, filename)
1133 if ignore_missing and not os.path.exists(from_path):
1135 to_path = os.path.join(to_dir, filename)
1136 shutil.move(from_path, to_path)
1138 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1139 .format(appid, len(apks), keepversions, len(archapks)))
1141 if len(apks) > keepversions:
1142 apklist = filter_apk_list_sorted(apks)
1143 # Move back the ones we don't want.
1144 for apk in apklist[keepversions:]:
1145 logging.info("Moving " + apk['apkname'] + " to archive")
1146 move_file(repodir, archivedir, apk['apkname'], False)
1147 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1148 for density in all_screen_densities:
1149 repo_icon_dir = get_icon_dir(repodir, density)
1150 archive_icon_dir = get_icon_dir(archivedir, density)
1151 if density not in apk['icons']:
1153 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1154 if 'srcname' in apk:
1155 move_file(repodir, archivedir, apk['srcname'], False)
1156 archapks.append(apk)
1158 elif len(apks) < keepversions and len(archapks) > 0:
1159 required = keepversions - len(apks)
1160 archapklist = filter_apk_list_sorted(archapks)
1161 # Move forward the ones we want again.
1162 for apk in archapklist[:required]:
1163 logging.info("Moving " + apk['apkname'] + " from archive")
1164 move_file(archivedir, repodir, apk['apkname'], False)
1165 move_file(archivedir, repodir, 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(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1172 if 'srcname' in apk:
1173 move_file(archivedir, repodir, apk['srcname'], False)
1174 archapks.remove(apk)
1178 def add_apks_to_per_app_repos(repodir, apks):
1179 apks_per_app = dict()
1181 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1182 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1183 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1184 apks_per_app[apk['id']] = apk
1186 if not os.path.exists(apk['per_app_icons']):
1187 logging.info('Adding new repo for only ' + apk['id'])
1188 os.makedirs(apk['per_app_icons'])
1190 apkpath = os.path.join(repodir, apk['apkname'])
1191 shutil.copy(apkpath, apk['per_app_repo'])
1192 apksigpath = apkpath + '.sig'
1193 if os.path.exists(apksigpath):
1194 shutil.copy(apksigpath, apk['per_app_repo'])
1195 apkascpath = apkpath + '.asc'
1196 if os.path.exists(apkascpath):
1197 shutil.copy(apkascpath, apk['per_app_repo'])
1206 global config, options
1208 # Parse command line...
1209 parser = ArgumentParser()
1210 common.setup_global_opts(parser)
1211 parser.add_argument("--create-key", action="store_true", default=False,
1212 help="Create a repo signing key in a keystore")
1213 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1214 help="Create skeleton metadata files that are missing")
1215 parser.add_argument("--delete-unknown", action="store_true", default=False,
1216 help="Delete APKs and/or OBBs without metadata from the repo")
1217 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1218 help="Report on build data status")
1219 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1220 help="Interactively ask about things that need updating.")
1221 parser.add_argument("-I", "--icons", action="store_true", default=False,
1222 help="Resize all the icons exceeding the max pixel size and exit")
1223 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1224 help="Specify editor to use in interactive mode. Default " +
1225 "is /etc/alternatives/editor")
1226 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1227 help="Update the wiki")
1228 parser.add_argument("--pretty", action="store_true", default=False,
1229 help="Produce human-readable index.xml")
1230 parser.add_argument("--clean", action="store_true", default=False,
1231 help="Clean update - don't uses caches, reprocess all apks")
1232 parser.add_argument("--nosign", action="store_true", default=False,
1233 help="When configured for signed indexes, create only unsigned indexes at this stage")
1234 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1235 help="Use date from apk instead of current time for newly added apks")
1236 options = parser.parse_args()
1238 config = common.read_config(options)
1240 if not ('jarsigner' in config and 'keytool' in config):
1241 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1245 if config['archive_older'] != 0:
1246 repodirs.append('archive')
1247 if not os.path.exists('archive'):
1251 resize_all_icons(repodirs)
1254 # check that icons exist now, rather than fail at the end of `fdroid update`
1255 for k in ['repo_icon', 'archive_icon']:
1257 if not os.path.exists(config[k]):
1258 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1261 # if the user asks to create a keystore, do it now, reusing whatever it can
1262 if options.create_key:
1263 if os.path.exists(config['keystore']):
1264 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1265 logging.critical("\t'" + config['keystore'] + "'")
1268 if 'repo_keyalias' not in config:
1269 config['repo_keyalias'] = socket.getfqdn()
1270 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1271 if 'keydname' not in config:
1272 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1273 common.write_to_config(config, 'keydname', config['keydname'])
1274 if 'keystore' not in config:
1275 config['keystore'] = common.default_config.keystore
1276 common.write_to_config(config, 'keystore', config['keystore'])
1278 password = common.genpassword()
1279 if 'keystorepass' not in config:
1280 config['keystorepass'] = password
1281 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1282 if 'keypass' not in config:
1283 config['keypass'] = password
1284 common.write_to_config(config, 'keypass', config['keypass'])
1285 common.genkeystore(config)
1288 apps = metadata.read_metadata()
1290 # Generate a list of categories...
1292 for app in apps.values():
1293 categories.update(app.Categories)
1295 # Read known apks data (will be updated and written back when we've finished)
1296 knownapks = common.KnownApks()
1298 # Gather information about all the apk files in the repo directory, using
1299 # cached data if possible.
1300 apkcachefile = os.path.join('tmp', 'apkcache')
1301 if not options.clean and os.path.exists(apkcachefile):
1302 with open(apkcachefile, 'rb') as cf:
1303 apkcache = pickle.load(cf, encoding='utf-8')
1304 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
1309 delete_disabled_builds(apps, apkcache, repodirs)
1311 # Scan all apks in the main repo
1312 apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1314 # Generate warnings for apk's with no metadata (or create skeleton
1315 # metadata files, if requested on the command line)
1318 if apk['id'] not in apps:
1319 if options.create_metadata:
1320 if 'name' not in apk:
1321 logging.error(apk['id'] + ' does not have a name! Skipping...')
1323 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
1324 f.write("License:Unknown\n")
1325 f.write("Web Site:\n")
1326 f.write("Source Code:\n")
1327 f.write("Issue Tracker:\n")
1328 f.write("Changelog:\n")
1329 f.write("Summary:" + apk['name'] + "\n")
1330 f.write("Description:\n")
1331 f.write(apk['name'] + "\n")
1334 logging.info("Generated skeleton metadata for " + apk['id'])
1337 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1338 if options.delete_unknown:
1339 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1340 rmf = os.path.join(repodirs[0], apk['apkname'])
1341 if not os.path.exists(rmf):
1342 logging.error("Could not find {0} to remove it".format(rmf))
1346 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1348 # update the metadata with the newly created ones included
1350 apps = metadata.read_metadata()
1352 insert_obbs(repodirs[0], apps, apks)
1354 # Scan the archive repo for apks as well
1355 if len(repodirs) > 1:
1356 archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1362 # Some information from the apks needs to be applied up to the application
1363 # level. When doing this, we use the info from the most recent version's apk.
1364 # We deal with figuring out when the app was added and last updated at the
1366 for appid, app in apps.items():
1368 for apk in apks + archapks:
1369 if apk['id'] == appid:
1370 if apk['versioncode'] > bestver:
1371 bestver = apk['versioncode']
1375 if not app.added or apk['added'] < app.added:
1376 app.added = apk['added']
1377 if not app.lastupdated or apk['added'] > app.lastupdated:
1378 app.lastupdated = apk['added']
1381 logging.debug("Don't know when " + appid + " was added")
1382 if not app.lastupdated:
1383 logging.debug("Don't know when " + appid + " was last updated")
1386 if app.Name is None:
1387 app.Name = app.AutoName or appid
1389 logging.debug("Application " + appid + " has no packages")
1391 if app.Name is None:
1392 app.Name = bestapk['name']
1393 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1394 if app.CurrentVersionCode is None:
1395 app.CurrentVersionCode = str(bestver)
1397 # Sort the app list by name, then the web site doesn't have to by default.
1398 # (we had to wait until we'd scanned the apks to do this, because mostly the
1399 # name comes from there!)
1400 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1402 # APKs are placed into multiple repos based on the app package, providing
1403 # per-app subscription feeds for nightly builds and things like it
1404 if config['per_app_repos']:
1405 add_apks_to_per_app_repos(repodirs[0], apks)
1406 for appid, app in apps.items():
1407 repodir = os.path.join(appid, 'fdroid', 'repo')
1409 appdict[appid] = app
1410 if os.path.isdir(repodir):
1411 make_index(appdict, [appid], apks, repodir, False, categories)
1413 logging.info('Skipping index generation for ' + appid)
1416 if len(repodirs) > 1:
1417 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1419 # Make the index for the main repo...
1420 make_index(apps, sortedids, apks, repodirs[0], False, categories)
1422 # If there's an archive repo, make the index for it. We already scanned it
1424 if len(repodirs) > 1:
1425 make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1427 if config['update_stats']:
1429 # Update known apks info...
1430 knownapks.writeifchanged()
1432 # Generate latest apps data for widget
1433 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1435 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1437 appid = line.rstrip()
1438 data += appid + "\t"
1440 data += app.Name + "\t"
1441 if app.icon is not None:
1442 data += app.icon + "\t"
1443 data += app.License + "\n"
1444 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1448 apkcache["METADATA_VERSION"] = METADATA_VERSION
1449 with open(apkcachefile, 'wb') as cf:
1450 pickle.dump(apkcache, cf)
1452 # Update the wiki...
1454 update_wiki(apps, sortedids, apks + archapks)
1456 logging.info("Finished.")
1458 if __name__ == "__main__":