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