chiark / gitweb /
Be consistent about root_dir/build_dir naming
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # checkupdates.py - part of the FDroid server tools
5 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import re
24 import urllib2
25 import time
26 import subprocess
27 from argparse import ArgumentParser
28 import traceback
29 import HTMLParser
30 from distutils.version import LooseVersion
31 import logging
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 'Update Check Data' not in app:
47             raise FDroidException('Missing Update Check Data')
48
49         urlcode, codeex, urlver, verex = app['Update Check Data'].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 def app_matches_packagename(app, package):
84     if not package:
85         return False
86     appid = app['Update Check Name'] or app['id']
87     if appid == "Ignore":
88         return True
89     return appid == package
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['Repo Type'] == '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['Repo Type']
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['Repo Type'], app['Repo'], build_dir)
117
118         vcs.gotorevision(None)
119
120         root_dir = build_dir
121         flavours = []
122         if len(app['builds']) > 0:
123             if app['builds'][-1]['subdir']:
124                 root_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
125             if app['builds'][-1]['gradle']:
126                 flavours = app['builds'][-1]['gradle']
127
128         hpak = None
129         htag = None
130         hver = None
131         hcode = "0"
132
133         tags = vcs.gettags()
134         logging.debug("All tags: " + ','.join(tags))
135         if pattern:
136             pat = re.compile(pattern)
137             tags = [tag for tag in tags if pat.match(tag)]
138             logging.debug("Matching tags: " + ','.join(tags))
139
140         if repotype in ('git',):
141             tags = vcs.latesttags(tags, 5)
142             logging.debug("Latest tags: " + ','.join(tags))
143
144         for tag in tags:
145             logging.debug("Check tag: '{0}'".format(tag))
146             vcs.gotorevision(tag)
147
148             # Only process tags where the manifest exists...
149             paths = common.manifest_paths(root_dir, flavours)
150             version, vercode, package = \
151                 common.parse_androidmanifests(paths, app['Update Check Ignore'])
152             if not app_matches_packagename(app, package) or not version or not vercode:
153                 continue
154
155             logging.debug("Manifest exists. Found version {0} ({1})"
156                           .format(version, vercode))
157             if int(vercode) > int(hcode):
158                 hpak = package
159                 htag = tag
160                 hcode = str(int(vercode))
161                 hver = version
162
163         if not hpak:
164             return (None, "Couldn't find package ID", None)
165         if hver:
166             return (hver, hcode, htag)
167         return (None, "Couldn't find any version information", None)
168
169     except VCSException as vcse:
170         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
171         return (None, msg, None)
172     except Exception:
173         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
174         return (None, msg, None)
175
176
177 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
178 # of the source repo. Whether this can be used reliably or not depends on
179 # the development procedures used by the project's developers. Use it with
180 # caution, because it's inappropriate for many projects.
181 # Returns (None, "a message") if this didn't work, or (version, vercode) for
182 # the details of the current version.
183 def check_repomanifest(app, branch=None):
184
185     try:
186
187         if app['Repo Type'] == 'srclib':
188             build_dir = os.path.join('build', 'srclib', app['Repo'])
189             repotype = common.getsrclibvcs(app['Repo'])
190         else:
191             build_dir = os.path.join('build', app['id'])
192             repotype = app['Repo Type']
193
194         # Set up vcs interface and make sure we have the latest code...
195         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
196
197         if repotype == 'git':
198             if branch:
199                 branch = 'origin/' + branch
200             vcs.gotorevision(branch)
201         elif repotype == 'git-svn':
202             vcs.gotorevision(branch)
203         elif repotype == 'hg':
204             vcs.gotorevision(branch)
205         elif repotype == 'bzr':
206             vcs.gotorevision(None)
207
208         root_dir = build_dir
209         flavours = []
210         if len(app['builds']) > 0:
211             if app['builds'][-1]['subdir']:
212                 root_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
213             if app['builds'][-1]['gradle']:
214                 flavours = app['builds'][-1]['gradle']
215
216         if not os.path.isdir(root_dir):
217             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
218
219         paths = common.manifest_paths(root_dir, flavours)
220
221         version, vercode, package = \
222             common.parse_androidmanifests(paths, app['Update Check Ignore'])
223         if not package:
224             return (None, "Couldn't find package ID")
225         if not app_matches_packagename(app, package):
226             return (None, "Package ID mismatch - got {0}".format(package))
227         if not version:
228             return (None, "Couldn't find latest version name")
229         if not vercode:
230             if "Ignore" == version:
231                 return (None, "Latest version is ignored")
232             return (None, "Couldn't find latest version code")
233
234         vercode = str(int(vercode))
235
236         logging.debug("Manifest exists. Found version {0} ({1})".format(version, vercode))
237
238         return (version, vercode)
239
240     except VCSException as vcse:
241         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
242         return (None, msg)
243     except Exception:
244         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
245         return (None, msg)
246
247
248 def check_repotrunk(app, branch=None):
249
250     try:
251         if app['Repo Type'] == 'srclib':
252             build_dir = os.path.join('build', 'srclib', app['Repo'])
253             repotype = common.getsrclibvcs(app['Repo'])
254         else:
255             build_dir = os.path.join('build', app['id'])
256             repotype = app['Repo Type']
257
258         if repotype not in ('git-svn', ):
259             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
260
261         # Set up vcs interface and make sure we have the latest code...
262         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
263
264         vcs.gotorevision(None)
265
266         ref = vcs.getref()
267         return (ref, ref)
268     except VCSException as vcse:
269         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
270         return (None, msg)
271     except Exception:
272         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
273         return (None, msg)
274
275
276 # Check for a new version by looking at the Google Play Store.
277 # Returns (None, "a message") if this didn't work, or (version, None) for
278 # the details of the current version.
279 def check_gplay(app):
280     time.sleep(15)
281     url = 'https://play.google.com/store/apps/details?id=' + app['id']
282     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
283     req = urllib2.Request(url, None, headers)
284     try:
285         resp = urllib2.urlopen(req, None, 20)
286         page = resp.read()
287     except urllib2.HTTPError, e:
288         return (None, str(e.code))
289     except Exception, e:
290         return (None, 'Failed:' + str(e))
291
292     version = None
293
294     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
295     if m:
296         html_parser = HTMLParser.HTMLParser()
297         version = html_parser.unescape(m.group(1))
298
299     if version == 'Varies with device':
300         return (None, 'Device-variable version, cannot use this method')
301
302     if not version:
303         return (None, "Couldn't find version")
304     return (version.strip(), None)
305
306
307 # Return all directories under startdir that contain any of the manifest
308 # files, and thus are probably an Android project.
309 def dirs_with_manifest(startdir):
310     for r, d, f in os.walk(startdir):
311         if any(m in f for m in [
312                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
313             yield r
314
315
316 # Tries to find a new subdir starting from the root build_dir. Returns said
317 # subdir relative to the build dir if found, None otherwise.
318 def check_changed_subdir(app):
319
320     if app['Repo Type'] == 'srclib':
321         build_dir = os.path.join('build', 'srclib', app['Repo'])
322     else:
323         build_dir = os.path.join('build', app['id'])
324
325     if not os.path.isdir(build_dir):
326         return None
327
328     flavours = []
329     if len(app['builds']) > 0 and app['builds'][-1]['gradle']:
330         flavours = app['builds'][-1]['gradle']
331
332     for d in dirs_with_manifest(build_dir):
333         logging.debug("Trying possible dir %s." % d)
334         m_paths = common.manifest_paths(d, flavours)
335         package = common.parse_androidmanifests(m_paths, app['Update Check Ignore'])[2]
336         if app_matches_packagename(app, package):
337             logging.debug("Manifest exists in possible dir %s." % d)
338             return os.path.relpath(d, build_dir)
339
340     return None
341
342
343 def fetch_autoname(app, tag):
344
345     if not app["Repo Type"] or app['Update Check Mode'] in ('None', 'Static'):
346         return None
347
348     if app['Repo Type'] == 'srclib':
349         app_dir = os.path.join('build', 'srclib', app['Repo'])
350     else:
351         app_dir = os.path.join('build', app['id'])
352
353     try:
354         vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
355         vcs.gotorevision(tag)
356     except VCSException:
357         return None
358
359     flavours = []
360     if len(app['builds']) > 0:
361         if app['builds'][-1]['subdir']:
362             app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
363         if app['builds'][-1]['gradle']:
364             flavours = app['builds'][-1]['gradle']
365
366     logging.debug("...fetch auto name from " + app_dir)
367     new_name = common.fetch_real_name(app_dir, flavours)
368     commitmsg = None
369     if new_name:
370         logging.debug("...got autoname '" + new_name + "'")
371         if new_name != app['Auto Name']:
372             app['Auto Name'] = new_name
373             if not commitmsg:
374                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
375     else:
376         logging.debug("...couldn't get autoname")
377
378     return commitmsg
379
380
381 def checkupdates_app(app, first=True):
382
383     # If a change is made, commitmsg should be set to a description of it.
384     # Only if this is set will changes be written back to the metadata.
385     commitmsg = None
386
387     tag = None
388     msg = None
389     vercode = None
390     noverok = False
391     mode = app['Update Check Mode']
392     if mode.startswith('Tags'):
393         pattern = mode[5:] if len(mode) > 4 else None
394         (version, vercode, tag) = check_tags(app, pattern)
395         msg = vercode
396     elif mode == 'RepoManifest':
397         (version, vercode) = check_repomanifest(app)
398         msg = vercode
399     elif mode.startswith('RepoManifest/'):
400         tag = mode[13:]
401         (version, vercode) = check_repomanifest(app, tag)
402         msg = vercode
403     elif mode == 'RepoTrunk':
404         (version, vercode) = check_repotrunk(app)
405         msg = vercode
406     elif mode == 'HTTP':
407         (version, vercode) = check_http(app)
408         msg = vercode
409     elif mode in ('None', 'Static'):
410         version = None
411         msg = 'Checking disabled'
412         noverok = True
413     else:
414         version = None
415         msg = 'Invalid update check method'
416
417     if first and version is None and vercode == "Couldn't find package ID":
418         logging.warn("Couldn't find any version information. Looking for a subdir change...")
419         new_subdir = check_changed_subdir(app)
420         if new_subdir is None:
421             logging.warn("Couldn't find any new subdir.")
422         else:
423             logging.warn("Trying a new subdir: %s" % new_subdir)
424             new_build = {}
425             metadata.fill_build_defaults(new_build)
426             new_build['version'] = "Ignore"
427             new_build['vercode'] = "-1"
428             new_build['subdir'] = new_subdir
429             app['builds'].append(new_build)
430             return checkupdates_app(app, first=False)
431
432     if version and vercode and app['Vercode Operation']:
433         oldvercode = str(int(vercode))
434         op = app['Vercode Operation'].replace("%c", oldvercode)
435         vercode = str(eval(op))
436         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
437
438     if version and any(version.startswith(s) for s in [
439             '${',  # Gradle variable names
440             '@string/',  # Strings we could not resolve
441             ]):
442         version = "Unknown"
443
444     updating = False
445     if version is None:
446         logmsg = "...{0} : {1}".format(app['id'], msg)
447         if noverok:
448             logging.info(logmsg)
449         else:
450             logging.warn(logmsg)
451     elif vercode == app['Current Version Code']:
452         logging.info("...up to date")
453     else:
454         app['Current Version'] = version
455         app['Current Version Code'] = str(int(vercode))
456         updating = True
457
458     commitmsg = fetch_autoname(app, tag)
459
460     if updating:
461         name = common.getappname(app)
462         ver = common.getcvname(app)
463         logging.info('...updating to version %s' % ver)
464         commitmsg = 'Update CV of %s to %s' % (name, ver)
465
466     if options.auto:
467         mode = app['Auto Update Mode']
468         if mode in ('None', 'Static'):
469             pass
470         elif mode.startswith('Version '):
471             pattern = mode[8:]
472             if pattern.startswith('+'):
473                 try:
474                     suffix, pattern = pattern.split(' ', 1)
475                 except ValueError:
476                     raise MetaDataException("Invalid AUM: " + mode)
477             else:
478                 suffix = ''
479             gotcur = False
480             latest = None
481             for build in app['builds']:
482                 if build['vercode'] == app['Current Version Code']:
483                     gotcur = True
484                 if not latest or int(build['vercode']) > int(latest['vercode']):
485                     latest = build
486
487             if int(latest['vercode']) > int(app['Current Version Code']):
488                 logging.info("Refusing to auto update, since the latest build is newer")
489
490             if not gotcur:
491                 newbuild = latest.copy()
492                 if 'origlines' in newbuild:
493                     del newbuild['origlines']
494                 newbuild['disable'] = False
495                 newbuild['vercode'] = app['Current Version Code']
496                 newbuild['version'] = app['Current Version'] + suffix
497                 logging.info("...auto-generating build for " + newbuild['version'])
498                 commit = pattern.replace('%v', newbuild['version'])
499                 commit = commit.replace('%c', newbuild['vercode'])
500                 newbuild['commit'] = commit
501                 app['builds'].append(newbuild)
502                 name = common.getappname(app)
503                 ver = common.getcvname(app)
504                 commitmsg = "Update %s to %s" % (name, ver)
505         else:
506             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
507
508     if commitmsg:
509         metadatapath = os.path.join('metadata', app['id'] + '.txt')
510         with open(metadatapath, 'w') as f:
511             metadata.write_metadata('txt', f, app)
512         if options.commit:
513             logging.info("Commiting update for " + metadatapath)
514             gitcmd = ["git", "commit", "-m", commitmsg]
515             if 'auto_author' in config:
516                 gitcmd.extend(['--author', config['auto_author']])
517             gitcmd.extend(["--", metadatapath])
518             if subprocess.call(gitcmd) != 0:
519                 logging.error("Git commit failed")
520                 sys.exit(1)
521
522
523 config = None
524 options = None
525
526
527 def main():
528
529     global config, options
530
531     # Parse command line...
532     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
533     common.setup_global_opts(parser)
534     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
535     parser.add_argument("--auto", action="store_true", default=False,
536                         help="Process auto-updates")
537     parser.add_argument("--autoonly", action="store_true", default=False,
538                         help="Only process apps with auto-updates")
539     parser.add_argument("--commit", action="store_true", default=False,
540                         help="Commit changes")
541     parser.add_argument("--gplay", action="store_true", default=False,
542                         help="Only print differences with the Play Store")
543     options = parser.parse_args()
544
545     config = common.read_config(options)
546
547     # Get all apps...
548     allapps = metadata.read_metadata()
549
550     apps = common.read_app_args(options.appid, allapps, False)
551
552     if options.gplay:
553         for app in apps:
554             version, reason = check_gplay(app)
555             if version is None:
556                 if reason == '404':
557                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
558                 else:
559                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
560             if version is not None:
561                 stored = app['Current Version']
562                 if not stored:
563                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
564                                  .format(common.getappname(app), version))
565                 elif LooseVersion(stored) < LooseVersion(version):
566                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
567                                  .format(common.getappname(app), version, stored))
568                 else:
569                     if stored != version:
570                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
571                                      .format(common.getappname(app), version, stored))
572                     else:
573                         logging.info("{0} has the same version {1} on the Play Store"
574                                      .format(common.getappname(app), version))
575         return
576
577     for appid, app in apps.iteritems():
578
579         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
580             logging.debug("Nothing to do for {0}...".format(appid))
581             continue
582
583         logging.info("Processing " + appid + '...')
584
585         checkupdates_app(app)
586
587     logging.info("Finished.")
588
589 if __name__ == "__main__":
590     main()