chiark / gitweb /
Rework app into a class
[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 not app.UpdateCheckData:
47             raise FDroidException('Missing Update Check Data')
48
49         urlcode, codeex, urlver, verex = app.UpdateCheckData.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.RepoType == '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.RepoType
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.RepoType, 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.RepoType == '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.RepoType
187
188         # Set up vcs interface and make sure we have the latest code...
189         vcs = common.getvcs(app.RepoType, 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.RepoType == '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.RepoType
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.RepoType, 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.RepoType == '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.RepoType or app.UpdateCheckMode in ('None', 'Static'):
334         return None
335
336     if app.RepoType == 'srclib':
337         build_dir = os.path.join('build', 'srclib', app.Repo)
338     else:
339         build_dir = os.path.join('build', app.id)
340
341     try:
342         vcs = common.getvcs(app.RepoType, app.Repo, build_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]['gradle']:
350             flavours = app.builds[-1]['gradle']
351
352     logging.debug("...fetch auto name from " + build_dir)
353     new_name = None
354     for subdir in possible_subdirs(app):
355         if subdir == '.':
356             root_dir = build_dir
357         else:
358             root_dir = os.path.join(build_dir, subdir)
359         new_name = common.fetch_real_name(root_dir, flavours)
360         if new_name is not None:
361             break
362     commitmsg = None
363     if new_name:
364         logging.debug("...got autoname '" + new_name + "'")
365         if new_name != app.AutoName:
366             app.AutoName = new_name
367             if not commitmsg:
368                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
369     else:
370         logging.debug("...couldn't get autoname")
371
372     return commitmsg
373
374
375 def checkupdates_app(app, first=True):
376
377     # If a change is made, commitmsg should be set to a description of it.
378     # Only if this is set will changes be written back to the metadata.
379     commitmsg = None
380
381     tag = None
382     msg = None
383     vercode = None
384     noverok = False
385     mode = app.UpdateCheckMode
386     if mode.startswith('Tags'):
387         pattern = mode[5:] if len(mode) > 4 else None
388         (version, vercode, tag) = check_tags(app, pattern)
389         msg = vercode
390     elif mode == 'RepoManifest':
391         (version, vercode) = check_repomanifest(app)
392         msg = vercode
393     elif mode.startswith('RepoManifest/'):
394         tag = mode[13:]
395         (version, vercode) = check_repomanifest(app, tag)
396         msg = vercode
397     elif mode == 'RepoTrunk':
398         (version, vercode) = check_repotrunk(app)
399         msg = vercode
400     elif mode == 'HTTP':
401         (version, vercode) = check_http(app)
402         msg = vercode
403     elif mode in ('None', 'Static'):
404         version = None
405         msg = 'Checking disabled'
406         noverok = True
407     else:
408         version = None
409         msg = 'Invalid update check method'
410
411     if version and vercode and app.VercodeOperation:
412         oldvercode = str(int(vercode))
413         op = app.VercodeOperation.replace("%c", oldvercode)
414         vercode = str(eval(op))
415         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
416
417     if version and any(version.startswith(s) for s in [
418             '${',  # Gradle variable names
419             '@string/',  # Strings we could not resolve
420             ]):
421         version = "Unknown"
422
423     updating = False
424     if version is None:
425         logmsg = "...{0} : {1}".format(app.id, msg)
426         if noverok:
427             logging.info(logmsg)
428         else:
429             logging.warn(logmsg)
430     elif vercode == app.CurrentVersionCode:
431         logging.info("...up to date")
432     else:
433         app.CurrentVersion = version
434         app.CurrentVersionCode = str(int(vercode))
435         updating = True
436
437     commitmsg = fetch_autoname(app, tag)
438
439     if updating:
440         name = common.getappname(app)
441         ver = common.getcvname(app)
442         logging.info('...updating to version %s' % ver)
443         commitmsg = 'Update CV of %s to %s' % (name, ver)
444
445     if options.auto:
446         mode = app.AutoUpdateMode
447         if mode in ('None', 'Static'):
448             pass
449         elif mode.startswith('Version '):
450             pattern = mode[8:]
451             if pattern.startswith('+'):
452                 try:
453                     suffix, pattern = pattern.split(' ', 1)
454                 except ValueError:
455                     raise MetaDataException("Invalid AUM: " + mode)
456             else:
457                 suffix = ''
458             gotcur = False
459             latest = None
460             for build in app.builds:
461                 if int(build['vercode']) >= int(app.CurrentVersionCode):
462                     gotcur = True
463                 if not latest or int(build['vercode']) > int(latest['vercode']):
464                     latest = build
465
466             if int(latest['vercode']) > int(app.CurrentVersionCode):
467                 logging.info("Refusing to auto update, since the latest build is newer")
468
469             if not gotcur:
470                 newbuild = latest.copy()
471                 if 'origlines' in newbuild:
472                     del newbuild['origlines']
473                 newbuild['disable'] = False
474                 newbuild['vercode'] = app.CurrentVersionCode
475                 newbuild['version'] = app.CurrentVersion + suffix
476                 logging.info("...auto-generating build for " + newbuild['version'])
477                 commit = pattern.replace('%v', newbuild['version'])
478                 commit = commit.replace('%c', newbuild['vercode'])
479                 newbuild['commit'] = commit
480                 app.builds.append(newbuild)
481                 name = common.getappname(app)
482                 ver = common.getcvname(app)
483                 commitmsg = "Update %s to %s" % (name, ver)
484         else:
485             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
486
487     if commitmsg:
488         metadatapath = os.path.join('metadata', app.id + '.txt')
489         with open(metadatapath, 'w') as f:
490             metadata.write_metadata('txt', f, app)
491         if options.commit:
492             logging.info("Commiting update for " + metadatapath)
493             gitcmd = ["git", "commit", "-m", commitmsg]
494             if 'auto_author' in config:
495                 gitcmd.extend(['--author', config['auto_author']])
496             gitcmd.extend(["--", metadatapath])
497             if subprocess.call(gitcmd) != 0:
498                 logging.error("Git commit failed")
499                 sys.exit(1)
500
501
502 config = None
503 options = None
504
505
506 def main():
507
508     global config, options
509
510     # Parse command line...
511     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
512     common.setup_global_opts(parser)
513     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
514     parser.add_argument("--auto", action="store_true", default=False,
515                         help="Process auto-updates")
516     parser.add_argument("--autoonly", action="store_true", default=False,
517                         help="Only process apps with auto-updates")
518     parser.add_argument("--commit", action="store_true", default=False,
519                         help="Commit changes")
520     parser.add_argument("--gplay", action="store_true", default=False,
521                         help="Only print differences with the Play Store")
522     options = parser.parse_args()
523
524     config = common.read_config(options)
525
526     # Get all apps...
527     allapps = metadata.read_metadata()
528
529     apps = common.read_app_args(options.appid, allapps, False)
530
531     if options.gplay:
532         for app in apps:
533             version, reason = check_gplay(app)
534             if version is None:
535                 if reason == '404':
536                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
537                 else:
538                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
539             if version is not None:
540                 stored = app.CurrentVersion
541                 if not stored:
542                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
543                                  .format(common.getappname(app), version))
544                 elif LooseVersion(stored) < LooseVersion(version):
545                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
546                                  .format(common.getappname(app), version, stored))
547                 else:
548                     if stored != version:
549                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
550                                      .format(common.getappname(app), version, stored))
551                     else:
552                         logging.info("{0} has the same version {1} on the Play Store"
553                                      .format(common.getappname(app), version))
554         return
555
556     for appid, app in apps.iteritems():
557
558         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
559             logging.debug("Nothing to do for {0}...".format(appid))
560             continue
561
562         logging.info("Processing " + appid + '...')
563
564         checkupdates_app(app)
565
566     logging.info("Finished.")
567
568 if __name__ == "__main__":
569     main()