chiark / gitweb /
7f58e156def4d926fd674f8eac9a162e08296a11
[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-2015, 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 argparse import ArgumentParser
28 import traceback
29 import HTMLParser
30 from distutils.version import LooseVersion
31 import logging
32 import copy
33
34 import common
35 import metadata
36 from common import VCSException, FDroidException
37 from metadata import MetaDataException
38
39
40 # Check for a new version by looking at a document retrieved via HTTP.
41 # The app's Update Check Data field is used to provide the information
42 # required.
43 def check_http(app):
44
45     try:
46
47         if not app.UpdateCheckData:
48             raise FDroidException('Missing Update Check Data')
49
50         urlcode, codeex, urlver, verex = app.UpdateCheckData.split('|')
51
52         vercode = "99999999"
53         if len(urlcode) > 0:
54             logging.debug("...requesting {0}".format(urlcode))
55             req = urllib2.Request(urlcode, None)
56             resp = urllib2.urlopen(req, None, 20)
57             page = resp.read()
58
59             m = re.search(codeex, page)
60             if not m:
61                 raise FDroidException("No RE match for version code")
62             vercode = m.group(1)
63
64         version = "??"
65         if len(urlver) > 0:
66             if urlver != '.':
67                 logging.debug("...requesting {0}".format(urlver))
68                 req = urllib2.Request(urlver, None)
69                 resp = urllib2.urlopen(req, None, 20)
70                 page = resp.read()
71
72             m = re.search(verex, page)
73             if not m:
74                 raise FDroidException("No RE match for version")
75             version = m.group(1)
76
77         return (version, vercode)
78
79     except FDroidException:
80         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
81         return (None, msg)
82
83
84 # Check for a new version by looking at the tags in the source repo.
85 # Whether this can be used reliably or not depends on
86 # the development procedures used by the project's developers. Use it with
87 # caution, because it's inappropriate for many projects.
88 # Returns (None, "a message") if this didn't work, or (version, vercode, tag) for
89 # the details of the current version.
90 def check_tags(app, pattern):
91
92     try:
93
94         if app.RepoType == '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.RepoType
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.RepoType, app.Repo, build_dir)
109
110         vcs.gotorevision(None)
111
112         last_build = metadata.Build()
113         if len(app.builds) > 0:
114             last_build = app.builds[-1]
115
116         if last_build.submodules:
117             vcs.initsubmodules()
118
119         hpak = None
120         htag = None
121         hver = None
122         hcode = "0"
123
124         tags = vcs.gettags()
125         if not tags:
126             return (None, "No tags found", None)
127
128         logging.debug("All tags: " + ','.join(tags))
129         if pattern:
130             pat = re.compile(pattern)
131             tags = [tag for tag in tags if pat.match(tag)]
132             if not tags:
133                 return (None, "No matching tags found", None)
134             logging.debug("Matching tags: " + ','.join(tags))
135
136         if len(tags) > 5 and repotype in ('git',):
137             tags = vcs.latesttags(tags, 5)
138             logging.debug("Latest tags: " + ','.join(tags))
139
140         for tag in tags:
141             logging.debug("Check tag: '{0}'".format(tag))
142             vcs.gotorevision(tag)
143
144             for subdir in possible_subdirs(app):
145                 if subdir == '.':
146                     root_dir = build_dir
147                 else:
148                     root_dir = os.path.join(build_dir, subdir)
149                 paths = common.manifest_paths(root_dir, last_build.gradle)
150                 version, vercode, package = common.parse_androidmanifests(paths, app)
151                 if vercode:
152                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
153                                   .format(subdir, version, vercode))
154                     if int(vercode) > int(hcode):
155                         hpak = package
156                         htag = tag
157                         hcode = str(int(vercode))
158                         hver = version
159
160         if not hpak:
161             return (None, "Couldn't find package ID", None)
162         if hver:
163             return (hver, hcode, htag)
164         return (None, "Couldn't find any version information", None)
165
166     except VCSException as vcse:
167         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
168         return (None, msg, None)
169     except Exception:
170         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
171         return (None, msg, None)
172
173
174 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
175 # of the source repo. Whether this can be used reliably or not depends on
176 # the development procedures used by the project's developers. Use it with
177 # caution, because it's inappropriate for many projects.
178 # Returns (None, "a message") if this didn't work, or (version, vercode) for
179 # the details of the current version.
180 def check_repomanifest(app, branch=None):
181
182     try:
183
184         if app.RepoType == 'srclib':
185             build_dir = os.path.join('build', 'srclib', app.Repo)
186             repotype = common.getsrclibvcs(app.Repo)
187         else:
188             build_dir = os.path.join('build', app.id)
189             repotype = app.RepoType
190
191         # Set up vcs interface and make sure we have the latest code...
192         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
193
194         if repotype == 'git':
195             if branch:
196                 branch = 'origin/' + branch
197             vcs.gotorevision(branch)
198         elif repotype == 'git-svn':
199             vcs.gotorevision(branch)
200         elif repotype == 'hg':
201             vcs.gotorevision(branch)
202         elif repotype == 'bzr':
203             vcs.gotorevision(None)
204
205         last_build = metadata.Build()
206         if len(app.builds) > 0:
207             last_build = app.builds[-1]
208
209         if last_build.submodules:
210             vcs.initsubmodules()
211
212         hpak = None
213         hver = None
214         hcode = "0"
215         for subdir in possible_subdirs(app):
216             if subdir == '.':
217                 root_dir = build_dir
218             else:
219                 root_dir = os.path.join(build_dir, subdir)
220             paths = common.manifest_paths(root_dir, last_build.gradle)
221             version, vercode, package = common.parse_androidmanifests(paths, app)
222             if vercode:
223                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
224                               .format(subdir, version, vercode))
225                 if int(vercode) > int(hcode):
226                     hpak = package
227                     hcode = str(int(vercode))
228                     hver = version
229
230         if not hpak:
231             return (None, "Couldn't find package ID")
232         if hver:
233             return (hver, hcode)
234         return (None, "Couldn't find any version information")
235
236     except VCSException as vcse:
237         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
238         return (None, msg)
239     except Exception:
240         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
241         return (None, msg)
242
243
244 def check_repotrunk(app, branch=None):
245
246     try:
247         if app.RepoType == 'srclib':
248             build_dir = os.path.join('build', 'srclib', app.Repo)
249             repotype = common.getsrclibvcs(app.Repo)
250         else:
251             build_dir = os.path.join('build', app.id)
252             repotype = app.RepoType
253
254         if repotype not in ('git-svn', ):
255             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
256
257         # Set up vcs interface and make sure we have the latest code...
258         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
259
260         vcs.gotorevision(None)
261
262         ref = vcs.getref()
263         return (ref, ref)
264     except VCSException as vcse:
265         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
266         return (None, msg)
267     except Exception:
268         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
269         return (None, msg)
270
271
272 # Check for a new version by looking at the Google Play Store.
273 # Returns (None, "a message") if this didn't work, or (version, None) for
274 # the details of the current version.
275 def check_gplay(app):
276     time.sleep(15)
277     url = 'https://play.google.com/store/apps/details?id=' + app.id
278     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
279     req = urllib2.Request(url, None, headers)
280     try:
281         resp = urllib2.urlopen(req, None, 20)
282         page = resp.read()
283     except urllib2.HTTPError as e:
284         return (None, str(e.code))
285     except Exception as e:
286         return (None, 'Failed:' + str(e))
287
288     version = None
289
290     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
291     if m:
292         html_parser = HTMLParser.HTMLParser()
293         version = html_parser.unescape(m.group(1))
294
295     if version == 'Varies with device':
296         return (None, 'Device-variable version, cannot use this method')
297
298     if not version:
299         return (None, "Couldn't find version")
300     return (version.strip(), None)
301
302
303 # Return all directories under startdir that contain any of the manifest
304 # files, and thus are probably an Android project.
305 def dirs_with_manifest(startdir):
306     for r, d, f in os.walk(startdir):
307         if any(m in f for m in [
308                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
309             yield r
310
311
312 # Tries to find a new subdir starting from the root build_dir. Returns said
313 # subdir relative to the build dir if found, None otherwise.
314 def possible_subdirs(app):
315
316     if app.RepoType == 'srclib':
317         build_dir = os.path.join('build', 'srclib', app.Repo)
318     else:
319         build_dir = os.path.join('build', app.id)
320
321     last_build = metadata.Build()
322     if len(app.builds) > 0:
323         last_build = app.builds[-1]
324
325     for d in dirs_with_manifest(build_dir):
326         m_paths = common.manifest_paths(d, last_build.gradle)
327         package = common.parse_androidmanifests(m_paths, app)[2]
328         if package is not None:
329             subdir = os.path.relpath(d, build_dir)
330             logging.debug("Adding possible subdir %s" % subdir)
331             yield subdir
332
333
334 def fetch_autoname(app, tag):
335
336     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
337         return None
338
339     if app.RepoType == 'srclib':
340         build_dir = os.path.join('build', 'srclib', app.Repo)
341     else:
342         build_dir = os.path.join('build', app.id)
343
344     try:
345         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
346         vcs.gotorevision(tag)
347     except VCSException:
348         return None
349
350     last_build = metadata.Build()
351     if len(app.builds) > 0:
352         last_build = app.builds[-1]
353
354     logging.debug("...fetch auto name from " + build_dir)
355     new_name = None
356     for subdir in possible_subdirs(app):
357         if subdir == '.':
358             root_dir = build_dir
359         else:
360             root_dir = os.path.join(build_dir, subdir)
361         new_name = common.fetch_real_name(root_dir, last_build.gradle)
362         if new_name is not None:
363             break
364     commitmsg = None
365     if new_name:
366         logging.debug("...got autoname '" + new_name + "'")
367         if new_name != app.AutoName:
368             app.AutoName = 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     return commitmsg
375
376
377 def checkupdates_app(app, first=True):
378
379     # If a change is made, commitmsg should be set to a description of it.
380     # Only if this is set will changes be written back to the metadata.
381     commitmsg = None
382
383     tag = None
384     msg = None
385     vercode = None
386     noverok = False
387     mode = app.UpdateCheckMode
388     if mode.startswith('Tags'):
389         pattern = mode[5:] if len(mode) > 4 else None
390         (version, vercode, tag) = check_tags(app, pattern)
391         msg = vercode
392     elif mode == 'RepoManifest':
393         (version, vercode) = check_repomanifest(app)
394         msg = vercode
395     elif mode.startswith('RepoManifest/'):
396         tag = mode[13:]
397         (version, vercode) = check_repomanifest(app, tag)
398         msg = vercode
399     elif mode == 'RepoTrunk':
400         (version, vercode) = check_repotrunk(app)
401         msg = vercode
402     elif mode == 'HTTP':
403         (version, vercode) = check_http(app)
404         msg = vercode
405     elif mode in ('None', 'Static'):
406         version = None
407         msg = 'Checking disabled'
408         noverok = True
409     else:
410         version = None
411         msg = 'Invalid update check method'
412
413     if version and vercode and app.VercodeOperation:
414         oldvercode = str(int(vercode))
415         op = app.VercodeOperation.replace("%c", oldvercode)
416         vercode = str(eval(op))
417         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
418
419     if version and any(version.startswith(s) for s in [
420             '${',  # Gradle variable names
421             '@string/',  # Strings we could not resolve
422             ]):
423         version = "Unknown"
424
425     updating = False
426     if version is None:
427         logmsg = "...{0} : {1}".format(app.id, msg)
428         if noverok:
429             logging.info(logmsg)
430         else:
431             logging.warn(logmsg)
432     elif vercode == app.CurrentVersionCode:
433         logging.info("...up to date")
434     else:
435         app.CurrentVersion = version
436         app.CurrentVersionCode = str(int(vercode))
437         updating = True
438
439     commitmsg = fetch_autoname(app, tag)
440
441     if updating:
442         name = common.getappname(app)
443         ver = common.getcvname(app)
444         logging.info('...updating to version %s' % ver)
445         commitmsg = 'Update CV of %s to %s' % (name, ver)
446
447     if options.auto:
448         mode = app.AutoUpdateMode
449         if mode in ('None', 'Static'):
450             pass
451         elif mode.startswith('Version '):
452             pattern = mode[8:]
453             if pattern.startswith('+'):
454                 try:
455                     suffix, pattern = pattern.split(' ', 1)
456                 except ValueError:
457                     raise MetaDataException("Invalid AUM: " + mode)
458             else:
459                 suffix = ''
460             gotcur = False
461             latest = None
462             for build in app.builds:
463                 if int(build.vercode) >= int(app.CurrentVersionCode):
464                     gotcur = True
465                 if not latest or int(build.vercode) > int(latest.vercode):
466                     latest = build
467
468             if int(latest.vercode) > int(app.CurrentVersionCode):
469                 logging.info("Refusing to auto update, since the latest build is newer")
470
471             if not gotcur:
472                 newbuild = copy.deepcopy(latest)
473                 newbuild.disable = False
474                 newbuild.vercode = app.CurrentVersionCode
475                 newbuild.version = app.CurrentVersion + suffix
476                 logging.info("...auto-generating build for " + newbuild.version)
477                 commit = pattern.replace('%v', newbuild.version)
478                 commit = commit.replace('%c', newbuild.vercode)
479                 newbuild.commit = commit
480                 app.builds.append(newbuild)
481                 name = common.getappname(app)
482                 ver = common.getcvname(app)
483                 commitmsg = "Update %s to %s" % (name, ver)
484         else:
485             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
486
487     if commitmsg:
488         metadatapath = os.path.join('metadata', app.id + '.txt')
489         with open(metadatapath, 'w') as f:
490             metadata.write_metadata('txt', f, app)
491         if options.commit:
492             logging.info("Commiting update for " + metadatapath)
493             gitcmd = ["git", "commit", "-m", commitmsg]
494             if 'auto_author' in config:
495                 gitcmd.extend(['--author', config['auto_author']])
496             gitcmd.extend(["--", metadatapath])
497             if subprocess.call(gitcmd) != 0:
498                 logging.error("Git commit failed")
499                 sys.exit(1)
500
501
502 config = None
503 options = None
504
505
506 def main():
507
508     global config, options
509
510     # Parse command line...
511     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
512     common.setup_global_opts(parser)
513     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
514     parser.add_argument("--auto", action="store_true", default=False,
515                         help="Process auto-updates")
516     parser.add_argument("--autoonly", action="store_true", default=False,
517                         help="Only process apps with auto-updates")
518     parser.add_argument("--commit", action="store_true", default=False,
519                         help="Commit changes")
520     parser.add_argument("--gplay", action="store_true", default=False,
521                         help="Only print differences with the Play Store")
522     options = parser.parse_args()
523
524     config = common.read_config(options)
525
526     # Get all apps...
527     allapps = metadata.read_metadata()
528
529     apps = common.read_app_args(options.appid, allapps, False)
530
531     if options.gplay:
532         for app in apps:
533             version, reason = check_gplay(app)
534             if version is None:
535                 if reason == '404':
536                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
537                 else:
538                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
539             if version is not None:
540                 stored = app.CurrentVersion
541                 if not stored:
542                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
543                                  .format(common.getappname(app), version))
544                 elif LooseVersion(stored) < LooseVersion(version):
545                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
546                                  .format(common.getappname(app), version, stored))
547                 else:
548                     if stored != version:
549                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
550                                      .format(common.getappname(app), version, stored))
551                     else:
552                         logging.info("{0} has the same version {1} on the Play Store"
553                                      .format(common.getappname(app), version))
554         return
555
556     for appid, app in apps.iteritems():
557
558         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
559             logging.debug("Nothing to do for {0}...".format(appid))
560             continue
561
562         logging.info("Processing " + appid + '...')
563
564         checkupdates_app(app)
565
566     logging.info("Finished.")
567
568 if __name__ == "__main__":
569     main()