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