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