chiark / gitweb /
Merge branch 'update-vagrantfile' into 'master'
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # checkupdates.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, 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 re
24 import urllib2
25 import time
26 import subprocess
27 from optparse import OptionParser
28 import traceback
29 import HTMLParser
30 from distutils.version import LooseVersion
31 import logging
32
33 import common
34 import metadata
35 from common import VCSException, FDroidException
36 from metadata import MetaDataException
37
38
39 # Check for a new version by looking at a document retrieved via HTTP.
40 # The app's Update Check Data field is used to provide the information
41 # required.
42 def check_http(app):
43
44     try:
45
46         if 'Update Check Data' not in app:
47             raise FDroidException('Missing Update Check Data')
48
49         urlcode, codeex, urlver, verex = app['Update Check Data'].split('|')
50
51         vercode = "99999999"
52         if len(urlcode) > 0:
53             logging.debug("...requesting {0}".format(urlcode))
54             req = urllib2.Request(urlcode, None)
55             resp = urllib2.urlopen(req, None, 20)
56             page = resp.read()
57
58             m = re.search(codeex, page)
59             if not m:
60                 raise FDroidException("No RE match for version code")
61             vercode = m.group(1)
62
63         version = "??"
64         if len(urlver) > 0:
65             if urlver != '.':
66                 logging.debug("...requesting {0}".format(urlver))
67                 req = urllib2.Request(urlver, None)
68                 resp = urllib2.urlopen(req, None, 20)
69                 page = resp.read()
70
71             m = re.search(verex, page)
72             if not m:
73                 raise FDroidException("No RE match for version")
74             version = m.group(1)
75
76         return (version, vercode)
77
78     except FDroidException:
79         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
80         return (None, msg)
81
82
83 # Check for a new version by looking at the tags in the source repo.
84 # Whether this can be used reliably or not depends on
85 # the development procedures used by the project's developers. Use it with
86 # caution, because it's inappropriate for many projects.
87 # Returns (None, "a message") if this didn't work, or (version, vercode) for
88 # the details of the current version.
89 def check_tags(app, pattern):
90
91     try:
92
93         appid = app['Update Check Name'] or app['id']
94         if app['Repo Type'] == 'srclib':
95             build_dir = os.path.join('build', 'srclib', app['Repo'])
96             repotype = common.getsrclibvcs(app['Repo'])
97         else:
98             build_dir = os.path.join('build/', app['id'])
99             repotype = app['Repo Type']
100
101         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
102             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
103
104         if repotype == 'git-svn' and ';' not in app['Repo']:
105             return (None, 'Tags update mode used in git-svn, but the repo was not set up with tags', None)
106
107         # Set up vcs interface and make sure we have the latest code...
108         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
109
110         vcs.gotorevision(None)
111
112         flavours = []
113         if len(app['builds']) > 0:
114             if app['builds'][-1]['subdir']:
115                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
116             if app['builds'][-1]['gradle']:
117                 flavours = app['builds'][-1]['gradle']
118
119         hpak = None
120         htag = None
121         hver = None
122         hcode = "0"
123
124         tags = vcs.gettags()
125         if pattern:
126             pat = re.compile(pattern)
127             tags = [tag for tag in tags if pat.match(tag)]
128
129         if repotype in ('git',):
130             tags = vcs.latesttags(tags, 5)
131
132         for tag in tags:
133             logging.debug("Check tag: '{0}'".format(tag))
134             vcs.gotorevision(tag)
135
136             # Only process tags where the manifest exists...
137             paths = common.manifest_paths(build_dir, flavours)
138             version, vercode, package = \
139                 common.parse_androidmanifests(paths, app['Update Check Ignore'])
140             if not package or package != appid or not version or not vercode:
141                 continue
142
143             logging.debug("Manifest exists. Found version {0} ({1})"
144                           .format(version, vercode))
145             if int(vercode) > int(hcode):
146                 hpak = package
147                 htag = tag
148                 hcode = str(int(vercode))
149                 hver = version
150
151         if not hpak:
152             return (None, "Couldn't find package ID", None)
153         if hver:
154             return (hver, hcode, htag)
155         return (None, "Couldn't find any version information", None)
156
157     except VCSException as vcse:
158         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
159         return (None, msg, None)
160     except Exception:
161         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
162         return (None, msg, None)
163
164
165 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
166 # of the source repo. Whether this can be used reliably or not depends on
167 # the development procedures used by the project's developers. Use it with
168 # caution, because it's inappropriate for many projects.
169 # Returns (None, "a message") if this didn't work, or (version, vercode) for
170 # the details of the current version.
171 def check_repomanifest(app, branch=None):
172
173     try:
174
175         appid = app['Update Check Name'] or app['id']
176         if app['Repo Type'] == 'srclib':
177             build_dir = os.path.join('build', 'srclib', app['Repo'])
178             repotype = common.getsrclibvcs(app['Repo'])
179         else:
180             build_dir = os.path.join('build/', app['id'])
181             repotype = app['Repo Type']
182
183         # Set up vcs interface and make sure we have the latest code...
184         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
185
186         if repotype == 'git':
187             if branch:
188                 branch = 'origin/' + branch
189             vcs.gotorevision(branch)
190         elif repotype == 'git-svn':
191             vcs.gotorevision(branch)
192         elif repotype == 'hg':
193             vcs.gotorevision(branch)
194         elif repotype == 'bzr':
195             vcs.gotorevision(None)
196
197         flavours = []
198         if len(app['builds']) > 0:
199             if app['builds'][-1]['subdir']:
200                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
201             if app['builds'][-1]['gradle']:
202                 flavours = app['builds'][-1]['gradle']
203
204         if not os.path.isdir(build_dir):
205             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
206
207         paths = common.manifest_paths(build_dir, flavours)
208
209         version, vercode, package = \
210             common.parse_androidmanifests(paths, app['Update Check Ignore'])
211         if not package:
212             return (None, "Couldn't find package ID")
213         if package != appid:
214             return (None, "Package ID mismatch")
215         if not version:
216             return (None, "Couldn't find latest version name")
217         if not vercode:
218             if "Ignore" == version:
219                 return (None, "Latest version is ignored")
220             return (None, "Couldn't find latest version code")
221
222         vercode = str(int(vercode))
223
224         logging.debug("Manifest exists. Found version {0} ({1})".format(version, vercode))
225
226         return (version, vercode)
227
228     except VCSException as vcse:
229         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
230         return (None, msg)
231     except Exception:
232         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
233         return (None, msg)
234
235
236 def check_repotrunk(app, branch=None):
237
238     try:
239         if app['Repo Type'] == 'srclib':
240             build_dir = os.path.join('build', 'srclib', app['Repo'])
241             repotype = common.getsrclibvcs(app['Repo'])
242         else:
243             build_dir = os.path.join('build/', app['id'])
244             repotype = app['Repo Type']
245
246         if repotype not in ('git-svn', ):
247             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
248
249         # Set up vcs interface and make sure we have the latest code...
250         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
251
252         vcs.gotorevision(None)
253
254         ref = vcs.getref()
255         return (ref, ref)
256     except VCSException as vcse:
257         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
258         return (None, msg)
259     except Exception:
260         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
261         return (None, msg)
262
263
264 # Check for a new version by looking at the Google Play Store.
265 # Returns (None, "a message") if this didn't work, or (version, None) for
266 # the details of the current version.
267 def check_gplay(app):
268     time.sleep(15)
269     url = 'https://play.google.com/store/apps/details?id=' + app['id']
270     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
271     req = urllib2.Request(url, None, headers)
272     try:
273         resp = urllib2.urlopen(req, None, 20)
274         page = resp.read()
275     except urllib2.HTTPError, e:
276         return (None, str(e.code))
277     except Exception, e:
278         return (None, 'Failed:' + str(e))
279
280     version = None
281
282     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
283     if m:
284         html_parser = HTMLParser.HTMLParser()
285         version = html_parser.unescape(m.group(1))
286
287     if version == 'Varies with device':
288         return (None, 'Device-variable version, cannot use this method')
289
290     if not version:
291         return (None, "Couldn't find version")
292     return (version.strip(), None)
293
294
295 # Return all directories under startdir that contain any of the manifest
296 # files, and thus are probably an Android project.
297 def dirs_with_manifest(startdir):
298     for r, d, f in os.walk(startdir):
299         if any(m in f for m in [
300                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
301             yield r
302
303
304 # Tries to find a new subdir starting from the root build_dir. Returns said
305 # subdir relative to the build dir if found, None otherwise.
306 def check_changed_subdir(app):
307
308     appid = app['Update Check Name'] or app['id']
309     if app['Repo Type'] == 'srclib':
310         build_dir = os.path.join('build', 'srclib', app['Repo'])
311     else:
312         build_dir = os.path.join('build/', app['id'])
313
314     if not os.path.isdir(build_dir):
315         return None
316
317     flavours = []
318     if len(app['builds']) > 0 and app['builds'][-1]['gradle']:
319         flavours = app['builds'][-1]['gradle']
320
321     for d in dirs_with_manifest(build_dir):
322         logging.debug("Trying possible dir %s." % d)
323         m_paths = common.manifest_paths(d, flavours)
324         package = common.parse_androidmanifests(m_paths, app['Update Check Ignore'])[2]
325         if package and package == appid:
326             logging.debug("Manifest exists in possible dir %s." % d)
327             return os.path.relpath(d, build_dir)
328
329     return None
330
331
332 def fetch_autoname(app, tag):
333
334     if not app["Repo Type"] or app['Update Check Mode'] in ('None', 'Static'):
335         return None
336
337     if app['Repo Type'] == 'srclib':
338         app_dir = os.path.join('build', 'srclib', app['Repo'])
339     else:
340         app_dir = os.path.join('build/', app['id'])
341
342     try:
343         vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
344         vcs.gotorevision(tag)
345     except VCSException:
346         return None
347
348     flavours = []
349     if len(app['builds']) > 0:
350         if app['builds'][-1]['subdir']:
351             app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
352         if app['builds'][-1]['gradle']:
353             flavours = app['builds'][-1]['gradle']
354
355     logging.debug("...fetch auto name from " + app_dir)
356     new_name = common.fetch_real_name(app_dir, flavours)
357     commitmsg = None
358     if new_name:
359         logging.debug("...got autoname '" + new_name + "'")
360         if new_name != app['Auto Name']:
361             app['Auto Name'] = new_name
362             if not commitmsg:
363                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
364     else:
365         logging.debug("...couldn't get autoname")
366
367     if app['Current Version'].startswith('@string/'):
368         cv = common.version_name(app['Current Version'], app_dir, flavours)
369         if app['Current Version'] != cv:
370             app['Current Version'] = cv
371             if not commitmsg:
372                 commitmsg = "Fix CV of {0}".format(common.getappname(app))
373
374     return commitmsg
375
376
377 def checkupdates_app(app, first=True):
378
379     # If a change is made, commitmsg should be set to a description of it.
380     # Only if this is set will changes be written back to the metadata.
381     commitmsg = None
382
383     tag = None
384     msg = None
385     vercode = None
386     noverok = False
387     mode = app['Update Check Mode']
388     if mode.startswith('Tags'):
389         pattern = mode[5:] if len(mode) > 4 else None
390         (version, vercode, tag) = check_tags(app, pattern)
391         msg = vercode
392     elif mode == 'RepoManifest':
393         (version, vercode) = check_repomanifest(app)
394         msg = vercode
395     elif mode.startswith('RepoManifest/'):
396         tag = mode[13:]
397         (version, vercode) = check_repomanifest(app, tag)
398         msg = vercode
399     elif mode == 'RepoTrunk':
400         (version, vercode) = check_repotrunk(app)
401         msg = vercode
402     elif mode == 'HTTP':
403         (version, vercode) = check_http(app)
404         msg = vercode
405     elif mode in ('None', 'Static'):
406         version = None
407         msg = 'Checking disabled'
408         noverok = True
409     else:
410         version = None
411         msg = 'Invalid update check method'
412
413     if first and version is None and vercode == "Couldn't find package ID":
414         logging.warn("Couldn't find any version information. Looking for a subdir change...")
415         new_subdir = check_changed_subdir(app)
416         if new_subdir is None:
417             logging.warn("Couldn't find any new subdir.")
418         else:
419             logging.warn("Trying a new subdir: %s" % new_subdir)
420             new_build = {}
421             metadata.fill_build_defaults(new_build)
422             new_build['version'] = "Ignore"
423             new_build['vercode'] = "-1"
424             new_build['subdir'] = new_subdir
425             app['builds'].append(new_build)
426             return checkupdates_app(app, first=False)
427
428     if version and vercode and app['Vercode Operation']:
429         op = app['Vercode Operation'].replace("%c", str(int(vercode)))
430         vercode = str(eval(op))
431
432     updating = False
433     if version is None:
434         logmsg = "...{0} : {1}".format(app['id'], msg)
435         if noverok:
436             logging.info(logmsg)
437         else:
438             logging.warn(logmsg)
439     elif vercode == app['Current Version Code']:
440         logging.info("...up to date")
441     else:
442         app['Current Version'] = version
443         app['Current Version Code'] = str(int(vercode))
444         updating = True
445
446     commitmsg = fetch_autoname(app, tag)
447
448     if updating:
449         name = common.getappname(app)
450         ver = common.getcvname(app)
451         logging.info('...updating to version %s' % ver)
452         commitmsg = 'Update CV of %s to %s' % (name, ver)
453
454     if options.auto:
455         mode = app['Auto Update Mode']
456         if mode in ('None', 'Static'):
457             pass
458         elif mode.startswith('Version '):
459             pattern = mode[8:]
460             if pattern.startswith('+'):
461                 try:
462                     suffix, pattern = pattern.split(' ', 1)
463                 except ValueError:
464                     raise MetaDataException("Invalid AUM: " + mode)
465             else:
466                 suffix = ''
467             gotcur = False
468             latest = None
469             for build in app['builds']:
470                 if build['vercode'] == app['Current Version Code']:
471                     gotcur = True
472                 if not latest or int(build['vercode']) > int(latest['vercode']):
473                     latest = build
474
475             if not gotcur:
476                 newbuild = latest.copy()
477                 if 'origlines' in newbuild:
478                     del newbuild['origlines']
479                 newbuild['disable'] = False
480                 newbuild['vercode'] = app['Current Version Code']
481                 newbuild['version'] = app['Current Version'] + suffix
482                 logging.info("...auto-generating build for " + newbuild['version'])
483                 commit = pattern.replace('%v', newbuild['version'])
484                 commit = commit.replace('%c', newbuild['vercode'])
485                 newbuild['commit'] = commit
486                 app['builds'].append(newbuild)
487                 name = common.getappname(app)
488                 ver = common.getcvname(app)
489                 commitmsg = "Update %s to %s" % (name, ver)
490         else:
491             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
492
493     if commitmsg:
494         metafile = os.path.join('metadata', app['id'] + '.txt')
495         metadata.write_metadata(metafile, app)
496         if options.commit:
497             logging.info("Commiting update for " + metafile)
498             gitcmd = ["git", "commit", "-m", commitmsg]
499             if 'auto_author' in config:
500                 gitcmd.extend(['--author', config['auto_author']])
501             gitcmd.extend(["--", metafile])
502             if subprocess.call(gitcmd) != 0:
503                 logging.error("Git commit failed")
504                 sys.exit(1)
505
506
507 config = None
508 options = None
509
510
511 def main():
512
513     global config, options
514
515     # Parse command line...
516     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
517     parser.add_option("-v", "--verbose", action="store_true", default=False,
518                       help="Spew out even more information than normal")
519     parser.add_option("-q", "--quiet", action="store_true", default=False,
520                       help="Restrict output to warnings and errors")
521     parser.add_option("--auto", action="store_true", default=False,
522                       help="Process auto-updates")
523     parser.add_option("--autoonly", action="store_true", default=False,
524                       help="Only process apps with auto-updates")
525     parser.add_option("--commit", action="store_true", default=False,
526                       help="Commit changes")
527     parser.add_option("--gplay", action="store_true", default=False,
528                       help="Only print differences with the Play Store")
529     (options, args) = parser.parse_args()
530
531     config = common.read_config(options)
532
533     # Get all apps...
534     allapps = metadata.read_metadata()
535
536     apps = common.read_app_args(args, allapps, False)
537
538     if options.gplay:
539         for app in apps:
540             version, reason = check_gplay(app)
541             if version is None:
542                 if reason == '404':
543                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
544                 else:
545                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
546             if version is not None:
547                 stored = app['Current Version']
548                 if not stored:
549                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
550                                  .format(common.getappname(app), version))
551                 elif LooseVersion(stored) < LooseVersion(version):
552                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
553                                  .format(common.getappname(app), version, stored))
554                 else:
555                     if stored != version:
556                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
557                                      .format(common.getappname(app), version, stored))
558                     else:
559                         logging.info("{0} has the same version {1} on the Play Store"
560                                      .format(common.getappname(app), version))
561         return
562
563     for appid, app in apps.iteritems():
564
565         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
566             logging.debug("Nothing to do for {0}...".format(appid))
567             continue
568
569         logging.info("Processing " + appid + '...')
570
571         checkupdates_app(app)
572
573     logging.info("Finished.")
574
575 if __name__ == "__main__":
576     main()