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