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