chiark / gitweb /
Catch update check failure and proceed, not bomb out
[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 = app.get_last_build()
113
114         if last_build.submodules:
115             vcs.initsubmodules()
116
117         hpak = None
118         htag = None
119         hver = None
120         hcode = "0"
121
122         tags = []
123         if repotype == 'git':
124             tags = vcs.latesttags()
125         else:
126             tags = vcs.gettags()
127         if not tags:
128             return (None, "No tags found", None)
129
130         logging.debug("All tags: " + ','.join(tags))
131         if pattern:
132             pat = re.compile(pattern)
133             tags = [tag for tag in tags if pat.match(tag)]
134             if not tags:
135                 return (None, "No matching tags found", None)
136             logging.debug("Matching tags: " + ','.join(tags))
137
138         if len(tags) > 5 and repotype == 'git':
139             tags = tags[:5]
140             logging.debug("Latest tags: " + ','.join(tags))
141
142         for tag in tags:
143             logging.debug("Check tag: '{0}'".format(tag))
144             vcs.gotorevision(tag)
145
146             for subdir in possible_subdirs(app):
147                 if subdir == '.':
148                     root_dir = build_dir
149                 else:
150                     root_dir = os.path.join(build_dir, subdir)
151                 paths = common.manifest_paths(root_dir, last_build.gradle)
152                 version, vercode, package = common.parse_androidmanifests(paths, app)
153                 if vercode:
154                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
155                                   .format(subdir, version, vercode))
156                     if int(vercode) > int(hcode):
157                         hpak = package
158                         htag = tag
159                         hcode = str(int(vercode))
160                         hver = version
161
162         if not hpak:
163             return (None, "Couldn't find package ID", None)
164         if hver:
165             return (hver, hcode, htag)
166         return (None, "Couldn't find any version information", None)
167
168     except VCSException as vcse:
169         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
170         return (None, msg, None)
171     except Exception:
172         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
173         return (None, msg, None)
174
175
176 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
177 # of the source repo. Whether this can be used reliably or not depends on
178 # the development procedures used by the project's developers. Use it with
179 # caution, because it's inappropriate for many projects.
180 # Returns (None, "a message") if this didn't work, or (version, vercode) for
181 # the details of the current version.
182 def check_repomanifest(app, branch=None):
183
184     try:
185
186         if app.RepoType == 'srclib':
187             build_dir = os.path.join('build', 'srclib', app.Repo)
188             repotype = common.getsrclibvcs(app.Repo)
189         else:
190             build_dir = os.path.join('build', app.id)
191             repotype = app.RepoType
192
193         # Set up vcs interface and make sure we have the latest code...
194         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
195
196         if repotype == 'git':
197             if branch:
198                 branch = 'origin/' + branch
199             vcs.gotorevision(branch)
200         elif repotype == 'git-svn':
201             vcs.gotorevision(branch)
202         elif repotype == 'hg':
203             vcs.gotorevision(branch)
204         elif repotype == 'bzr':
205             vcs.gotorevision(None)
206
207         last_build = metadata.Build()
208         if len(app.builds) > 0:
209             last_build = app.builds[-1]
210
211         if last_build.submodules:
212             vcs.initsubmodules()
213
214         hpak = None
215         hver = None
216         hcode = "0"
217         for subdir in possible_subdirs(app):
218             if subdir == '.':
219                 root_dir = build_dir
220             else:
221                 root_dir = os.path.join(build_dir, subdir)
222             paths = common.manifest_paths(root_dir, last_build.gradle)
223             version, vercode, package = common.parse_androidmanifests(paths, app)
224             if vercode:
225                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
226                               .format(subdir, version, vercode))
227                 if int(vercode) > int(hcode):
228                     hpak = package
229                     hcode = str(int(vercode))
230                     hver = version
231
232         if not hpak:
233             return (None, "Couldn't find package ID")
234         if hver:
235             return (hver, hcode)
236         return (None, "Couldn't find any version information")
237
238     except VCSException as vcse:
239         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
240         return (None, msg)
241     except Exception:
242         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
243         return (None, msg)
244
245
246 def check_repotrunk(app, branch=None):
247
248     try:
249         if app.RepoType == 'srclib':
250             build_dir = os.path.join('build', 'srclib', app.Repo)
251             repotype = common.getsrclibvcs(app.Repo)
252         else:
253             build_dir = os.path.join('build', app.id)
254             repotype = app.RepoType
255
256         if repotype not in ('git-svn', ):
257             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
258
259         # Set up vcs interface and make sure we have the latest code...
260         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
261
262         vcs.gotorevision(None)
263
264         ref = vcs.getref()
265         return (ref, ref)
266     except VCSException as vcse:
267         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
268         return (None, msg)
269     except Exception:
270         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
271         return (None, msg)
272
273
274 # Check for a new version by looking at the Google Play Store.
275 # Returns (None, "a message") if this didn't work, or (version, None) for
276 # the details of the current version.
277 def check_gplay(app):
278     time.sleep(15)
279     url = 'https://play.google.com/store/apps/details?id=' + app.id
280     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
281     req = urllib.request.Request(url, None, headers)
282     try:
283         resp = urllib.request.urlopen(req, None, 20)
284         page = resp.read()
285     except urllib.error.HTTPError as e:
286         return (None, str(e.code))
287     except Exception as e:
288         return (None, 'Failed:' + str(e))
289
290     version = None
291
292     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
293     if m:
294         html_parser = HTMLParser()
295         version = html_parser.unescape(m.group(1))
296
297     if version == 'Varies with device':
298         return (None, 'Device-variable version, cannot use this method')
299
300     if not version:
301         return (None, "Couldn't find version")
302     return (version.strip(), None)
303
304
305 # Return all directories under startdir that contain any of the manifest
306 # files, and thus are probably an Android project.
307 def dirs_with_manifest(startdir):
308     for r, d, f in os.walk(startdir):
309         if any(m in f for m in [
310                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
311             yield r
312
313
314 # Tries to find a new subdir starting from the root build_dir. Returns said
315 # subdir relative to the build dir if found, None otherwise.
316 def possible_subdirs(app):
317
318     if app.RepoType == 'srclib':
319         build_dir = os.path.join('build', 'srclib', app.Repo)
320     else:
321         build_dir = os.path.join('build', app.id)
322
323     last_build = app.get_last_build()
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 = app.get_last_build()
351
352     logging.debug("...fetch auto name from " + build_dir)
353     new_name = None
354     for subdir in possible_subdirs(app):
355         if subdir == '.':
356             root_dir = build_dir
357         else:
358             root_dir = os.path.join(build_dir, subdir)
359         new_name = common.fetch_real_name(root_dir, last_build.gradle)
360         if new_name is not None:
361             break
362     commitmsg = None
363     if new_name:
364         logging.debug("...got autoname '" + new_name + "'")
365         if new_name != app.AutoName:
366             app.AutoName = new_name
367             if not commitmsg:
368                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
369     else:
370         logging.debug("...couldn't get autoname")
371
372     return commitmsg
373
374
375 def checkupdates_app(app, first=True):
376
377     # If a change is made, commitmsg should be set to a description of it.
378     # Only if this is set will changes be written back to the metadata.
379     commitmsg = None
380
381     tag = None
382     msg = None
383     vercode = None
384     noverok = False
385     mode = app.UpdateCheckMode
386     if mode.startswith('Tags'):
387         pattern = mode[5:] if len(mode) > 4 else None
388         (version, vercode, tag) = check_tags(app, pattern)
389         if version == 'Unknown':
390             version = tag
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 not app.CurrentVersionCode:
450             logging.warn("Can't auto-update app with no current version code: " + app.id)
451         elif 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.versionCode) >= int(app.CurrentVersionCode):
466                     gotcur = True
467                 if not latest or int(build.versionCode) > int(latest.versionCode):
468                     latest = build
469
470             if int(latest.versionCode) > 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.versionCode = app.CurrentVersionCode
477                 newbuild.versionName = app.CurrentVersion + suffix
478                 logging.info("...auto-generating build for " + newbuild.versionName)
479                 commit = pattern.replace('%v', newbuild.versionName)
480                 commit = commit.replace('%c', newbuild.versionCode)
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         metadata.write_metadata(metadatapath, app)
492         if options.commit:
493             logging.info("Commiting update for " + metadatapath)
494             gitcmd = ["git", "commit", "-m", commitmsg]
495             if 'auto_author' in config:
496                 gitcmd.extend(['--author', config['auto_author']])
497             gitcmd.extend(["--", metadatapath])
498             if subprocess.call(gitcmd) != 0:
499                 logging.error("Git commit failed")
500                 sys.exit(1)
501
502
503 config = None
504 options = None
505
506
507 def main():
508
509     global config, options
510
511     # Parse command line...
512     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
513     common.setup_global_opts(parser)
514     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
515     parser.add_argument("--auto", action="store_true", default=False,
516                         help="Process auto-updates")
517     parser.add_argument("--autoonly", action="store_true", default=False,
518                         help="Only process apps with auto-updates")
519     parser.add_argument("--commit", action="store_true", default=False,
520                         help="Commit changes")
521     parser.add_argument("--gplay", action="store_true", default=False,
522                         help="Only print differences with the Play Store")
523     metadata.add_metadata_arguments(parser)
524     options = parser.parse_args()
525     metadata.warnings_action = options.W
526
527     config = common.read_config(options)
528
529     # Get all apps...
530     allapps = metadata.read_metadata()
531
532     apps = common.read_app_args(options.appid, allapps, False)
533
534     if options.gplay:
535         for app in apps:
536             version, reason = check_gplay(app)
537             if version is None:
538                 if reason == '404':
539                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
540                 else:
541                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
542             if version is not None:
543                 stored = app.CurrentVersion
544                 if not stored:
545                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
546                                  .format(common.getappname(app), version))
547                 elif LooseVersion(stored) < LooseVersion(version):
548                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
549                                  .format(common.getappname(app), version, stored))
550                 else:
551                     if stored != version:
552                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
553                                      .format(common.getappname(app), version, stored))
554                     else:
555                         logging.info("{0} has the same version {1} on the Play Store"
556                                      .format(common.getappname(app), version))
557         return
558
559     for appid, app in apps.items():
560
561         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
562             logging.debug("Nothing to do for {0}...".format(appid))
563             continue
564
565         logging.info("Processing " + appid + '...')
566
567         try:
568             checkupdates_app(app)
569         except Exception as e:
570             logging.error("...checkupdate failed for {0} : {1}".format(appid, e))
571
572     logging.info("Finished.")
573
574
575 if __name__ == "__main__":
576     main()