chiark / gitweb /
checkupdates: don't blindly accept subdir
[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         flavours = []
121         if len(app['builds']) > 0:
122             if app['builds'][-1]['gradle']:
123                 flavours = app['builds'][-1]['gradle']
124
125         hpak = None
126         htag = None
127         hver = None
128         hcode = "0"
129
130         tags = vcs.gettags()
131         logging.debug("All tags: " + ','.join(tags))
132         if pattern:
133             pat = re.compile(pattern)
134             tags = [tag for tag in tags if pat.match(tag)]
135             logging.debug("Matching tags: " + ','.join(tags))
136
137         if repotype in ('git',):
138             tags = vcs.latesttags(tags, 5)
139             logging.debug("Latest tags: " + ','.join(tags))
140
141         for tag in tags:
142             logging.debug("Check tag: '{0}'".format(tag))
143             vcs.gotorevision(tag)
144
145             for subdir in possible_subdirs(app):
146                 if subdir == '.':
147                     root_dir = build_dir
148                 else:
149                     root_dir = os.path.join(build_dir, subdir)
150                 paths = common.manifest_paths(root_dir, flavours)
151                 version, vercode, package = \
152                     common.parse_androidmanifests(paths, app['Update Check Ignore'])
153                 if vercode:
154                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
155                                   .format(subdir, version, vercode))
156                     if int(vercode) > int(hcode):
157                         hpak = package
158                         htag = tag
159                         hcode = str(int(vercode))
160                         hver = version
161
162         if not hpak:
163             return (None, "Couldn't find package ID", None)
164         if hver:
165             return (hver, hcode, htag)
166         return (None, "Couldn't find any version information", None)
167
168     except VCSException as vcse:
169         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
170         return (None, msg, None)
171     except Exception:
172         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
173         return (None, msg, None)
174
175
176 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
177 # of the source repo. Whether this can be used reliably or not depends on
178 # the development procedures used by the project's developers. Use it with
179 # caution, because it's inappropriate for many projects.
180 # Returns (None, "a message") if this didn't work, or (version, vercode) for
181 # the details of the current version.
182 def check_repomanifest(app, branch=None):
183
184     try:
185
186         if app['Repo Type'] == 'srclib':
187             build_dir = os.path.join('build', 'srclib', app['Repo'])
188             repotype = common.getsrclibvcs(app['Repo'])
189         else:
190             build_dir = os.path.join('build', app['id'])
191             repotype = app['Repo Type']
192
193         # Set up vcs interface and make sure we have the latest code...
194         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
195
196         if repotype == 'git':
197             if branch:
198                 branch = 'origin/' + branch
199             vcs.gotorevision(branch)
200         elif repotype == 'git-svn':
201             vcs.gotorevision(branch)
202         elif repotype == 'hg':
203             vcs.gotorevision(branch)
204         elif repotype == 'bzr':
205             vcs.gotorevision(None)
206
207         flavours = []
208         if len(app['builds']) > 0:
209             if app['builds'][-1]['gradle']:
210                 flavours = app['builds'][-1]['gradle']
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, flavours)
221             version, vercode, package = \
222                 common.parse_androidmanifests(paths, app['Update Check Ignore'])
223             if vercode:
224                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
225                               .format(subdir, version, vercode))
226                 if int(vercode) > int(hcode):
227                     hpak = package
228                     hcode = str(int(vercode))
229                     hver = version
230
231         if not hpak:
232             return (None, "Couldn't find package ID")
233         if hver:
234             return (hver, hcode)
235         return (None, "Couldn't find any version information")
236
237     except VCSException as vcse:
238         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
239         return (None, msg)
240     except Exception:
241         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
242         return (None, msg)
243
244
245 def check_repotrunk(app, branch=None):
246
247     try:
248         if app['Repo Type'] == 'srclib':
249             build_dir = os.path.join('build', 'srclib', app['Repo'])
250             repotype = common.getsrclibvcs(app['Repo'])
251         else:
252             build_dir = os.path.join('build', app['id'])
253             repotype = app['Repo Type']
254
255         if repotype not in ('git-svn', ):
256             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
257
258         # Set up vcs interface and make sure we have the latest code...
259         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
260
261         vcs.gotorevision(None)
262
263         ref = vcs.getref()
264         return (ref, ref)
265     except VCSException as vcse:
266         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
267         return (None, msg)
268     except Exception:
269         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
270         return (None, msg)
271
272
273 # Check for a new version by looking at the Google Play Store.
274 # Returns (None, "a message") if this didn't work, or (version, None) for
275 # the details of the current version.
276 def check_gplay(app):
277     time.sleep(15)
278     url = 'https://play.google.com/store/apps/details?id=' + app['id']
279     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
280     req = urllib2.Request(url, None, headers)
281     try:
282         resp = urllib2.urlopen(req, None, 20)
283         page = resp.read()
284     except urllib2.HTTPError, e:
285         return (None, str(e.code))
286     except Exception, e:
287         return (None, 'Failed:' + str(e))
288
289     version = None
290
291     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
292     if m:
293         html_parser = HTMLParser.HTMLParser()
294         version = html_parser.unescape(m.group(1))
295
296     if version == 'Varies with device':
297         return (None, 'Device-variable version, cannot use this method')
298
299     if not version:
300         return (None, "Couldn't find version")
301     return (version.strip(), None)
302
303
304 # Return all directories under startdir that contain any of the manifest
305 # files, and thus are probably an Android project.
306 def dirs_with_manifest(startdir):
307     for r, d, f in os.walk(startdir):
308         if any(m in f for m in [
309                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
310             yield r
311
312
313 # Tries to find a new subdir starting from the root build_dir. Returns said
314 # subdir relative to the build dir if found, None otherwise.
315 def possible_subdirs(app):
316
317     if app['Repo Type'] == 'srclib':
318         build_dir = os.path.join('build', 'srclib', app['Repo'])
319     else:
320         build_dir = os.path.join('build', app['id'])
321
322     flavours = []
323     if len(app['builds']) > 0:
324         build = app['builds'][-1]
325         if build['gradle']:
326             flavours = build['gradle']
327
328     for d in dirs_with_manifest(build_dir):
329         m_paths = common.manifest_paths(d, flavours)
330         package = common.parse_androidmanifests(m_paths, app['Update Check Ignore'])[2]
331         if app_matches_packagename(app, package):
332             subdir = os.path.relpath(d, build_dir)
333             logging.debug("Adding possible subdir %s" % subdir)
334             yield subdir
335
336
337 def fetch_autoname(app, tag):
338
339     if not app["Repo Type"] or app['Update Check Mode'] in ('None', 'Static'):
340         return None
341
342     if app['Repo Type'] == 'srclib':
343         app_dir = os.path.join('build', 'srclib', app['Repo'])
344     else:
345         app_dir = os.path.join('build', app['id'])
346
347     try:
348         vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
349         vcs.gotorevision(tag)
350     except VCSException:
351         return None
352
353     flavours = []
354     if len(app['builds']) > 0:
355         if app['builds'][-1]['subdir']:
356             app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
357         if app['builds'][-1]['gradle']:
358             flavours = app['builds'][-1]['gradle']
359
360     logging.debug("...fetch auto name from " + app_dir)
361     new_name = common.fetch_real_name(app_dir, flavours)
362     commitmsg = None
363     if new_name:
364         logging.debug("...got autoname '" + new_name + "'")
365         if new_name != app['Auto Name']:
366             app['Auto Name'] = new_name
367             if not commitmsg:
368                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
369     else:
370         logging.debug("...couldn't get autoname")
371
372     return commitmsg
373
374
375 def checkupdates_app(app, first=True):
376
377     # If a change is made, commitmsg should be set to a description of it.
378     # Only if this is set will changes be written back to the metadata.
379     commitmsg = None
380
381     tag = None
382     msg = None
383     vercode = None
384     noverok = False
385     mode = app['Update Check Mode']
386     if mode.startswith('Tags'):
387         pattern = mode[5:] if len(mode) > 4 else None
388         (version, vercode, tag) = check_tags(app, pattern)
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['Vercode Operation']:
412         oldvercode = str(int(vercode))
413         op = app['Vercode Operation'].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['Current Version Code']:
431         logging.info("...up to date")
432     else:
433         app['Current Version'] = version
434         app['Current Version Code'] = str(int(vercode))
435         updating = True
436
437     commitmsg = fetch_autoname(app, tag)
438
439     if updating:
440         name = common.getappname(app)
441         ver = common.getcvname(app)
442         logging.info('...updating to version %s' % ver)
443         commitmsg = 'Update CV of %s to %s' % (name, ver)
444
445     if options.auto:
446         mode = app['Auto Update Mode']
447         if mode in ('None', 'Static'):
448             pass
449         elif mode.startswith('Version '):
450             pattern = mode[8:]
451             if pattern.startswith('+'):
452                 try:
453                     suffix, pattern = pattern.split(' ', 1)
454                 except ValueError:
455                     raise MetaDataException("Invalid AUM: " + mode)
456             else:
457                 suffix = ''
458             gotcur = False
459             latest = None
460             for build in app['builds']:
461                 if int(build['vercode']) >= int(app['Current Version Code']):
462                     gotcur = True
463                 if not latest or int(build['vercode']) > int(latest['vercode']):
464                     latest = build
465
466             if int(latest['vercode']) > int(app['Current Version Code']):
467                 logging.info("Refusing to auto update, since the latest build is newer")
468
469             if not gotcur:
470                 newbuild = latest.copy()
471                 if 'origlines' in newbuild:
472                     del newbuild['origlines']
473                 newbuild['disable'] = False
474                 newbuild['vercode'] = app['Current Version Code']
475                 newbuild['version'] = app['Current Version'] + suffix
476                 logging.info("...auto-generating build for " + newbuild['version'])
477                 commit = pattern.replace('%v', newbuild['version'])
478                 commit = commit.replace('%c', newbuild['vercode'])
479                 newbuild['commit'] = commit
480                 app['builds'].append(newbuild)
481                 name = common.getappname(app)
482                 ver = common.getcvname(app)
483                 commitmsg = "Update %s to %s" % (name, ver)
484         else:
485             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
486
487     if commitmsg:
488         metadatapath = os.path.join('metadata', app['id'] + '.txt')
489         with open(metadatapath, 'w') as f:
490             metadata.write_metadata('txt', f, app)
491         if options.commit:
492             logging.info("Commiting update for " + metadatapath)
493             gitcmd = ["git", "commit", "-m", commitmsg]
494             if 'auto_author' in config:
495                 gitcmd.extend(['--author', config['auto_author']])
496             gitcmd.extend(["--", metadatapath])
497             if subprocess.call(gitcmd) != 0:
498                 logging.error("Git commit failed")
499                 sys.exit(1)
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     options = parser.parse_args()
523
524     config = common.read_config(options)
525
526     # Get all apps...
527     allapps = metadata.read_metadata()
528
529     apps = common.read_app_args(options.appid, allapps, False)
530
531     if options.gplay:
532         for app in apps:
533             version, reason = check_gplay(app)
534             if version is None:
535                 if reason == '404':
536                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
537                 else:
538                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
539             if version is not None:
540                 stored = app['Current Version']
541                 if not stored:
542                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
543                                  .format(common.getappname(app), version))
544                 elif LooseVersion(stored) < LooseVersion(version):
545                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
546                                  .format(common.getappname(app), version, stored))
547                 else:
548                     if stored != version:
549                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
550                                      .format(common.getappname(app), version, stored))
551                     else:
552                         logging.info("{0} has the same version {1} on the Play Store"
553                                      .format(common.getappname(app), version))
554         return
555
556     for appid, app in apps.iteritems():
557
558         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
559             logging.debug("Nothing to do for {0}...".format(appid))
560             continue
561
562         logging.info("Processing " + appid + '...')
563
564         checkupdates_app(app)
565
566     logging.info("Finished.")
567
568 if __name__ == "__main__":
569     main()