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