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