chiark / gitweb /
Centralise handling of default gradle flavours
[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
362     logging.debug("...fetch auto name from " + app_dir)
363     new_name = common.fetch_real_name(app_dir, flavours)
364     commitmsg = None
365     if new_name:
366         logging.debug("...got autoname '" + new_name + "'")
367         if new_name != app['Auto Name']:
368             app['Auto Name'] = new_name
369             if not commitmsg:
370                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
371     else:
372         logging.debug("...couldn't get autoname")
373
374     if app['Current Version'].startswith('@string/'):
375         cv = common.version_name(app['Current Version'], app_dir, flavours)
376         if app['Current Version'] != cv:
377             app['Current Version'] = cv
378             if not commitmsg:
379                 commitmsg = "Fix CV of {0}".format(common.getappname(app))
380
381     return commitmsg
382
383
384 def checkupdates_app(app, first=True):
385
386     # If a change is made, commitmsg should be set to a description of it.
387     # Only if this is set will changes be written back to the metadata.
388     commitmsg = None
389
390     tag = None
391     msg = None
392     vercode = None
393     noverok = False
394     mode = app['Update Check Mode']
395     if mode.startswith('Tags'):
396         pattern = mode[5:] if len(mode) > 4 else None
397         (version, vercode, tag) = check_tags(app, pattern)
398         msg = vercode
399     elif mode == 'RepoManifest':
400         (version, vercode) = check_repomanifest(app)
401         msg = vercode
402     elif mode.startswith('RepoManifest/'):
403         tag = mode[13:]
404         (version, vercode) = check_repomanifest(app, tag)
405         msg = vercode
406     elif mode == 'RepoTrunk':
407         (version, vercode) = check_repotrunk(app)
408         msg = vercode
409     elif mode == 'HTTP':
410         (version, vercode) = check_http(app)
411         msg = vercode
412     elif mode in ('None', 'Static'):
413         version = None
414         msg = 'Checking disabled'
415         noverok = True
416     else:
417         version = None
418         msg = 'Invalid update check method'
419
420     if first and version is None and vercode == "Couldn't find package ID":
421         logging.warn("Couldn't find any version information. Looking for a subdir change...")
422         new_subdir = check_changed_subdir(app)
423         if new_subdir is None:
424             logging.warn("Couldn't find any new subdir.")
425         else:
426             logging.warn("Trying a new subdir: %s" % new_subdir)
427             new_build = {}
428             metadata.fill_build_defaults(new_build)
429             new_build['version'] = "Ignore"
430             new_build['vercode'] = "-1"
431             new_build['subdir'] = new_subdir
432             app['builds'].append(new_build)
433             return checkupdates_app(app, first=False)
434
435     if version and vercode and app['Vercode Operation']:
436         op = app['Vercode Operation'].replace("%c", str(int(vercode)))
437         vercode = str(eval(op))
438
439     updating = False
440     if version is None:
441         logmsg = "...{0} : {1}".format(app['id'], msg)
442         if noverok:
443             logging.info(logmsg)
444         else:
445             logging.warn(logmsg)
446     elif vercode == app['Current Version Code']:
447         logging.info("...up to date")
448     else:
449         app['Current Version'] = version
450         app['Current Version Code'] = str(int(vercode))
451         updating = True
452
453     commitmsg = fetch_autoname(app, tag)
454
455     if updating:
456         name = common.getappname(app)
457         ver = common.getcvname(app)
458         logging.info('...updating to version %s' % ver)
459         commitmsg = 'Update CV of %s to %s' % (name, ver)
460
461     if options.auto:
462         mode = app['Auto Update Mode']
463         if mode in ('None', 'Static'):
464             pass
465         elif mode.startswith('Version '):
466             pattern = mode[8:]
467             if pattern.startswith('+'):
468                 try:
469                     suffix, pattern = pattern.split(' ', 1)
470                 except ValueError:
471                     raise MetaDataException("Invalid AUM: " + mode)
472             else:
473                 suffix = ''
474             gotcur = False
475             latest = None
476             for build in app['builds']:
477                 if build['vercode'] == app['Current Version Code']:
478                     gotcur = True
479                 if not latest or int(build['vercode']) > int(latest['vercode']):
480                     latest = build
481
482             if not gotcur:
483                 newbuild = latest.copy()
484                 if 'origlines' in newbuild:
485                     del newbuild['origlines']
486                 newbuild['disable'] = False
487                 newbuild['vercode'] = app['Current Version Code']
488                 newbuild['version'] = app['Current Version'] + suffix
489                 logging.info("...auto-generating build for " + newbuild['version'])
490                 commit = pattern.replace('%v', newbuild['version'])
491                 commit = commit.replace('%c', newbuild['vercode'])
492                 newbuild['commit'] = commit
493                 app['builds'].append(newbuild)
494                 name = common.getappname(app)
495                 ver = common.getcvname(app)
496                 commitmsg = "Update %s to %s" % (name, ver)
497         else:
498             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
499
500     if commitmsg:
501         metafile = os.path.join('metadata', app['id'] + '.txt')
502         metadata.write_metadata(metafile, app)
503         if options.commit:
504             logging.info("Commiting update for " + metafile)
505             gitcmd = ["git", "commit", "-m", commitmsg]
506             if 'auto_author' in config:
507                 gitcmd.extend(['--author', config['auto_author']])
508             gitcmd.extend(["--", metafile])
509             if subprocess.call(gitcmd) != 0:
510                 logging.error("Git commit failed")
511                 sys.exit(1)
512
513
514 config = None
515 options = None
516
517
518 def main():
519
520     global config, options
521
522     # Parse command line...
523     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
524     parser.add_option("-v", "--verbose", action="store_true", default=False,
525                       help="Spew out even more information than normal")
526     parser.add_option("-q", "--quiet", action="store_true", default=False,
527                       help="Restrict output to warnings and errors")
528     parser.add_option("--auto", action="store_true", default=False,
529                       help="Process auto-updates")
530     parser.add_option("--autoonly", action="store_true", default=False,
531                       help="Only process apps with auto-updates")
532     parser.add_option("--commit", action="store_true", default=False,
533                       help="Commit changes")
534     parser.add_option("--gplay", action="store_true", default=False,
535                       help="Only print differences with the Play Store")
536     (options, args) = parser.parse_args()
537
538     config = common.read_config(options)
539
540     # Get all apps...
541     allapps = metadata.read_metadata()
542
543     apps = common.read_app_args(args, allapps, False)
544
545     if options.gplay:
546         for app in apps:
547             version, reason = check_gplay(app)
548             if version is None:
549                 if reason == '404':
550                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
551                 else:
552                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
553             if version is not None:
554                 stored = app['Current Version']
555                 if not stored:
556                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
557                                  .format(common.getappname(app), version))
558                 elif LooseVersion(stored) < LooseVersion(version):
559                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
560                                  .format(common.getappname(app), version, stored))
561                 else:
562                     if stored != version:
563                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
564                                      .format(common.getappname(app), version, stored))
565                     else:
566                         logging.info("{0} has the same version {1} on the Play Store"
567                                      .format(common.getappname(app), version))
568         return
569
570     for appid, app in apps.iteritems():
571
572         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
573             logging.debug("Nothing to do for {0}...".format(appid))
574             continue
575
576         logging.info("Processing " + appid + '...')
577
578         checkupdates_app(app)
579
580     logging.info("Finished.")
581
582 if __name__ == "__main__":
583     main()