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