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