chiark / gitweb /
Merge branch 'additional_tests' into 'master'
[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 _
34 from . import common
35 from . import metadata
36 from .exception import VCSException, NoSubmodulesException, FDroidException, MetaDataException
37
38
39 # Check for a new version by looking at a document retrieved via HTTP.
40 # The app's Update Check Data field is used to provide the information
41 # required.
42 def check_http(app):
43
44     try:
45
46         if not app.UpdateCheckData:
47             raise FDroidException('Missing Update Check Data')
48
49         urlcode, codeex, urlver, verex = app.UpdateCheckData.split('|')
50
51         vercode = "99999999"
52         if len(urlcode) > 0:
53             logging.debug("...requesting {0}".format(urlcode))
54             req = urllib.request.Request(urlcode, None)
55             resp = urllib.request.urlopen(req, None, 20)
56             page = resp.read().decode('utf-8')
57
58             m = re.search(codeex, page)
59             if not m:
60                 raise FDroidException("No RE match for version code")
61             vercode = m.group(1).strip()
62
63         version = "??"
64         if len(urlver) > 0:
65             if urlver != '.':
66                 logging.debug("...requesting {0}".format(urlver))
67                 req = urllib.request.Request(urlver, None)
68                 resp = urllib.request.urlopen(req, None, 20)
69                 page = resp.read().decode('utf-8')
70
71             m = re.search(verex, page)
72             if not m:
73                 raise FDroidException("No RE match for version")
74             version = m.group(1)
75
76         return (version, vercode)
77
78     except FDroidException:
79         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
80         return (None, msg)
81
82
83 # Check for a new version by looking at the tags in the source repo.
84 # Whether this can be used reliably or not depends on
85 # the development procedures used by the project's developers. Use it with
86 # caution, because it's inappropriate for many projects.
87 # Returns (None, "a message") if this didn't work, or (version, vercode, tag) for
88 # the details of the current version.
89 def check_tags(app, pattern):
90
91     try:
92
93         if app.RepoType == 'srclib':
94             build_dir = os.path.join('build', 'srclib', app.Repo)
95             repotype = common.getsrclibvcs(app.Repo)
96         else:
97             build_dir = os.path.join('build', app.id)
98             repotype = app.RepoType
99
100         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
101             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
102
103         if repotype == 'git-svn' and ';' not in app.Repo:
104             return (None, 'Tags update mode used in git-svn, but the repo was not set up with tags', None)
105
106         # Set up vcs interface and make sure we have the latest code...
107         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
108
109         vcs.gotorevision(None)
110
111         last_build = app.get_last_build()
112
113         try_init_submodules(app, last_build, vcs)
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         try_init_submodules(app, last_build, vcs)
210
211         hpak = None
212         hver = None
213         hcode = "0"
214         for subdir in possible_subdirs(app):
215             if subdir == '.':
216                 root_dir = build_dir
217             else:
218                 root_dir = os.path.join(build_dir, subdir)
219             paths = common.manifest_paths(root_dir, last_build.gradle)
220             version, vercode, package = common.parse_androidmanifests(paths, app)
221             if vercode:
222                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
223                               .format(subdir, version, vercode))
224                 if int(vercode) > int(hcode):
225                     hpak = package
226                     hcode = str(int(vercode))
227                     hver = version
228
229         if not hpak:
230             return (None, "Couldn't find package ID")
231         if hver:
232             return (hver, hcode)
233         return (None, "Couldn't find any version information")
234
235     except VCSException as vcse:
236         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
237         return (None, msg)
238     except Exception:
239         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
240         return (None, msg)
241
242
243 def check_repotrunk(app):
244
245     try:
246         if app.RepoType == 'srclib':
247             build_dir = os.path.join('build', 'srclib', app.Repo)
248             repotype = common.getsrclibvcs(app.Repo)
249         else:
250             build_dir = os.path.join('build', app.id)
251             repotype = app.RepoType
252
253         if repotype not in ('git-svn', ):
254             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
255
256         # Set up vcs interface and make sure we have the latest code...
257         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
258
259         vcs.gotorevision(None)
260
261         ref = vcs.getref()
262         return (ref, ref)
263     except VCSException as vcse:
264         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
265         return (None, msg)
266     except Exception:
267         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
268         return (None, msg)
269
270
271 # Check for a new version by looking at the Google Play Store.
272 # Returns (None, "a message") if this didn't work, or (version, None) for
273 # the details of the current version.
274 def check_gplay(app):
275     time.sleep(15)
276     url = 'https://play.google.com/store/apps/details?id=' + app.id
277     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
278     req = urllib.request.Request(url, None, headers)
279     try:
280         resp = urllib.request.urlopen(req, None, 20)
281         page = resp.read().decode()
282     except urllib.error.HTTPError as e:
283         return (None, str(e.code))
284     except Exception as e:
285         return (None, 'Failed:' + str(e))
286
287     version = None
288
289     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
290     if m:
291         version = html.unescape(m.group(1))
292
293     if version == 'Varies with device':
294         return (None, 'Device-variable version, cannot use this method')
295
296     if not version:
297         return (None, "Couldn't find version")
298     return (version.strip(), None)
299
300
301 def try_init_submodules(app, last_build, vcs):
302     """Try to init submodules if the last build entry used them.
303     They might have been removed from the app's repo in the meantime,
304     so if we can't find any submodules we continue with the updates check.
305     If there is any other error in initializing them then we stop the check.
306     """
307     if last_build.submodules:
308         try:
309             vcs.initsubmodules()
310         except NoSubmodulesException:
311             logging.info("No submodules present for {}".format(app.Name))
312
313
314 # Return all directories under startdir that contain any of the manifest
315 # files, and thus are probably an Android project.
316 def dirs_with_manifest(startdir):
317     for root, dirs, files in os.walk(startdir):
318         if any(m in files for m in [
319                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
320             yield root
321
322
323 # Tries to find a new subdir starting from the root build_dir. Returns said
324 # subdir relative to the build dir if found, None otherwise.
325 def possible_subdirs(app):
326
327     if app.RepoType == 'srclib':
328         build_dir = os.path.join('build', 'srclib', app.Repo)
329     else:
330         build_dir = os.path.join('build', app.id)
331
332     last_build = app.get_last_build()
333
334     for d in dirs_with_manifest(build_dir):
335         m_paths = common.manifest_paths(d, last_build.gradle)
336         package = common.parse_androidmanifests(m_paths, app)[2]
337         if package is not None:
338             subdir = os.path.relpath(d, build_dir)
339             logging.debug("Adding possible subdir %s" % subdir)
340             yield subdir
341
342
343 def fetch_autoname(app, tag):
344
345     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
346         return None
347
348     if app.RepoType == 'srclib':
349         build_dir = os.path.join('build', 'srclib', app.Repo)
350     else:
351         build_dir = os.path.join('build', app.id)
352
353     try:
354         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
355         vcs.gotorevision(tag)
356     except VCSException:
357         return None
358
359     last_build = app.get_last_build()
360
361     logging.debug("...fetch auto name from " + build_dir)
362     new_name = None
363     for subdir in possible_subdirs(app):
364         if subdir == '.':
365             root_dir = build_dir
366         else:
367             root_dir = os.path.join(build_dir, subdir)
368         new_name = common.fetch_real_name(root_dir, last_build.gradle)
369         if new_name is not None:
370             break
371     commitmsg = None
372     if new_name:
373         logging.debug("...got autoname '" + new_name + "'")
374         if new_name != app.AutoName:
375             app.AutoName = new_name
376             if not commitmsg:
377                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
378     else:
379         logging.debug("...couldn't get autoname")
380
381     return commitmsg
382
383
384 def checkupdates_app(app):
385
386     # If a change is made, commitmsg should be set to a description of it.
387     # Only if this is set will changes be written back to the metadata.
388     commitmsg = None
389
390     tag = None
391     msg = None
392     vercode = None
393     noverok = False
394     mode = app.UpdateCheckMode
395     if mode.startswith('Tags'):
396         pattern = mode[5:] if len(mode) > 4 else None
397         (version, vercode, tag) = check_tags(app, pattern)
398         if version == 'Unknown':
399             version = tag
400         msg = vercode
401     elif mode == 'RepoManifest':
402         (version, vercode) = check_repomanifest(app)
403         msg = vercode
404     elif mode.startswith('RepoManifest/'):
405         tag = mode[13:]
406         (version, vercode) = check_repomanifest(app, tag)
407         msg = vercode
408     elif mode == 'RepoTrunk':
409         (version, vercode) = check_repotrunk(app)
410         msg = vercode
411     elif mode == 'HTTP':
412         (version, vercode) = check_http(app)
413         msg = vercode
414     elif mode in ('None', 'Static'):
415         version = None
416         msg = 'Checking disabled'
417         noverok = True
418     else:
419         version = None
420         msg = 'Invalid update check method'
421
422     if version and vercode and app.VercodeOperation:
423         oldvercode = str(int(vercode))
424         op = app.VercodeOperation.replace("%c", oldvercode)
425         vercode = str(eval(op))
426         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
427
428     if version and any(version.startswith(s) for s in [
429             '${',  # Gradle variable names
430             '@string/',  # Strings we could not resolve
431             ]):
432         version = "Unknown"
433
434     updating = False
435     if version is None:
436         logmsg = "...{0} : {1}".format(app.id, msg)
437         if noverok:
438             logging.info(logmsg)
439         else:
440             logging.warn(logmsg)
441     elif vercode == app.CurrentVersionCode:
442         logging.info("...up to date")
443     else:
444         logging.debug("...updating - old vercode={0}, new vercode={1}".format(
445             app.CurrentVersionCode, vercode))
446         app.CurrentVersion = version
447         app.CurrentVersionCode = str(int(vercode))
448         updating = True
449
450     commitmsg = fetch_autoname(app, tag)
451
452     if updating:
453         name = common.getappname(app)
454         ver = common.getcvname(app)
455         logging.info('...updating to version %s' % ver)
456         commitmsg = 'Update CV of %s to %s' % (name, ver)
457
458     if options.auto:
459         mode = app.AutoUpdateMode
460         if not app.CurrentVersionCode:
461             logging.warn("Can't auto-update app with no current version code: " + app.id)
462         elif mode in ('None', 'Static'):
463             pass
464         elif mode.startswith('Version '):
465             pattern = mode[8:]
466             if pattern.startswith('+'):
467                 try:
468                     suffix, pattern = pattern.split(' ', 1)
469                 except ValueError:
470                     raise MetaDataException("Invalid AUM: " + mode)
471             else:
472                 suffix = ''
473             gotcur = False
474             latest = None
475             for build in app.builds:
476                 if int(build.versionCode) >= int(app.CurrentVersionCode):
477                     gotcur = True
478                 if not latest or int(build.versionCode) > int(latest.versionCode):
479                     latest = build
480
481             if int(latest.versionCode) > int(app.CurrentVersionCode):
482                 logging.info("Refusing to auto update, since the latest build is newer")
483
484             if not gotcur:
485                 newbuild = copy.deepcopy(latest)
486                 newbuild.disable = False
487                 newbuild.versionCode = app.CurrentVersionCode
488                 newbuild.versionName = app.CurrentVersion + suffix
489                 logging.info("...auto-generating build for " + newbuild.versionName)
490                 commit = pattern.replace('%v', newbuild.versionName)
491                 commit = commit.replace('%c', newbuild.versionCode)
492                 newbuild.commit = commit
493                 app.builds.append(newbuild)
494                 name = common.getappname(app)
495                 ver = common.getcvname(app)
496                 commitmsg = "Update %s to %s" % (name, ver)
497         else:
498             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
499
500     if commitmsg:
501         metadatapath = os.path.join('metadata', app.id + '.txt')
502         metadata.write_metadata(metadatapath, app)
503         if options.commit:
504             logging.info("Commiting update for " + metadatapath)
505             gitcmd = ["git", "commit", "-m", commitmsg]
506             if 'auto_author' in config:
507                 gitcmd.extend(['--author', config['auto_author']])
508             gitcmd.extend(["--", metadatapath])
509             if subprocess.call(gitcmd) != 0:
510                 raise FDroidException("Git commit failed")
511
512
513 config = None
514 options = None
515
516
517 def main():
518
519     global config, options
520
521     # Parse command line...
522     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
523     common.setup_global_opts(parser)
524     parser.add_argument("appid", nargs='*', help=_("applicationId to check for updates"))
525     parser.add_argument("--auto", action="store_true", default=False,
526                         help=_("Process auto-updates"))
527     parser.add_argument("--autoonly", action="store_true", default=False,
528                         help=_("Only process apps with auto-updates"))
529     parser.add_argument("--commit", action="store_true", default=False,
530                         help=_("Commit changes"))
531     parser.add_argument("--gplay", action="store_true", default=False,
532                         help=_("Only print differences with the Play Store"))
533     metadata.add_metadata_arguments(parser)
534     options = parser.parse_args()
535     metadata.warnings_action = options.W
536
537     config = common.read_config(options)
538
539     # Get all apps...
540     allapps = metadata.read_metadata()
541
542     apps = common.read_app_args(options.appid, allapps, False)
543
544     if options.gplay:
545         for appid, app in apps.items():
546             version, reason = check_gplay(app)
547             if version is None:
548                 if reason == '404':
549                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
550                 else:
551                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
552             if version is not None:
553                 stored = app.CurrentVersion
554                 if not stored:
555                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
556                                  .format(common.getappname(app), version))
557                 elif LooseVersion(stored) < LooseVersion(version):
558                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
559                                  .format(common.getappname(app), version, stored))
560                 else:
561                     if stored != version:
562                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
563                                      .format(common.getappname(app), version, stored))
564                     else:
565                         logging.info("{0} has the same version {1} on the Play Store"
566                                      .format(common.getappname(app), version))
567         return
568
569     for appid, app in apps.items():
570
571         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
572             logging.debug(_("Nothing to do for {appid}.").format(appid=appid))
573             continue
574
575         logging.info(_("Processing {appid}").format(appid=appid))
576
577         try:
578             checkupdates_app(app)
579         except Exception as e:
580             logging.error(_("...checkupdate failed for {appid} : {error}")
581                           .format(appid=appid, error=e))
582
583     logging.info(_("Finished"))
584
585
586 if __name__ == "__main__":
587     main()