chiark / gitweb /
Merge branch 'fdroid-build-in-git-repo' into 'master'
[fdroidserver.git] / fdroidserver / metadata.py
1 #!/usr/bin/env python3
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, 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 json
21 import os
22 import re
23 import glob
24 import cgi
25 import logging
26 import textwrap
27 import io
28
29 import yaml
30 # use libyaml if it is available
31 try:
32     from yaml import CLoader
33     YamlLoader = CLoader
34 except ImportError:
35     from yaml import Loader
36     YamlLoader = Loader
37
38 # use the C implementation when available
39 import xml.etree.cElementTree as ElementTree
40
41 import fdroidserver.common
42
43 srclibs = None
44
45
46 class MetaDataException(Exception):
47
48     def __init__(self, value):
49         self.value = value
50
51     def __str__(self):
52         return self.value
53
54 # To filter which ones should be written to the metadata files if
55 # present
56 app_fields = set([
57     'Disabled',
58     'AntiFeatures',
59     'Provides',
60     'Categories',
61     'License',
62     'Author Name',
63     'Author Email',
64     'Web Site',
65     'Source Code',
66     'Issue Tracker',
67     'Changelog',
68     'Donate',
69     'FlattrID',
70     'Bitcoin',
71     'Litecoin',
72     'Name',
73     'Auto Name',
74     'Summary',
75     'Description',
76     'Requires Root',
77     'Repo Type',
78     'Repo',
79     'Binaries',
80     'Maintainer Notes',
81     'Archive Policy',
82     'Auto Update Mode',
83     'Update Check Mode',
84     'Update Check Ignore',
85     'Vercode Operation',
86     'Update Check Name',
87     'Update Check Data',
88     'Current Version',
89     'Current Version Code',
90     'No Source Since',
91
92     'comments',  # For formats that don't do inline comments
93     'builds',    # For formats that do builds as a list
94 ])
95
96
97 class App():
98
99     def __init__(self):
100         self.Disabled = None
101         self.AntiFeatures = []
102         self.Provides = None
103         self.Categories = ['None']
104         self.License = 'Unknown'
105         self.AuthorName = None
106         self.AuthorEmail = None
107         self.WebSite = ''
108         self.SourceCode = ''
109         self.IssueTracker = ''
110         self.Changelog = ''
111         self.Donate = None
112         self.FlattrID = None
113         self.Bitcoin = None
114         self.Litecoin = None
115         self.Name = None
116         self.AutoName = ''
117         self.Summary = ''
118         self.Description = ''
119         self.RequiresRoot = False
120         self.RepoType = ''
121         self.Repo = ''
122         self.Binaries = None
123         self.MaintainerNotes = ''
124         self.ArchivePolicy = None
125         self.AutoUpdateMode = 'None'
126         self.UpdateCheckMode = 'None'
127         self.UpdateCheckIgnore = None
128         self.VercodeOperation = None
129         self.UpdateCheckName = None
130         self.UpdateCheckData = None
131         self.CurrentVersion = ''
132         self.CurrentVersionCode = None
133         self.NoSourceSince = ''
134
135         self.id = None
136         self.metadatapath = None
137         self.builds = []
138         self.comments = {}
139         self.added = None
140         self.lastupdated = None
141         self._modified = set()
142
143     # Translates human-readable field names to attribute names, e.g.
144     # 'Auto Name' to 'AutoName'
145     @classmethod
146     def field_to_attr(cls, f):
147         return f.replace(' ', '')
148
149     # Translates attribute names to human-readable field names, e.g.
150     # 'AutoName' to 'Auto Name'
151     @classmethod
152     def attr_to_field(cls, k):
153         if k in app_fields:
154             return k
155         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
156         return f
157
158     # Constructs an old-fashioned dict with the human-readable field
159     # names. Should only be used for tests.
160     def field_dict(self):
161         d = {}
162         for k, v in self.__dict__.items():
163             if k == 'builds':
164                 d['builds'] = []
165                 for build in v:
166                     b = {k: v for k, v in build.__dict__.items() if not k.startswith('_')}
167                     d['builds'].append(b)
168             elif not k.startswith('_'):
169                 f = App.attr_to_field(k)
170                 d[f] = v
171         return d
172
173     # Gets the value associated to a field name, e.g. 'Auto Name'
174     def get_field(self, f):
175         if f not in app_fields:
176             raise MetaDataException('Unrecognised app field: ' + f)
177         k = App.field_to_attr(f)
178         return getattr(self, k)
179
180     # Sets the value associated to a field name, e.g. 'Auto Name'
181     def set_field(self, f, v):
182         if f not in app_fields:
183             raise MetaDataException('Unrecognised app field: ' + f)
184         k = App.field_to_attr(f)
185         self.__dict__[k] = v
186         self._modified.add(k)
187
188     # Appends to the value associated to a field name, e.g. 'Auto Name'
189     def append_field(self, f, v):
190         if f not in app_fields:
191             raise MetaDataException('Unrecognised app field: ' + f)
192         k = App.field_to_attr(f)
193         if k not in self.__dict__:
194             self.__dict__[k] = [v]
195         else:
196             self.__dict__[k].append(v)
197
198     # Like dict.update(), but using human-readable field names
199     def update_fields(self, d):
200         for f, v in d.items():
201             if f == 'builds':
202                 for b in v:
203                     build = Build()
204                     build.update_flags(b)
205                     self.builds.append(build)
206             else:
207                 self.set_field(f, v)
208
209 TYPE_UNKNOWN = 0
210 TYPE_OBSOLETE = 1
211 TYPE_STRING = 2
212 TYPE_BOOL = 3
213 TYPE_LIST = 4
214 TYPE_SCRIPT = 5
215 TYPE_MULTILINE = 6
216 TYPE_BUILD = 7
217 TYPE_BUILD_V2 = 8
218
219 fieldtypes = {
220     'Description': TYPE_MULTILINE,
221     'Maintainer Notes': TYPE_MULTILINE,
222     'Categories': TYPE_LIST,
223     'AntiFeatures': TYPE_LIST,
224     'Build Version': TYPE_BUILD,
225     'Build': TYPE_BUILD_V2,
226     'Use Built': TYPE_OBSOLETE,
227 }
228
229
230 def fieldtype(name):
231     if name in fieldtypes:
232         return fieldtypes[name]
233     return TYPE_STRING
234
235
236 # In the order in which they are laid out on files
237 build_flags_order = [
238     'disable',
239     'commit',
240     'subdir',
241     'submodules',
242     'init',
243     'patch',
244     'gradle',
245     'maven',
246     'kivy',
247     'output',
248     'srclibs',
249     'oldsdkloc',
250     'encoding',
251     'forceversion',
252     'forcevercode',
253     'rm',
254     'extlibs',
255     'prebuild',
256     'update',
257     'target',
258     'scanignore',
259     'scandelete',
260     'build',
261     'buildjni',
262     'ndk',
263     'preassemble',
264     'gradleprops',
265     'antcommands',
266     'novcheck',
267 ]
268
269
270 build_flags = set(build_flags_order + ['version', 'vercode'])
271
272
273 class Build():
274
275     def __init__(self):
276         self.disable = False
277         self.commit = None
278         self.subdir = None
279         self.submodules = False
280         self.init = ''
281         self.patch = []
282         self.gradle = []
283         self.maven = False
284         self.kivy = False
285         self.output = None
286         self.srclibs = []
287         self.oldsdkloc = False
288         self.encoding = None
289         self.forceversion = False
290         self.forcevercode = False
291         self.rm = []
292         self.extlibs = []
293         self.prebuild = ''
294         self.update = []
295         self.target = None
296         self.scanignore = []
297         self.scandelete = []
298         self.build = ''
299         self.buildjni = []
300         self.ndk = None
301         self.preassemble = []
302         self.gradleprops = []
303         self.antcommands = []
304         self.novcheck = False
305
306         self._modified = set()
307
308     def get_flag(self, f):
309         if f not in build_flags:
310             raise MetaDataException('Unrecognised build flag: ' + f)
311         return getattr(self, f)
312
313     def set_flag(self, f, v):
314         if f == 'versionName':
315             f = 'version'
316         if f == 'versionCode':
317             f = 'vercode'
318         if f not in build_flags:
319             raise MetaDataException('Unrecognised build flag: ' + f)
320         self.__dict__[f] = v
321         self._modified.add(f)
322
323     def append_flag(self, f, v):
324         if f not in build_flags:
325             raise MetaDataException('Unrecognised build flag: ' + f)
326         if f not in self.__dict__:
327             self.__dict__[f] = [v]
328         else:
329             self.__dict__[f].append(v)
330
331     def build_method(self):
332         for f in ['maven', 'gradle', 'kivy']:
333             if self.get_flag(f):
334                 return f
335         if self.output:
336             return 'raw'
337         return 'ant'
338
339     # like build_method, but prioritize output=
340     def output_method(self):
341         if self.output:
342             return 'raw'
343         for f in ['maven', 'gradle', 'kivy']:
344             if self.get_flag(f):
345                 return f
346         return 'ant'
347
348     def ndk_path(self):
349         version = self.ndk
350         if not version:
351             version = 'r10e'  # falls back to latest
352         paths = fdroidserver.common.config['ndk_paths']
353         if version not in paths:
354             return ''
355         return paths[version]
356
357     def update_flags(self, d):
358         for f, v in d.items():
359             self.set_flag(f, v)
360
361 flagtypes = {
362     'extlibs': TYPE_LIST,
363     'srclibs': TYPE_LIST,
364     'patch': TYPE_LIST,
365     'rm': TYPE_LIST,
366     'buildjni': TYPE_LIST,
367     'preassemble': TYPE_LIST,
368     'update': TYPE_LIST,
369     'scanignore': TYPE_LIST,
370     'scandelete': TYPE_LIST,
371     'gradle': TYPE_LIST,
372     'antcommands': TYPE_LIST,
373     'gradleprops': TYPE_LIST,
374     'init': TYPE_SCRIPT,
375     'prebuild': TYPE_SCRIPT,
376     'build': TYPE_SCRIPT,
377     'submodules': TYPE_BOOL,
378     'oldsdkloc': TYPE_BOOL,
379     'forceversion': TYPE_BOOL,
380     'forcevercode': TYPE_BOOL,
381     'novcheck': TYPE_BOOL,
382 }
383
384
385 def flagtype(name):
386     if name in flagtypes:
387         return flagtypes[name]
388     return TYPE_STRING
389
390
391 # Designates a metadata field type and checks that it matches
392 #
393 # 'name'     - The long name of the field type
394 # 'matching' - List of possible values or regex expression
395 # 'sep'      - Separator to use if value may be a list
396 # 'fields'   - Metadata fields (Field:Value) of this type
397 # 'flags'    - Build flags (flag=value) of this type
398 #
399 class FieldValidator():
400
401     def __init__(self, name, matching, fields, flags):
402         self.name = name
403         self.matching = matching
404         self.compiled = re.compile(matching)
405         self.fields = fields
406         self.flags = flags
407
408     def check(self, v, appid):
409         if not v:
410             return
411         if type(v) == list:
412             values = v
413         else:
414             values = [v]
415         for v in values:
416             if not self.compiled.match(v):
417                 raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
418                                         % (v, self.name, appid, self.matching))
419
420 # Generic value types
421 valuetypes = {
422     FieldValidator("Integer",
423                    r'^[1-9][0-9]*$',
424                    [],
425                    ['vercode']),
426
427     FieldValidator("Hexadecimal",
428                    r'^[0-9a-f]+$',
429                    ['FlattrID'],
430                    []),
431
432     FieldValidator("HTTP link",
433                    r'^http[s]?://',
434                    ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
435
436     FieldValidator("Email",
437                    r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
438                    ["AuthorEmail"], []),
439
440     FieldValidator("Bitcoin address",
441                    r'^[a-zA-Z0-9]{27,34}$',
442                    ["Bitcoin"],
443                    []),
444
445     FieldValidator("Litecoin address",
446                    r'^L[a-zA-Z0-9]{33}$',
447                    ["Litecoin"],
448                    []),
449
450     FieldValidator("Repo Type",
451                    r'^(git|git-svn|svn|hg|bzr|srclib)$',
452                    ["RepoType"],
453                    []),
454
455     FieldValidator("Binaries",
456                    r'^http[s]?://',
457                    ["Binaries"],
458                    []),
459
460     FieldValidator("Archive Policy",
461                    r'^[0-9]+ versions$',
462                    ["ArchivePolicy"],
463                    []),
464
465     FieldValidator("Anti-Feature",
466                    r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
467                    ["AntiFeatures"],
468                    []),
469
470     FieldValidator("Auto Update Mode",
471                    r"^(Version .+|None)$",
472                    ["AutoUpdateMode"],
473                    []),
474
475     FieldValidator("Update Check Mode",
476                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
477                    ["UpdateCheckMode"],
478                    [])
479 }
480
481
482 # Check an app's metadata information for integrity errors
483 def check_metadata(app):
484     for v in valuetypes:
485         for k in v.fields:
486             if k not in app._modified:
487                 continue
488             v.check(app.__dict__[k], app.id)
489         for build in app.builds:
490             for k in v.flags:
491                 if k not in build._modified:
492                     continue
493                 v.check(build.__dict__[k], app.id)
494
495
496 # Formatter for descriptions. Create an instance, and call parseline() with
497 # each line of the description source from the metadata. At the end, call
498 # end() and then text_txt and text_html will contain the result.
499 class DescriptionFormatter:
500
501     stNONE = 0
502     stPARA = 1
503     stUL = 2
504     stOL = 3
505
506     def __init__(self, linkres):
507         self.bold = False
508         self.ital = False
509         self.state = self.stNONE
510         self.laststate = self.stNONE
511         self.text_html = ''
512         self.text_txt = ''
513         self.html = io.StringIO()
514         self.text = io.StringIO()
515         self.para_lines = []
516         self.linkResolver = None
517         self.linkResolver = linkres
518
519     def endcur(self, notstates=None):
520         if notstates and self.state in notstates:
521             return
522         if self.state == self.stPARA:
523             self.endpara()
524         elif self.state == self.stUL:
525             self.endul()
526         elif self.state == self.stOL:
527             self.endol()
528
529     def endpara(self):
530         self.laststate = self.state
531         self.state = self.stNONE
532         whole_para = ' '.join(self.para_lines)
533         self.addtext(whole_para)
534         wrapped = textwrap.fill(whole_para, 80,
535                                 break_long_words=False,
536                                 break_on_hyphens=False)
537         self.text.write(wrapped)
538         self.html.write('</p>')
539         del self.para_lines[:]
540
541     def endul(self):
542         self.html.write('</ul>')
543         self.laststate = self.state
544         self.state = self.stNONE
545
546     def endol(self):
547         self.html.write('</ol>')
548         self.laststate = self.state
549         self.state = self.stNONE
550
551     def formatted(self, txt, html):
552         res = ''
553         if html:
554             txt = cgi.escape(txt)
555         while True:
556             index = txt.find("''")
557             if index == -1:
558                 return res + txt
559             res += txt[:index]
560             txt = txt[index:]
561             if txt.startswith("'''"):
562                 if html:
563                     if self.bold:
564                         res += '</b>'
565                     else:
566                         res += '<b>'
567                 self.bold = not self.bold
568                 txt = txt[3:]
569             else:
570                 if html:
571                     if self.ital:
572                         res += '</i>'
573                     else:
574                         res += '<i>'
575                 self.ital = not self.ital
576                 txt = txt[2:]
577
578     def linkify(self, txt):
579         res_plain = ''
580         res_html = ''
581         while True:
582             index = txt.find("[")
583             if index == -1:
584                 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
585             res_plain += self.formatted(txt[:index], False)
586             res_html += self.formatted(txt[:index], True)
587             txt = txt[index:]
588             if txt.startswith("[["):
589                 index = txt.find("]]")
590                 if index == -1:
591                     raise MetaDataException("Unterminated ]]")
592                 url = txt[2:index]
593                 if self.linkResolver:
594                     url, urltext = self.linkResolver(url)
595                 else:
596                     urltext = url
597                 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
598                 res_plain += urltext
599                 txt = txt[index + 2:]
600             else:
601                 index = txt.find("]")
602                 if index == -1:
603                     raise MetaDataException("Unterminated ]")
604                 url = txt[1:index]
605                 index2 = url.find(' ')
606                 if index2 == -1:
607                     urltxt = url
608                 else:
609                     urltxt = url[index2 + 1:]
610                     url = url[:index2]
611                     if url == urltxt:
612                         raise MetaDataException("Url title is just the URL - use [url]")
613                 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
614                 res_plain += urltxt
615                 if urltxt != url:
616                     res_plain += ' (' + url + ')'
617                 txt = txt[index + 1:]
618
619     def addtext(self, txt):
620         p, h = self.linkify(txt)
621         self.html.write(h)
622
623     def parseline(self, line):
624         if not line:
625             self.endcur()
626         elif line.startswith('* '):
627             self.endcur([self.stUL])
628             if self.state != self.stUL:
629                 self.html.write('<ul>')
630                 self.state = self.stUL
631                 if self.laststate != self.stNONE:
632                     self.text.write('\n\n')
633             else:
634                 self.text.write('\n')
635             self.text.write(line)
636             self.html.write('<li>')
637             self.addtext(line[1:])
638             self.html.write('</li>')
639         elif line.startswith('# '):
640             self.endcur([self.stOL])
641             if self.state != self.stOL:
642                 self.html.write('<ol>')
643                 self.state = self.stOL
644                 if self.laststate != self.stNONE:
645                     self.text.write('\n\n')
646             else:
647                 self.text.write('\n')
648             self.text.write(line)
649             self.html.write('<li>')
650             self.addtext(line[1:])
651             self.html.write('</li>')
652         else:
653             self.para_lines.append(line)
654             self.endcur([self.stPARA])
655             if self.state == self.stNONE:
656                 self.state = self.stPARA
657                 if self.laststate != self.stNONE:
658                     self.text.write('\n\n')
659                 self.html.write('<p>')
660
661     def end(self):
662         self.endcur()
663         self.text_txt = self.text.getvalue()
664         self.text_html = self.html.getvalue()
665         self.text.close()
666         self.html.close()
667
668
669 # Parse multiple lines of description as written in a metadata file, returning
670 # a single string in text format and wrapped to 80 columns.
671 def description_txt(s):
672     ps = DescriptionFormatter(None)
673     for line in s.splitlines():
674         ps.parseline(line)
675     ps.end()
676     return ps.text_txt
677
678
679 # Parse multiple lines of description as written in a metadata file, returning
680 # a single string in wiki format. Used for the Maintainer Notes field as well,
681 # because it's the same format.
682 def description_wiki(s):
683     return s
684
685
686 # Parse multiple lines of description as written in a metadata file, returning
687 # a single string in HTML format.
688 def description_html(s, linkres):
689     ps = DescriptionFormatter(linkres)
690     for line in s.splitlines():
691         ps.parseline(line)
692     ps.end()
693     return ps.text_html
694
695
696 def parse_srclib(metadatapath):
697
698     thisinfo = {}
699
700     # Defaults for fields that come from metadata
701     thisinfo['Repo Type'] = ''
702     thisinfo['Repo'] = ''
703     thisinfo['Subdir'] = None
704     thisinfo['Prepare'] = None
705
706     if not os.path.exists(metadatapath):
707         return thisinfo
708
709     metafile = open(metadatapath, "r", encoding='utf-8')
710
711     n = 0
712     for line in metafile:
713         n += 1
714         line = line.rstrip('\r\n')
715         if not line or line.startswith("#"):
716             continue
717
718         try:
719             f, v = line.split(':', 1)
720         except ValueError:
721             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
722
723         if f == "Subdir":
724             thisinfo[f] = v.split(',')
725         else:
726             thisinfo[f] = v
727
728     metafile.close()
729
730     return thisinfo
731
732
733 def read_srclibs():
734     """Read all srclib metadata.
735
736     The information read will be accessible as metadata.srclibs, which is a
737     dictionary, keyed on srclib name, with the values each being a dictionary
738     in the same format as that returned by the parse_srclib function.
739
740     A MetaDataException is raised if there are any problems with the srclib
741     metadata.
742     """
743     global srclibs
744
745     # They were already loaded
746     if srclibs is not None:
747         return
748
749     srclibs = {}
750
751     srcdir = 'srclibs'
752     if not os.path.exists(srcdir):
753         os.makedirs(srcdir)
754
755     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
756         srclibname = os.path.basename(metadatapath[:-4])
757         srclibs[srclibname] = parse_srclib(metadatapath)
758
759
760 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
761 # returned by the parse_txt_metadata function.
762 def read_metadata(xref=True):
763
764     # Always read the srclibs before the apps, since they can use a srlib as
765     # their source repository.
766     read_srclibs()
767
768     apps = {}
769
770     for basedir in ('metadata', 'tmp'):
771         if not os.path.exists(basedir):
772             os.makedirs(basedir)
773
774     # If there are multiple metadata files for a single appid, then the first
775     # file that is parsed wins over all the others, and the rest throw an
776     # exception. So the original .txt format is parsed first, at least until
777     # newer formats stabilize.
778
779     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
780                                + glob.glob(os.path.join('metadata', '*.json'))
781                                + glob.glob(os.path.join('metadata', '*.xml'))
782                                + glob.glob(os.path.join('metadata', '*.yml'))
783                                + glob.glob('.fdroid.json')
784                                + glob.glob('.fdroid.xml')
785                                + glob.glob('.fdroid.yml')):
786         app = parse_metadata(metadatapath)
787         if app.id in apps:
788             raise MetaDataException("Found multiple metadata files for " + app.id)
789         check_metadata(app)
790         apps[app.id] = app
791
792     if xref:
793         # Parse all descriptions at load time, just to ensure cross-referencing
794         # errors are caught early rather than when they hit the build server.
795         def linkres(appid):
796             if appid in apps:
797                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
798             raise MetaDataException("Cannot resolve app id " + appid)
799
800         for appid, app in apps.items():
801             try:
802                 description_html(app.Description, linkres)
803             except MetaDataException as e:
804                 raise MetaDataException("Problem with description of " + appid +
805                                         " - " + str(e))
806
807     return apps
808
809 # Port legacy ';' separators
810 list_sep = re.compile(r'[,;]')
811
812
813 def split_list_values(s):
814     res = []
815     for v in re.split(list_sep, s):
816         if not v:
817             continue
818         v = v.strip()
819         if not v:
820             continue
821         res.append(v)
822     return res
823
824
825 def get_default_app_info(metadatapath=None):
826     if metadatapath is None:
827         appid = None
828     else:
829         appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
830
831     if appid == '.fdroid':  # we have local metadata in the app's source
832         if os.path.exists('AndroidManifest.xml'):
833             manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
834         else:
835             pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
836             for root, dirs, files in os.walk(os.getcwd()):
837                 if 'build.gradle' in files:
838                     p = os.path.join(root, 'build.gradle')
839                     with open(p) as f:
840                         data = f.read()
841                     m = pattern.search(data)
842                     if m:
843                         logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
844                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
845                         break
846         if manifestroot is None:
847             raise MetaDataException("Cannot find a packageName for {0}!".format(metadatapath))
848         appid = manifestroot.attrib['package']
849
850     app = App()
851     app.metadatapath = metadatapath
852     if appid is not None:
853         app.id = appid
854
855     return app
856
857
858 def sorted_builds(builds):
859     return sorted(builds, key=lambda build: int(build.vercode))
860
861
862 esc_newlines = re.compile(r'\\( |\n)')
863
864
865 # This function uses __dict__ to be faster
866 def post_metadata_parse(app):
867
868     for k in app._modified:
869         v = app.__dict__[k]
870         if type(v) in (float, int):
871             app.__dict__[k] = str(v)
872
873     for build in app.builds:
874         for k in build._modified:
875             v = build.__dict__[k]
876             if type(v) in (float, int):
877                 build.__dict__[k] = str(v)
878                 continue
879             ftype = flagtype(k)
880
881             if ftype == TYPE_SCRIPT:
882                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
883             elif ftype == TYPE_BOOL:
884                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
885                 if isinstance(v, str):
886                     build.__dict__[k] = _decode_bool(v)
887             elif ftype == TYPE_STRING:
888                 if isinstance(v, bool) and v:
889                     build.__dict__[k] = 'yes'
890
891     if not app.Description:
892         app.Description = 'No description available'
893
894     app.builds = sorted_builds(app.builds)
895
896
897 # Parse metadata for a single application.
898 #
899 #  'metadatapath' - the filename to read. The package id for the application comes
900 #               from this filename. Pass None to get a blank entry.
901 #
902 # Returns a dictionary containing all the details of the application. There are
903 # two major kinds of information in the dictionary. Keys beginning with capital
904 # letters correspond directory to identically named keys in the metadata file.
905 # Keys beginning with lower case letters are generated in one way or another,
906 # and are not found verbatim in the metadata.
907 #
908 # Known keys not originating from the metadata are:
909 #
910 #  'builds'           - a list of dictionaries containing build information
911 #                       for each defined build
912 #  'comments'         - a list of comments from the metadata file. Each is
913 #                       a list of the form [field, comment] where field is
914 #                       the name of the field it preceded in the metadata
915 #                       file. Where field is None, the comment goes at the
916 #                       end of the file. Alternatively, 'build:version' is
917 #                       for a comment before a particular build version.
918 #  'descriptionlines' - original lines of description as formatted in the
919 #                       metadata file.
920 #
921
922
923 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
924 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
925
926
927 def _decode_bool(s):
928     if bool_true.match(s):
929         return True
930     if bool_false.match(s):
931         return False
932     raise MetaDataException("Invalid bool '%s'" % s)
933
934
935 def parse_metadata(metadatapath):
936     _, ext = fdroidserver.common.get_extension(metadatapath)
937     accepted = fdroidserver.common.config['accepted_formats']
938     if ext not in accepted:
939         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
940             metadatapath, ', '.join(accepted)))
941
942     app = App()
943     app.metadatapath = metadatapath
944     app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
945
946     with open(metadatapath, 'r', encoding='utf-8') as mf:
947         if ext == 'txt':
948             parse_txt_metadata(mf, app)
949         elif ext == 'json':
950             parse_json_metadata(mf, app)
951         elif ext == 'xml':
952             parse_xml_metadata(mf, app)
953         elif ext == 'yml':
954             parse_yaml_metadata(mf, app)
955         else:
956             raise MetaDataException('Unknown metadata format: %s' % metadatapath)
957
958     post_metadata_parse(app)
959     return app
960
961
962 def parse_json_metadata(mf, app):
963
964     # fdroid metadata is only strings and booleans, no floats or ints.
965     # TODO create schema using https://pypi.python.org/pypi/jsonschema
966     jsoninfo = json.load(mf, parse_int=lambda s: s,
967                          parse_float=lambda s: s)
968     app.update_fields(jsoninfo)
969     for f in ['Description', 'Maintainer Notes']:
970         v = app.get_field(f)
971         app.set_field(f, '\n'.join(v))
972     return app
973
974
975 def parse_xml_metadata(mf, app):
976
977     tree = ElementTree.ElementTree(file=mf)
978     root = tree.getroot()
979
980     if root.tag != 'resources':
981         raise MetaDataException('resources file does not have root element <resources/>')
982
983     for child in root:
984         if child.tag != 'builds':
985             # builds does not have name="" attrib
986             name = child.attrib['name']
987
988         if child.tag == 'string':
989             app.set_field(name, child.text)
990         elif child.tag == 'string-array':
991             for item in child:
992                 app.append_field(name, item.text)
993         elif child.tag == 'builds':
994             for b in child:
995                 build = Build()
996                 for key in b:
997                     build.set_flag(key.tag, key.text)
998                 app.builds.append(build)
999
1000     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1001     if not isinstance(app.RequiresRoot, bool):
1002         app.RequiresRoot = app.RequiresRoot == 'true'
1003
1004     return app
1005
1006
1007 def parse_yaml_metadata(mf, app):
1008
1009     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1010     app.update_fields(yamlinfo)
1011     return app
1012
1013
1014 build_line_sep = re.compile(r'(?<!\\),')
1015 build_cont = re.compile(r'^[ \t]')
1016
1017
1018 def parse_txt_metadata(mf, app):
1019
1020     linedesc = None
1021
1022     def add_buildflag(p, build):
1023         if not p.strip():
1024             raise MetaDataException("Empty build flag at {1}"
1025                                     .format(buildlines[0], linedesc))
1026         bv = p.split('=', 1)
1027         if len(bv) != 2:
1028             raise MetaDataException("Invalid build flag at {0} in {1}"
1029                                     .format(buildlines[0], linedesc))
1030
1031         pk, pv = bv
1032         pk = pk.lstrip()
1033         t = flagtype(pk)
1034         if t == TYPE_LIST:
1035             pv = split_list_values(pv)
1036             build.set_flag(pk, pv)
1037         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1038             build.set_flag(pk, pv)
1039         elif t == TYPE_BOOL:
1040             build.set_flag(pk, _decode_bool(pv))
1041
1042     def parse_buildline(lines):
1043         v = "".join(lines)
1044         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1045         if len(parts) < 3:
1046             raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
1047         build = Build()
1048         build.version = parts[0]
1049         build.vercode = parts[1]
1050         if parts[2].startswith('!'):
1051             # For backwards compatibility, handle old-style disabling,
1052             # including attempting to extract the commit from the message
1053             build.disable = parts[2][1:]
1054             commit = 'unknown - see disabled'
1055             index = parts[2].rfind('at ')
1056             if index != -1:
1057                 commit = parts[2][index + 3:]
1058                 if commit.endswith(')'):
1059                     commit = commit[:-1]
1060             build.commit = commit
1061         else:
1062             build.commit = parts[2]
1063         for p in parts[3:]:
1064             add_buildflag(p, build)
1065
1066         return build
1067
1068     def add_comments(key):
1069         if not curcomments:
1070             return
1071         app.comments[key] = list(curcomments)
1072         del curcomments[:]
1073
1074     mode = 0
1075     buildlines = []
1076     multiline_lines = []
1077     curcomments = []
1078     build = None
1079     vc_seen = set()
1080
1081     c = 0
1082     for line in mf:
1083         c += 1
1084         linedesc = "%s:%d" % (mf.name, c)
1085         line = line.rstrip('\r\n')
1086         if mode == 3:
1087             if build_cont.match(line):
1088                 if line.endswith('\\'):
1089                     buildlines.append(line[:-1].lstrip())
1090                 else:
1091                     buildlines.append(line.lstrip())
1092                     bl = ''.join(buildlines)
1093                     add_buildflag(bl, build)
1094                     del buildlines[:]
1095             else:
1096                 if not build.commit and not build.disable:
1097                     raise MetaDataException("No commit specified for {0} in {1}"
1098                                             .format(build.version, linedesc))
1099
1100                 app.builds.append(build)
1101                 add_comments('build:' + build.vercode)
1102                 mode = 0
1103
1104         if mode == 0:
1105             if not line:
1106                 continue
1107             if line.startswith("#"):
1108                 curcomments.append(line[1:].strip())
1109                 continue
1110             try:
1111                 f, v = line.split(':', 1)
1112             except ValueError:
1113                 raise MetaDataException("Invalid metadata in " + linedesc)
1114
1115             # Translate obsolete fields...
1116             if f == 'Market Version':
1117                 f = 'Current Version'
1118             if f == 'Market Version Code':
1119                 f = 'Current Version Code'
1120
1121             ftype = fieldtype(f)
1122             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1123                 add_comments(f)
1124             if ftype == TYPE_MULTILINE:
1125                 mode = 1
1126                 if v:
1127                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1128             elif ftype == TYPE_STRING:
1129                 app.set_field(f, v)
1130             elif ftype == TYPE_LIST:
1131                 app.set_field(f, split_list_values(v))
1132             elif ftype == TYPE_BUILD:
1133                 if v.endswith("\\"):
1134                     mode = 2
1135                     del buildlines[:]
1136                     buildlines.append(v[:-1])
1137                 else:
1138                     build = parse_buildline([v])
1139                     app.builds.append(build)
1140                     add_comments('build:' + app.builds[-1].vercode)
1141             elif ftype == TYPE_BUILD_V2:
1142                 vv = v.split(',')
1143                 if len(vv) != 2:
1144                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1145                                             .format(v, linedesc))
1146                 build = Build()
1147                 build.version = vv[0]
1148                 build.vercode = vv[1]
1149                 if build.vercode in vc_seen:
1150                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1151                                             build.vercode, linedesc))
1152                 vc_seen.add(build.vercode)
1153                 del buildlines[:]
1154                 mode = 3
1155             elif ftype == TYPE_OBSOLETE:
1156                 pass        # Just throw it away!
1157             else:
1158                 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1159         elif mode == 1:     # Multiline field
1160             if line == '.':
1161                 mode = 0
1162                 app.set_field(f, '\n'.join(multiline_lines))
1163                 del multiline_lines[:]
1164             else:
1165                 multiline_lines.append(line)
1166         elif mode == 2:     # Line continuation mode in Build Version
1167             if line.endswith("\\"):
1168                 buildlines.append(line[:-1])
1169             else:
1170                 buildlines.append(line)
1171                 build = parse_buildline(buildlines)
1172                 app.builds.append(build)
1173                 add_comments('build:' + app.builds[-1].vercode)
1174                 mode = 0
1175     add_comments(None)
1176
1177     # Mode at end of file should always be 0
1178     if mode == 1:
1179         raise MetaDataException(f + " not terminated in " + mf.name)
1180     if mode == 2:
1181         raise MetaDataException("Unterminated continuation in " + mf.name)
1182     if mode == 3:
1183         raise MetaDataException("Unterminated build in " + mf.name)
1184
1185     return app
1186
1187
1188 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1189
1190     def w_comments(key):
1191         if key not in app.comments:
1192             return
1193         for line in app.comments[key]:
1194             w_comment(line)
1195
1196     def w_field_always(f, v=None):
1197         if v is None:
1198             v = app.get_field(f)
1199         w_comments(f)
1200         w_field(f, v)
1201
1202     def w_field_nonempty(f, v=None):
1203         if v is None:
1204             v = app.get_field(f)
1205         w_comments(f)
1206         if v:
1207             w_field(f, v)
1208
1209     w_field_nonempty('Disabled')
1210     w_field_nonempty('AntiFeatures')
1211     w_field_nonempty('Provides')
1212     w_field_always('Categories')
1213     w_field_always('License')
1214     w_field_nonempty('Author Name')
1215     w_field_nonempty('Author Email')
1216     w_field_always('Web Site')
1217     w_field_always('Source Code')
1218     w_field_always('Issue Tracker')
1219     w_field_nonempty('Changelog')
1220     w_field_nonempty('Donate')
1221     w_field_nonempty('FlattrID')
1222     w_field_nonempty('Bitcoin')
1223     w_field_nonempty('Litecoin')
1224     mf.write('\n')
1225     w_field_nonempty('Name')
1226     w_field_nonempty('Auto Name')
1227     w_field_always('Summary')
1228     w_field_always('Description', description_txt(app.Description))
1229     mf.write('\n')
1230     if app.RequiresRoot:
1231         w_field_always('Requires Root', 'yes')
1232         mf.write('\n')
1233     if app.RepoType:
1234         w_field_always('Repo Type')
1235         w_field_always('Repo')
1236         if app.Binaries:
1237             w_field_always('Binaries')
1238         mf.write('\n')
1239
1240     for build in app.builds:
1241
1242         if build.version == "Ignore":
1243             continue
1244
1245         w_comments('build:' + build.vercode)
1246         w_build(build)
1247         mf.write('\n')
1248
1249     if app.MaintainerNotes:
1250         w_field_always('Maintainer Notes', app.MaintainerNotes)
1251         mf.write('\n')
1252
1253     w_field_nonempty('Archive Policy')
1254     w_field_always('Auto Update Mode')
1255     w_field_always('Update Check Mode')
1256     w_field_nonempty('Update Check Ignore')
1257     w_field_nonempty('Vercode Operation')
1258     w_field_nonempty('Update Check Name')
1259     w_field_nonempty('Update Check Data')
1260     if app.CurrentVersion:
1261         w_field_always('Current Version')
1262         w_field_always('Current Version Code')
1263     if app.NoSourceSince:
1264         mf.write('\n')
1265         w_field_always('No Source Since')
1266     w_comments(None)
1267
1268
1269 # Write a metadata file in txt format.
1270 #
1271 # 'mf'      - Writer interface (file, StringIO, ...)
1272 # 'app'     - The app data
1273 def write_txt(mf, app):
1274
1275     def w_comment(line):
1276         mf.write("# %s\n" % line)
1277
1278     def w_field(f, v):
1279         t = fieldtype(f)
1280         if t == TYPE_LIST:
1281             v = ','.join(v)
1282         elif t == TYPE_MULTILINE:
1283             v = '\n' + v + '\n.'
1284         mf.write("%s:%s\n" % (f, v))
1285
1286     def w_build(build):
1287         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1288
1289         for f in build_flags_order:
1290             v = build.get_flag(f)
1291             if not v:
1292                 continue
1293
1294             t = flagtype(f)
1295             mf.write('    %s=' % f)
1296             if t == TYPE_STRING:
1297                 mf.write(v)
1298             elif t == TYPE_BOOL:
1299                 mf.write('yes')
1300             elif t == TYPE_SCRIPT:
1301                 first = True
1302                 for s in v.split(' && '):
1303                     if first:
1304                         first = False
1305                     else:
1306                         mf.write(' && \\\n        ')
1307                     mf.write(s)
1308             elif t == TYPE_LIST:
1309                 mf.write(','.join(v))
1310
1311             mf.write('\n')
1312
1313     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1314
1315
1316 def write_yaml(mf, app):
1317
1318     def w_comment(line):
1319         mf.write("# %s\n" % line)
1320
1321     def escape(v):
1322         if not v:
1323             return ''
1324         if any(c in v for c in [': ', '%', '@', '*']):
1325             return "'" + v.replace("'", "''") + "'"
1326         return v
1327
1328     def w_field(f, v, prefix='', t=None):
1329         if t is None:
1330             t = fieldtype(f)
1331         v = ''
1332         if t == TYPE_LIST:
1333             v = '\n'
1334             for e in v:
1335                 v += prefix + ' - ' + escape(e) + '\n'
1336         elif t == TYPE_MULTILINE:
1337             v = ' |\n'
1338             for l in v.splitlines():
1339                 if l:
1340                     v += prefix + '  ' + l + '\n'
1341                 else:
1342                     v += '\n'
1343         elif t == TYPE_BOOL:
1344             v = ' yes\n'
1345         elif t == TYPE_SCRIPT:
1346             cmds = [s + '&& \\' for s in v.split('&& ')]
1347             if len(cmds) > 0:
1348                 cmds[-1] = cmds[-1][:-len('&& \\')]
1349             w_field(f, cmds, prefix, 'multiline')
1350             return
1351         else:
1352             v = ' ' + escape(v) + '\n'
1353
1354         mf.write(prefix)
1355         mf.write(f)
1356         mf.write(":")
1357         mf.write(v)
1358
1359     global first_build
1360     first_build = True
1361
1362     def w_build(build):
1363         global first_build
1364         if first_build:
1365             mf.write("builds:\n")
1366             first_build = False
1367
1368         w_field('versionName', build.version, '  - ', TYPE_STRING)
1369         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1370         for f in build_flags_order:
1371             v = build.get_flag(f)
1372             if not v:
1373                 continue
1374
1375             w_field(f, v, '    ', flagtype(f))
1376
1377     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1378
1379
1380 def write_metadata(metadatapath, app):
1381     _, ext = fdroidserver.common.get_extension(metadatapath)
1382     accepted = fdroidserver.common.config['accepted_formats']
1383     if ext not in accepted:
1384         raise MetaDataException('Cannot write "%s", not an accepted format, use: %s' % (
1385             metadatapath, ', '.join(accepted)))
1386
1387     with open(metadatapath, 'w') as mf:
1388         if ext == 'txt':
1389             return write_txt(mf, app)
1390         elif ext == 'yml':
1391             return write_yaml(mf, app)
1392     raise MetaDataException('Unknown metadata format: %s' % metadatapath)