chiark / gitweb /
Don't auto update apps with the latest build disabled
[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, metadata
34 from common import BuildException
35 from common import VCSException
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 not 'Update Check Data' in app:
47             raise Exception('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.info("...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 Exception("No RE match for version code")
61             vercode = m.group(1)
62
63         version = "??"
64         if len(urlver) > 0:
65             if urlver != '.':
66                 logging.info("...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 Exception("No RE match for version")
74             version = m.group(1)
75
76         return (version, vercode)
77
78     except Exception:
79         msg = "Could not complete http check for app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
80         return (None, msg)
81
82 # Check for a new version by looking at the tags in the source repo.
83 # Whether this can be used reliably or not depends on
84 # the development procedures used by the project's developers. Use it with
85 # caution, because it's inappropriate for many projects.
86 # Returns (None, "a message") if this didn't work, or (version, vercode) for
87 # the details of the current version.
88 def check_tags(app, pattern):
89
90     try:
91
92         if app['Repo Type'] == 'srclib':
93             build_dir = os.path.join('build', 'srclib', app['Repo'])
94             repotype = common.getsrclibvcs(app['Repo'])
95         else:
96             build_dir = os.path.join('build/', app['id'])
97             repotype = app['Repo Type']
98
99         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
100             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
101
102         # Set up vcs interface and make sure we have the latest code...
103         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
104
105         vcs.gotorevision(None)
106
107         flavour = None
108         if len(app['builds']) > 0:
109             if 'subdir' in app['builds'][-1]:
110                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
111             if 'gradle' in app['builds'][-1]:
112                 flavour = app['builds'][-1]['gradle']
113
114         htag = None
115         hver = None
116         hcode = "0"
117
118         tags = vcs.gettags()
119         if pattern:
120             pat = re.compile(pattern)
121             tags = [tag for tag in tags if pat.match(tag)]
122
123         for tag in tags:
124             logging.info("Check tag: '{0}'".format(tag))
125             vcs.gotorevision(tag)
126
127             # Only process tags where the manifest exists...
128             paths = common.manifest_paths(build_dir, flavour)
129             version, vercode, package = common.parse_androidmanifests(paths)
130             if package and package == app['id'] and version and vercode:
131                 logging.info("Manifest exists. Found version %s (%s)" % (
132                         version, vercode))
133                 if int(vercode) > int(hcode):
134                     htag = tag
135                     hcode = str(int(vercode))
136                     hver = version
137
138         if hver:
139             return (hver, hcode, htag)
140         return (None, "Couldn't find any version information", None)
141
142     except BuildException as be:
143         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
144         return (None, msg, None)
145     except VCSException as vcse:
146         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
147         return (None, msg, None)
148     except Exception:
149         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
150         return (None, msg, None)
151
152 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
153 # of the source repo. Whether this can be used reliably or not depends on
154 # the development procedures used by the project's developers. Use it with
155 # caution, because it's inappropriate for many projects.
156 # Returns (None, "a message") if this didn't work, or (version, vercode) for
157 # the details of the current version.
158 def check_repomanifest(app, branch=None):
159
160     try:
161
162         if app['Repo Type'] == 'srclib':
163             build_dir = os.path.join('build', 'srclib', app['Repo'])
164             repotype = common.getsrclibvcs(app['Repo'])
165         else:
166             build_dir = os.path.join('build/', app['id'])
167             repotype = app['Repo Type']
168
169         # Set up vcs interface and make sure we have the latest code...
170         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
171
172         if repotype == 'git':
173             if branch:
174                 branch = 'origin/'+branch
175             vcs.gotorevision(branch)
176         elif repotype == 'git-svn':
177             vcs.gotorevision(branch)
178         elif repotype == 'svn':
179             vcs.gotorevision(None)
180         elif repotype == 'hg':
181             vcs.gotorevision(branch)
182         elif repotype == 'bzr':
183             vcs.gotorevision(None)
184
185         flavour = None
186
187         if len(app['builds']) > 0:
188             if 'subdir' in app['builds'][-1]:
189                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
190             if 'gradle' in app['builds'][-1]:
191                 flavour = app['builds'][-1]['gradle']
192
193         if not os.path.isdir(build_dir):
194             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
195
196         paths = common.manifest_paths(build_dir, flavour)
197
198         version, vercode, package = common.parse_androidmanifests(paths)
199         if not package:
200             return (None, "Couldn't find package ID")
201         if package != app['id']:
202             return (None, "Package ID mismatch")
203         if not version:
204             return (None,"Couldn't find latest version name")
205         if not vercode:
206             return (None,"Couldn't find latest version code")
207
208         vercode = str(int(vercode))
209
210         logging.info("Manifest exists. Found version %s (%s)" % (version, vercode))
211
212         return (version, vercode)
213
214     except BuildException as be:
215         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
216         return (None, msg)
217     except VCSException as vcse:
218         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
219         return (None, msg)
220     except Exception:
221         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
222         return (None, msg)
223
224 def check_repotrunk(app, branch=None):
225
226     try:
227         if app['Repo Type'] == 'srclib':
228             build_dir = os.path.join('build', 'srclib', app['Repo'])
229             repotype = common.getsrclibvcs(app['Repo'])
230         else:
231             build_dir = os.path.join('build/', app['id'])
232             repotype = app['Repo Type']
233
234         if repotype not in ('svn', 'git-svn'):
235             return (None, 'RepoTrunk update mode only makes sense in svn and git-svn repositories')
236
237         # Set up vcs interface and make sure we have the latest code...
238         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
239
240         vcs.gotorevision(None)
241
242         ref = vcs.getref()
243         return (ref, ref)
244     except BuildException as be:
245         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
246         return (None, msg)
247     except VCSException as vcse:
248         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
249         return (None, msg)
250     except Exception:
251         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
252         return (None, msg)
253
254 # Check for a new version by looking at the Google Play Store.
255 # Returns (None, "a message") if this didn't work, or (version, None) for
256 # the details of the current version.
257 def check_gplay(app):
258     time.sleep(15)
259     url = 'https://play.google.com/store/apps/details?id=' + app['id']
260     headers = {'User-Agent' : 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
261     req = urllib2.Request(url, None, headers)
262     try:
263         resp = urllib2.urlopen(req, None, 20)
264         page = resp.read()
265     except urllib2.HTTPError, e:
266         return (None, str(e.code))
267     except Exception, e:
268         return (None, 'Failed:' + str(e))
269
270     version = None
271
272     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
273     if m:
274         html_parser = HTMLParser.HTMLParser()
275         version = html_parser.unescape(m.group(1))
276
277     if version == 'Varies with device':
278         return (None, 'Device-variable version, cannot use this method')
279
280     if not version:
281         return (None, "Couldn't find version")
282     return (version.strip(), None)
283
284
285 config = None
286 options = None
287
288 def main():
289
290     global config, options
291
292     # Parse command line...
293     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
294     parser.add_option("-v", "--verbose", action="store_true", default=False,
295                       help="Spew out even more information than normal")
296     parser.add_option("--auto", action="store_true", default=False,
297                       help="Process auto-updates")
298     parser.add_option("--autoonly", action="store_true", default=False,
299                       help="Only process apps with auto-updates")
300     parser.add_option("--commit", action="store_true", default=False,
301                       help="Commit changes")
302     parser.add_option("--gplay", action="store_true", default=False,
303                       help="Only print differences with the Play Store")
304     (options, args) = parser.parse_args()
305
306     config = common.read_config(options)
307
308     # Get all apps...
309     allapps = metadata.read_metadata(options.verbose)
310
311     apps = common.read_app_args(args, allapps, False)
312
313     if options.gplay:
314         for app in apps:
315             version, reason = check_gplay(app)
316             if version is None:
317                 if reason == '404':
318                     logging.info("%s is not in the Play Store" % common.getappname(app))
319                 else:
320                     logging.info("%s encountered a problem: %s" % (common.getappname(app), reason))
321             if version is not None:
322                 stored = app['Current Version']
323                 if not stored:
324                     logging.info("%s has no Current Version but has version %s on the Play Store" % (
325                             common.getappname(app), version))
326                 elif LooseVersion(stored) < LooseVersion(version):
327                     logging.info("%s has version %s on the Play Store, which is bigger than %s" % (
328                             common.getappname(app), version, stored))
329                 else:
330                     if stored != version:
331                         logging.info("%s has version %s on the Play Store, which differs from %s" % (
332                                 common.getappname(app), version, stored))
333                     else:
334                         logging.info("%s has the same version %s on the Play Store" % (
335                                 common.getappname(app), version))
336         return
337
338
339     for app in apps:
340
341         if options.autoonly and app['Auto Update Mode'] == 'None':
342             logging.info("Nothing to do for %s..." % app['id'])
343             continue
344
345         logging.info("Processing " + app['id'] + '...')
346
347         writeit = False
348         logmsg = None
349
350         tag = None
351         msg = None
352         vercode = None
353         mode = app['Update Check Mode']
354         if mode.startswith('Tags'):
355             pattern = mode[5:] if len(mode) > 4 else None
356             (version, vercode, tag) = check_tags(app, pattern)
357         elif mode == 'RepoManifest':
358             (version, vercode) = check_repomanifest(app)
359         elif mode.startswith('RepoManifest/'):
360             tag = mode[13:]
361             (version, vercode) = check_repomanifest(app, tag)
362         elif mode == 'RepoTrunk':
363             (version, vercode) = check_repotrunk(app)
364         elif mode == 'HTTP':
365             (version, vercode) = check_http(app)
366         elif mode == 'Static':
367             version = None
368             msg = 'Checking disabled'
369         elif mode == 'None':
370             version = None
371             msg = 'Checking disabled'
372         else:
373             version = None
374             msg = 'Invalid update check method'
375
376         if vercode and app['Vercode Operation']:
377             op = app['Vercode Operation'].replace("%c", str(int(vercode)))
378             vercode = str(eval(op))
379
380         updating = False
381         if not version:
382             logging.info("...%s" % msg)
383         elif vercode == app['Current Version Code']:
384             logging.info("...up to date")
385         else:
386             app['Current Version'] = version
387             app['Current Version Code'] = str(int(vercode))
388             updating = True
389             writeit = True
390
391         # Do the Auto Name thing as well as finding the CV real name
392         if len(app["Repo Type"]) > 0:
393
394             try:
395
396                 if app['Repo Type'] == 'srclib':
397                     app_dir = os.path.join('build', 'srclib', app['Repo'])
398                 else:
399                     app_dir = os.path.join('build/', app['id'])
400
401                 vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
402                 vcs.gotorevision(tag)
403
404                 flavour = None
405                 if len(app['builds']) > 0:
406                     if 'subdir' in app['builds'][-1]:
407                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
408                     if 'gradle' in app['builds'][-1]:
409                         flavour = app['builds'][-1]['gradle']
410
411                 new_name = common.fetch_real_name(app_dir, flavour)
412                 if new_name != app['Auto Name']:
413                     app['Auto Name'] = new_name
414
415                 if app['Current Version'].startswith('@string/'):
416                     cv = common.version_name(app['Current Version'], app_dir, flavour)
417                     if app['Current Version'] != cv:
418                         app['Current Version'] = cv
419                         writeit = True
420             except Exception:
421                 logging.info("ERROR: Auto Name or Current Version failed for %s due to exception: %s" % (app['id'], traceback.format_exc()))
422
423         if updating:
424             name = common.getappname(app)
425             ver = common.getcvname(app)
426             logging.info('...updating to version %s' % ver)
427             logmsg = 'Update CV of %s to %s' % (name, ver)
428
429         if options.auto:
430             mode = app['Auto Update Mode']
431             if mode == 'None':
432                 pass
433             elif mode.startswith('Version '):
434                 pattern = mode[8:]
435                 if pattern.startswith('+'):
436                     try:
437                         suffix, pattern = pattern.split(' ', 1)
438                     except ValueError:
439                         raise MetaDataException("Invalid AUM: " + mode)
440                 else:
441                     suffix = ''
442                 gotcur = False
443                 latest = None
444                 for build in app['builds']:
445                     if build['vercode'] == app['Current Version Code']:
446                         gotcur = True
447                     if not latest or int(build['vercode']) > int(latest['vercode']):
448                         latest = build
449
450                 if 'disable' in latest:
451                     logging.warn('Skipping %s since the latest build is disabled' % app['id'])
452                 elif not gotcur:
453                     newbuild = latest.copy()
454                     if 'origlines' in newbuild:
455                         del newbuild['origlines']
456                     newbuild['vercode'] = app['Current Version Code']
457                     newbuild['version'] = app['Current Version'] + suffix
458                     logging.info("...auto-generating build for " + newbuild['version'])
459                     commit = pattern.replace('%v', newbuild['version'])
460                     commit = commit.replace('%c', newbuild['vercode'])
461                     newbuild['commit'] = commit
462                     app['builds'].append(newbuild)
463                     writeit = True
464                     name = common.getappname(app)
465                     ver = common.getcvname(app)
466                     logmsg = "Update %s to %s" % (name, ver)
467             else:
468                 logging.info('Invalid auto update mode "' + mode + '"')
469
470         if writeit:
471             metafile = os.path.join('metadata', app['id'] + '.txt')
472             metadata.write_metadata(metafile, app)
473             if options.commit and logmsg:
474                 logging.info("Commiting update for " + metafile)
475                 gitcmd = ["git", "commit", "-m",
476                     logmsg]
477                 if 'auto_author' in config:
478                     gitcmd.extend(['--author', config['auto_author']])
479                 gitcmd.extend(["--", metafile])
480                 if subprocess.call(gitcmd) != 0:
481                     logging.info("Git commit failed")
482                     sys.exit(1)
483
484     logging.info("Finished.")
485
486 if __name__ == "__main__":
487     main()
488