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