chiark / gitweb /
update: make icon extraction less dependent on aapt
[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 os
21 import re
22 import urllib.request
23 import urllib.error
24 import time
25 import subprocess
26 import sys
27 from argparse import ArgumentParser
28 import traceback
29 import html
30 from distutils.version import LooseVersion
31 import logging
32 import copy
33
34 from . import _
35 from . import common
36 from . import metadata
37 from .exception import VCSException, NoSubmodulesException, FDroidException, 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).strip()
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 = app.get_last_build()
113
114         try_init_submodules(app, last_build, vcs)
115
116         hpak = None
117         htag = None
118         hver = None
119         hcode = "0"
120
121         tags = []
122         if repotype == 'git':
123             tags = vcs.latesttags()
124         else:
125             tags = vcs.gettags()
126         if not tags:
127             return (None, "No tags found", None)
128
129         logging.debug("All tags: " + ','.join(tags))
130         if pattern:
131             pat = re.compile(pattern)
132             tags = [tag for tag in tags if pat.match(tag)]
133             if not tags:
134                 return (None, "No matching tags found", None)
135             logging.debug("Matching tags: " + ','.join(tags))
136
137         if len(tags) > 5 and repotype == 'git':
138             tags = 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, last_build.gradle)
151                 version, vercode, package = common.parse_androidmanifests(paths, app)
152                 if vercode:
153                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
154                                   .format(subdir, version, vercode))
155                     if int(vercode) > int(hcode):
156                         hpak = package
157                         htag = tag
158                         hcode = str(int(vercode))
159                         hver = version
160
161         if not hpak:
162             return (None, "Couldn't find package ID", None)
163         if hver:
164             return (hver, hcode, htag)
165         return (None, "Couldn't find any version information", None)
166
167     except VCSException as vcse:
168         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
169         return (None, msg, None)
170     except Exception:
171         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
172         return (None, msg, None)
173
174
175 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
176 # of the source repo. Whether this can be used reliably or not depends on
177 # the development procedures used by the project's developers. Use it with
178 # caution, because it's inappropriate for many projects.
179 # Returns (None, "a message") if this didn't work, or (version, vercode) for
180 # the details of the current version.
181 def check_repomanifest(app, branch=None):
182
183     try:
184
185         if app.RepoType == 'srclib':
186             build_dir = os.path.join('build', 'srclib', app.Repo)
187             repotype = common.getsrclibvcs(app.Repo)
188         else:
189             build_dir = os.path.join('build', app.id)
190             repotype = app.RepoType
191
192         # Set up vcs interface and make sure we have the latest code...
193         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
194
195         if repotype == 'git':
196             if branch:
197                 branch = 'origin/' + branch
198             vcs.gotorevision(branch)
199         elif repotype == 'git-svn':
200             vcs.gotorevision(branch)
201         elif repotype == 'hg':
202             vcs.gotorevision(branch)
203         elif repotype == 'bzr':
204             vcs.gotorevision(None)
205
206         last_build = metadata.Build()
207         if len(app.builds) > 0:
208             last_build = app.builds[-1]
209
210         try_init_submodules(app, last_build, vcs)
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, last_build.gradle)
221             version, vercode, package = common.parse_androidmanifests(paths, app)
222             if vercode:
223                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
224                               .format(subdir, version, vercode))
225                 if int(vercode) > int(hcode):
226                     hpak = package
227                     hcode = str(int(vercode))
228                     hver = version
229
230         if not hpak:
231             return (None, "Couldn't find package ID")
232         if hver:
233             return (hver, hcode)
234         return (None, "Couldn't find any version information")
235
236     except VCSException as vcse:
237         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
238         return (None, msg)
239     except Exception:
240         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
241         return (None, msg)
242
243
244 def check_repotrunk(app):
245
246     try:
247         if app.RepoType == 'srclib':
248             build_dir = os.path.join('build', 'srclib', app.Repo)
249             repotype = common.getsrclibvcs(app.Repo)
250         else:
251             build_dir = os.path.join('build', app.id)
252             repotype = app.RepoType
253
254         if repotype not in ('git-svn', ):
255             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
256
257         # Set up vcs interface and make sure we have the latest code...
258         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
259
260         vcs.gotorevision(None)
261
262         ref = vcs.getref()
263         return (ref, ref)
264     except VCSException as vcse:
265         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
266         return (None, msg)
267     except Exception:
268         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
269         return (None, msg)
270
271
272 # Check for a new version by looking at the Google Play Store.
273 # Returns (None, "a message") if this didn't work, or (version, None) for
274 # the details of the current version.
275 def check_gplay(app):
276     time.sleep(15)
277     url = 'https://play.google.com/store/apps/details?id=' + app.id
278     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
279     req = urllib.request.Request(url, None, headers)
280     try:
281         resp = urllib.request.urlopen(req, None, 20)
282         page = resp.read().decode()
283     except urllib.error.HTTPError as e:
284         return (None, str(e.code))
285     except Exception as e:
286         return (None, 'Failed:' + str(e))
287
288     version = None
289
290     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
291     if m:
292         version = html.unescape(m.group(1))
293
294     if version == 'Varies with device':
295         return (None, 'Device-variable version, cannot use this method')
296
297     if not version:
298         return (None, "Couldn't find version")
299     return (version.strip(), None)
300
301
302 def try_init_submodules(app, last_build, vcs):
303     """Try to init submodules if the last build entry used them.
304     They might have been removed from the app's repo in the meantime,
305     so if we can't find any submodules we continue with the updates check.
306     If there is any other error in initializing them then we stop the check.
307     """
308     if last_build.submodules:
309         try:
310             vcs.initsubmodules()
311         except NoSubmodulesException:
312             logging.info("No submodules present for {}".format(app.Name))
313
314
315 # Return all directories under startdir that contain any of the manifest
316 # files, and thus are probably an Android project.
317 def dirs_with_manifest(startdir):
318     for root, dirs, files in os.walk(startdir):
319         if any(m in files for m in [
320                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
321             yield root
322
323
324 # Tries to find a new subdir starting from the root build_dir. Returns said
325 # subdir relative to the build dir if found, None otherwise.
326 def possible_subdirs(app):
327
328     if app.RepoType == 'srclib':
329         build_dir = os.path.join('build', 'srclib', app.Repo)
330     else:
331         build_dir = os.path.join('build', app.id)
332
333     last_build = app.get_last_build()
334
335     for d in dirs_with_manifest(build_dir):
336         m_paths = common.manifest_paths(d, last_build.gradle)
337         package = common.parse_androidmanifests(m_paths, app)[2]
338         if package is not None:
339             subdir = os.path.relpath(d, build_dir)
340             logging.debug("Adding possible subdir %s" % subdir)
341             yield subdir
342
343
344 def fetch_autoname(app, tag):
345
346     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
347         return None
348
349     if app.RepoType == 'srclib':
350         build_dir = os.path.join('build', 'srclib', app.Repo)
351     else:
352         build_dir = os.path.join('build', app.id)
353
354     try:
355         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
356         vcs.gotorevision(tag)
357     except VCSException:
358         return None
359
360     last_build = app.get_last_build()
361
362     logging.debug("...fetch auto name from " + build_dir)
363     new_name = None
364     for subdir in possible_subdirs(app):
365         if subdir == '.':
366             root_dir = build_dir
367         else:
368             root_dir = os.path.join(build_dir, subdir)
369         new_name = common.fetch_real_name(root_dir, last_build.gradle)
370         if new_name is not None:
371             break
372     commitmsg = None
373     if new_name:
374         logging.debug("...got autoname '" + new_name + "'")
375         if new_name != app.AutoName:
376             app.AutoName = new_name
377             if not commitmsg:
378                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
379     else:
380         logging.debug("...couldn't get autoname")
381
382     return commitmsg
383
384
385 def checkupdates_app(app):
386
387     # If a change is made, commitmsg should be set to a description of it.
388     # Only if this is set will changes be written back to the metadata.
389     commitmsg = None
390
391     tag = None
392     msg = None
393     vercode = None
394     noverok = False
395     mode = app.UpdateCheckMode
396     if mode.startswith('Tags'):
397         pattern = mode[5:] if len(mode) > 4 else None
398         (version, vercode, tag) = check_tags(app, pattern)
399         if version == 'Unknown':
400             version = tag
401         msg = vercode
402     elif mode == 'RepoManifest':
403         (version, vercode) = check_repomanifest(app)
404         msg = vercode
405     elif mode.startswith('RepoManifest/'):
406         tag = mode[13:]
407         (version, vercode) = check_repomanifest(app, tag)
408         msg = vercode
409     elif mode == 'RepoTrunk':
410         (version, vercode) = check_repotrunk(app)
411         msg = vercode
412     elif mode == 'HTTP':
413         (version, vercode) = check_http(app)
414         msg = vercode
415     elif mode in ('None', 'Static'):
416         version = None
417         msg = 'Checking disabled'
418         noverok = True
419     else:
420         version = None
421         msg = 'Invalid update check method'
422
423     if version and vercode and app.VercodeOperation:
424         oldvercode = str(int(vercode))
425         op = app.VercodeOperation.replace("%c", oldvercode)
426         vercode = str(eval(op))
427         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
428
429     if version and any(version.startswith(s) for s in [
430             '${',  # Gradle variable names
431             '@string/',  # Strings we could not resolve
432             ]):
433         version = "Unknown"
434
435     updating = False
436     if version is None:
437         logmsg = "...{0} : {1}".format(app.id, msg)
438         if noverok:
439             logging.info(logmsg)
440         else:
441             logging.warn(logmsg)
442     elif vercode == app.CurrentVersionCode:
443         logging.info("...up to date")
444     else:
445         logging.debug("...updating - old vercode={0}, new vercode={1}".format(
446             app.CurrentVersionCode, vercode))
447         app.CurrentVersion = version
448         app.CurrentVersionCode = str(int(vercode))
449         updating = True
450
451     commitmsg = fetch_autoname(app, tag)
452
453     if updating:
454         name = common.getappname(app)
455         ver = common.getcvname(app)
456         logging.info('...updating to version %s' % ver)
457         commitmsg = 'Update CV of %s to %s' % (name, ver)
458
459     if options.auto:
460         mode = app.AutoUpdateMode
461         if not app.CurrentVersionCode:
462             logging.warn("Can't auto-update app with no current version code: " + app.id)
463         elif mode in ('None', 'Static'):
464             pass
465         elif mode.startswith('Version '):
466             pattern = mode[8:]
467             if pattern.startswith('+'):
468                 try:
469                     suffix, pattern = pattern.split(' ', 1)
470                 except ValueError:
471                     raise MetaDataException("Invalid AUM: " + mode)
472             else:
473                 suffix = ''
474             gotcur = False
475             latest = None
476             for build in app.builds:
477                 if int(build.versionCode) >= int(app.CurrentVersionCode):
478                     gotcur = True
479                 if not latest or int(build.versionCode) > int(latest.versionCode):
480                     latest = build
481
482             if int(latest.versionCode) > int(app.CurrentVersionCode):
483                 logging.info("Refusing to auto update, since the latest build is newer")
484
485             if not gotcur:
486                 newbuild = copy.deepcopy(latest)
487                 newbuild.disable = False
488                 newbuild.versionCode = app.CurrentVersionCode
489                 newbuild.versionName = app.CurrentVersion + suffix
490                 logging.info("...auto-generating build for " + newbuild.versionName)
491                 commit = pattern.replace('%v', newbuild.versionName)
492                 commit = commit.replace('%c', newbuild.versionCode)
493                 newbuild.commit = commit
494                 app.builds.append(newbuild)
495                 name = common.getappname(app)
496                 ver = common.getcvname(app)
497                 commitmsg = "Update %s to %s" % (name, ver)
498         else:
499             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
500
501     if commitmsg:
502         metadatapath = os.path.join('metadata', app.id + '.txt')
503         metadata.write_metadata(metadatapath, app)
504         if options.commit:
505             logging.info("Commiting update for " + metadatapath)
506             gitcmd = ["git", "commit", "-m", commitmsg]
507             if 'auto_author' in config:
508                 gitcmd.extend(['--author', config['auto_author']])
509             gitcmd.extend(["--", metadatapath])
510             if subprocess.call(gitcmd) != 0:
511                 raise FDroidException("Git commit failed")
512
513
514 def update_wiki(gplaylog, locallog):
515     if config.get('wiki_server') and config.get('wiki_path'):
516         try:
517             import mwclient
518             site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
519                                  path=config['wiki_path'])
520             site.login(config['wiki_user'], config['wiki_password'])
521
522             # Write a page with the last build log for this version code
523             wiki_page_path = 'checkupdates_' + time.strftime('%s', start_timestamp)
524             newpage = site.Pages[wiki_page_path]
525             txt = ''
526             txt += "* command line: <code>" + ' '.join(sys.argv) + "</code>\n"
527             txt += "* started at " + common.get_wiki_timestamp(start_timestamp) + '\n'
528             txt += "* completed at " + common.get_wiki_timestamp() + '\n'
529             txt += "\n\n"
530             txt += common.get_android_tools_version_log()
531             txt += "\n\n"
532             if gplaylog:
533                 txt += '== --gplay check ==\n\n'
534                 txt += gplaylog
535             if locallog:
536                 txt += '== local source check ==\n\n'
537                 txt += locallog
538             newpage.save(txt, summary='Run log')
539             newpage = site.Pages['checkupdates']
540             newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
541         except Exception as e:
542             logging.error(_('Error while attempting to publish log: %s') % e)
543
544
545 config = None
546 options = None
547 start_timestamp = time.gmtime()
548
549
550 def main():
551
552     global config, options
553
554     # Parse command line...
555     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
556     common.setup_global_opts(parser)
557     parser.add_argument("appid", nargs='*', help=_("applicationId to check for updates"))
558     parser.add_argument("--auto", action="store_true", default=False,
559                         help=_("Process auto-updates"))
560     parser.add_argument("--autoonly", action="store_true", default=False,
561                         help=_("Only process apps with auto-updates"))
562     parser.add_argument("--commit", action="store_true", default=False,
563                         help=_("Commit changes"))
564     parser.add_argument("--gplay", action="store_true", default=False,
565                         help=_("Only print differences with the Play Store"))
566     metadata.add_metadata_arguments(parser)
567     options = parser.parse_args()
568     metadata.warnings_action = options.W
569
570     config = common.read_config(options)
571
572     # Get all apps...
573     allapps = metadata.read_metadata()
574
575     apps = common.read_app_args(options.appid, allapps, False)
576
577     gplaylog = ''
578     if options.gplay:
579         for appid, app in apps.items():
580             gplaylog += '* ' + appid + '\n'
581             version, reason = check_gplay(app)
582             if version is None:
583                 if reason == '404':
584                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
585                 else:
586                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
587             if version is not None:
588                 stored = app.CurrentVersion
589                 if not stored:
590                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
591                                  .format(common.getappname(app), version))
592                 elif LooseVersion(stored) < LooseVersion(version):
593                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
594                                  .format(common.getappname(app), version, stored))
595                 else:
596                     if stored != version:
597                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
598                                      .format(common.getappname(app), version, stored))
599                     else:
600                         logging.info("{0} has the same version {1} on the Play Store"
601                                      .format(common.getappname(app), version))
602         update_wiki(gplaylog, None)
603         return
604
605     locallog = ''
606     for appid, app in apps.items():
607
608         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
609             logging.debug(_("Nothing to do for {appid}.").format(appid=appid))
610             continue
611
612         msg = _("Processing {appid}").format(appid=appid)
613         logging.info(msg)
614         locallog += '* ' + msg + '\n'
615
616         try:
617             checkupdates_app(app)
618         except Exception as e:
619             msg = _("...checkupdate failed for {appid} : {error}").format(appid=appid, error=e)
620             logging.error(msg)
621             locallog += msg + '\n'
622
623     update_wiki(None, locallog)
624
625     logging.info(_("Finished"))
626
627
628 if __name__ == "__main__":
629     main()