chiark / gitweb /
ACCESS_SUPERUSER is now deprecated
[fdroidserver.git] / fdroidserver / update.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # update.py - part of the FDroid server tools
5 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import glob
25 import re
26 import zipfile
27 import hashlib
28 import pickle
29 from xml.dom.minidom import Document
30 from optparse import OptionParser
31 import time
32 from pyasn1.error import PyAsn1Error
33 from pyasn1.codec.der import decoder, encoder
34 from pyasn1_modules import rfc2315
35 from hashlib import md5
36 from binascii import hexlify, unhexlify
37
38 from PIL import Image
39 import logging
40
41 import common
42 import metadata
43 from common import FDroidPopen, SdkToolsPopen
44 from metadata import MetaDataException
45
46
47 def get_densities():
48     return ['640', '480', '320', '240', '160', '120']
49
50
51 def dpi_to_px(density):
52     return (int(density) * 48) / 160
53
54
55 def px_to_dpi(px):
56     return (int(px) * 160) / 48
57
58
59 def get_icon_dir(repodir, density):
60     if density is None:
61         return os.path.join(repodir, "icons")
62     return os.path.join(repodir, "icons-%s" % density)
63
64
65 def get_icon_dirs(repodir):
66     for density in get_densities():
67         yield get_icon_dir(repodir, density)
68     yield os.path.join(repodir, "icons")
69
70
71 def update_wiki(apps, sortedids, apks):
72     """Update the wiki
73
74     :param apps: fully populated list of all applications
75     :param apks: all apks, except...
76     """
77     logging.info("Updating wiki")
78     wikicat = 'Apps'
79     wikiredircat = 'App Redirects'
80     import mwclient
81     site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
82                          path=config['wiki_path'])
83     site.login(config['wiki_user'], config['wiki_password'])
84     generated_pages = {}
85     generated_redirects = {}
86
87     for appid in sortedids:
88         app = apps[appid]
89
90         wikidata = ''
91         if app['Disabled']:
92             wikidata += '{{Disabled|' + app['Disabled'] + '}}\n'
93         if app['AntiFeatures']:
94             for af in app['AntiFeatures'].split(','):
95                 wikidata += '{{AntiFeature|' + af + '}}\n'
96         wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|dogecoin=%s|license=%s|root=%s}}\n' % (
97             appid,
98             app['Name'],
99             time.strftime('%Y-%m-%d', app['added']) if 'added' in app else '',
100             time.strftime('%Y-%m-%d', app['lastupdated']) if 'lastupdated' in app else '',
101             app['Source Code'],
102             app['Issue Tracker'],
103             app['Web Site'],
104             app['Donate'],
105             app['FlattrID'],
106             app['Bitcoin'],
107             app['Litecoin'],
108             app['Dogecoin'],
109             app['License'],
110             app.get('Requires Root', 'No'))
111
112         if app['Provides']:
113             wikidata += "This app provides: %s" % ', '.join(app['Summary'].split(','))
114
115         wikidata += app['Summary']
116         wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
117
118         wikidata += "=Description=\n"
119         wikidata += metadata.description_wiki(app['Description']) + "\n"
120
121         wikidata += "=Maintainer Notes=\n"
122         if 'Maintainer Notes' in app:
123             wikidata += metadata.description_wiki(app['Maintainer Notes']) + "\n"
124         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)
125
126         # Get a list of all packages for this application...
127         apklist = []
128         gotcurrentver = False
129         cantupdate = False
130         buildfails = False
131         for apk in apks:
132             if apk['id'] == appid:
133                 if str(apk['versioncode']) == app['Current Version Code']:
134                     gotcurrentver = True
135                 apklist.append(apk)
136         # Include ones we can't build, as a special case...
137         for thisbuild in app['builds']:
138             if thisbuild['disable']:
139                 if thisbuild['vercode'] == app['Current Version Code']:
140                     cantupdate = True
141                 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
142                 apklist.append({'versioncode': int(thisbuild['vercode']),
143                                 'version': thisbuild['version'],
144                                 'buildproblem': thisbuild['disable']
145                                 })
146             else:
147                 builtit = False
148                 for apk in apklist:
149                     if apk['versioncode'] == int(thisbuild['vercode']):
150                         builtit = True
151                         break
152                 if not builtit:
153                     buildfails = True
154                     apklist.append({'versioncode': int(thisbuild['vercode']),
155                                     'version': thisbuild['version'],
156                                     'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, thisbuild['vercode'])
157                                     })
158         if app['Current Version Code'] == '0':
159             cantupdate = True
160         # Sort with most recent first...
161         apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
162
163         wikidata += "=Versions=\n"
164         if len(apklist) == 0:
165             wikidata += "We currently have no versions of this app available."
166         elif not gotcurrentver:
167             wikidata += "We don't have the current version of this app."
168         else:
169             wikidata += "We have the current version of this app."
170         wikidata += " (Check mode: " + app['Update Check Mode'] + ") "
171         wikidata += " (Auto-update mode: " + app['Auto Update Mode'] + ")\n\n"
172         if len(app['No Source Since']) > 0:
173             wikidata += "This application has partially or entirely been missing source code since version " + app['No Source Since'] + ".\n\n"
174         if len(app['Current Version']) > 0:
175             wikidata += "The current (recommended) version is " + app['Current Version']
176             wikidata += " (version code " + app['Current Version Code'] + ").\n\n"
177         validapks = 0
178         for apk in apklist:
179             wikidata += "==" + apk['version'] + "==\n"
180
181             if 'buildproblem' in apk:
182                 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
183             else:
184                 validapks += 1
185                 wikidata += "This version is built and signed by "
186                 if 'srcname' in apk:
187                     wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
188                 else:
189                     wikidata += "the original developer.\n\n"
190             wikidata += "Version code: " + str(apk['versioncode']) + '\n'
191
192         wikidata += '\n[[Category:' + wikicat + ']]\n'
193         if len(app['No Source Since']) > 0:
194             wikidata += '\n[[Category:Apps missing source code]]\n'
195         if validapks == 0 and not app['Disabled']:
196             wikidata += '\n[[Category:Apps with no packages]]\n'
197         if cantupdate and not app['Disabled']:
198             wikidata += "\n[[Category:Apps we can't update]]\n"
199         if buildfails and not app['Disabled']:
200             wikidata += "\n[[Category:Apps with failing builds]]\n"
201         elif not gotcurrentver and not cantupdate and not app['Disabled'] and app['Update Check Mode'] != "Static":
202             wikidata += '\n[[Category:Apps to Update]]\n'
203         if app['Disabled']:
204             wikidata += '\n[[Category:Apps that are disabled]]\n'
205         if app['Update Check Mode'] == 'None' and not app['Disabled']:
206             wikidata += '\n[[Category:Apps with no update check]]\n'
207         for appcat in app['Categories']:
208             wikidata += '\n[[Category:{0}]]\n'.format(appcat)
209
210         # We can't have underscores in the page name, even if they're in
211         # the package ID, because MediaWiki messes with them...
212         pagename = appid.replace('_', ' ')
213
214         # Drop a trailing newline, because mediawiki is going to drop it anyway
215         # and it we don't we'll think the page has changed when it hasn't...
216         if wikidata.endswith('\n'):
217             wikidata = wikidata[:-1]
218
219         generated_pages[pagename] = wikidata
220
221         # Make a redirect from the name to the ID too, unless there's
222         # already an existing page with the name and it isn't a redirect.
223         noclobber = False
224         apppagename = app['Name'].replace('_', ' ')
225         apppagename = apppagename.replace('{', '')
226         apppagename = apppagename.replace('}', ' ')
227         apppagename = apppagename.replace(':', ' ')
228         # Drop double spaces caused mostly by replacing ':' above
229         apppagename = apppagename.replace('  ', ' ')
230         for expagename in site.allpages(prefix=apppagename,
231                                         filterredir='nonredirects',
232                                         generator=False):
233             if expagename == apppagename:
234                 noclobber = True
235         # Another reason not to make the redirect page is if the app name
236         # is the same as it's ID, because that will overwrite the real page
237         # with an redirect to itself! (Although it seems like an odd
238         # scenario this happens a lot, e.g. where there is metadata but no
239         # builds or binaries to extract a name from.
240         if apppagename == pagename:
241             noclobber = True
242         if not noclobber:
243             generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
244
245     for tcat, genp in [(wikicat, generated_pages),
246                        (wikiredircat, generated_redirects)]:
247         catpages = site.Pages['Category:' + tcat]
248         existingpages = []
249         for page in catpages:
250             existingpages.append(page.name)
251             if page.name in genp:
252                 pagetxt = page.edit()
253                 if pagetxt != genp[page.name]:
254                     logging.debug("Updating modified page " + page.name)
255                     page.save(genp[page.name], summary='Auto-updated')
256                 else:
257                     logging.debug("Page " + page.name + " is unchanged")
258             else:
259                 logging.warn("Deleting page " + page.name)
260                 page.delete('No longer published')
261         for pagename, text in genp.items():
262             logging.debug("Checking " + pagename)
263             if pagename not in existingpages:
264                 logging.debug("Creating page " + pagename)
265                 try:
266                     newpage = site.Pages[pagename]
267                     newpage.save(text, summary='Auto-created')
268                 except:
269                     logging.error("...FAILED to create page '{0}'".format(pagename))
270
271     # Purge server cache to ensure counts are up to date
272     site.pages['Repository Maintenance'].purge()
273
274
275 def delete_disabled_builds(apps, apkcache, repodirs):
276     """Delete disabled build outputs.
277
278     :param apps: list of all applications, as per metadata.read_metadata
279     :param apkcache: current apk cache information
280     :param repodirs: the repo directories to process
281     """
282     for appid, app in apps.iteritems():
283         for build in app['builds']:
284             if build['disable']:
285                 apkfilename = appid + '_' + str(build['vercode']) + '.apk'
286                 for repodir in repodirs:
287                     apkpath = os.path.join(repodir, apkfilename)
288                     ascpath = apkpath + ".asc"
289                     srcpath = os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz")
290                     for name in [apkpath, srcpath, ascpath]:
291                         if os.path.exists(name):
292                             logging.warn("Deleting disabled build output " + apkfilename)
293                             os.remove(name)
294                 if apkfilename in apkcache:
295                     del apkcache[apkfilename]
296
297
298 def resize_icon(iconpath, density):
299
300     if not os.path.isfile(iconpath):
301         return
302
303     try:
304         im = Image.open(iconpath)
305         size = dpi_to_px(density)
306
307         if any(length > size for length in im.size):
308             oldsize = im.size
309             im.thumbnail((size, size), Image.ANTIALIAS)
310             logging.debug("%s was too large at %s - new size is %s" % (
311                 iconpath, oldsize, im.size))
312             im.save(iconpath, "PNG")
313
314     except Exception, e:
315         logging.error("Failed resizing {0} - {1}".format(iconpath, e))
316
317
318 def resize_all_icons(repodirs):
319     """Resize all icons that exceed the max size
320
321     :param repodirs: the repo directories to process
322     """
323     for repodir in repodirs:
324         for density in get_densities():
325             icon_dir = get_icon_dir(repodir, density)
326             icon_glob = os.path.join(icon_dir, '*.png')
327             for iconpath in glob.glob(icon_glob):
328                 resize_icon(iconpath, density)
329
330
331 cert_path_regex = re.compile(r'^META-INF/.*\.RSA$')
332
333
334 def getsig(apkpath):
335     """ Get the signing certificate of an apk. To get the same md5 has that
336     Android gets, we encode the .RSA certificate in a specific format and pass
337     it hex-encoded to the md5 digest algorithm.
338
339     :param apkpath: path to the apk
340     :returns: A string containing the md5 of the signature of the apk or None
341               if an error occurred.
342     """
343
344     cert = None
345
346     # verify the jar signature is correct
347     args = ['jarsigner', '-verify', apkpath]
348     p = FDroidPopen(args)
349     if p.returncode != 0:
350         logging.critical(apkpath + " has a bad signature!")
351         return None
352
353     with zipfile.ZipFile(apkpath, 'r') as apk:
354
355         certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
356
357         if len(certs) < 1:
358             logging.error("Found no signing certificates on %s" % apkpath)
359             return None
360         if len(certs) > 1:
361             logging.error("Found multiple signing certificates on %s" % apkpath)
362             return None
363
364         cert = apk.read(certs[0])
365
366     content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
367     if content.getComponentByName('contentType') != rfc2315.signedData:
368         logging.error("Unexpected format.")
369         return None
370
371     content = decoder.decode(content.getComponentByName('content'),
372                              asn1Spec=rfc2315.SignedData())[0]
373     try:
374         certificates = content.getComponentByName('certificates')
375     except PyAsn1Error:
376         logging.error("Certificates not found.")
377         return None
378
379     cert_encoded = encoder.encode(certificates)[4:]
380
381     return md5(cert_encoded.encode('hex')).hexdigest()
382
383
384 def scan_apks(apps, apkcache, repodir, knownapks):
385     """Scan the apks in the given repo directory.
386
387     This also extracts the icons.
388
389     :param apps: list of all applications, as per metadata.read_metadata
390     :param apkcache: current apk cache information
391     :param repodir: repo directory to scan
392     :param knownapks: known apks info
393     :returns: (apks, cachechanged) where apks is a list of apk information,
394               and cachechanged is True if the apkcache got changed.
395     """
396
397     cachechanged = False
398
399     icon_dirs = get_icon_dirs(repodir)
400     for icon_dir in icon_dirs:
401         if os.path.exists(icon_dir):
402             if options.clean:
403                 shutil.rmtree(icon_dir)
404                 os.makedirs(icon_dir)
405         else:
406             os.makedirs(icon_dir)
407
408     apks = []
409     name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
410     vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
411     vername_pat = re.compile(".*versionName='([^']*)'.*")
412     label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
413     icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
414     icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
415     sdkversion_pat = re.compile(".*'([0-9]*)'.*")
416     string_pat = re.compile(".*'([^']*)'.*")
417     for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
418
419         apkfilename = apkfile[len(repodir) + 1:]
420         if ' ' in apkfilename:
421             logging.critical("Spaces in filenames are not allowed.")
422             sys.exit(1)
423
424         # Calculate the sha256...
425         sha = hashlib.sha256()
426         with open(apkfile, 'rb') as f:
427             while True:
428                 t = f.read(16384)
429                 if len(t) == 0:
430                     break
431                 sha.update(t)
432             shasum = sha.hexdigest()
433
434         usecache = False
435         if apkfilename in apkcache:
436             thisinfo = apkcache[apkfilename]
437             if thisinfo['sha256'] == shasum:
438                 logging.debug("Reading " + apkfilename + " from cache")
439                 usecache = True
440             else:
441                 logging.debug("Ignoring stale cache data for " + apkfilename)
442
443         if not usecache:
444             logging.debug("Processing " + apkfilename)
445             thisinfo = {}
446             thisinfo['apkname'] = apkfilename
447             thisinfo['sha256'] = shasum
448             srcfilename = apkfilename[:-4] + "_src.tar.gz"
449             if os.path.exists(os.path.join(repodir, srcfilename)):
450                 thisinfo['srcname'] = srcfilename
451             thisinfo['size'] = os.path.getsize(apkfile)
452             thisinfo['permissions'] = set()
453             thisinfo['features'] = set()
454             thisinfo['icons_src'] = {}
455             thisinfo['icons'] = {}
456             p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
457             if p.returncode != 0:
458                 if options.delete_unknown:
459                     if os.path.exists(apkfile):
460                         logging.error("Failed to get apk information, deleting " + apkfile)
461                         os.remove(apkfile)
462                     else:
463                         logging.error("Could not find {0} to remove it".format(apkfile))
464                 else:
465                     logging.error("Failed to get apk information, skipping " + apkfile)
466                 continue
467             for line in p.output.splitlines():
468                 if line.startswith("package:"):
469                     try:
470                         thisinfo['id'] = re.match(name_pat, line).group(1)
471                         thisinfo['versioncode'] = int(re.match(vercode_pat, line).group(1))
472                         thisinfo['version'] = re.match(vername_pat, line).group(1)
473                     except Exception, e:
474                         logging.error("Package matching failed: " + str(e))
475                         logging.info("Line was: " + line)
476                         sys.exit(1)
477                 elif line.startswith("application:"):
478                     thisinfo['name'] = re.match(label_pat, line).group(1)
479                     # Keep path to non-dpi icon in case we need it
480                     match = re.match(icon_pat_nodpi, line)
481                     if match:
482                         thisinfo['icons_src']['-1'] = match.group(1)
483                 elif line.startswith("launchable-activity:"):
484                     # Only use launchable-activity as fallback to application
485                     if not thisinfo['name']:
486                         thisinfo['name'] = re.match(label_pat, line).group(1)
487                     if '-1' not in thisinfo['icons_src']:
488                         match = re.match(icon_pat_nodpi, line)
489                         if match:
490                             thisinfo['icons_src']['-1'] = match.group(1)
491                 elif line.startswith("application-icon-"):
492                     match = re.match(icon_pat, line)
493                     if match:
494                         density = match.group(1)
495                         path = match.group(2)
496                         thisinfo['icons_src'][density] = path
497                 elif line.startswith("sdkVersion:"):
498                     m = re.match(sdkversion_pat, line)
499                     if m is None:
500                         logging.error(line.replace('sdkVersion:', '')
501                                       + ' is not a valid minSdkVersion!')
502                     else:
503                         thisinfo['sdkversion'] = m.group(1)
504                 elif line.startswith("maxSdkVersion:"):
505                     thisinfo['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
506                 elif line.startswith("native-code:"):
507                     thisinfo['nativecode'] = []
508                     for arch in line[13:].split(' '):
509                         thisinfo['nativecode'].append(arch[1:-1])
510                 elif line.startswith("uses-permission:"):
511                     perm = re.match(string_pat, line).group(1)
512                     if perm.startswith("android.permission."):
513                         perm = perm[19:]
514                     thisinfo['permissions'].add(perm)
515                 elif line.startswith("uses-feature:"):
516                     perm = re.match(string_pat, line).group(1)
517                     # Filter out this, it's only added with the latest SDK tools and
518                     # causes problems for lots of apps.
519                     if perm != "android.hardware.screen.portrait" \
520                             and perm != "android.hardware.screen.landscape":
521                         if perm.startswith("android.feature."):
522                             perm = perm[16:]
523                         thisinfo['features'].add(perm)
524
525             if 'sdkversion' not in thisinfo:
526                 logging.warn("No SDK version information found in {0}".format(apkfile))
527                 thisinfo['sdkversion'] = 0
528
529             # Check for debuggable apks...
530             if common.isApkDebuggable(apkfile, config):
531                 logging.warn('{0} is set to android:debuggable="true"'.format(apkfile))
532
533             # Get the signature (or md5 of, to be precise)...
534             logging.debug('Getting signature of {0}'.format(apkfile))
535             thisinfo['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
536             if not thisinfo['sig']:
537                 logging.critical("Failed to get apk signature")
538                 sys.exit(1)
539
540             apk = zipfile.ZipFile(apkfile, 'r')
541
542             iconfilename = "%s.%s.png" % (
543                 thisinfo['id'],
544                 thisinfo['versioncode'])
545
546             # Extract the icon file...
547             densities = get_densities()
548             empty_densities = []
549             for density in densities:
550                 if density not in thisinfo['icons_src']:
551                     empty_densities.append(density)
552                     continue
553                 iconsrc = thisinfo['icons_src'][density]
554                 icon_dir = get_icon_dir(repodir, density)
555                 icondest = os.path.join(icon_dir, iconfilename)
556
557                 try:
558                     iconfile = open(icondest, 'wb')
559                     iconfile.write(apk.read(iconsrc))
560                     iconfile.close()
561                     thisinfo['icons'][density] = iconfilename
562
563                 except:
564                     logging.warn("Error retrieving icon file")
565                     del thisinfo['icons'][density]
566                     del thisinfo['icons_src'][density]
567                     empty_densities.append(density)
568
569             if '-1' in thisinfo['icons_src']:
570                 iconsrc = thisinfo['icons_src']['-1']
571                 iconpath = os.path.join(
572                     get_icon_dir(repodir, None), iconfilename)
573                 iconfile = open(iconpath, 'wb')
574                 iconfile.write(apk.read(iconsrc))
575                 iconfile.close()
576                 try:
577                     im = Image.open(iconpath)
578                     dpi = px_to_dpi(im.size[0])
579                     for density in densities:
580                         if density in thisinfo['icons']:
581                             break
582                         if density == densities[-1] or dpi >= int(density):
583                             thisinfo['icons'][density] = iconfilename
584                             shutil.move(iconpath,
585                                         os.path.join(get_icon_dir(repodir, density), iconfilename))
586                             empty_densities.remove(density)
587                             break
588                 except Exception, e:
589                     logging.warn("Failed reading {0} - {1}".format(iconpath, e))
590
591             if thisinfo['icons']:
592                 thisinfo['icon'] = iconfilename
593
594             apk.close()
595
596             # First try resizing down to not lose quality
597             last_density = None
598             for density in densities:
599                 if density not in empty_densities:
600                     last_density = density
601                     continue
602                 if last_density is None:
603                     continue
604                 logging.debug("Density %s not available, resizing down from %s"
605                               % (density, last_density))
606
607                 last_iconpath = os.path.join(
608                     get_icon_dir(repodir, last_density), iconfilename)
609                 iconpath = os.path.join(
610                     get_icon_dir(repodir, density), iconfilename)
611                 try:
612                     im = Image.open(last_iconpath)
613                 except:
614                     logging.warn("Invalid image file at %s" % last_iconpath)
615                     continue
616
617                 size = dpi_to_px(density)
618
619                 im.thumbnail((size, size), Image.ANTIALIAS)
620                 im.save(iconpath, "PNG")
621                 empty_densities.remove(density)
622
623             # Then just copy from the highest resolution available
624             last_density = None
625             for density in reversed(densities):
626                 if density not in empty_densities:
627                     last_density = density
628                     continue
629                 if last_density is None:
630                     continue
631                 logging.debug("Density %s not available, copying from lower density %s"
632                               % (density, last_density))
633
634                 shutil.copyfile(
635                     os.path.join(get_icon_dir(repodir, last_density), iconfilename),
636                     os.path.join(get_icon_dir(repodir, density), iconfilename))
637
638                 empty_densities.remove(density)
639
640             for density in densities:
641                 icon_dir = get_icon_dir(repodir, density)
642                 icondest = os.path.join(icon_dir, iconfilename)
643                 resize_icon(icondest, density)
644
645             # Copy from icons-mdpi to icons since mdpi is the baseline density
646             baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
647             if os.path.isfile(baseline):
648                 shutil.copyfile(baseline,
649                                 os.path.join(get_icon_dir(repodir, None), iconfilename))
650
651             # Record in known apks, getting the added date at the same time..
652             added = knownapks.recordapk(thisinfo['apkname'], thisinfo['id'])
653             if added:
654                 thisinfo['added'] = added
655
656             apkcache[apkfilename] = thisinfo
657             cachechanged = True
658
659         apks.append(thisinfo)
660
661     return apks, cachechanged
662
663
664 repo_pubkey_fingerprint = None
665
666
667 def make_index(apps, sortedids, apks, repodir, archive, categories):
668     """Make a repo index.
669
670     :param apps: fully populated apps list
671     :param apks: full populated apks list
672     :param repodir: the repo directory
673     :param archive: True if this is the archive repo, False if it's the
674                     main one.
675     :param categories: list of categories
676     """
677
678     doc = Document()
679
680     def addElement(name, value, doc, parent):
681         el = doc.createElement(name)
682         el.appendChild(doc.createTextNode(value))
683         parent.appendChild(el)
684
685     def addElementCDATA(name, value, doc, parent):
686         el = doc.createElement(name)
687         el.appendChild(doc.createCDATASection(value))
688         parent.appendChild(el)
689
690     root = doc.createElement("fdroid")
691     doc.appendChild(root)
692
693     repoel = doc.createElement("repo")
694
695     if archive:
696         repoel.setAttribute("name", config['archive_name'])
697         if config['repo_maxage'] != 0:
698             repoel.setAttribute("maxage", str(config['repo_maxage']))
699         repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
700         repoel.setAttribute("url", config['archive_url'])
701         addElement('description', config['archive_description'], doc, repoel)
702
703     else:
704         repoel.setAttribute("name", config['repo_name'])
705         if config['repo_maxage'] != 0:
706             repoel.setAttribute("maxage", str(config['repo_maxage']))
707         repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
708         repoel.setAttribute("url", config['repo_url'])
709         addElement('description', config['repo_description'], doc, repoel)
710
711     repoel.setAttribute("version", "12")
712     repoel.setAttribute("timestamp", str(int(time.time())))
713
714     if 'repo_keyalias' in config:
715
716         # Generate a certificate fingerprint the same way keytool does it
717         # (but with slightly different formatting)
718         def cert_fingerprint(data):
719             digest = hashlib.sha256(data).digest()
720             ret = []
721             ret.append(' '.join("%02X" % ord(b) for b in digest))
722             return " ".join(ret)
723
724         def extract_pubkey():
725             global repo_pubkey_fingerprint
726             if 'repo_pubkey' in config:
727                 pubkey = unhexlify(config['repo_pubkey'])
728             else:
729                 p = FDroidPopen(['keytool', '-exportcert',
730                                  '-alias', config['repo_keyalias'],
731                                  '-keystore', config['keystore'],
732                                  '-storepass:file', config['keystorepassfile']]
733                                 + config['smartcardoptions'], output=False)
734                 if p.returncode != 0:
735                     msg = "Failed to get repo pubkey!"
736                     if config['keystore'] == 'NONE':
737                         msg += ' Is your crypto smartcard plugged in?'
738                     logging.critical(msg)
739                     sys.exit(1)
740                 pubkey = p.output
741             repo_pubkey_fingerprint = cert_fingerprint(pubkey)
742             return hexlify(pubkey)
743
744         repoel.setAttribute("pubkey", extract_pubkey())
745
746     root.appendChild(repoel)
747
748     for appid in sortedids:
749         app = apps[appid]
750
751         if app['Disabled'] is not None:
752             continue
753
754         # Get a list of the apks for this app...
755         apklist = []
756         for apk in apks:
757             if apk['id'] == appid:
758                 apklist.append(apk)
759
760         if len(apklist) == 0:
761             continue
762
763         apel = doc.createElement("application")
764         apel.setAttribute("id", app['id'])
765         root.appendChild(apel)
766
767         addElement('id', app['id'], doc, apel)
768         if 'added' in app:
769             addElement('added', time.strftime('%Y-%m-%d', app['added']), doc, apel)
770         if 'lastupdated' in app:
771             addElement('lastupdated', time.strftime('%Y-%m-%d', app['lastupdated']), doc, apel)
772         addElement('name', app['Name'], doc, apel)
773         addElement('summary', app['Summary'], doc, apel)
774         if app['icon']:
775             addElement('icon', app['icon'], doc, apel)
776
777         def linkres(appid):
778             if appid in apps:
779                 return ("fdroid.app:" + appid, apps[appid]['Name'])
780             raise MetaDataException("Cannot resolve app id " + appid)
781
782         addElement('desc',
783                    metadata.description_html(app['Description'], linkres),
784                    doc, apel)
785         addElement('license', app['License'], doc, apel)
786         if 'Categories' in app:
787             addElement('categories', ','.join(app["Categories"]), doc, apel)
788             # We put the first (primary) category in LAST, which will have
789             # the desired effect of making clients that only understand one
790             # category see that one.
791             addElement('category', app["Categories"][0], doc, apel)
792         addElement('web', app['Web Site'], doc, apel)
793         addElement('source', app['Source Code'], doc, apel)
794         addElement('tracker', app['Issue Tracker'], doc, apel)
795         if app['Donate']:
796             addElement('donate', app['Donate'], doc, apel)
797         if app['Bitcoin']:
798             addElement('bitcoin', app['Bitcoin'], doc, apel)
799         if app['Litecoin']:
800             addElement('litecoin', app['Litecoin'], doc, apel)
801         if app['Dogecoin']:
802             addElement('dogecoin', app['Dogecoin'], doc, apel)
803         if app['FlattrID']:
804             addElement('flattr', app['FlattrID'], doc, apel)
805
806         # These elements actually refer to the current version (i.e. which
807         # one is recommended. They are historically mis-named, and need
808         # changing, but stay like this for now to support existing clients.
809         addElement('marketversion', app['Current Version'], doc, apel)
810         addElement('marketvercode', app['Current Version Code'], doc, apel)
811
812         if app['AntiFeatures']:
813             af = app['AntiFeatures'].split(',')
814             # TODO: Temporarily not including UpstreamNonFree in the index,
815             # because current F-Droid clients do not understand it, and also
816             # look ugly when they encounter an unknown antifeature. This
817             # filtering can be removed in time...
818             if 'UpstreamNonFree' in af:
819                 af.remove('UpstreamNonFree')
820             if af:
821                 addElement('antifeatures', ','.join(af), doc, apel)
822         if app['Provides']:
823             pv = app['Provides'].split(',')
824             addElement('provides', ','.join(pv), doc, apel)
825         if app['Requires Root']:
826             addElement('requirements', 'root', doc, apel)
827
828         # Sort the apk list into version order, just so the web site
829         # doesn't have to do any work by default...
830         apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
831
832         # Check for duplicates - they will make the client unhappy...
833         for i in range(len(apklist) - 1):
834             if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
835                 logging.critical("duplicate versions: '%s' - '%s'" % (
836                     apklist[i]['apkname'], apklist[i + 1]['apkname']))
837                 sys.exit(1)
838
839         current_version_code = 0
840         current_version_file = None
841         for apk in apklist:
842             # find the APK for the "Current Version"
843             if current_version_code < apk['versioncode']:
844                 current_version_code = apk['versioncode']
845             if current_version_code < int(app['Current Version Code']):
846                 current_version_file = apk['apkname']
847
848             apkel = doc.createElement("package")
849             apel.appendChild(apkel)
850             addElement('version', apk['version'], doc, apkel)
851             addElement('versioncode', str(apk['versioncode']), doc, apkel)
852             addElement('apkname', apk['apkname'], doc, apkel)
853             if 'srcname' in apk:
854                 addElement('srcname', apk['srcname'], doc, apkel)
855             for hash_type in ['sha256']:
856                 if hash_type not in apk:
857                     continue
858                 hashel = doc.createElement("hash")
859                 hashel.setAttribute("type", hash_type)
860                 hashel.appendChild(doc.createTextNode(apk[hash_type]))
861                 apkel.appendChild(hashel)
862             addElement('sig', apk['sig'], doc, apkel)
863             addElement('size', str(apk['size']), doc, apkel)
864             addElement('sdkver', str(apk['sdkversion']), doc, apkel)
865             if 'maxsdkversion' in apk:
866                 addElement('maxsdkver', str(apk['maxsdkversion']), doc, apkel)
867             if 'added' in apk:
868                 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
869             if len(apk['permissions']) > 0:
870                 addElement('permissions', ','.join(apk['permissions']), doc, apkel)
871             if 'nativecode' in apk and len(apk['nativecode']) > 0:
872                 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
873             if len(apk['features']) > 0:
874                 addElement('features', ','.join(apk['features']), doc, apkel)
875
876         if current_version_file is not None \
877                 and config['make_current_version_link'] \
878                 and repodir == 'repo':  # only create these
879             sanitized_name = re.sub('''[ '"&%?+=/]''', '',
880                                     app[config['current_version_name_source']])
881             apklinkname = sanitized_name + '.apk'
882             current_version_path = os.path.join(repodir, current_version_file)
883             if os.path.exists(apklinkname):
884                 os.remove(apklinkname)
885             os.symlink(current_version_path, apklinkname)
886             # also symlink gpg signature, if it exists
887             for extension in ('.asc', '.sig'):
888                 sigfile_path = current_version_path + extension
889                 if os.path.exists(sigfile_path):
890                     siglinkname = apklinkname + extension
891                     if os.path.exists(siglinkname):
892                         os.remove(siglinkname)
893                     os.symlink(sigfile_path, siglinkname)
894
895     of = open(os.path.join(repodir, 'index.xml'), 'wb')
896     if options.pretty:
897         output = doc.toprettyxml()
898     else:
899         output = doc.toxml()
900     of.write(output)
901     of.close()
902
903     if 'repo_keyalias' in config:
904
905         if options.nosign:
906             logging.info("Creating unsigned index in preparation for signing")
907         else:
908             logging.info("Creating signed index with this key (SHA256):")
909             logging.info("%s" % repo_pubkey_fingerprint)
910
911         # Create a jar of the index...
912         jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
913         p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
914         if p.returncode != 0:
915             logging.critical("Failed to create {0}".format(jar_output))
916             sys.exit(1)
917
918         # Sign the index...
919         signed = os.path.join(repodir, 'index.jar')
920         if options.nosign:
921             # Remove old signed index if not signing
922             if os.path.exists(signed):
923                 os.remove(signed)
924         else:
925             args = ['jarsigner', '-keystore', config['keystore'],
926                     '-storepass:file', config['keystorepassfile'],
927                     '-digestalg', 'SHA1', '-sigalg', 'MD5withRSA',
928                     signed, config['repo_keyalias']]
929             if config['keystore'] == 'NONE':
930                 args += config['smartcardoptions']
931             else:  # smardcards never use -keypass
932                 args += ['-keypass:file', config['keypassfile']]
933             p = FDroidPopen(args)
934             if p.returncode != 0:
935                 logging.critical("Failed to sign index")
936                 sys.exit(1)
937
938     # Copy the repo icon into the repo directory...
939     icon_dir = os.path.join(repodir, 'icons')
940     iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
941     shutil.copyfile(config['repo_icon'], iconfilename)
942
943     # Write a category list in the repo to allow quick access...
944     catdata = ''
945     for cat in categories:
946         catdata += cat + '\n'
947     f = open(os.path.join(repodir, 'categories.txt'), 'w')
948     f.write(catdata)
949     f.close()
950
951
952 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
953
954     for appid, app in apps.iteritems():
955
956         # Get a list of the apks for this app...
957         apklist = []
958         for apk in apks:
959             if apk['id'] == appid:
960                 apklist.append(apk)
961
962         # Sort the apk list into version order...
963         apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
964
965         if app['Archive Policy']:
966             keepversions = int(app['Archive Policy'][:-9])
967         else:
968             keepversions = defaultkeepversions
969
970         if len(apklist) > keepversions:
971             for apk in apklist[keepversions:]:
972                 logging.info("Moving " + apk['apkname'] + " to archive")
973                 shutil.move(os.path.join(repodir, apk['apkname']),
974                             os.path.join(archivedir, apk['apkname']))
975                 if 'srcname' in apk:
976                     shutil.move(os.path.join(repodir, apk['srcname']),
977                                 os.path.join(archivedir, apk['srcname']))
978                     # Move GPG signature too...
979                     sigfile = apk['srcname'] + '.asc'
980                     sigsrc = os.path.join(repodir, sigfile)
981                     if os.path.exists(sigsrc):
982                         shutil.move(sigsrc, os.path.join(archivedir, sigfile))
983
984                 archapks.append(apk)
985                 apks.remove(apk)
986
987
988 config = None
989 options = None
990
991
992 def main():
993
994     global config, options
995
996     # Parse command line...
997     parser = OptionParser()
998     parser.add_option("-c", "--create-metadata", action="store_true", default=False,
999                       help="Create skeleton metadata files that are missing")
1000     parser.add_option("--delete-unknown", action="store_true", default=False,
1001                       help="Delete APKs without metadata from the repo")
1002     parser.add_option("-v", "--verbose", action="store_true", default=False,
1003                       help="Spew out even more information than normal")
1004     parser.add_option("-q", "--quiet", action="store_true", default=False,
1005                       help="Restrict output to warnings and errors")
1006     parser.add_option("-b", "--buildreport", action="store_true", default=False,
1007                       help="Report on build data status")
1008     parser.add_option("-i", "--interactive", default=False, action="store_true",
1009                       help="Interactively ask about things that need updating.")
1010     parser.add_option("-I", "--icons", action="store_true", default=False,
1011                       help="Resize all the icons exceeding the max pixel size and exit")
1012     parser.add_option("-e", "--editor", default="/etc/alternatives/editor",
1013                       help="Specify editor to use in interactive mode. Default " +
1014                       "is /etc/alternatives/editor")
1015     parser.add_option("-w", "--wiki", default=False, action="store_true",
1016                       help="Update the wiki")
1017     parser.add_option("", "--pretty", action="store_true", default=False,
1018                       help="Produce human-readable index.xml")
1019     parser.add_option("--clean", action="store_true", default=False,
1020                       help="Clean update - don't uses caches, reprocess all apks")
1021     parser.add_option("--nosign", action="store_true", default=False,
1022                       help="When configured for signed indexes, create only unsigned indexes at this stage")
1023     (options, args) = parser.parse_args()
1024
1025     config = common.read_config(options)
1026
1027     repodirs = ['repo']
1028     if config['archive_older'] != 0:
1029         repodirs.append('archive')
1030         if not os.path.exists('archive'):
1031             os.mkdir('archive')
1032
1033     if options.icons:
1034         resize_all_icons(repodirs)
1035         sys.exit(0)
1036
1037     # check that icons exist now, rather than fail at the end of `fdroid update`
1038     for k in ['repo_icon', 'archive_icon']:
1039         if k in config:
1040             if not os.path.exists(config[k]):
1041                 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1042                 sys.exit(1)
1043
1044     # Get all apps...
1045     apps = metadata.read_metadata()
1046
1047     # Generate a list of categories...
1048     categories = set()
1049     for app in apps.itervalues():
1050         categories.update(app['Categories'])
1051
1052     # Read known apks data (will be updated and written back when we've finished)
1053     knownapks = common.KnownApks()
1054
1055     # Gather information about all the apk files in the repo directory, using
1056     # cached data if possible.
1057     apkcachefile = os.path.join('tmp', 'apkcache')
1058     if not options.clean and os.path.exists(apkcachefile):
1059         with open(apkcachefile, 'rb') as cf:
1060             apkcache = pickle.load(cf)
1061     else:
1062         apkcache = {}
1063     cachechanged = False
1064
1065     delete_disabled_builds(apps, apkcache, repodirs)
1066
1067     # Scan all apks in the main repo
1068     apks, cc = scan_apks(apps, apkcache, repodirs[0], knownapks)
1069     if cc:
1070         cachechanged = True
1071
1072     # Generate warnings for apk's with no metadata (or create skeleton
1073     # metadata files, if requested on the command line)
1074     newmetadata = False
1075     for apk in apks:
1076         if apk['id'] not in apps:
1077             if options.create_metadata:
1078                 if 'name' not in apk:
1079                     logging.error(apk['id'] + ' does not have a name! Skipping...')
1080                     continue
1081                 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w')
1082                 f.write("License:Unknown\n")
1083                 f.write("Web Site:\n")
1084                 f.write("Source Code:\n")
1085                 f.write("Issue Tracker:\n")
1086                 f.write("Summary:" + apk['name'] + "\n")
1087                 f.write("Description:\n")
1088                 f.write(apk['name'] + "\n")
1089                 f.write(".\n")
1090                 f.close()
1091                 logging.info("Generated skeleton metadata for " + apk['id'])
1092                 newmetadata = True
1093             else:
1094                 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1095                 if options.delete_unknown:
1096                     logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1097                     rmf = os.path.join(repodirs[0], apk['apkname'])
1098                     if not os.path.exists(rmf):
1099                         logging.error("Could not find {0} to remove it".format(rmf))
1100                     else:
1101                         os.remove(rmf)
1102                 else:
1103                     logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1104
1105     # update the metadata with the newly created ones included
1106     if newmetadata:
1107         apps = metadata.read_metadata()
1108
1109     # Scan the archive repo for apks as well
1110     if len(repodirs) > 1:
1111         archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks)
1112         if cc:
1113             cachechanged = True
1114     else:
1115         archapks = []
1116
1117     # Some information from the apks needs to be applied up to the application
1118     # level. When doing this, we use the info from the most recent version's apk.
1119     # We deal with figuring out when the app was added and last updated at the
1120     # same time.
1121     for appid, app in apps.iteritems():
1122         bestver = 0
1123         added = None
1124         lastupdated = None
1125         for apk in apks + archapks:
1126             if apk['id'] == appid:
1127                 if apk['versioncode'] > bestver:
1128                     bestver = apk['versioncode']
1129                     bestapk = apk
1130
1131                 if 'added' in apk:
1132                     if not added or apk['added'] < added:
1133                         added = apk['added']
1134                     if not lastupdated or apk['added'] > lastupdated:
1135                         lastupdated = apk['added']
1136
1137         if added:
1138             app['added'] = added
1139         else:
1140             logging.warn("Don't know when " + appid + " was added")
1141         if lastupdated:
1142             app['lastupdated'] = lastupdated
1143         else:
1144             logging.warn("Don't know when " + appid + " was last updated")
1145
1146         if bestver == 0:
1147             if app['Name'] is None:
1148                 app['Name'] = app['Auto Name'] or appid
1149             app['icon'] = None
1150             logging.warn("Application " + appid + " has no packages")
1151         else:
1152             if app['Name'] is None:
1153                 app['Name'] = bestapk['name']
1154             app['icon'] = bestapk['icon'] if 'icon' in bestapk else None
1155
1156     # Sort the app list by name, then the web site doesn't have to by default.
1157     # (we had to wait until we'd scanned the apks to do this, because mostly the
1158     # name comes from there!)
1159     sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid]['Name'].upper())
1160
1161     if len(repodirs) > 1:
1162         archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1163
1164     # Make the index for the main repo...
1165     make_index(apps, sortedids, apks, repodirs[0], False, categories)
1166
1167     # If there's an archive repo,  make the index for it. We already scanned it
1168     # earlier on.
1169     if len(repodirs) > 1:
1170         make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1171
1172     if config['update_stats']:
1173
1174         # Update known apks info...
1175         knownapks.writeifchanged()
1176
1177         # Generate latest apps data for widget
1178         if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1179             data = ''
1180             for line in file(os.path.join('stats', 'latestapps.txt')):
1181                 appid = line.rstrip()
1182                 data += appid + "\t"
1183                 app = apps[appid]
1184                 data += app['Name'] + "\t"
1185                 if app['icon'] is not None:
1186                     data += app['icon'] + "\t"
1187                 data += app['License'] + "\n"
1188             f = open(os.path.join(repodirs[0], 'latestapps.dat'), 'w')
1189             f.write(data)
1190             f.close()
1191
1192     if cachechanged:
1193         with open(apkcachefile, 'wb') as cf:
1194             pickle.dump(apkcache, cf)
1195
1196     # Update the wiki...
1197     if options.wiki:
1198         update_wiki(apps, sortedids, apks + archapks)
1199
1200     logging.info("Finished.")
1201
1202 if __name__ == "__main__":
1203     main()