3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2016, Blue Jay Wireless
5 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
6 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
7 # Copyright (C) 2013-2014, Daniel Martà <mvdan@mvdan.cc>
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU Affero General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU Affero General Public License for more details.
19 # You should have received a copy of the GNU Affero General Public License
20 # along with this program. If not, see <http://www.gnu.org/licenses/>.
32 from datetime import datetime
33 from argparse import ArgumentParser
36 from binascii import hexlify
38 from PIL import Image, PngImagePlugin
44 from . import metadata
45 from .common import SdkToolsPopen
46 from .exception import BuildException, FDroidException
50 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
51 UNSET_VERSION_CODE = -0x100000000
53 APK_NAME_PAT = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
54 APK_VERCODE_PAT = re.compile(".*versionCode='([0-9]*)'.*")
55 APK_VERNAME_PAT = re.compile(".*versionName='([^']*)'.*")
56 APK_LABEL_ICON_PAT = re.compile(".*\s+label='(.*)'\s+icon='(.*?)'")
57 APK_SDK_VERSION_PAT = re.compile(".*'([0-9]*)'.*")
58 APK_PERMISSION_PAT = \
59 re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
60 APK_FEATURE_PAT = re.compile(".*name='([^']*)'.*")
62 screen_densities = ['65534', '640', '480', '320', '240', '160', '120']
63 # resolutions must end with 'dpi'
64 screen_resolutions = {
77 all_screen_densities = ['0'] + screen_densities
79 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
80 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
82 ALLOWED_EXTENSIONS = ('png', 'jpg', 'jpeg')
83 GRAPHIC_NAMES = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
84 SCREENSHOT_DIRS = ('phoneScreenshots', 'sevenInchScreenshots',
85 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
87 BLANK_PNG_INFO = PngImagePlugin.PngInfo()
90 def dpi_to_px(density):
91 return (int(density) * 48) / 160
95 return (int(px) * 160) / 48
98 def get_icon_dir(repodir, density):
99 if density == '0' or density == '65534':
100 return os.path.join(repodir, "icons")
102 return os.path.join(repodir, "icons-%s" % density)
105 def get_icon_dirs(repodir):
106 for density in screen_densities:
107 yield get_icon_dir(repodir, density)
110 def get_all_icon_dirs(repodir):
111 for density in all_screen_densities:
112 yield get_icon_dir(repodir, density)
115 def update_wiki(apps, sortedids, apks):
118 :param apps: fully populated list of all applications
119 :param apks: all apks, except...
121 logging.info("Updating wiki")
123 wikiredircat = 'App Redirects'
125 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
126 path=config['wiki_path'])
127 site.login(config['wiki_user'], config['wiki_password'])
129 generated_redirects = {}
131 for appid in sortedids:
132 app = metadata.App(apps[appid])
136 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
138 for af in sorted(app.AntiFeatures):
139 wikidata += '{{AntiFeature|' + af + '}}\n'
144 wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|changelog=%s|donate=%s|flattr=%s|liberapay=%s|bitcoin=%s|litecoin=%s|license=%s|root=%s|author=%s|email=%s}}\n' % (
147 app.added.strftime('%Y-%m-%d') if app.added else '',
148 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
164 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
166 wikidata += app.Summary
167 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
169 wikidata += "=Description=\n"
170 wikidata += metadata.description_wiki(app.Description) + "\n"
172 wikidata += "=Maintainer Notes=\n"
173 if app.MaintainerNotes:
174 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
175 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)
177 # Get a list of all packages for this application...
179 gotcurrentver = False
183 if apk['packageName'] == appid:
184 if str(apk['versionCode']) == app.CurrentVersionCode:
187 # Include ones we can't build, as a special case...
188 for build in app.builds:
190 if build.versionCode == app.CurrentVersionCode:
192 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
193 apklist.append({'versionCode': int(build.versionCode),
194 'versionName': build.versionName,
195 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
200 if apk['versionCode'] == int(build.versionCode):
205 apklist.append({'versionCode': int(build.versionCode),
206 'versionName': build.versionName,
207 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
209 if app.CurrentVersionCode == '0':
211 # Sort with most recent first...
212 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
214 wikidata += "=Versions=\n"
215 if len(apklist) == 0:
216 wikidata += "We currently have no versions of this app available."
217 elif not gotcurrentver:
218 wikidata += "We don't have the current version of this app."
220 wikidata += "We have the current version of this app."
221 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
222 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
223 if len(app.NoSourceSince) > 0:
224 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
225 if len(app.CurrentVersion) > 0:
226 wikidata += "The current (recommended) version is " + app.CurrentVersion
227 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
230 wikidata += "==" + apk['versionName'] + "==\n"
232 if 'buildproblem' in apk:
233 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
236 wikidata += "This version is built and signed by "
238 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
240 wikidata += "the original developer.\n\n"
241 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
243 wikidata += '\n[[Category:' + wikicat + ']]\n'
244 if len(app.NoSourceSince) > 0:
245 wikidata += '\n[[Category:Apps missing source code]]\n'
246 if validapks == 0 and not app.Disabled:
247 wikidata += '\n[[Category:Apps with no packages]]\n'
248 if cantupdate and not app.Disabled:
249 wikidata += "\n[[Category:Apps we cannot update]]\n"
250 if buildfails and not app.Disabled:
251 wikidata += "\n[[Category:Apps with failing builds]]\n"
252 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
253 wikidata += '\n[[Category:Apps to Update]]\n'
255 wikidata += '\n[[Category:Apps that are disabled]]\n'
256 if app.UpdateCheckMode == 'None' and not app.Disabled:
257 wikidata += '\n[[Category:Apps with no update check]]\n'
258 for appcat in app.Categories:
259 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
261 # We can't have underscores in the page name, even if they're in
262 # the package ID, because MediaWiki messes with them...
263 pagename = appid.replace('_', ' ')
265 # Drop a trailing newline, because mediawiki is going to drop it anyway
266 # and it we don't we'll think the page has changed when it hasn't...
267 if wikidata.endswith('\n'):
268 wikidata = wikidata[:-1]
270 generated_pages[pagename] = wikidata
272 # Make a redirect from the name to the ID too, unless there's
273 # already an existing page with the name and it isn't a redirect.
275 apppagename = app.Name
277 apppagename = apppagename.replace(ch, ' ')
278 # Drop double spaces caused mostly by replacing ':' above
279 apppagename = apppagename.replace(' ', ' ')
280 for expagename in site.allpages(prefix=apppagename,
281 filterredir='nonredirects',
283 if expagename == apppagename:
285 # Another reason not to make the redirect page is if the app name
286 # is the same as it's ID, because that will overwrite the real page
287 # with an redirect to itself! (Although it seems like an odd
288 # scenario this happens a lot, e.g. where there is metadata but no
289 # builds or binaries to extract a name from.
290 if apppagename == pagename:
293 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
295 for tcat, genp in [(wikicat, generated_pages),
296 (wikiredircat, generated_redirects)]:
297 catpages = site.Pages['Category:' + tcat]
299 for page in catpages:
300 existingpages.append(page.name)
301 if page.name in genp:
302 pagetxt = page.text()
303 if pagetxt != genp[page.name]:
304 logging.debug("Updating modified page " + page.name)
305 page.save(genp[page.name], summary='Auto-updated')
307 logging.debug("Page " + page.name + " is unchanged")
309 logging.warn("Deleting page " + page.name)
310 page.delete('No longer published')
311 for pagename, text in genp.items():
312 logging.debug("Checking " + pagename)
313 if pagename not in existingpages:
314 logging.debug("Creating page " + pagename)
316 newpage = site.Pages[pagename]
317 newpage.save(text, summary='Auto-created')
318 except Exception as e:
319 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
321 # Purge server cache to ensure counts are up to date
322 site.Pages['Repository Maintenance'].purge()
324 # Write a page with the last build log for this version code
325 wiki_page_path = 'update_' + time.strftime('%s', start_timestamp)
326 newpage = site.Pages[wiki_page_path]
328 txt += "* command line: <code>" + ' '.join(sys.argv) + "</code>\n"
329 txt += "* started at " + common.get_wiki_timestamp(start_timestamp) + '\n'
330 txt += "* completed at " + common.get_wiki_timestamp() + '\n'
331 txt += common.get_git_describe_link()
333 txt += common.get_android_tools_version_log()
334 newpage.save(txt, summary='Run log')
335 newpage = site.Pages['update']
336 newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
339 def delete_disabled_builds(apps, apkcache, repodirs):
340 """Delete disabled build outputs.
342 :param apps: list of all applications, as per metadata.read_metadata
343 :param apkcache: current apk cache information
344 :param repodirs: the repo directories to process
346 for appid, app in apps.items():
347 for build in app['builds']:
348 if not build.disable:
350 apkfilename = common.get_release_filename(app, build)
351 iconfilename = "%s.%s.png" % (
354 for repodir in repodirs:
356 os.path.join(repodir, apkfilename),
357 os.path.join(repodir, apkfilename + '.asc'),
358 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
360 for density in all_screen_densities:
361 repo_dir = get_icon_dir(repodir, density)
362 files.append(os.path.join(repo_dir, iconfilename))
365 if os.path.exists(f):
366 logging.info("Deleting disabled build output " + f)
368 if apkfilename in apkcache:
369 del apkcache[apkfilename]
372 def resize_icon(iconpath, density):
374 if not os.path.isfile(iconpath):
379 fp = open(iconpath, 'rb')
381 size = dpi_to_px(density)
383 if any(length > size for length in im.size):
385 im.thumbnail((size, size), Image.ANTIALIAS)
386 logging.debug("%s was too large at %s - new size is %s" % (
387 iconpath, oldsize, im.size))
388 im.save(iconpath, "PNG", optimize=True,
389 pnginfo=BLANK_PNG_INFO, icc_profile=None)
391 except Exception as e:
392 logging.error(_("Failed resizing {path}: {error}".format(path=iconpath, error=e)))
399 def resize_all_icons(repodirs):
400 """Resize all icons that exceed the max size
402 :param repodirs: the repo directories to process
404 for repodir in repodirs:
405 for density in screen_densities:
406 icon_dir = get_icon_dir(repodir, density)
407 icon_glob = os.path.join(icon_dir, '*.png')
408 for iconpath in glob.glob(icon_glob):
409 resize_icon(iconpath, density)
413 """ Get the signing certificate of an apk. To get the same md5 has that
414 Android gets, we encode the .RSA certificate in a specific format and pass
415 it hex-encoded to the md5 digest algorithm.
417 :param apkpath: path to the apk
418 :returns: A string containing the md5 of the signature of the apk or None
419 if an error occurred.
422 with zipfile.ZipFile(apkpath, 'r') as apk:
423 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
426 logging.error(_("No signing certificates found in {path}").format(path=apkpath))
429 logging.error(_("Found multiple signing certificates in {path}").format(path=apkpath))
432 cert = apk.read(certs[0])
434 cert_encoded = common.get_certificate(cert)
436 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
439 def get_cache_file():
440 return os.path.join('tmp', 'apkcache')
444 """Get the cached dict of the APK index
446 Gather information about all the apk files in the repo directory,
447 using cached data if possible. Some of the index operations take a
448 long time, like calculating the SHA-256 and verifying the APK
451 The cache is invalidated if the metadata version is different, or
452 the 'allow_disabled_algorithms' config/option is different. In
453 those cases, there is no easy way to know what has changed from
454 the cache, so just rerun the whole thing.
459 apkcachefile = get_cache_file()
460 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
461 if not options.clean and os.path.exists(apkcachefile):
462 with open(apkcachefile, 'rb') as cf:
463 apkcache = pickle.load(cf, encoding='utf-8')
464 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
465 or apkcache.get('allow_disabled_algorithms') != ada:
470 apkcache["METADATA_VERSION"] = METADATA_VERSION
471 apkcache['allow_disabled_algorithms'] = ada
476 def write_cache(apkcache):
477 apkcachefile = get_cache_file()
478 cache_path = os.path.dirname(apkcachefile)
479 if not os.path.exists(cache_path):
480 os.makedirs(cache_path)
481 with open(apkcachefile, 'wb') as cf:
482 pickle.dump(apkcache, cf)
485 def get_icon_bytes(apkzip, iconsrc):
486 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
488 return apkzip.read(iconsrc)
490 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
493 def sha256sum(filename):
494 '''Calculate the sha256 of the given file'''
495 sha = hashlib.sha256()
496 with open(filename, 'rb') as f:
502 return sha.hexdigest()
505 def has_known_vulnerability(filename):
506 """checks for known vulnerabilities in the APK
508 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
509 version. Google also enforces this:
510 https://support.google.com/faqs/answer/6376725?hl=en
512 Checks whether there are more than one classes.dex or AndroidManifest.xml
513 files, which is invalid and an essential part of the "Master Key" attack.
514 http://www.saurik.com/id/17
516 Janus is similar to Master Key but is perhaps easier to scan for.
517 https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures
522 # statically load this pattern
523 if not hasattr(has_known_vulnerability, "pattern"):
524 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
526 with open(filename.encode(), 'rb') as fp:
528 if first4 != b'\x50\x4b\x03\x04':
529 raise FDroidException(_('{path} has bad file signature "{pattern}", possible Janus exploit!')
530 .format(path=filename, pattern=first4.decode().replace('\n', ' ')) + '\n'
531 + 'https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures')
534 with zipfile.ZipFile(filename) as zf:
535 for name in zf.namelist():
536 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
539 chunk = lib.read(4096)
542 m = has_known_vulnerability.pattern.search(chunk)
544 version = m.group(1).decode('ascii')
545 if (version.startswith('1.0.1') and len(version) > 5 and version[5] >= 'r') \
546 or (version.startswith('1.0.2') and len(version) > 5 and version[5] >= 'f') \
547 or re.match(r'[1-9]\.[1-9]\.[0-9].*', version):
548 logging.debug(_('"{path}" contains recent {name} ({version})')
549 .format(path=filename, name=name, version=version))
551 logging.warning(_('"{path}" contains outdated {name} ({version})')
552 .format(path=filename, name=name, version=version))
555 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
556 if name in files_in_apk:
557 logging.warning(_('{apkfilename} has multiple {name} files, looks like Master Key exploit!')
558 .format(apkfilename=filename, name=name))
560 files_in_apk.add(name)
564 def insert_obbs(repodir, apps, apks):
565 """Scans the .obb files in a given repo directory and adds them to the
566 relevant APK instances. OBB files have versionCodes like APK
567 files, and they are loosely associated. If there is an OBB file
568 present, then any APK with the same or higher versionCode will use
569 that OBB file. There are two OBB types: main and patch, each APK
570 can only have only have one of each.
572 https://developer.android.com/google/play/expansion-files.html
574 :param repodir: repo directory to scan
575 :param apps: list of current, valid apps
576 :param apks: current information on all APKs
580 def obbWarnDelete(f, msg):
581 logging.warning(msg + ' ' + f)
582 if options.delete_unknown:
583 logging.error(_("Deleting unknown file: {path}").format(path=f))
587 java_Integer_MIN_VALUE = -pow(2, 31)
588 currentPackageNames = apps.keys()
589 for f in glob.glob(os.path.join(repodir, '*.obb')):
590 obbfile = os.path.basename(f)
591 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
592 chunks = obbfile.split('.')
593 if chunks[0] != 'main' and chunks[0] != 'patch':
594 obbWarnDelete(f, _('OBB filename must start with "main." or "patch.":'))
596 if not re.match(r'^-?[0-9]+$', chunks[1]):
597 obbWarnDelete(f, _('The OBB version code must come after "{name}.":')
598 .format(name=chunks[0]))
600 versionCode = int(chunks[1])
601 packagename = ".".join(chunks[2:-1])
603 highestVersionCode = java_Integer_MIN_VALUE
604 if packagename not in currentPackageNames:
605 obbWarnDelete(f, _("OBB's packagename does not match a supported APK:"))
608 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
609 highestVersionCode = apk['versionCode']
610 if versionCode > highestVersionCode:
611 obbWarnDelete(f, _('OBB file has newer versionCode({integer}) than any APK:')
612 .format(integer=str(versionCode)))
614 obbsha256 = sha256sum(f)
615 obbs.append((packagename, versionCode, obbfile, obbsha256))
618 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
619 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
620 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
621 apk['obbMainFile'] = obbfile
622 apk['obbMainFileSha256'] = obbsha256
623 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
624 apk['obbPatchFile'] = obbfile
625 apk['obbPatchFileSha256'] = obbsha256
626 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
630 def translate_per_build_anti_features(apps, apks):
631 """Grab the anti-features list from the build metadata
633 For most Anti-Features, they are really most applicable per-APK,
634 not for an app. An app can fix a vulnerability, add/remove
635 tracking, etc. This reads the 'antifeatures' list from the Build
636 entries in the fdroiddata metadata file, then transforms it into
637 the 'antiFeatures' list of unique items for the index.
639 The field key is all lower case in the metadata file to match the
640 rest of the Build fields. It is 'antiFeatures' camel case in the
641 implementation, index, and fdroidclient since it is translated
642 from the build 'antifeatures' field, not directly included.
646 antiFeatures = dict()
647 for packageName, app in apps.items():
649 for build in app['builds']:
650 afl = build.get('antifeatures')
652 d[int(build.versionCode)] = afl
654 antiFeatures[packageName] = d
657 d = antiFeatures.get(apk['packageName'])
659 afl = d.get(apk['versionCode'])
661 apk['antiFeatures'].update(afl)
664 def _get_localized_dict(app, locale):
665 '''get the dict to add localized store metadata to'''
666 if 'localized' not in app:
667 app['localized'] = collections.OrderedDict()
668 if locale not in app['localized']:
669 app['localized'][locale] = collections.OrderedDict()
670 return app['localized'][locale]
673 def _set_localized_text_entry(app, locale, key, f):
674 limit = config['char_limits'][key]
675 localized = _get_localized_dict(app, locale)
677 text = fp.read()[:limit]
679 localized[key] = text
682 def _set_author_entry(app, key, f):
683 limit = config['char_limits']['author']
685 text = fp.read()[:limit]
690 def _strip_and_copy_image(inpath, outpath):
691 """Remove any metadata from image and copy it to new path
693 Sadly, image metadata like EXIF can be used to exploit devices.
694 It is not used at all in the F-Droid ecosystem, so its much safer
695 just to remove it entirely.
699 extension = common.get_extension(inpath)[1]
700 if os.path.isdir(outpath):
701 outpath = os.path.join(outpath, os.path.basename(inpath))
702 if extension == 'png':
703 with open(inpath, 'rb') as fp:
704 in_image = Image.open(fp)
705 in_image.save(outpath, "PNG", optimize=True,
706 pnginfo=BLANK_PNG_INFO, icc_profile=None)
707 elif extension == 'jpg' or extension == 'jpeg':
708 with open(inpath, 'rb') as fp:
709 in_image = Image.open(fp)
710 data = list(in_image.getdata())
711 out_image = Image.new(in_image.mode, in_image.size)
712 out_image.putdata(data)
713 out_image.save(outpath, "JPEG", optimize=True)
715 raise FDroidException(_('Unsupported file type "{extension}" for repo graphic')
716 .format(extension=extension))
719 def copy_triple_t_store_metadata(apps):
720 """Include store metadata from the app's source repo
722 The Triple-T Gradle Play Publisher is a plugin that has a standard
723 file layout for all of the metadata and graphics that the Google
724 Play Store accepts. Since F-Droid has the git repo, it can just
725 pluck those files directly. This method reads any text files into
726 the app dict, then copies any graphics into the fdroid repo
729 This needs to be run before insert_localized_app_metadata() so that
730 the graphics files that are copied into the fdroid repo get
733 https://github.com/Triple-T/gradle-play-publisher#upload-images
734 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
738 if not os.path.isdir('build'):
739 return # nothing to do
741 for packageName, app in apps.items():
742 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
743 logging.debug('Triple-T Gradle Play Publisher: ' + d)
744 for root, dirs, files in os.walk(d):
745 segments = root.split('/')
746 locale = segments[-2]
748 if f == 'fulldescription':
749 _set_localized_text_entry(app, locale, 'description',
750 os.path.join(root, f))
752 elif f == 'shortdescription':
753 _set_localized_text_entry(app, locale, 'summary',
754 os.path.join(root, f))
757 _set_localized_text_entry(app, locale, 'name',
758 os.path.join(root, f))
761 _set_localized_text_entry(app, locale, 'video',
762 os.path.join(root, f))
764 elif f == 'whatsnew':
765 _set_localized_text_entry(app, segments[-1], 'whatsNew',
766 os.path.join(root, f))
768 elif f == 'contactEmail':
769 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
771 elif f == 'contactPhone':
772 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
774 elif f == 'contactWebsite':
775 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
778 base, extension = common.get_extension(f)
779 dirname = os.path.basename(root)
780 if extension in ALLOWED_EXTENSIONS \
781 and (dirname in GRAPHIC_NAMES or dirname in SCREENSHOT_DIRS):
782 if segments[-2] == 'listing':
783 locale = segments[-3]
785 locale = segments[-2]
786 destdir = os.path.join('repo', packageName, locale, dirname)
787 os.makedirs(destdir, mode=0o755, exist_ok=True)
788 sourcefile = os.path.join(root, f)
789 destfile = os.path.join(destdir, os.path.basename(f))
790 logging.debug('copying ' + sourcefile + ' ' + destfile)
791 _strip_and_copy_image(sourcefile, destfile)
794 def insert_localized_app_metadata(apps):
795 """scans standard locations for graphics and localized text
797 Scans for localized description files, store graphics, and
798 screenshot PNG files in statically defined screenshots directory
799 and adds them to the app metadata. The screenshots and graphic
800 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
801 and must be in the following layout:
802 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
804 repo/packageName/locale/featureGraphic.png
805 repo/packageName/locale/phoneScreenshots/1.png
806 repo/packageName/locale/phoneScreenshots/2.png
808 The changelog files must be text files named with the versionCode
809 ending with ".txt" and must be in the following layout:
810 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
812 repo/packageName/locale/changelogs/12345.txt
814 This will scan the each app's source repo then the metadata/ dir
815 for these standard locations of changelog files. If it finds
816 them, they will be added to the dict of all packages, with the
817 versions in the metadata/ folder taking precendence over the what
818 is in the app's source repo.
820 Where "packageName" is the app's packageName and "locale" is the locale
821 of the graphics, e.g. what language they are in, using the IETF RFC5646
822 format (en-US, fr-CA, es-MX, etc).
824 This will also scan the app's git for a fastlane folder, and the
825 metadata/ folder and the apps' source repos for standard locations
826 of graphic and screenshot files. If it finds them, it will copy
827 them into the repo. The fastlane files follow this pattern:
828 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
832 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'src', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
833 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
834 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
835 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
837 for srcd in sorted(sourcedirs):
838 if not os.path.isdir(srcd):
840 for root, dirs, files in os.walk(srcd):
841 segments = root.split('/')
842 packageName = segments[1]
843 if packageName not in apps:
844 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
846 locale = segments[-1]
847 destdir = os.path.join('repo', packageName, locale)
849 # flavours specified in build receipt
851 if apps[packageName] and 'builds' in apps[packageName] and len(apps[packageName].builds) > 0\
852 and 'gradle' in apps[packageName].builds[-1]:
853 build_flavours = apps[packageName].builds[-1].gradle
855 if len(segments) >= 5 and segments[4] == "fastlane" and segments[3] not in build_flavours:
856 logging.debug("ignoring due to wrong flavour")
860 if f in ('description.txt', 'full_description.txt'):
861 _set_localized_text_entry(apps[packageName], locale, 'description',
862 os.path.join(root, f))
864 elif f in ('summary.txt', 'short_description.txt'):
865 _set_localized_text_entry(apps[packageName], locale, 'summary',
866 os.path.join(root, f))
868 elif f in ('name.txt', 'title.txt'):
869 _set_localized_text_entry(apps[packageName], locale, 'name',
870 os.path.join(root, f))
872 elif f == 'video.txt':
873 _set_localized_text_entry(apps[packageName], locale, 'video',
874 os.path.join(root, f))
876 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
877 locale = segments[-2]
878 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
879 os.path.join(root, f))
882 base, extension = common.get_extension(f)
883 if locale == 'images':
884 locale = segments[-2]
885 destdir = os.path.join('repo', packageName, locale)
886 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
887 os.makedirs(destdir, mode=0o755, exist_ok=True)
888 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
889 _strip_and_copy_image(os.path.join(root, f), destdir)
891 if d in SCREENSHOT_DIRS:
892 if locale == 'images':
893 locale = segments[-2]
894 destdir = os.path.join('repo', packageName, locale)
895 for f in glob.glob(os.path.join(root, d, '*.*')):
896 _ignored, extension = common.get_extension(f)
897 if extension in ALLOWED_EXTENSIONS:
898 screenshotdestdir = os.path.join(destdir, d)
899 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
900 logging.debug('copying ' + f + ' ' + screenshotdestdir)
901 _strip_and_copy_image(f, screenshotdestdir)
903 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
905 if not os.path.isdir(d):
907 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
908 if not os.path.isfile(f):
910 segments = f.split('/')
911 packageName = segments[1]
913 screenshotdir = segments[3]
914 filename = os.path.basename(f)
915 base, extension = common.get_extension(filename)
917 if packageName not in apps:
918 logging.warning(_('Found "{path}" graphic without metadata for app "{name}"!')
919 .format(path=filename, name=packageName))
921 graphics = _get_localized_dict(apps[packageName], locale)
923 if extension not in ALLOWED_EXTENSIONS:
924 logging.warning(_('Only PNG and JPEG are supported for graphics, found: {path}').format(path=f))
925 elif base in GRAPHIC_NAMES:
926 # there can only be zero or one of these per locale
927 graphics[base] = filename
928 elif screenshotdir in SCREENSHOT_DIRS:
929 # there can any number of these per locale
930 logging.debug(_('adding to {name}: {path}').format(name=screenshotdir, path=f))
931 if screenshotdir not in graphics:
932 graphics[screenshotdir] = []
933 graphics[screenshotdir].append(filename)
935 logging.warning(_('Unsupported graphics file found: {path}').format(path=f))
938 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
939 """Scan a repo for all files with an extension except APK/OBB
941 :param apkcache: current cached info about all repo files
942 :param repodir: repo directory to scan
943 :param knownapks: list of all known files, as per metadata.read_metadata
944 :param use_date_from_file: use date from file (instead of current date)
945 for newly added files
950 repodir = repodir.encode('utf-8')
951 for name in os.listdir(repodir):
952 file_extension = common.get_file_extension(name)
953 if file_extension == 'apk' or file_extension == 'obb':
955 filename = os.path.join(repodir, name)
956 name_utf8 = name.decode('utf-8')
957 if filename.endswith(b'_src.tar.gz'):
958 logging.debug(_('skipping source tarball: {path}')
959 .format(path=filename.decode('utf-8')))
961 if not common.is_repo_file(filename):
963 stat = os.stat(filename)
964 if stat.st_size == 0:
965 raise FDroidException(_('{path} is zero size!')
966 .format(path=filename))
968 shasum = sha256sum(filename)
971 repo_file = apkcache[name]
972 # added time is cached as tuple but used here as datetime instance
973 if 'added' in repo_file:
974 a = repo_file['added']
975 if isinstance(a, datetime):
976 repo_file['added'] = a
978 repo_file['added'] = datetime(*a[:6])
979 if repo_file.get('hash') == shasum:
980 logging.debug(_("Reading {apkfilename} from cache")
981 .format(apkfilename=name_utf8))
984 logging.debug(_("Ignoring stale cache data for {apkfilename}")
985 .format(apkfilename=name_utf8))
988 logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
989 repo_file = collections.OrderedDict()
990 repo_file['name'] = os.path.splitext(name_utf8)[0]
991 # TODO rename apkname globally to something more generic
992 repo_file['apkName'] = name_utf8
993 repo_file['hash'] = shasum
994 repo_file['hashType'] = 'sha256'
995 repo_file['versionCode'] = 0
996 repo_file['versionName'] = shasum[0:7]
997 # the static ID is the SHA256 unless it is set in the metadata
998 repo_file['packageName'] = shasum
1000 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
1002 repo_file['packageName'] = m.group(1)
1003 repo_file['versionCode'] = int(m.group(2))
1004 srcfilename = name + b'_src.tar.gz'
1005 if os.path.exists(os.path.join(repodir, srcfilename)):
1006 repo_file['srcname'] = srcfilename.decode('utf-8')
1007 repo_file['size'] = stat.st_size
1009 apkcache[name] = repo_file
1012 if use_date_from_file:
1013 timestamp = stat.st_ctime
1014 default_date_param = time.gmtime(time.mktime(datetime.fromtimestamp(timestamp).timetuple()))
1016 default_date_param = None
1018 # Record in knownapks, getting the added date at the same time..
1019 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
1020 default_date=default_date_param)
1022 repo_file['added'] = added
1024 repo_files.append(repo_file)
1026 return repo_files, cachechanged
1029 def scan_apk(apk_file):
1031 Scans an APK file and returns dictionary with metadata of the APK.
1033 Attention: This does *not* verify that the APK signature is correct.
1035 :param apk_file: The (ideally absolute) path to the APK file
1036 :raises BuildException
1037 :return A dict containing APK metadata
1040 'hash': sha256sum(apk_file),
1041 'hashType': 'sha256',
1042 'uses-permission': [],
1043 'uses-permission-sdk-23': [],
1047 'antiFeatures': set(),
1050 if common.use_androguard():
1051 scan_apk_androguard(apk, apk_file)
1053 scan_apk_aapt(apk, apk_file)
1055 # Get the signature, or rather the signing key fingerprints
1056 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
1057 apk['sig'] = getsig(apk_file)
1059 raise BuildException("Failed to get apk signature")
1060 apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
1062 if not apk.get('signer'):
1063 raise BuildException("Failed to get apk signing key fingerprint")
1065 # Get size of the APK
1066 apk['size'] = os.path.getsize(apk_file)
1068 if 'minSdkVersion' not in apk:
1069 logging.warning("No SDK version information found in {0}".format(apk_file))
1070 apk['minSdkVersion'] = 3 # aapt defaults to 3 as the min
1071 if 'targetSdkVersion' not in apk:
1072 apk['targetSdkVersion'] = apk['minSdkVersion']
1074 # Check for known vulnerabilities
1075 if has_known_vulnerability(apk_file):
1076 apk['antiFeatures'].add('KnownVuln')
1081 def _get_apk_icons_src(apkfile, icon_name):
1082 """Extract the paths to the app icon in all available densities
1086 density_re = re.compile('^res/(.*)/{}\.(png|xml)$'.format(icon_name))
1087 with zipfile.ZipFile(apkfile) as zf:
1088 for filename in zf.namelist():
1089 m = density_re.match(filename)
1091 folder = m.group(1).split('-')
1092 if len(folder) > 1 and folder[1].endswith('dpi'):
1093 density = screen_resolutions[folder[1]]
1096 icons_src[density] = m.group(0)
1097 if icons_src.get('-1') is None and '160' in icons_src:
1098 icons_src['-1'] = icons_src['160']
1102 def scan_apk_aapt(apk, apkfile):
1103 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1104 if p.returncode != 0:
1105 if options.delete_unknown:
1106 if os.path.exists(apkfile):
1107 logging.error(_("Failed to get apk information, deleting {path}").format(path=apkfile))
1110 logging.error("Could not find {0} to remove it".format(apkfile))
1112 logging.error(_("Failed to get apk information, skipping {path}").format(path=apkfile))
1113 raise BuildException(_("Invalid APK"))
1115 for line in p.output.splitlines():
1116 if line.startswith("package:"):
1118 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1119 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1120 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1121 except Exception as e:
1122 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1123 elif line.startswith("application:"):
1124 m = re.match(APK_LABEL_ICON_PAT, line)
1126 apk['name'] = m.group(1)
1127 icon_name = os.path.splitext(os.path.basename(m.group(2)))[0]
1128 elif not apk.get('name') and line.startswith("launchable-activity:"):
1129 # Only use launchable-activity as fallback to application
1130 apk['name'] = re.match(APK_LABEL_ICON_PAT, line).group(1)
1131 elif line.startswith("sdkVersion:"):
1132 m = re.match(APK_SDK_VERSION_PAT, line)
1134 logging.error(line.replace('sdkVersion:', '')
1135 + ' is not a valid minSdkVersion!')
1137 apk['minSdkVersion'] = m.group(1)
1138 elif line.startswith("targetSdkVersion:"):
1139 m = re.match(APK_SDK_VERSION_PAT, line)
1141 logging.error(line.replace('targetSdkVersion:', '')
1142 + ' is not a valid targetSdkVersion!')
1144 apk['targetSdkVersion'] = m.group(1)
1145 elif line.startswith("maxSdkVersion:"):
1146 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1147 elif line.startswith("native-code:"):
1148 apk['nativecode'] = []
1149 for arch in line[13:].split(' '):
1150 apk['nativecode'].append(arch[1:-1])
1151 elif line.startswith('uses-permission:'):
1152 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1153 if perm_match['maxSdkVersion']:
1154 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1155 permission = UsesPermission(
1157 perm_match['maxSdkVersion']
1160 apk['uses-permission'].append(permission)
1161 elif line.startswith('uses-permission-sdk-23:'):
1162 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1163 if perm_match['maxSdkVersion']:
1164 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1165 permission_sdk_23 = UsesPermissionSdk23(
1167 perm_match['maxSdkVersion']
1170 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1172 elif line.startswith('uses-feature:'):
1173 feature = re.match(APK_FEATURE_PAT, line).group(1)
1174 # Filter out this, it's only added with the latest SDK tools and
1175 # causes problems for lots of apps.
1176 if feature != "android.hardware.screen.portrait" \
1177 and feature != "android.hardware.screen.landscape":
1178 if feature.startswith("android.feature."):
1179 feature = feature[16:]
1180 apk['features'].add(feature)
1181 apk['icons_src'] = _get_apk_icons_src(apkfile, icon_name)
1184 def _ensure_final_value(packageName, arsc, value):
1185 """Ensure incoming value is always the value, not the resid
1187 androguard will sometimes return the Android "resId" aka
1188 Resource ID instead of the actual value. This checks whether
1189 the value is actually a resId, then performs the Android
1190 Resource lookup as needed.
1196 try: # can be a literal value or a resId
1197 res_id = int(value.replace("@", "0x"), 16)
1198 res_id = arsc.get_id(packageName, res_id)[1]
1199 returnValue = arsc.get_string(packageName, res_id)[1]
1205 def _sanitize_sdk_version(value):
1206 """Sanitize the raw values from androguard to handle bad values
1208 minSdkVersion/targetSdkVersion/maxSdkVersion must be integers,
1209 but that doesn't stop devs from doing strange things like
1210 setting them using Android XML strings.
1212 https://gitlab.com/souch/SMSbypass/blob/v0.9/app/src/main/AndroidManifest.xml#L29
1213 https://gitlab.com/souch/SMSbypass/blob/v0.9/app/src/main/res/values/strings.xml#L27
1216 sdk_version = int(value)
1218 return str(sdk_version) # heinous, but this is still str in the codebase
1219 except (TypeError, ValueError):
1224 def scan_apk_androguard(apk, apkfile):
1226 from androguard.core.bytecodes.apk import APK
1227 apkobject = APK(apkfile)
1228 if apkobject.is_valid_APK():
1229 arsc = apkobject.get_android_resources()
1231 if options.delete_unknown:
1232 if os.path.exists(apkfile):
1233 logging.error(_("Failed to get apk information, deleting {path}")
1234 .format(path=apkfile))
1237 logging.error(_("Could not find {path} to remove it")
1238 .format(path=apkfile))
1240 logging.error(_("Failed to get apk information, skipping {path}")
1241 .format(path=apkfile))
1242 raise BuildException(_("Invalid APK"))
1244 raise FDroidException("androguard library is not installed and aapt not present")
1245 except FileNotFoundError:
1246 logging.error(_("Could not open apk file for analysis"))
1247 raise BuildException(_("Invalid APK"))
1249 apk['packageName'] = apkobject.get_package()
1250 apk['versionCode'] = int(apkobject.get_androidversion_code())
1251 apk['name'] = apkobject.get_app_name()
1253 apk['versionName'] = _ensure_final_value(apk['packageName'], arsc,
1254 apkobject.get_androidversion_name())
1256 minSdkVersion = _sanitize_sdk_version(apkobject.get_min_sdk_version())
1257 if minSdkVersion is not None:
1258 apk['minSdkVersion'] = minSdkVersion
1260 targetSdkVersion = _sanitize_sdk_version(apkobject.get_target_sdk_version())
1261 if targetSdkVersion is not None:
1262 apk['targetSdkVersion'] = targetSdkVersion
1264 maxSdkVersion = _sanitize_sdk_version(apkobject.get_max_sdk_version())
1265 if maxSdkVersion is not None:
1266 apk['maxSdkVersion'] = maxSdkVersion
1268 icon_id_str = apkobject.get_element("application", "icon")
1270 icon_id = int(icon_id_str.replace("@", "0x"), 16)
1271 resource_id = arsc.get_id(apk['packageName'], icon_id)
1273 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1275 icon_name = os.path.splitext(os.path.basename(apkobject.get_app_icon()))[0]
1276 apk['icons_src'] = _get_apk_icons_src(apkfile, icon_name)
1278 arch_re = re.compile("^lib/(.*)/.*$")
1279 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1281 apk['nativecode'] = []
1282 apk['nativecode'].extend(sorted(list(arch)))
1284 xml = apkobject.get_android_manifest_xml()
1285 xmlns = xml.nsmap.get('android')
1287 xmlns = 'http://schemas.android.com/apk/res/android'
1289 for item in xml.findall('uses-permission'):
1290 name = str(item.attrib['{' + xmlns + '}name'])
1291 maxSdkVersion = item.attrib.get('{' + xmlns + '}maxSdkVersion')
1292 maxSdkVersion = int(maxSdkVersion) if maxSdkVersion else None
1293 permission = UsesPermission(
1297 apk['uses-permission'].append(permission)
1298 for name, maxSdkVersion in apkobject.get_uses_implied_permission_list():
1299 permission = UsesPermission(
1303 apk['uses-permission'].append(permission)
1305 for item in xml.findall('uses-permission-sdk-23'):
1306 name = str(item.attrib['{' + xmlns + '}name'])
1307 maxSdkVersion = item.attrib.get('{' + xmlns + '}maxSdkVersion')
1308 maxSdkVersion = int(maxSdkVersion) if maxSdkVersion else None
1309 permission_sdk_23 = UsesPermissionSdk23(
1313 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1315 for item in xml.findall('uses-feature'):
1316 key = '{' + xmlns + '}name'
1317 if key not in item.attrib:
1319 feature = str(item.attrib[key])
1320 if feature != "android.hardware.screen.portrait" \
1321 and feature != "android.hardware.screen.landscape":
1322 if feature.startswith("android.feature."):
1323 feature = feature[16:]
1324 required = item.attrib.get('{' + xmlns + '}required')
1325 if required is None or required == 'true':
1326 apk['features'].append(feature)
1329 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1330 allow_disabled_algorithms=False, archive_bad_sig=False):
1331 """Processes the apk with the given filename in the given repo directory.
1333 This also extracts the icons.
1335 :param apkcache: current apk cache information
1336 :param apkfilename: the filename of the apk to scan
1337 :param repodir: repo directory to scan
1338 :param knownapks: known apks info
1339 :param use_date_from_apk: use date from APK (instead of current date)
1340 for newly added APKs
1341 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1342 disabled algorithms in the signature (e.g. MD5)
1343 :param archive_bad_sig: move APKs with a bad signature to the archive
1344 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1345 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1349 apkfile = os.path.join(repodir, apkfilename)
1351 cachechanged = False
1353 if apkfilename in apkcache:
1354 apk = apkcache[apkfilename]
1355 if apk.get('hash') == sha256sum(apkfile):
1356 logging.debug(_("Reading {apkfilename} from cache")
1357 .format(apkfilename=apkfilename))
1360 logging.debug(_("Ignoring stale cache data for {apkfilename}")
1361 .format(apkfilename=apkfilename))
1364 logging.debug(_("Processing {apkfilename}").format(apkfilename=apkfilename))
1367 apk = scan_apk(apkfile)
1368 except BuildException:
1369 logging.warning(_("Skipping '{apkfilename}' with invalid signature!")
1370 .format(apkfilename=apkfilename))
1371 return True, None, False
1373 # Check for debuggable apks...
1374 if common.is_apk_and_debuggable(apkfile):
1375 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1377 if options.rename_apks:
1378 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1379 std_short_name = os.path.join(repodir, n)
1380 if apkfile != std_short_name:
1381 if os.path.exists(std_short_name):
1382 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1383 if apkfile != std_long_name:
1384 if os.path.exists(std_long_name):
1385 dupdir = os.path.join('duplicates', repodir)
1386 if not os.path.isdir(dupdir):
1387 os.makedirs(dupdir, exist_ok=True)
1388 dupfile = os.path.join('duplicates', std_long_name)
1389 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1390 os.rename(apkfile, dupfile)
1391 return True, None, False
1393 os.rename(apkfile, std_long_name)
1394 apkfile = std_long_name
1396 os.rename(apkfile, std_short_name)
1397 apkfile = std_short_name
1398 apkfilename = apkfile[len(repodir) + 1:]
1400 apk['apkName'] = apkfilename
1401 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1402 if os.path.exists(os.path.join(repodir, srcfilename)):
1403 apk['srcname'] = srcfilename
1405 # verify the jar signature is correct, allow deprecated
1406 # algorithms only if the APK is in the archive.
1408 if not common.verify_apk_signature(apkfile):
1409 if repodir == 'archive' or allow_disabled_algorithms:
1410 if common.verify_old_apk_signature(apkfile):
1411 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1419 logging.warning(_('Archiving {apkfilename} with invalid signature!')
1420 .format(apkfilename=apkfilename))
1421 move_apk_between_sections(repodir, 'archive', apk)
1423 logging.warning(_('Skipping {apkfilename} with invalid signature!')
1424 .format(apkfilename=apkfilename))
1425 return True, None, False
1427 apkzip = zipfile.ZipFile(apkfile, 'r')
1429 manifest = apkzip.getinfo('AndroidManifest.xml')
1430 # 1980-0-0 means zeroed out, any other invalid date should trigger a warning
1431 if (1980, 0, 0) != manifest.date_time[0:3]:
1433 common.check_system_clock(datetime(*manifest.date_time), apkfilename)
1434 except ValueError as e:
1435 logging.warning(_("{apkfilename}'s AndroidManifest.xml has a bad date: ")
1436 .format(apkfilename=apkfile) + str(e))
1438 # extract icons from APK zip file
1439 iconfilename = "%s.%s" % (apk['packageName'], apk['versionCode'])
1441 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1443 apkzip.close() # ensure that APK zip file gets closed
1445 # resize existing icons for densities missing in the APK
1446 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1448 if use_date_from_apk and manifest.date_time[1] != 0:
1449 default_date_param = datetime(*manifest.date_time)
1451 default_date_param = None
1453 # Record in known apks, getting the added date at the same time..
1454 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1455 default_date=default_date_param)
1457 apk['added'] = added
1459 apkcache[apkfilename] = apk
1462 return False, apk, cachechanged
1465 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1466 """Processes the apks in the given repo directory.
1468 This also extracts the icons.
1470 :param apkcache: current apk cache information
1471 :param repodir: repo directory to scan
1472 :param knownapks: known apks info
1473 :param use_date_from_apk: use date from APK (instead of current date)
1474 for newly added APKs
1475 :returns: (apks, cachechanged) where apks is a list of apk information,
1476 and cachechanged is True if the apkcache got changed.
1479 cachechanged = False
1481 for icon_dir in get_all_icon_dirs(repodir):
1482 if os.path.exists(icon_dir):
1484 shutil.rmtree(icon_dir)
1485 os.makedirs(icon_dir)
1487 os.makedirs(icon_dir)
1490 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1491 apkfilename = apkfile[len(repodir) + 1:]
1492 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1493 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1494 use_date_from_apk, ada, True)
1498 cachechanged = cachechanged or cachethis
1500 return apks, cachechanged
1503 def extract_apk_icons(icon_filename, apk, apkzip, repo_dir):
1504 """Extracts PNG icons from an APK with the supported pixel densities
1506 Extracts icons from the given APK zip in various densities, saves
1507 them into given repo directory and stores their names in the APK
1508 metadata dictionary. If the icon is an XML icon, then this tries
1509 to find PNG icon that can replace it.
1511 :param icon_filename: A string representing the icon's file name
1512 :param apk: A populated dictionary containing APK metadata.
1513 Needs to have 'icons_src' key
1514 :param apkzip: An opened zipfile.ZipFile of the APK file
1515 :param repo_dir: The directory of the APK's repository
1516 :return: A list of icon densities that are missing
1519 res_name_re = re.compile(r'res/(drawable|mipmap)-(x*[hlm]dpi|anydpi).*/(.*)_[0-9]+dp.(png|xml)')
1521 for f in apkzip.namelist():
1522 m = res_name_re.match(f)
1523 if m and m.group(4) == 'png':
1524 density = screen_resolutions[m.group(2)]
1525 pngs[m.group(3) + '/' + density] = m.group(0)
1528 empty_densities = []
1529 for density in screen_densities:
1530 if density not in apk['icons_src']:
1531 empty_densities.append(density)
1533 icon_src = apk['icons_src'][density]
1534 icon_dir = get_icon_dir(repo_dir, density)
1537 # Extract the icon files per density
1538 if icon_src.endswith('.xml'):
1539 m = res_name_re.match(icon_src)
1541 name = pngs.get(m.group(3) + '/' + str(density))
1544 if icon_src.endswith('.xml'):
1545 empty_densities.append(density)
1547 icon_dest = os.path.join(icon_dir, icon_filename + icon_type)
1550 with open(icon_dest, 'wb') as f:
1551 f.write(get_icon_bytes(apkzip, icon_src))
1552 apk['icons'][density] = icon_filename + icon_type
1553 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1554 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1555 del apk['icons_src'][density]
1556 empty_densities.append(density)
1558 # '-1' here is a remnant of the parsing of aapt output, meaning "no DPI specified"
1559 if '-1' in apk['icons_src']:
1560 icon_src = apk['icons_src']['-1']
1561 icon_type = icon_src[-4:]
1562 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename + icon_type)
1563 with open(icon_path, 'wb') as f:
1564 f.write(get_icon_bytes(apkzip, icon_src))
1565 if icon_type == '.png':
1568 im = Image.open(icon_path)
1569 dpi = px_to_dpi(im.size[0])
1570 for density in screen_densities:
1571 if density in apk['icons']:
1573 if density == screen_densities[-1] or dpi >= int(density):
1574 apk['icons'][density] = icon_filename + icon_type
1575 shutil.move(icon_path,
1576 os.path.join(get_icon_dir(repo_dir, density), icon_filename + icon_type))
1577 empty_densities.remove(density)
1579 except Exception as e:
1580 logging.warning(_("Failed reading {path}: {error}")
1581 .format(path=icon_path, error=e))
1583 if im and hasattr(im, 'close'):
1587 apk['icon'] = icon_filename + icon_type
1589 return empty_densities
1592 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1594 Resize existing PNG icons for densities missing in the APK to ensure all densities are available
1596 :param empty_densities: A list of icon densities that are missing
1597 :param icon_filename: A string representing the icon's file name
1598 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1599 :param repo_dir: The directory of the APK's repository
1602 icon_filename += '.png'
1603 # First try resizing down to not lose quality
1605 for density in screen_densities:
1606 if density == '65534': # not possible to generate 'anydpi' from other densities
1608 if density not in empty_densities:
1609 last_density = density
1611 if last_density is None:
1613 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1615 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1616 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1619 fp = open(last_icon_path, 'rb')
1622 size = dpi_to_px(density)
1624 im.thumbnail((size, size), Image.ANTIALIAS)
1625 im.save(icon_path, "PNG", optimize=True,
1626 pnginfo=BLANK_PNG_INFO, icc_profile=None)
1627 empty_densities.remove(density)
1628 except Exception as e:
1629 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1634 # Then just copy from the highest resolution available
1636 for density in reversed(screen_densities):
1637 if density not in empty_densities:
1638 last_density = density
1641 if last_density is None:
1645 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1646 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1648 empty_densities.remove(density)
1650 for density in screen_densities:
1651 icon_dir = get_icon_dir(repo_dir, density)
1652 icon_dest = os.path.join(icon_dir, icon_filename)
1653 resize_icon(icon_dest, density)
1655 # Copy from icons-mdpi to icons since mdpi is the baseline density
1656 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1657 if os.path.isfile(baseline):
1658 apk['icons']['0'] = icon_filename
1659 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1662 def apply_info_from_latest_apk(apps, apks):
1664 Some information from the apks needs to be applied up to the application level.
1665 When doing this, we use the info from the most recent version's apk.
1666 We deal with figuring out when the app was added and last updated at the same time.
1668 for appid, app in apps.items():
1669 bestver = UNSET_VERSION_CODE
1671 if apk['packageName'] == appid:
1672 if apk['versionCode'] > bestver:
1673 bestver = apk['versionCode']
1677 if not app.added or apk['added'] < app.added:
1678 app.added = apk['added']
1679 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1680 app.lastUpdated = apk['added']
1683 logging.debug("Don't know when " + appid + " was added")
1684 if not app.lastUpdated:
1685 logging.debug("Don't know when " + appid + " was last updated")
1687 if bestver == UNSET_VERSION_CODE:
1689 if app.Name is None:
1690 app.Name = app.AutoName or appid
1692 logging.debug("Application " + appid + " has no packages")
1694 if app.Name is None:
1695 app.Name = bestapk['name']
1696 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1697 if app.CurrentVersionCode is None:
1698 app.CurrentVersionCode = str(bestver)
1701 def make_categories_txt(repodir, categories):
1702 '''Write a category list in the repo to allow quick access'''
1704 for cat in sorted(categories):
1705 catdata += cat + '\n'
1706 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1710 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1712 def filter_apk_list_sorted(apk_list):
1714 for apk in apk_list:
1715 if apk['packageName'] == appid:
1718 # Sort the apk list by version code. First is highest/newest.
1719 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1721 for appid, app in apps.items():
1723 if app.ArchivePolicy:
1724 keepversions = int(app.ArchivePolicy[:-9])
1726 keepversions = defaultkeepversions
1728 logging.debug(_("Checking archiving for {appid} - apks:{integer}, keepversions:{keep}, archapks:{arch}")
1729 .format(appid=appid, integer=len(apks), keep=keepversions, arch=len(archapks)))
1731 current_app_apks = filter_apk_list_sorted(apks)
1732 if len(current_app_apks) > keepversions:
1733 # Move back the ones we don't want.
1734 for apk in current_app_apks[keepversions:]:
1735 move_apk_between_sections(repodir, archivedir, apk)
1736 archapks.append(apk)
1739 current_app_archapks = filter_apk_list_sorted(archapks)
1740 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1742 # Move forward the ones we want again, except DisableAlgorithm
1743 for apk in current_app_archapks:
1744 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1745 move_apk_between_sections(archivedir, repodir, apk)
1746 archapks.remove(apk)
1749 if kept == keepversions:
1753 def move_apk_between_sections(from_dir, to_dir, apk):
1754 """move an APK from repo to archive or vice versa"""
1756 def _move_file(from_dir, to_dir, filename, ignore_missing):
1757 from_path = os.path.join(from_dir, filename)
1758 if ignore_missing and not os.path.exists(from_path):
1760 to_path = os.path.join(to_dir, filename)
1761 if not os.path.exists(to_dir):
1763 shutil.move(from_path, to_path)
1765 if from_dir == to_dir:
1768 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1769 _move_file(from_dir, to_dir, apk['apkName'], False)
1770 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1771 for density in all_screen_densities:
1772 from_icon_dir = get_icon_dir(from_dir, density)
1773 to_icon_dir = get_icon_dir(to_dir, density)
1774 if density not in apk.get('icons', []):
1776 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1777 if 'srcname' in apk:
1778 _move_file(from_dir, to_dir, apk['srcname'], False)
1781 def add_apks_to_per_app_repos(repodir, apks):
1782 apks_per_app = dict()
1784 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1785 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1786 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1787 apks_per_app[apk['packageName']] = apk
1789 if not os.path.exists(apk['per_app_icons']):
1790 logging.info(_('Adding new repo for only {name}').format(name=apk['packageName']))
1791 os.makedirs(apk['per_app_icons'])
1793 apkpath = os.path.join(repodir, apk['apkName'])
1794 shutil.copy(apkpath, apk['per_app_repo'])
1795 apksigpath = apkpath + '.sig'
1796 if os.path.exists(apksigpath):
1797 shutil.copy(apksigpath, apk['per_app_repo'])
1798 apkascpath = apkpath + '.asc'
1799 if os.path.exists(apkascpath):
1800 shutil.copy(apkascpath, apk['per_app_repo'])
1803 def create_metadata_from_template(apk):
1804 '''create a new metadata file using internal or external template
1806 Generate warnings for apk's with no metadata (or create skeleton
1807 metadata files, if requested on the command line). Though the
1808 template file is YAML, this uses neither pyyaml nor ruamel.yaml
1809 since those impose things on the metadata file made from the
1810 template: field sort order, empty field value, formatting, etc.
1814 if os.path.exists('template.yml'):
1815 with open('template.yml') as f:
1817 if 'name' in apk and apk['name'] != '':
1818 metatxt = re.sub(r'''^(((Auto)?Name|Summary):)[ '"\.]*$''',
1819 r'\1 ' + apk['name'],
1821 flags=re.IGNORECASE | re.MULTILINE)
1823 logging.warning(_('{appid} does not have a name! Using package name instead.')
1824 .format(appid=apk['packageName']))
1825 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1826 r'\1 ' + apk['packageName'],
1828 flags=re.IGNORECASE | re.MULTILINE)
1829 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1833 app['Categories'] = [os.path.basename(os.getcwd())]
1834 # include some blanks as part of the template
1835 app['AuthorName'] = ''
1838 app['IssueTracker'] = ''
1839 app['SourceCode'] = ''
1840 app['CurrentVersionCode'] = 2147483647 # Java's Integer.MAX_VALUE
1841 if 'name' in apk and apk['name'] != '':
1842 app['Name'] = apk['name']
1844 logging.warning(_('{appid} does not have a name! Using package name instead.')
1845 .format(appid=apk['packageName']))
1846 app['Name'] = apk['packageName']
1847 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1848 yaml.dump(app, f, default_flow_style=False)
1849 logging.info(_("Generated skeleton metadata for {appid}").format(appid=apk['packageName']))
1854 start_timestamp = time.gmtime()
1859 global config, options
1861 # Parse command line...
1862 parser = ArgumentParser()
1863 common.setup_global_opts(parser)
1864 parser.add_argument("--create-key", action="store_true", default=False,
1865 help=_("Add a repo signing key to an unsigned repo"))
1866 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1867 help=_("Add skeleton metadata files for APKs that are missing them"))
1868 parser.add_argument("--delete-unknown", action="store_true", default=False,
1869 help=_("Delete APKs and/or OBBs without metadata from the repo"))
1870 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1871 help=_("Report on build data status"))
1872 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1873 help=_("Interactively ask about things that need updating."))
1874 parser.add_argument("-I", "--icons", action="store_true", default=False,
1875 help=_("Resize all the icons exceeding the max pixel size and exit"))
1876 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1877 help=_("Specify editor to use in interactive mode. Default " +
1878 "is {path}").format(path='/etc/alternatives/editor'))
1879 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1880 help=_("Update the wiki"))
1881 parser.add_argument("--pretty", action="store_true", default=False,
1882 help=_("Produce human-readable XML/JSON for index files"))
1883 parser.add_argument("--clean", action="store_true", default=False,
1884 help=_("Clean update - don't uses caches, reprocess all APKs"))
1885 parser.add_argument("--nosign", action="store_true", default=False,
1886 help=_("When configured for signed indexes, create only unsigned indexes at this stage"))
1887 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1888 help=_("Use date from APK instead of current time for newly added APKs"))
1889 parser.add_argument("--rename-apks", action="store_true", default=False,
1890 help=_("Rename APK files that do not match package.name_123.apk"))
1891 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1892 help=_("Include APKs that are signed with disabled algorithms like MD5"))
1893 metadata.add_metadata_arguments(parser)
1894 options = parser.parse_args()
1895 metadata.warnings_action = options.W
1897 config = common.read_config(options)
1899 if not ('jarsigner' in config and 'keytool' in config):
1900 raise FDroidException(_('Java JDK not found! Install in standard location or set java_paths!'))
1903 if config['archive_older'] != 0:
1904 repodirs.append('archive')
1905 if not os.path.exists('archive'):
1909 resize_all_icons(repodirs)
1912 if options.rename_apks:
1913 options.clean = True
1915 # check that icons exist now, rather than fail at the end of `fdroid update`
1916 for k in ['repo_icon', 'archive_icon']:
1918 if not os.path.exists(config[k]):
1919 logging.critical(_('{name} "{path}" does not exist! Correct it in config.py.')
1920 .format(name=k, path=config[k]))
1923 # if the user asks to create a keystore, do it now, reusing whatever it can
1924 if options.create_key:
1925 if os.path.exists(config['keystore']):
1926 logging.critical(_("Cowardily refusing to overwrite existing signing key setup!"))
1927 logging.critical("\t'" + config['keystore'] + "'")
1930 if 'repo_keyalias' not in config:
1931 config['repo_keyalias'] = socket.getfqdn()
1932 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1933 if 'keydname' not in config:
1934 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1935 common.write_to_config(config, 'keydname', config['keydname'])
1936 if 'keystore' not in config:
1937 config['keystore'] = common.default_config['keystore']
1938 common.write_to_config(config, 'keystore', config['keystore'])
1940 password = common.genpassword()
1941 if 'keystorepass' not in config:
1942 config['keystorepass'] = password
1943 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1944 if 'keypass' not in config:
1945 config['keypass'] = password
1946 common.write_to_config(config, 'keypass', config['keypass'])
1947 common.genkeystore(config)
1950 apps = metadata.read_metadata()
1952 # Generate a list of categories...
1954 for app in apps.values():
1955 categories.update(app.Categories)
1957 # Read known apks data (will be updated and written back when we've finished)
1958 knownapks = common.KnownApks()
1961 apkcache = get_cache()
1963 # Delete builds for disabled apps
1964 delete_disabled_builds(apps, apkcache, repodirs)
1966 # Scan all apks in the main repo
1967 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1969 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1970 options.use_date_from_apk)
1971 cachechanged = cachechanged or fcachechanged
1974 if apk['packageName'] not in apps:
1975 if options.create_metadata:
1976 create_metadata_from_template(apk)
1977 apps = metadata.read_metadata()
1979 msg = _("{apkfilename} ({appid}) has no metadata!") \
1980 .format(apkfilename=apk['apkName'], appid=apk['packageName'])
1981 if options.delete_unknown:
1982 logging.warn(msg + '\n\t' + _("deleting: repo/{apkfilename}")
1983 .format(apkfilename=apk['apkName']))
1984 rmf = os.path.join(repodirs[0], apk['apkName'])
1985 if not os.path.exists(rmf):
1986 logging.error(_("Could not find {path} to remove it").format(path=rmf))
1990 logging.warn(msg + '\n\t' + _("Use `fdroid update -c` to create it."))
1992 copy_triple_t_store_metadata(apps)
1993 insert_obbs(repodirs[0], apps, apks)
1994 insert_localized_app_metadata(apps)
1995 translate_per_build_anti_features(apps, apks)
1997 # Scan the archive repo for apks as well
1998 if len(repodirs) > 1:
1999 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
2005 # Apply information from latest apks to the application and update dates
2006 apply_info_from_latest_apk(apps, apks + archapks)
2008 # Sort the app list by name, then the web site doesn't have to by default.
2009 # (we had to wait until we'd scanned the apks to do this, because mostly the
2010 # name comes from there!)
2011 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
2013 # APKs are placed into multiple repos based on the app package, providing
2014 # per-app subscription feeds for nightly builds and things like it
2015 if config['per_app_repos']:
2016 add_apks_to_per_app_repos(repodirs[0], apks)
2017 for appid, app in apps.items():
2018 repodir = os.path.join(appid, 'fdroid', 'repo')
2020 appdict[appid] = app
2021 if os.path.isdir(repodir):
2022 index.make(appdict, [appid], apks, repodir, False)
2024 logging.info(_('Skipping index generation for {appid}').format(appid=appid))
2027 if len(repodirs) > 1:
2028 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
2030 # Make the index for the main repo...
2031 index.make(apps, sortedids, apks, repodirs[0], False)
2032 make_categories_txt(repodirs[0], categories)
2034 # If there's an archive repo, make the index for it. We already scanned it
2036 if len(repodirs) > 1:
2037 index.make(apps, sortedids, archapks, repodirs[1], True)
2039 git_remote = config.get('binary_transparency_remote')
2040 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
2042 btlog.make_binary_transparency_log(repodirs)
2044 if config['update_stats']:
2045 # Update known apks info...
2046 knownapks.writeifchanged()
2048 # Generate latest apps data for widget
2049 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
2051 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
2053 appid = line.rstrip()
2054 data += appid + "\t"
2056 data += app.Name + "\t"
2057 if app.icon is not None:
2058 data += app.icon + "\t"
2059 data += app.License + "\n"
2060 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
2064 write_cache(apkcache)
2066 # Update the wiki...
2068 update_wiki(apps, sortedids, apks + archapks)
2070 logging.info(_("Finished"))
2073 if __name__ == "__main__":