chiark / gitweb /
Switch all headers to python3
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python3
2 #
3 # checkupdates.py - part of the FDroid server tools
4 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import re
23 import urllib2
24 import time
25 import subprocess
26 from argparse import ArgumentParser
27 import traceback
28 import HTMLParser
29 from distutils.version import LooseVersion
30 import logging
31 import copy
32
33 import common
34 import metadata
35 from common import VCSException, FDroidException
36 from metadata import 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 = urllib2.Request(urlcode, None)
55             resp = urllib2.urlopen(req, None, 20)
56             page = resp.read()
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)
62
63         version = "??"
64         if len(urlver) > 0:
65             if urlver != '.':
66                 logging.debug("...requesting {0}".format(urlver))
67                 req = urllib2.Request(urlver, None)
68                 resp = urllib2.urlopen(req, None, 20)
69                 page = resp.read()
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 = metadata.Build()
112         if len(app.builds) > 0:
113             last_build = app.builds[-1]
114
115         if last_build.submodules:
116             vcs.initsubmodules()
117
118         hpak = None
119         htag = None
120         hver = None
121         hcode = "0"
122
123         tags = vcs.gettags()
124         if not tags:
125             return (None, "No tags found", None)
126
127         logging.debug("All tags: " + ','.join(tags))
128         if pattern:
129             pat = re.compile(pattern)
130             tags = [tag for tag in tags if pat.match(tag)]
131             if not tags:
132                 return (None, "No matching tags found", None)
133             logging.debug("Matching tags: " + ','.join(tags))
134
135         if len(tags) > 5 and repotype in ('git',):
136             tags = vcs.latesttags(tags, 5)
137             logging.debug("Latest tags: " + ','.join(tags))
138
139         for tag in tags:
140             logging.debug("Check tag: '{0}'".format(tag))
141             vcs.gotorevision(tag)
142
143             for subdir in possible_subdirs(app):
144                 if subdir == '.':
145                     root_dir = build_dir
146                 else:
147                     root_dir = os.path.join(build_dir, subdir)
148                 paths = common.manifest_paths(root_dir, last_build.gradle)
149                 version, vercode, package = common.parse_androidmanifests(paths, app)
150                 if vercode:
151                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
152                                   .format(subdir, version, vercode))
153                     if int(vercode) > int(hcode):
154                         hpak = package
155                         htag = tag
156                         hcode = str(int(vercode))
157                         hver = version
158
159         if not hpak:
160             return (None, "Couldn't find package ID", None)
161         if hver:
162             return (hver, hcode, htag)
163         return (None, "Couldn't find any version information", None)
164
165     except VCSException as vcse:
166         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
167         return (None, msg, None)
168     except Exception:
169         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
170         return (None, msg, None)
171
172
173 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
174 # of the source repo. Whether this can be used reliably or not depends on
175 # the development procedures used by the project's developers. Use it with
176 # caution, because it's inappropriate for many projects.
177 # Returns (None, "a message") if this didn't work, or (version, vercode) for
178 # the details of the current version.
179 def check_repomanifest(app, branch=None):
180
181     try:
182
183         if app.RepoType == 'srclib':
184             build_dir = os.path.join('build', 'srclib', app.Repo)
185             repotype = common.getsrclibvcs(app.Repo)
186         else:
187             build_dir = os.path.join('build', app.id)
188             repotype = app.RepoType
189
190         # Set up vcs interface and make sure we have the latest code...
191         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
192
193         if repotype == 'git':
194             if branch:
195                 branch = 'origin/' + branch
196             vcs.gotorevision(branch)
197         elif repotype == 'git-svn':
198             vcs.gotorevision(branch)
199         elif repotype == 'hg':
200             vcs.gotorevision(branch)
201         elif repotype == 'bzr':
202             vcs.gotorevision(None)
203
204         last_build = metadata.Build()
205         if len(app.builds) > 0:
206             last_build = app.builds[-1]
207
208         if last_build.submodules:
209             vcs.initsubmodules()
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, branch=None):
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 = urllib2.Request(url, None, headers)
279     try:
280         resp = urllib2.urlopen(req, None, 20)
281         page = resp.read()
282     except urllib2.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         html_parser = HTMLParser.HTMLParser()
292         version = html_parser.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 = metadata.Build()
321     if len(app.builds) > 0:
322         last_build = app.builds[-1]
323
324     for d in dirs_with_manifest(build_dir):
325         m_paths = common.manifest_paths(d, last_build.gradle)
326         package = common.parse_androidmanifests(m_paths, app)[2]
327         if package is not None:
328             subdir = os.path.relpath(d, build_dir)
329             logging.debug("Adding possible subdir %s" % subdir)
330             yield subdir
331
332
333 def fetch_autoname(app, tag):
334
335     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
336         return None
337
338     if app.RepoType == 'srclib':
339         build_dir = os.path.join('build', 'srclib', app.Repo)
340     else:
341         build_dir = os.path.join('build', app.id)
342
343     try:
344         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
345         vcs.gotorevision(tag)
346     except VCSException:
347         return None
348
349     last_build = metadata.Build()
350     if len(app.builds) > 0:
351         last_build = app.builds[-1]
352
353     logging.debug("...fetch auto name from " + build_dir)
354     new_name = None
355     for subdir in possible_subdirs(app):
356         if subdir == '.':
357             root_dir = build_dir
358         else:
359             root_dir = os.path.join(build_dir, subdir)
360         new_name = common.fetch_real_name(root_dir, last_build.gradle)
361         if new_name is not None:
362             break
363     commitmsg = None
364     if new_name:
365         logging.debug("...got autoname '" + new_name + "'")
366         if new_name != app.AutoName:
367             app.AutoName = new_name
368             if not commitmsg:
369                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
370     else:
371         logging.debug("...couldn't get autoname")
372
373     return commitmsg
374
375
376 def checkupdates_app(app, first=True):
377
378     # If a change is made, commitmsg should be set to a description of it.
379     # Only if this is set will changes be written back to the metadata.
380     commitmsg = None
381
382     tag = None
383     msg = None
384     vercode = None
385     noverok = False
386     mode = app.UpdateCheckMode
387     if mode.startswith('Tags'):
388         pattern = mode[5:] if len(mode) > 4 else None
389         (version, vercode, tag) = check_tags(app, pattern)
390         if version == 'Unknown':
391             version = tag
392         msg = vercode
393     elif mode == 'RepoManifest':
394         (version, vercode) = check_repomanifest(app)
395         msg = vercode
396     elif mode.startswith('RepoManifest/'):
397         tag = mode[13:]
398         (version, vercode) = check_repomanifest(app, tag)
399         msg = vercode
400     elif mode == 'RepoTrunk':
401         (version, vercode) = check_repotrunk(app)
402         msg = vercode
403     elif mode == 'HTTP':
404         (version, vercode) = check_http(app)
405         msg = vercode
406     elif mode in ('None', 'Static'):
407         version = None
408         msg = 'Checking disabled'
409         noverok = True
410     else:
411         version = None
412         msg = 'Invalid update check method'
413
414     if version and vercode and app.VercodeOperation:
415         oldvercode = str(int(vercode))
416         op = app.VercodeOperation.replace("%c", oldvercode)
417         vercode = str(eval(op))
418         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
419
420     if version and any(version.startswith(s) for s in [
421             '${',  # Gradle variable names
422             '@string/',  # Strings we could not resolve
423             ]):
424         version = "Unknown"
425
426     updating = False
427     if version is None:
428         logmsg = "...{0} : {1}".format(app.id, msg)
429         if noverok:
430             logging.info(logmsg)
431         else:
432             logging.warn(logmsg)
433     elif vercode == app.CurrentVersionCode:
434         logging.info("...up to date")
435     else:
436         app.CurrentVersion = version
437         app.CurrentVersionCode = str(int(vercode))
438         updating = True
439
440     commitmsg = fetch_autoname(app, tag)
441
442     if updating:
443         name = common.getappname(app)
444         ver = common.getcvname(app)
445         logging.info('...updating to version %s' % ver)
446         commitmsg = 'Update CV of %s to %s' % (name, ver)
447
448     if options.auto:
449         mode = app.AutoUpdateMode
450         if 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.vercode) >= int(app.CurrentVersionCode):
465                     gotcur = True
466                 if not latest or int(build.vercode) > int(latest.vercode):
467                     latest = build
468
469             if int(latest.vercode) > 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.vercode = app.CurrentVersionCode
476                 newbuild.version = app.CurrentVersion + suffix
477                 logging.info("...auto-generating build for " + newbuild.version)
478                 commit = pattern.replace('%v', newbuild.version)
479                 commit = commit.replace('%c', newbuild.vercode)
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         with open(metadatapath, 'w') as f:
491             metadata.write_metadata('txt', f, app)
492         if options.commit:
493             logging.info("Commiting update for " + metadatapath)
494             gitcmd = ["git", "commit", "-m", commitmsg]
495             if 'auto_author' in config:
496                 gitcmd.extend(['--author', config['auto_author']])
497             gitcmd.extend(["--", metadatapath])
498             if subprocess.call(gitcmd) != 0:
499                 logging.error("Git commit failed")
500                 sys.exit(1)
501
502
503 config = None
504 options = None
505
506
507 def main():
508
509     global config, options
510
511     # Parse command line...
512     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
513     common.setup_global_opts(parser)
514     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
515     parser.add_argument("--auto", action="store_true", default=False,
516                         help="Process auto-updates")
517     parser.add_argument("--autoonly", action="store_true", default=False,
518                         help="Only process apps with auto-updates")
519     parser.add_argument("--commit", action="store_true", default=False,
520                         help="Commit changes")
521     parser.add_argument("--gplay", action="store_true", default=False,
522                         help="Only print differences with the Play Store")
523     options = parser.parse_args()
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 app in apps:
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.iteritems():
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         checkupdates_app(app)
566
567     logging.info("Finished.")
568
569 if __name__ == "__main__":
570     main()