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