chiark / gitweb /
856a514c1404330a7ead0351f097951f05f35264
[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 from html.parser import HTMLParser
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         html_parser = HTMLParser()
293         version = html_parser.unescape(m.group(1))
294
295     if version == 'Varies with device':
296         return (None, 'Device-variable version, cannot use this method')
297
298     if not version:
299         return (None, "Couldn't find version")
300     return (version.strip(), None)
301
302
303 # Return all directories under startdir that contain any of the manifest
304 # files, and thus are probably an Android project.
305 def dirs_with_manifest(startdir):
306     for r, d, f in os.walk(startdir):
307         if any(m in f for m in [
308                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
309             yield r
310
311
312 # Tries to find a new subdir starting from the root build_dir. Returns said
313 # subdir relative to the build dir if found, None otherwise.
314 def possible_subdirs(app):
315
316     if app.RepoType == 'srclib':
317         build_dir = os.path.join('build', 'srclib', app.Repo)
318     else:
319         build_dir = os.path.join('build', app.id)
320
321     last_build = app.get_last_build()
322
323     for d in dirs_with_manifest(build_dir):
324         m_paths = common.manifest_paths(d, last_build.gradle)
325         package = common.parse_androidmanifests(m_paths, app)[2]
326         if package is not None:
327             subdir = os.path.relpath(d, build_dir)
328             logging.debug("Adding possible subdir %s" % subdir)
329             yield subdir
330
331
332 def fetch_autoname(app, tag):
333
334     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
335         return None
336
337     if app.RepoType == 'srclib':
338         build_dir = os.path.join('build', 'srclib', app.Repo)
339     else:
340         build_dir = os.path.join('build', app.id)
341
342     try:
343         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
344         vcs.gotorevision(tag)
345     except VCSException:
346         return None
347
348     last_build = app.get_last_build()
349
350     logging.debug("...fetch auto name from " + build_dir)
351     new_name = None
352     for subdir in possible_subdirs(app):
353         if subdir == '.':
354             root_dir = build_dir
355         else:
356             root_dir = os.path.join(build_dir, subdir)
357         new_name = common.fetch_real_name(root_dir, last_build.gradle)
358         if new_name is not None:
359             break
360     commitmsg = None
361     if new_name:
362         logging.debug("...got autoname '" + new_name + "'")
363         if new_name != app.AutoName:
364             app.AutoName = new_name
365             if not commitmsg:
366                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
367     else:
368         logging.debug("...couldn't get autoname")
369
370     return commitmsg
371
372
373 def checkupdates_app(app):
374
375     # If a change is made, commitmsg should be set to a description of it.
376     # Only if this is set will changes be written back to the metadata.
377     commitmsg = None
378
379     tag = None
380     msg = None
381     vercode = None
382     noverok = False
383     mode = app.UpdateCheckMode
384     if mode.startswith('Tags'):
385         pattern = mode[5:] if len(mode) > 4 else None
386         (version, vercode, tag) = check_tags(app, pattern)
387         if version == 'Unknown':
388             version = tag
389         msg = vercode
390     elif mode == 'RepoManifest':
391         (version, vercode) = check_repomanifest(app)
392         msg = vercode
393     elif mode.startswith('RepoManifest/'):
394         tag = mode[13:]
395         (version, vercode) = check_repomanifest(app, tag)
396         msg = vercode
397     elif mode == 'RepoTrunk':
398         (version, vercode) = check_repotrunk(app)
399         msg = vercode
400     elif mode == 'HTTP':
401         (version, vercode) = check_http(app)
402         msg = vercode
403     elif mode in ('None', 'Static'):
404         version = None
405         msg = 'Checking disabled'
406         noverok = True
407     else:
408         version = None
409         msg = 'Invalid update check method'
410
411     if version and vercode and app.VercodeOperation:
412         oldvercode = str(int(vercode))
413         op = app.VercodeOperation.replace("%c", oldvercode)
414         vercode = str(eval(op))
415         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
416
417     if version and any(version.startswith(s) for s in [
418             '${',  # Gradle variable names
419             '@string/',  # Strings we could not resolve
420             ]):
421         version = "Unknown"
422
423     updating = False
424     if version is None:
425         logmsg = "...{0} : {1}".format(app.id, msg)
426         if noverok:
427             logging.info(logmsg)
428         else:
429             logging.warn(logmsg)
430     elif vercode == app.CurrentVersionCode:
431         logging.info("...up to date")
432     else:
433         logging.debug("...updating - old vercode={0}, new vercode={1}".format(
434             app.CurrentVersionCode, vercode))
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                 raise FDroidException("Git commit failed")
500
501
502 config = None
503 options = None
504
505
506 def main():
507
508     global config, options
509
510     # Parse command line...
511     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
512     common.setup_global_opts(parser)
513     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
514     parser.add_argument("--auto", action="store_true", default=False,
515                         help="Process auto-updates")
516     parser.add_argument("--autoonly", action="store_true", default=False,
517                         help="Only process apps with auto-updates")
518     parser.add_argument("--commit", action="store_true", default=False,
519                         help="Commit changes")
520     parser.add_argument("--gplay", action="store_true", default=False,
521                         help="Only print differences with the Play Store")
522     metadata.add_metadata_arguments(parser)
523     options = parser.parse_args()
524     metadata.warnings_action = options.W
525
526     config = common.read_config(options)
527
528     # Get all apps...
529     allapps = metadata.read_metadata()
530
531     apps = common.read_app_args(options.appid, allapps, False)
532
533     if options.gplay:
534         for appid, app in apps.items():
535             version, reason = check_gplay(app)
536             if version is None:
537                 if reason == '404':
538                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
539                 else:
540                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
541             if version is not None:
542                 stored = app.CurrentVersion
543                 if not stored:
544                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
545                                  .format(common.getappname(app), version))
546                 elif LooseVersion(stored) < LooseVersion(version):
547                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
548                                  .format(common.getappname(app), version, stored))
549                 else:
550                     if stored != version:
551                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
552                                      .format(common.getappname(app), version, stored))
553                     else:
554                         logging.info("{0} has the same version {1} on the Play Store"
555                                      .format(common.getappname(app), version))
556         return
557
558     for appid, app in apps.items():
559
560         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
561             logging.debug("Nothing to do for {0}...".format(appid))
562             continue
563
564         logging.info("Processing " + appid + '...')
565
566         try:
567             checkupdates_app(app)
568         except Exception as e:
569             logging.error("...checkupdate failed for {0} : {1}".format(appid, e))
570
571     logging.info("Finished.")
572
573
574 if __name__ == "__main__":
575     main()