chiark / gitweb /
94dbd2980119675e3b4ced8f1db7c4d9ac9857f9
[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 LooseVersion(stored) < LooseVersion(version):
318                     print "%s has version %s on the Play Store, which is bigger than %s" % (
319                             common.getappname(app), version, stored)
320                 elif options.verbose:
321                     print "%s has the same version %s on the Play Store" % (
322                             common.getappname(app), version)
323         return
324
325
326     for app in apps:
327
328         if options.autoonly and app['Auto Update Mode'] == 'None':
329             if options.verbose:
330                 print "Nothing to do for %s..." % app['id']
331             continue
332
333         print "Processing " + app['id'] + '...'
334
335         writeit = False
336         logmsg = None
337
338         tag = None
339         msg = None
340         vercode = None
341         mode = app['Update Check Mode']
342         if mode == 'Tags':
343             (version, vercode, tag) = check_tags(app)
344         elif mode == 'RepoManifest':
345             (version, vercode) = check_repomanifest(app)
346         elif mode.startswith('RepoManifest/'):
347             tag = mode[13:]
348             (version, vercode) = check_repomanifest(app, tag)
349         elif mode == 'RepoTrunk':
350             (version, vercode) = check_repotrunk(app)
351         elif mode == 'HTTP':
352             (version, vercode) = check_http(app)
353         elif mode == 'Static':
354             version = None
355             msg = 'Checking disabled'
356         elif mode == 'None':
357             version = None
358             msg = 'Checking disabled'
359         else:
360             version = None
361             msg = 'Invalid update check method'
362
363         if vercode and app['Vercode Operation']:
364             op = app['Vercode Operation'].replace("%c", str(int(vercode)))
365             vercode = str(eval(op))
366
367         updating = False
368         if not version:
369             print "...%s" % msg
370         elif vercode == app['Current Version Code']:
371             print "...up to date"
372         else:
373             app['Current Version'] = version
374             app['Current Version Code'] = str(int(vercode))
375             updating = True
376             writeit = True
377
378         # Do the Auto Name thing as well as finding the CV real name
379         if len(app["Repo Type"]) > 0:
380
381             try:
382
383                 if app['Repo Type'] == 'srclib':
384                     app_dir = os.path.join('build', 'srclib', app['Repo'])
385                 else:
386                     app_dir = os.path.join('build/', app['id'])
387
388                 vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
389                 vcs.gotorevision(tag)
390
391                 flavour = None
392                 if len(app['builds']) > 0:
393                     if 'subdir' in app['builds'][-1]:
394                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
395                     if 'gradle' in app['builds'][-1]:
396                         flavour = app['builds'][-1]['gradle']
397
398                 new_name = common.fetch_real_name(app_dir, flavour)
399                 if new_name != app['Auto Name']:
400                     app['Auto Name'] = new_name
401
402                 if app['Current Version'].startswith('@string/'):
403                     cv = common.version_name(app['Current Version'], app_dir, flavour)
404                     if app['Current Version'] != cv:
405                         app['Current Version'] = cv
406                         writeit = True
407             except Exception:
408                 print "ERROR: Auto Name or Current Version failed for %s due to exception: %s" % (app['id'], traceback.format_exc())
409
410         if updating:
411             name = common.getappname(app)
412             ver = common.getcvname(app)
413             print '...updating to version %s' % ver
414             logmsg = 'Update CV of %s to %s' % (name, ver)
415
416         if options.auto:
417             mode = app['Auto Update Mode']
418             if mode == 'None':
419                 pass
420             elif mode.startswith('Version '):
421                 pattern = mode[8:]
422                 if pattern.startswith('+'):
423                     try:
424                         suffix, pattern = pattern.split(' ', 1)
425                     except ValueError:
426                         raise MetaDataException("Invalid AUM: " + mode)
427                 else:
428                     suffix = ''
429                 gotcur = False
430                 latest = None
431                 for build in app['builds']:
432                     if build['vercode'] == app['Current Version Code']:
433                         gotcur = True
434                     if not latest or int(build['vercode']) > int(latest['vercode']):
435                         latest = build
436                 if not gotcur:
437                     newbuild = latest.copy()
438                     if 'origlines' in newbuild:
439                         del newbuild['origlines']
440                     newbuild['vercode'] = app['Current Version Code']
441                     newbuild['version'] = app['Current Version'] + suffix
442                     print "...auto-generating build for " + newbuild['version']
443                     commit = pattern.replace('%v', newbuild['version'])
444                     commit = commit.replace('%c', newbuild['vercode'])
445                     newbuild['commit'] = commit
446                     app['builds'].append(newbuild)
447                     writeit = True
448                     name = common.getappname(app)
449                     ver = common.getcvname(app)
450                     logmsg = "Update %s to %s" % (name, ver)
451             else:
452                 print 'Invalid auto update mode "' + mode + '"'
453
454         if writeit:
455             metafile = os.path.join('metadata', app['id'] + '.txt')
456             metadata.write_metadata(metafile, app)
457             if options.commit and logmsg:
458                 print "Commiting update for " + metafile
459                 gitcmd = ["git", "commit", "-m",
460                     logmsg]
461                 if 'auto_author' in config:
462                     gitcmd.extend(['--author', config['auto_author']])
463                 gitcmd.extend(["--", metafile])
464                 if subprocess.call(gitcmd) != 0:
465                     print "Git commit failed"
466                     sys.exit(1)
467
468     print "Finished."
469
470 if __name__ == "__main__":
471     main()
472