chiark / gitweb /
FDroidPopen must have a locale to support UTF-8 filenames
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python3
2 #
3 # checkupdates.py - part of the FDroid server tools
4 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import re
23 import urllib.request
24 import urllib.error
25 import time
26 import subprocess
27 from argparse import ArgumentParser
28 import traceback
29 from html.parser import HTMLParser
30 from distutils.version import LooseVersion
31 import logging
32 import copy
33
34 from . import common
35 from . import metadata
36 from .common import VCSException, FDroidException
37 from .metadata import MetaDataException
38
39
40 # Check for a new version by looking at a document retrieved via HTTP.
41 # The app's Update Check Data field is used to provide the information
42 # required.
43 def check_http(app):
44
45     try:
46
47         if not app.UpdateCheckData:
48             raise FDroidException('Missing Update Check Data')
49
50         urlcode, codeex, urlver, verex = app.UpdateCheckData.split('|')
51
52         vercode = "99999999"
53         if len(urlcode) > 0:
54             logging.debug("...requesting {0}".format(urlcode))
55             req = urllib.request.Request(urlcode, None)
56             resp = urllib.request.urlopen(req, None, 20)
57             page = resp.read().decode('utf-8')
58
59             m = re.search(codeex, page)
60             if not m:
61                 raise FDroidException("No RE match for version code")
62             vercode = m.group(1)
63
64         version = "??"
65         if len(urlver) > 0:
66             if urlver != '.':
67                 logging.debug("...requesting {0}".format(urlver))
68                 req = urllib.request.Request(urlver, None)
69                 resp = urllib.request.urlopen(req, None, 20)
70                 page = resp.read().decode('utf-8')
71
72             m = re.search(verex, page)
73             if not m:
74                 raise FDroidException("No RE match for version")
75             version = m.group(1)
76
77         return (version, vercode)
78
79     except FDroidException:
80         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
81         return (None, msg)
82
83
84 # Check for a new version by looking at the tags in the source repo.
85 # Whether this can be used reliably or not depends on
86 # the development procedures used by the project's developers. Use it with
87 # caution, because it's inappropriate for many projects.
88 # Returns (None, "a message") if this didn't work, or (version, vercode, tag) for
89 # the details of the current version.
90 def check_tags(app, pattern):
91
92     try:
93
94         if app.RepoType == '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.RepoType
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.RepoType, app.Repo, build_dir)
109
110         vcs.gotorevision(None)
111
112         last_build = metadata.Build()
113         if len(app.builds) > 0:
114             last_build = app.builds[-1]
115
116         if last_build.submodules:
117             vcs.initsubmodules()
118
119         hpak = None
120         htag = None
121         hver = None
122         hcode = "0"
123
124         tags = []
125         if repotype == 'git':
126             tags = vcs.latesttags()
127         else:
128             tags = vcs.gettags()
129         if not tags:
130             return (None, "No tags found", None)
131
132         logging.debug("All tags: " + ','.join(tags))
133         if pattern:
134             pat = re.compile(pattern)
135             tags = [tag for tag in tags if pat.match(tag)]
136             if not tags:
137                 return (None, "No matching tags found", None)
138             logging.debug("Matching tags: " + ','.join(tags))
139
140         if len(tags) > 5 and repotype == 'git':
141             tags = tags[:5]
142             logging.debug("Latest tags: " + ','.join(tags))
143
144         for tag in tags:
145             logging.debug("Check tag: '{0}'".format(tag))
146             vcs.gotorevision(tag)
147
148             for subdir in possible_subdirs(app):
149                 if subdir == '.':
150                     root_dir = build_dir
151                 else:
152                     root_dir = os.path.join(build_dir, subdir)
153                 paths = common.manifest_paths(root_dir, last_build.gradle)
154                 version, vercode, package = common.parse_androidmanifests(paths, app)
155                 if vercode:
156                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
157                                   .format(subdir, version, vercode))
158                     if int(vercode) > int(hcode):
159                         hpak = package
160                         htag = tag
161                         hcode = str(int(vercode))
162                         hver = version
163
164         if not hpak:
165             return (None, "Couldn't find package ID", None)
166         if hver:
167             return (hver, hcode, htag)
168         return (None, "Couldn't find any version information", None)
169
170     except VCSException as vcse:
171         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
172         return (None, msg, None)
173     except Exception:
174         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
175         return (None, msg, None)
176
177
178 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
179 # of the source repo. Whether this can be used reliably or not depends on
180 # the development procedures used by the project's developers. Use it with
181 # caution, because it's inappropriate for many projects.
182 # Returns (None, "a message") if this didn't work, or (version, vercode) for
183 # the details of the current version.
184 def check_repomanifest(app, branch=None):
185
186     try:
187
188         if app.RepoType == 'srclib':
189             build_dir = os.path.join('build', 'srclib', app.Repo)
190             repotype = common.getsrclibvcs(app.Repo)
191         else:
192             build_dir = os.path.join('build', app.id)
193             repotype = app.RepoType
194
195         # Set up vcs interface and make sure we have the latest code...
196         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
197
198         if repotype == 'git':
199             if branch:
200                 branch = 'origin/' + branch
201             vcs.gotorevision(branch)
202         elif repotype == 'git-svn':
203             vcs.gotorevision(branch)
204         elif repotype == 'hg':
205             vcs.gotorevision(branch)
206         elif repotype == 'bzr':
207             vcs.gotorevision(None)
208
209         last_build = metadata.Build()
210         if len(app.builds) > 0:
211             last_build = app.builds[-1]
212
213         if last_build.submodules:
214             vcs.initsubmodules()
215
216         hpak = None
217         hver = None
218         hcode = "0"
219         for subdir in possible_subdirs(app):
220             if subdir == '.':
221                 root_dir = build_dir
222             else:
223                 root_dir = os.path.join(build_dir, subdir)
224             paths = common.manifest_paths(root_dir, last_build.gradle)
225             version, vercode, package = common.parse_androidmanifests(paths, app)
226             if vercode:
227                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
228                               .format(subdir, version, vercode))
229                 if int(vercode) > int(hcode):
230                     hpak = package
231                     hcode = str(int(vercode))
232                     hver = version
233
234         if not hpak:
235             return (None, "Couldn't find package ID")
236         if hver:
237             return (hver, hcode)
238         return (None, "Couldn't find any version information")
239
240     except VCSException as vcse:
241         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
242         return (None, msg)
243     except Exception:
244         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
245         return (None, msg)
246
247
248 def check_repotrunk(app, branch=None):
249
250     try:
251         if app.RepoType == 'srclib':
252             build_dir = os.path.join('build', 'srclib', app.Repo)
253             repotype = common.getsrclibvcs(app.Repo)
254         else:
255             build_dir = os.path.join('build', app.id)
256             repotype = app.RepoType
257
258         if repotype not in ('git-svn', ):
259             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
260
261         # Set up vcs interface and make sure we have the latest code...
262         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
263
264         vcs.gotorevision(None)
265
266         ref = vcs.getref()
267         return (ref, ref)
268     except VCSException as vcse:
269         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
270         return (None, msg)
271     except Exception:
272         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
273         return (None, msg)
274
275
276 # Check for a new version by looking at the Google Play Store.
277 # Returns (None, "a message") if this didn't work, or (version, None) for
278 # the details of the current version.
279 def check_gplay(app):
280     time.sleep(15)
281     url = 'https://play.google.com/store/apps/details?id=' + app.id
282     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
283     req = urllib.request.Request(url, None, headers)
284     try:
285         resp = urllib.request.urlopen(req, None, 20)
286         page = resp.read()
287     except urllib.error.HTTPError as e:
288         return (None, str(e.code))
289     except Exception as e:
290         return (None, 'Failed:' + str(e))
291
292     version = None
293
294     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
295     if m:
296         html_parser = HTMLParser()
297         version = html_parser.unescape(m.group(1))
298
299     if version == 'Varies with device':
300         return (None, 'Device-variable version, cannot use this method')
301
302     if not version:
303         return (None, "Couldn't find version")
304     return (version.strip(), None)
305
306
307 # Return all directories under startdir that contain any of the manifest
308 # files, and thus are probably an Android project.
309 def dirs_with_manifest(startdir):
310     for r, d, f in os.walk(startdir):
311         if any(m in f for m in [
312                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
313             yield r
314
315
316 # Tries to find a new subdir starting from the root build_dir. Returns said
317 # subdir relative to the build dir if found, None otherwise.
318 def possible_subdirs(app):
319
320     if app.RepoType == 'srclib':
321         build_dir = os.path.join('build', 'srclib', app.Repo)
322     else:
323         build_dir = os.path.join('build', app.id)
324
325     last_build = metadata.Build()
326     if len(app.builds) > 0:
327         last_build = app.builds[-1]
328
329     for d in dirs_with_manifest(build_dir):
330         m_paths = common.manifest_paths(d, last_build.gradle)
331         package = common.parse_androidmanifests(m_paths, app)[2]
332         if package is not None:
333             subdir = os.path.relpath(d, build_dir)
334             logging.debug("Adding possible subdir %s" % subdir)
335             yield subdir
336
337
338 def fetch_autoname(app, tag):
339
340     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
341         return None
342
343     if app.RepoType == 'srclib':
344         build_dir = os.path.join('build', 'srclib', app.Repo)
345     else:
346         build_dir = os.path.join('build', app.id)
347
348     try:
349         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
350         vcs.gotorevision(tag)
351     except VCSException:
352         return None
353
354     last_build = metadata.Build()
355     if len(app.builds) > 0:
356         last_build = app.builds[-1]
357
358     logging.debug("...fetch auto name from " + build_dir)
359     new_name = None
360     for subdir in possible_subdirs(app):
361         if subdir == '.':
362             root_dir = build_dir
363         else:
364             root_dir = os.path.join(build_dir, subdir)
365         new_name = common.fetch_real_name(root_dir, last_build.gradle)
366         if new_name is not None:
367             break
368     commitmsg = None
369     if new_name:
370         logging.debug("...got autoname '" + new_name + "'")
371         if new_name != app.AutoName:
372             app.AutoName = new_name
373             if not commitmsg:
374                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
375     else:
376         logging.debug("...couldn't get autoname")
377
378     return commitmsg
379
380
381 def checkupdates_app(app, first=True):
382
383     # If a change is made, commitmsg should be set to a description of it.
384     # Only if this is set will changes be written back to the metadata.
385     commitmsg = None
386
387     tag = None
388     msg = None
389     vercode = None
390     noverok = False
391     mode = app.UpdateCheckMode
392     if mode.startswith('Tags'):
393         pattern = mode[5:] if len(mode) > 4 else None
394         (version, vercode, tag) = check_tags(app, pattern)
395         if version == 'Unknown':
396             version = tag
397         msg = vercode
398     elif mode == 'RepoManifest':
399         (version, vercode) = check_repomanifest(app)
400         msg = vercode
401     elif mode.startswith('RepoManifest/'):
402         tag = mode[13:]
403         (version, vercode) = check_repomanifest(app, tag)
404         msg = vercode
405     elif mode == 'RepoTrunk':
406         (version, vercode) = check_repotrunk(app)
407         msg = vercode
408     elif mode == 'HTTP':
409         (version, vercode) = check_http(app)
410         msg = vercode
411     elif mode in ('None', 'Static'):
412         version = None
413         msg = 'Checking disabled'
414         noverok = True
415     else:
416         version = None
417         msg = 'Invalid update check method'
418
419     if version and vercode and app.VercodeOperation:
420         oldvercode = str(int(vercode))
421         op = app.VercodeOperation.replace("%c", oldvercode)
422         vercode = str(eval(op))
423         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
424
425     if version and any(version.startswith(s) for s in [
426             '${',  # Gradle variable names
427             '@string/',  # Strings we could not resolve
428             ]):
429         version = "Unknown"
430
431     updating = False
432     if version is None:
433         logmsg = "...{0} : {1}".format(app.id, msg)
434         if noverok:
435             logging.info(logmsg)
436         else:
437             logging.warn(logmsg)
438     elif vercode == app.CurrentVersionCode:
439         logging.info("...up to date")
440     else:
441         app.CurrentVersion = version
442         app.CurrentVersionCode = str(int(vercode))
443         updating = True
444
445     commitmsg = fetch_autoname(app, tag)
446
447     if updating:
448         name = common.getappname(app)
449         ver = common.getcvname(app)
450         logging.info('...updating to version %s' % ver)
451         commitmsg = 'Update CV of %s to %s' % (name, ver)
452
453     if options.auto:
454         mode = app.AutoUpdateMode
455         if mode in ('None', 'Static'):
456             pass
457         elif mode.startswith('Version '):
458             pattern = mode[8:]
459             if pattern.startswith('+'):
460                 try:
461                     suffix, pattern = pattern.split(' ', 1)
462                 except ValueError:
463                     raise MetaDataException("Invalid AUM: " + mode)
464             else:
465                 suffix = ''
466             gotcur = False
467             latest = None
468             for build in app.builds:
469                 if int(build.vercode) >= int(app.CurrentVersionCode):
470                     gotcur = True
471                 if not latest or int(build.vercode) > int(latest.vercode):
472                     latest = build
473
474             if int(latest.vercode) > int(app.CurrentVersionCode):
475                 logging.info("Refusing to auto update, since the latest build is newer")
476
477             if not gotcur:
478                 newbuild = copy.deepcopy(latest)
479                 newbuild.disable = False
480                 newbuild.vercode = app.CurrentVersionCode
481                 newbuild.version = app.CurrentVersion + suffix
482                 logging.info("...auto-generating build for " + newbuild.version)
483                 commit = pattern.replace('%v', newbuild.version)
484                 commit = commit.replace('%c', newbuild.vercode)
485                 newbuild.commit = commit
486                 app.builds.append(newbuild)
487                 name = common.getappname(app)
488                 ver = common.getcvname(app)
489                 commitmsg = "Update %s to %s" % (name, ver)
490         else:
491             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
492
493     if commitmsg:
494         metadatapath = os.path.join('metadata', app.id + '.txt')
495         metadata.write_metadata(metadatapath, app)
496         if options.commit:
497             logging.info("Commiting update for " + metadatapath)
498             gitcmd = ["git", "commit", "-m", commitmsg]
499             if 'auto_author' in config:
500                 gitcmd.extend(['--author', config['auto_author']])
501             gitcmd.extend(["--", metadatapath])
502             if subprocess.call(gitcmd) != 0:
503                 logging.error("Git commit failed")
504                 sys.exit(1)
505
506
507 config = None
508 options = None
509
510
511 def main():
512
513     global config, options
514
515     # Parse command line...
516     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
517     common.setup_global_opts(parser)
518     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
519     parser.add_argument("--auto", action="store_true", default=False,
520                         help="Process auto-updates")
521     parser.add_argument("--autoonly", action="store_true", default=False,
522                         help="Only process apps with auto-updates")
523     parser.add_argument("--commit", action="store_true", default=False,
524                         help="Commit changes")
525     parser.add_argument("--gplay", action="store_true", default=False,
526                         help="Only print differences with the Play Store")
527     options = parser.parse_args()
528
529     config = common.read_config(options)
530
531     # Get all apps...
532     allapps = metadata.read_metadata()
533
534     apps = common.read_app_args(options.appid, allapps, False)
535
536     if options.gplay:
537         for app in apps:
538             version, reason = check_gplay(app)
539             if version is None:
540                 if reason == '404':
541                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
542                 else:
543                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
544             if version is not None:
545                 stored = app.CurrentVersion
546                 if not stored:
547                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
548                                  .format(common.getappname(app), version))
549                 elif LooseVersion(stored) < LooseVersion(version):
550                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
551                                  .format(common.getappname(app), version, stored))
552                 else:
553                     if stored != version:
554                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
555                                      .format(common.getappname(app), version, stored))
556                     else:
557                         logging.info("{0} has the same version {1} on the Play Store"
558                                      .format(common.getappname(app), version))
559         return
560
561     for appid, app in apps.items():
562
563         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
564             logging.debug("Nothing to do for {0}...".format(appid))
565             continue
566
567         logging.info("Processing " + appid + '...')
568
569         checkupdates_app(app)
570
571     logging.info("Finished.")
572
573 if __name__ == "__main__":
574     main()