chiark / gitweb /
Don't double check package name
[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         subdir = build['subdir']
328         if subdir and os.path.isdir(os.path.join(build_dir, subdir)):
329             logging.debug("Adding possible subdir %s" % subdir)
330             yield subdir
331
332     for d in dirs_with_manifest(build_dir):
333         m_paths = common.manifest_paths(d, flavours)
334         package = common.parse_androidmanifests(m_paths, app['Update Check Ignore'])[2]
335         if app_matches_packagename(app, package):
336             subdir = os.path.relpath(d, build_dir)
337             logging.debug("Adding possible subdir %s" % subdir)
338             yield subdir
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 version and vercode and app['Vercode Operation']:
416         oldvercode = str(int(vercode))
417         op = app['Vercode Operation'].replace("%c", oldvercode)
418         vercode = str(eval(op))
419         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
420
421     if version and any(version.startswith(s) for s in [
422             '${',  # Gradle variable names
423             '@string/',  # Strings we could not resolve
424             ]):
425         version = "Unknown"
426
427     updating = False
428     if version is None:
429         logmsg = "...{0} : {1}".format(app['id'], msg)
430         if noverok:
431             logging.info(logmsg)
432         else:
433             logging.warn(logmsg)
434     elif vercode == app['Current Version Code']:
435         logging.info("...up to date")
436     else:
437         app['Current Version'] = version
438         app['Current Version Code'] = str(int(vercode))
439         updating = True
440
441     commitmsg = fetch_autoname(app, tag)
442
443     if updating:
444         name = common.getappname(app)
445         ver = common.getcvname(app)
446         logging.info('...updating to version %s' % ver)
447         commitmsg = 'Update CV of %s to %s' % (name, ver)
448
449     if options.auto:
450         mode = app['Auto Update Mode']
451         if mode in ('None', 'Static'):
452             pass
453         elif mode.startswith('Version '):
454             pattern = mode[8:]
455             if pattern.startswith('+'):
456                 try:
457                     suffix, pattern = pattern.split(' ', 1)
458                 except ValueError:
459                     raise MetaDataException("Invalid AUM: " + mode)
460             else:
461                 suffix = ''
462             gotcur = False
463             latest = None
464             for build in app['builds']:
465                 if build['vercode'] == app['Current Version Code']:
466                     gotcur = True
467                 if not latest or int(build['vercode']) > int(latest['vercode']):
468                     latest = build
469
470             if int(latest['vercode']) > int(app['Current Version Code']):
471                 logging.info("Refusing to auto update, since the latest build is newer")
472
473             if not gotcur:
474                 newbuild = latest.copy()
475                 if 'origlines' in newbuild:
476                     del newbuild['origlines']
477                 newbuild['disable'] = False
478                 newbuild['vercode'] = app['Current Version Code']
479                 newbuild['version'] = app['Current Version'] + suffix
480                 logging.info("...auto-generating build for " + newbuild['version'])
481                 commit = pattern.replace('%v', newbuild['version'])
482                 commit = commit.replace('%c', newbuild['vercode'])
483                 newbuild['commit'] = commit
484                 app['builds'].append(newbuild)
485                 name = common.getappname(app)
486                 ver = common.getcvname(app)
487                 commitmsg = "Update %s to %s" % (name, ver)
488         else:
489             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
490
491     if commitmsg:
492         metadatapath = os.path.join('metadata', app['id'] + '.txt')
493         with open(metadatapath, 'w') as f:
494             metadata.write_metadata('txt', f, app)
495         if options.commit:
496             logging.info("Commiting update for " + metadatapath)
497             gitcmd = ["git", "commit", "-m", commitmsg]
498             if 'auto_author' in config:
499                 gitcmd.extend(['--author', config['auto_author']])
500             gitcmd.extend(["--", metadatapath])
501             if subprocess.call(gitcmd) != 0:
502                 logging.error("Git commit failed")
503                 sys.exit(1)
504
505
506 config = None
507 options = None
508
509
510 def main():
511
512     global config, options
513
514     # Parse command line...
515     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
516     common.setup_global_opts(parser)
517     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
518     parser.add_argument("--auto", action="store_true", default=False,
519                         help="Process auto-updates")
520     parser.add_argument("--autoonly", action="store_true", default=False,
521                         help="Only process apps with auto-updates")
522     parser.add_argument("--commit", action="store_true", default=False,
523                         help="Commit changes")
524     parser.add_argument("--gplay", action="store_true", default=False,
525                         help="Only print differences with the Play Store")
526     options = parser.parse_args()
527
528     config = common.read_config(options)
529
530     # Get all apps...
531     allapps = metadata.read_metadata()
532
533     apps = common.read_app_args(options.appid, allapps, False)
534
535     if options.gplay:
536         for app in apps:
537             version, reason = check_gplay(app)
538             if version is None:
539                 if reason == '404':
540                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
541                 else:
542                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
543             if version is not None:
544                 stored = app['Current Version']
545                 if not stored:
546                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
547                                  .format(common.getappname(app), version))
548                 elif LooseVersion(stored) < LooseVersion(version):
549                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
550                                  .format(common.getappname(app), version, stored))
551                 else:
552                     if stored != version:
553                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
554                                      .format(common.getappname(app), version, stored))
555                     else:
556                         logging.info("{0} has the same version {1} on the Play Store"
557                                      .format(common.getappname(app), version))
558         return
559
560     for appid, app in apps.iteritems():
561
562         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
563             logging.debug("Nothing to do for {0}...".format(appid))
564             continue
565
566         logging.info("Processing " + appid + '...')
567
568         checkupdates_app(app)
569
570     logging.info("Finished.")
571
572 if __name__ == "__main__":
573     main()