chiark / gitweb /
b617ea9aa12d7e13a1a44851dfc75e560ca82526
[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         if version == 'Unknown':
392             version = tag
393         msg = vercode
394     elif mode == 'RepoManifest':
395         (version, vercode) = check_repomanifest(app)
396         msg = vercode
397     elif mode.startswith('RepoManifest/'):
398         tag = mode[13:]
399         (version, vercode) = check_repomanifest(app, tag)
400         msg = vercode
401     elif mode == 'RepoTrunk':
402         (version, vercode) = check_repotrunk(app)
403         msg = vercode
404     elif mode == 'HTTP':
405         (version, vercode) = check_http(app)
406         msg = vercode
407     elif mode in ('None', 'Static'):
408         version = None
409         msg = 'Checking disabled'
410         noverok = True
411     else:
412         version = None
413         msg = 'Invalid update check method'
414
415     if version and vercode and app.VercodeOperation:
416         oldvercode = str(int(vercode))
417         op = app.VercodeOperation.replace("%c", oldvercode)
418         vercode = str(eval(op))
419         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
420
421     if version and any(version.startswith(s) for s in [
422             '${',  # Gradle variable names
423             '@string/',  # Strings we could not resolve
424             ]):
425         version = "Unknown"
426
427     updating = False
428     if version is None:
429         logmsg = "...{0} : {1}".format(app.id, msg)
430         if noverok:
431             logging.info(logmsg)
432         else:
433             logging.warn(logmsg)
434     elif vercode == app.CurrentVersionCode:
435         logging.info("...up to date")
436     else:
437         app.CurrentVersion = version
438         app.CurrentVersionCode = str(int(vercode))
439         updating = True
440
441     commitmsg = fetch_autoname(app, tag)
442
443     if updating:
444         name = common.getappname(app)
445         ver = common.getcvname(app)
446         logging.info('...updating to version %s' % ver)
447         commitmsg = 'Update CV of %s to %s' % (name, ver)
448
449     if options.auto:
450         mode = app.AutoUpdateMode
451         if mode in ('None', 'Static'):
452             pass
453         elif mode.startswith('Version '):
454             pattern = mode[8:]
455             if pattern.startswith('+'):
456                 try:
457                     suffix, pattern = pattern.split(' ', 1)
458                 except ValueError:
459                     raise MetaDataException("Invalid AUM: " + mode)
460             else:
461                 suffix = ''
462             gotcur = False
463             latest = None
464             for build in app.builds:
465                 if int(build.vercode) >= int(app.CurrentVersionCode):
466                     gotcur = True
467                 if not latest or int(build.vercode) > int(latest.vercode):
468                     latest = build
469
470             if int(latest.vercode) > int(app.CurrentVersionCode):
471                 logging.info("Refusing to auto update, since the latest build is newer")
472
473             if not gotcur:
474                 newbuild = copy.deepcopy(latest)
475                 newbuild.disable = False
476                 newbuild.vercode = app.CurrentVersionCode
477                 newbuild.version = app.CurrentVersion + suffix
478                 logging.info("...auto-generating build for " + newbuild.version)
479                 commit = pattern.replace('%v', newbuild.version)
480                 commit = commit.replace('%c', newbuild.vercode)
481                 newbuild.commit = commit
482                 app.builds.append(newbuild)
483                 name = common.getappname(app)
484                 ver = common.getcvname(app)
485                 commitmsg = "Update %s to %s" % (name, ver)
486         else:
487             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
488
489     if commitmsg:
490         metadatapath = os.path.join('metadata', app.id + '.txt')
491         with open(metadatapath, 'w') as f:
492             metadata.write_metadata('txt', f, app)
493         if options.commit:
494             logging.info("Commiting update for " + metadatapath)
495             gitcmd = ["git", "commit", "-m", commitmsg]
496             if 'auto_author' in config:
497                 gitcmd.extend(['--author', config['auto_author']])
498             gitcmd.extend(["--", metadatapath])
499             if subprocess.call(gitcmd) != 0:
500                 logging.error("Git commit failed")
501                 sys.exit(1)
502
503
504 config = None
505 options = None
506
507
508 def main():
509
510     global config, options
511
512     # Parse command line...
513     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
514     common.setup_global_opts(parser)
515     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
516     parser.add_argument("--auto", action="store_true", default=False,
517                         help="Process auto-updates")
518     parser.add_argument("--autoonly", action="store_true", default=False,
519                         help="Only process apps with auto-updates")
520     parser.add_argument("--commit", action="store_true", default=False,
521                         help="Commit changes")
522     parser.add_argument("--gplay", action="store_true", default=False,
523                         help="Only print differences with the Play Store")
524     options = parser.parse_args()
525
526     config = common.read_config(options)
527
528     # Get all apps...
529     allapps = metadata.read_metadata()
530
531     apps = common.read_app_args(options.appid, allapps, False)
532
533     if options.gplay:
534         for app in apps:
535             version, reason = check_gplay(app)
536             if version is None:
537                 if reason == '404':
538                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
539                 else:
540                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
541             if version is not None:
542                 stored = app.CurrentVersion
543                 if not stored:
544                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
545                                  .format(common.getappname(app), version))
546                 elif LooseVersion(stored) < LooseVersion(version):
547                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
548                                  .format(common.getappname(app), version, stored))
549                 else:
550                     if stored != version:
551                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
552                                      .format(common.getappname(app), version, stored))
553                     else:
554                         logging.info("{0} has the same version {1} on the Play Store"
555                                      .format(common.getappname(app), version))
556         return
557
558     for appid, app in apps.iteritems():
559
560         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
561             logging.debug("Nothing to do for {0}...".format(appid))
562             continue
563
564         logging.info("Processing " + appid + '...')
565
566         checkupdates_app(app)
567
568     logging.info("Finished.")
569
570 if __name__ == "__main__":
571     main()