chiark / gitweb /
checkupdates: use html.unescape instead of HTMLParser.unescape
[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 os
21 import re
22 import urllib.request
23 import urllib.error
24 import time
25 import subprocess
26 from argparse import ArgumentParser
27 import traceback
28 import html
29 from distutils.version import LooseVersion
30 import logging
31 import copy
32
33 from . import common
34 from . import metadata
35 from .exception import VCSException, FDroidException, MetaDataException
36
37
38 # Check for a new version by looking at a document retrieved via HTTP.
39 # The app's Update Check Data field is used to provide the information
40 # required.
41 def check_http(app):
42
43     try:
44
45         if not app.UpdateCheckData:
46             raise FDroidException('Missing Update Check Data')
47
48         urlcode, codeex, urlver, verex = app.UpdateCheckData.split('|')
49
50         vercode = "99999999"
51         if len(urlcode) > 0:
52             logging.debug("...requesting {0}".format(urlcode))
53             req = urllib.request.Request(urlcode, None)
54             resp = urllib.request.urlopen(req, None, 20)
55             page = resp.read().decode('utf-8')
56
57             m = re.search(codeex, page)
58             if not m:
59                 raise FDroidException("No RE match for version code")
60             vercode = m.group(1).strip()
61
62         version = "??"
63         if len(urlver) > 0:
64             if urlver != '.':
65                 logging.debug("...requesting {0}".format(urlver))
66                 req = urllib.request.Request(urlver, None)
67                 resp = urllib.request.urlopen(req, None, 20)
68                 page = resp.read().decode('utf-8')
69
70             m = re.search(verex, page)
71             if not m:
72                 raise FDroidException("No RE match for version")
73             version = m.group(1)
74
75         return (version, vercode)
76
77     except FDroidException:
78         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
79         return (None, msg)
80
81
82 # Check for a new version by looking at the tags in the source repo.
83 # Whether this can be used reliably or not depends on
84 # the development procedures used by the project's developers. Use it with
85 # caution, because it's inappropriate for many projects.
86 # Returns (None, "a message") if this didn't work, or (version, vercode, tag) for
87 # the details of the current version.
88 def check_tags(app, pattern):
89
90     try:
91
92         if app.RepoType == 'srclib':
93             build_dir = os.path.join('build', 'srclib', app.Repo)
94             repotype = common.getsrclibvcs(app.Repo)
95         else:
96             build_dir = os.path.join('build', app.id)
97             repotype = app.RepoType
98
99         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
100             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
101
102         if repotype == 'git-svn' and ';' not in app.Repo:
103             return (None, 'Tags update mode used in git-svn, but the repo was not set up with tags', None)
104
105         # Set up vcs interface and make sure we have the latest code...
106         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
107
108         vcs.gotorevision(None)
109
110         last_build = app.get_last_build()
111
112         if last_build.submodules:
113             vcs.initsubmodules()
114
115         hpak = None
116         htag = None
117         hver = None
118         hcode = "0"
119
120         tags = []
121         if repotype == 'git':
122             tags = vcs.latesttags()
123         else:
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 == 'git':
137             tags = 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):
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 = urllib.request.Request(url, None, headers)
280     try:
281         resp = urllib.request.urlopen(req, None, 20)
282         page = resp.read().decode()
283     except urllib.error.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         version = html.unescape(m.group(1))
293
294     if version == 'Varies with device':
295         return (None, 'Device-variable version, cannot use this method')
296
297     if not version:
298         return (None, "Couldn't find version")
299     return (version.strip(), None)
300
301
302 # Return all directories under startdir that contain any of the manifest
303 # files, and thus are probably an Android project.
304 def dirs_with_manifest(startdir):
305     for r, d, f in os.walk(startdir):
306         if any(m in f for m in [
307                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
308             yield r
309
310
311 # Tries to find a new subdir starting from the root build_dir. Returns said
312 # subdir relative to the build dir if found, None otherwise.
313 def possible_subdirs(app):
314
315     if app.RepoType == 'srclib':
316         build_dir = os.path.join('build', 'srclib', app.Repo)
317     else:
318         build_dir = os.path.join('build', app.id)
319
320     last_build = app.get_last_build()
321
322     for d in dirs_with_manifest(build_dir):
323         m_paths = common.manifest_paths(d, last_build.gradle)
324         package = common.parse_androidmanifests(m_paths, app)[2]
325         if package is not None:
326             subdir = os.path.relpath(d, build_dir)
327             logging.debug("Adding possible subdir %s" % subdir)
328             yield subdir
329
330
331 def fetch_autoname(app, tag):
332
333     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
334         return None
335
336     if app.RepoType == 'srclib':
337         build_dir = os.path.join('build', 'srclib', app.Repo)
338     else:
339         build_dir = os.path.join('build', app.id)
340
341     try:
342         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
343         vcs.gotorevision(tag)
344     except VCSException:
345         return None
346
347     last_build = app.get_last_build()
348
349     logging.debug("...fetch auto name from " + build_dir)
350     new_name = None
351     for subdir in possible_subdirs(app):
352         if subdir == '.':
353             root_dir = build_dir
354         else:
355             root_dir = os.path.join(build_dir, subdir)
356         new_name = common.fetch_real_name(root_dir, last_build.gradle)
357         if new_name is not None:
358             break
359     commitmsg = None
360     if new_name:
361         logging.debug("...got autoname '" + new_name + "'")
362         if new_name != app.AutoName:
363             app.AutoName = new_name
364             if not commitmsg:
365                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
366     else:
367         logging.debug("...couldn't get autoname")
368
369     return commitmsg
370
371
372 def checkupdates_app(app):
373
374     # If a change is made, commitmsg should be set to a description of it.
375     # Only if this is set will changes be written back to the metadata.
376     commitmsg = None
377
378     tag = None
379     msg = None
380     vercode = None
381     noverok = False
382     mode = app.UpdateCheckMode
383     if mode.startswith('Tags'):
384         pattern = mode[5:] if len(mode) > 4 else None
385         (version, vercode, tag) = check_tags(app, pattern)
386         if version == 'Unknown':
387             version = tag
388         msg = vercode
389     elif mode == 'RepoManifest':
390         (version, vercode) = check_repomanifest(app)
391         msg = vercode
392     elif mode.startswith('RepoManifest/'):
393         tag = mode[13:]
394         (version, vercode) = check_repomanifest(app, tag)
395         msg = vercode
396     elif mode == 'RepoTrunk':
397         (version, vercode) = check_repotrunk(app)
398         msg = vercode
399     elif mode == 'HTTP':
400         (version, vercode) = check_http(app)
401         msg = vercode
402     elif mode in ('None', 'Static'):
403         version = None
404         msg = 'Checking disabled'
405         noverok = True
406     else:
407         version = None
408         msg = 'Invalid update check method'
409
410     if version and vercode and app.VercodeOperation:
411         oldvercode = str(int(vercode))
412         op = app.VercodeOperation.replace("%c", oldvercode)
413         vercode = str(eval(op))
414         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
415
416     if version and any(version.startswith(s) for s in [
417             '${',  # Gradle variable names
418             '@string/',  # Strings we could not resolve
419             ]):
420         version = "Unknown"
421
422     updating = False
423     if version is None:
424         logmsg = "...{0} : {1}".format(app.id, msg)
425         if noverok:
426             logging.info(logmsg)
427         else:
428             logging.warn(logmsg)
429     elif vercode == app.CurrentVersionCode:
430         logging.info("...up to date")
431     else:
432         logging.debug("...updating - old vercode={0}, new vercode={1}".format(
433             app.CurrentVersionCode, vercode))
434         app.CurrentVersion = version
435         app.CurrentVersionCode = str(int(vercode))
436         updating = True
437
438     commitmsg = fetch_autoname(app, tag)
439
440     if updating:
441         name = common.getappname(app)
442         ver = common.getcvname(app)
443         logging.info('...updating to version %s' % ver)
444         commitmsg = 'Update CV of %s to %s' % (name, ver)
445
446     if options.auto:
447         mode = app.AutoUpdateMode
448         if not app.CurrentVersionCode:
449             logging.warn("Can't auto-update app with no current version code: " + app.id)
450         elif mode in ('None', 'Static'):
451             pass
452         elif mode.startswith('Version '):
453             pattern = mode[8:]
454             if pattern.startswith('+'):
455                 try:
456                     suffix, pattern = pattern.split(' ', 1)
457                 except ValueError:
458                     raise MetaDataException("Invalid AUM: " + mode)
459             else:
460                 suffix = ''
461             gotcur = False
462             latest = None
463             for build in app.builds:
464                 if int(build.versionCode) >= int(app.CurrentVersionCode):
465                     gotcur = True
466                 if not latest or int(build.versionCode) > int(latest.versionCode):
467                     latest = build
468
469             if int(latest.versionCode) > int(app.CurrentVersionCode):
470                 logging.info("Refusing to auto update, since the latest build is newer")
471
472             if not gotcur:
473                 newbuild = copy.deepcopy(latest)
474                 newbuild.disable = False
475                 newbuild.versionCode = app.CurrentVersionCode
476                 newbuild.versionName = app.CurrentVersion + suffix
477                 logging.info("...auto-generating build for " + newbuild.versionName)
478                 commit = pattern.replace('%v', newbuild.versionName)
479                 commit = commit.replace('%c', newbuild.versionCode)
480                 newbuild.commit = commit
481                 app.builds.append(newbuild)
482                 name = common.getappname(app)
483                 ver = common.getcvname(app)
484                 commitmsg = "Update %s to %s" % (name, ver)
485         else:
486             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
487
488     if commitmsg:
489         metadatapath = os.path.join('metadata', app.id + '.txt')
490         metadata.write_metadata(metadatapath, 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                 raise FDroidException("Git commit failed")
499
500
501 config = None
502 options = None
503
504
505 def main():
506
507     global config, options
508
509     # Parse command line...
510     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
511     common.setup_global_opts(parser)
512     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
513     parser.add_argument("--auto", action="store_true", default=False,
514                         help="Process auto-updates")
515     parser.add_argument("--autoonly", action="store_true", default=False,
516                         help="Only process apps with auto-updates")
517     parser.add_argument("--commit", action="store_true", default=False,
518                         help="Commit changes")
519     parser.add_argument("--gplay", action="store_true", default=False,
520                         help="Only print differences with the Play Store")
521     metadata.add_metadata_arguments(parser)
522     options = parser.parse_args()
523     metadata.warnings_action = options.W
524
525     config = common.read_config(options)
526
527     # Get all apps...
528     allapps = metadata.read_metadata()
529
530     apps = common.read_app_args(options.appid, allapps, False)
531
532     if options.gplay:
533         for appid, app in apps.items():
534             version, reason = check_gplay(app)
535             if version is None:
536                 if reason == '404':
537                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
538                 else:
539                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
540             if version is not None:
541                 stored = app.CurrentVersion
542                 if not stored:
543                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
544                                  .format(common.getappname(app), version))
545                 elif LooseVersion(stored) < LooseVersion(version):
546                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
547                                  .format(common.getappname(app), version, stored))
548                 else:
549                     if stored != version:
550                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
551                                      .format(common.getappname(app), version, stored))
552                     else:
553                         logging.info("{0} has the same version {1} on the Play Store"
554                                      .format(common.getappname(app), version))
555         return
556
557     for appid, app in apps.items():
558
559         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
560             logging.debug("Nothing to do for {0}...".format(appid))
561             continue
562
563         logging.info("Processing " + appid + '...')
564
565         try:
566             checkupdates_app(app)
567         except Exception as e:
568             logging.error("...checkupdate failed for {0} : {1}".format(appid, e))
569
570     logging.info("Finished.")
571
572
573 if __name__ == "__main__":
574     main()