chiark / gitweb /
f20b22303ef8291e2bb3db3df073001dc09be614
[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 = 'r12b'  # 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         packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
787         if packageName in apps:
788             raise MetaDataException("Found multiple metadata files for " + packageName)
789         app = parse_metadata(metadatapath)
790         check_metadata(app)
791         apps[app.id] = app
792
793     if xref:
794         # Parse all descriptions at load time, just to ensure cross-referencing
795         # errors are caught early rather than when they hit the build server.
796         def linkres(appid):
797             if appid in apps:
798                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
799             raise MetaDataException("Cannot resolve app id " + appid)
800
801         for appid, app in apps.items():
802             try:
803                 description_html(app.Description, linkres)
804             except MetaDataException as e:
805                 raise MetaDataException("Problem with description of " + appid +
806                                         " - " + str(e))
807
808     return apps
809
810 # Port legacy ';' separators
811 list_sep = re.compile(r'[,;]')
812
813
814 def split_list_values(s):
815     res = []
816     for v in re.split(list_sep, s):
817         if not v:
818             continue
819         v = v.strip()
820         if not v:
821             continue
822         res.append(v)
823     return res
824
825
826 def get_default_app_info(metadatapath=None):
827     if metadatapath is None:
828         appid = None
829     else:
830         appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
831
832     if appid == '.fdroid':  # we have local metadata in the app's source
833         if os.path.exists('AndroidManifest.xml'):
834             manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
835         else:
836             pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
837             for root, dirs, files in os.walk(os.getcwd()):
838                 if 'build.gradle' in files:
839                     p = os.path.join(root, 'build.gradle')
840                     with open(p, 'rb') as f:
841                         data = f.read()
842                     m = pattern.search(data)
843                     if m:
844                         logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
845                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
846                         break
847         if manifestroot is None:
848             raise MetaDataException("Cannot find a packageName for {0}!".format(metadatapath))
849         appid = manifestroot.attrib['package']
850
851     app = App()
852     app.metadatapath = metadatapath
853     if appid is not None:
854         app.id = appid
855
856     return app
857
858
859 def sorted_builds(builds):
860     return sorted(builds, key=lambda build: int(build.vercode))
861
862
863 esc_newlines = re.compile(r'\\( |\n)')
864
865
866 # This function uses __dict__ to be faster
867 def post_metadata_parse(app):
868
869     for k in app._modified:
870         v = app.__dict__[k]
871         if type(v) in (float, int):
872             app.__dict__[k] = str(v)
873
874     for build in app.builds:
875         for k in build._modified:
876             v = build.__dict__[k]
877             if type(v) in (float, int):
878                 build.__dict__[k] = str(v)
879                 continue
880             ftype = flagtype(k)
881
882             if ftype == TYPE_SCRIPT:
883                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
884             elif ftype == TYPE_BOOL:
885                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
886                 if isinstance(v, str):
887                     build.__dict__[k] = _decode_bool(v)
888             elif ftype == TYPE_STRING:
889                 if isinstance(v, bool) and v:
890                     build.__dict__[k] = 'yes'
891
892     if not app.Description:
893         app.Description = 'No description available'
894
895     app.builds = sorted_builds(app.builds)
896
897
898 # Parse metadata for a single application.
899 #
900 #  'metadatapath' - the filename to read. The package id for the application comes
901 #               from this filename. Pass None to get a blank entry.
902 #
903 # Returns a dictionary containing all the details of the application. There are
904 # two major kinds of information in the dictionary. Keys beginning with capital
905 # letters correspond directory to identically named keys in the metadata file.
906 # Keys beginning with lower case letters are generated in one way or another,
907 # and are not found verbatim in the metadata.
908 #
909 # Known keys not originating from the metadata are:
910 #
911 #  'builds'           - a list of dictionaries containing build information
912 #                       for each defined build
913 #  'comments'         - a list of comments from the metadata file. Each is
914 #                       a list of the form [field, comment] where field is
915 #                       the name of the field it preceded in the metadata
916 #                       file. Where field is None, the comment goes at the
917 #                       end of the file. Alternatively, 'build:version' is
918 #                       for a comment before a particular build version.
919 #  'descriptionlines' - original lines of description as formatted in the
920 #                       metadata file.
921 #
922
923
924 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
925 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
926
927
928 def _decode_bool(s):
929     if bool_true.match(s):
930         return True
931     if bool_false.match(s):
932         return False
933     raise MetaDataException("Invalid bool '%s'" % s)
934
935
936 def parse_metadata(metadatapath):
937     _, ext = fdroidserver.common.get_extension(metadatapath)
938     accepted = fdroidserver.common.config['accepted_formats']
939     if ext not in accepted:
940         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
941             metadatapath, ', '.join(accepted)))
942
943     app = App()
944     app.metadatapath = metadatapath
945     app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
946
947     with open(metadatapath, 'r', encoding='utf-8') as mf:
948         if ext == 'txt':
949             parse_txt_metadata(mf, app)
950         elif ext == 'json':
951             parse_json_metadata(mf, app)
952         elif ext == 'xml':
953             parse_xml_metadata(mf, app)
954         elif ext == 'yml':
955             parse_yaml_metadata(mf, app)
956         else:
957             raise MetaDataException('Unknown metadata format: %s' % metadatapath)
958
959     post_metadata_parse(app)
960     return app
961
962
963 def parse_json_metadata(mf, app):
964
965     # fdroid metadata is only strings and booleans, no floats or ints.
966     # TODO create schema using https://pypi.python.org/pypi/jsonschema
967     jsoninfo = json.load(mf, parse_int=lambda s: s,
968                          parse_float=lambda s: s)
969     app.update_fields(jsoninfo)
970     for f in ['Description', 'Maintainer Notes']:
971         v = app.get_field(f)
972         app.set_field(f, '\n'.join(v))
973     return app
974
975
976 def parse_xml_metadata(mf, app):
977
978     tree = ElementTree.ElementTree(file=mf)
979     root = tree.getroot()
980
981     if root.tag != 'resources':
982         raise MetaDataException('resources file does not have root element <resources/>')
983
984     for child in root:
985         if child.tag != 'builds':
986             # builds does not have name="" attrib
987             name = child.attrib['name']
988
989         if child.tag == 'string':
990             app.set_field(name, child.text)
991         elif child.tag == 'string-array':
992             for item in child:
993                 app.append_field(name, item.text)
994         elif child.tag == 'builds':
995             for b in child:
996                 build = Build()
997                 for key in b:
998                     build.set_flag(key.tag, key.text)
999                 app.builds.append(build)
1000
1001     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1002     if not isinstance(app.RequiresRoot, bool):
1003         app.RequiresRoot = app.RequiresRoot == 'true'
1004
1005     return app
1006
1007
1008 def parse_yaml_metadata(mf, app):
1009
1010     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1011     app.update_fields(yamlinfo)
1012     return app
1013
1014
1015 build_line_sep = re.compile(r'(?<!\\),')
1016 build_cont = re.compile(r'^[ \t]')
1017
1018
1019 def parse_txt_metadata(mf, app):
1020
1021     linedesc = None
1022
1023     def add_buildflag(p, build):
1024         if not p.strip():
1025             raise MetaDataException("Empty build flag at {1}"
1026                                     .format(buildlines[0], linedesc))
1027         bv = p.split('=', 1)
1028         if len(bv) != 2:
1029             raise MetaDataException("Invalid build flag at {0} in {1}"
1030                                     .format(buildlines[0], linedesc))
1031
1032         pk, pv = bv
1033         pk = pk.lstrip()
1034         t = flagtype(pk)
1035         if t == TYPE_LIST:
1036             pv = split_list_values(pv)
1037             build.set_flag(pk, pv)
1038         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1039             build.set_flag(pk, pv)
1040         elif t == TYPE_BOOL:
1041             build.set_flag(pk, _decode_bool(pv))
1042
1043     def parse_buildline(lines):
1044         v = "".join(lines)
1045         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1046         if len(parts) < 3:
1047             raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
1048         build = Build()
1049         build.version = parts[0]
1050         build.vercode = parts[1]
1051         if parts[2].startswith('!'):
1052             # For backwards compatibility, handle old-style disabling,
1053             # including attempting to extract the commit from the message
1054             build.disable = parts[2][1:]
1055             commit = 'unknown - see disabled'
1056             index = parts[2].rfind('at ')
1057             if index != -1:
1058                 commit = parts[2][index + 3:]
1059                 if commit.endswith(')'):
1060                     commit = commit[:-1]
1061             build.commit = commit
1062         else:
1063             build.commit = parts[2]
1064         for p in parts[3:]:
1065             add_buildflag(p, build)
1066
1067         return build
1068
1069     def add_comments(key):
1070         if not curcomments:
1071             return
1072         app.comments[key] = list(curcomments)
1073         del curcomments[:]
1074
1075     mode = 0
1076     buildlines = []
1077     multiline_lines = []
1078     curcomments = []
1079     build = None
1080     vc_seen = set()
1081
1082     c = 0
1083     for line in mf:
1084         c += 1
1085         linedesc = "%s:%d" % (mf.name, c)
1086         line = line.rstrip('\r\n')
1087         if mode == 3:
1088             if build_cont.match(line):
1089                 if line.endswith('\\'):
1090                     buildlines.append(line[:-1].lstrip())
1091                 else:
1092                     buildlines.append(line.lstrip())
1093                     bl = ''.join(buildlines)
1094                     add_buildflag(bl, build)
1095                     del buildlines[:]
1096             else:
1097                 if not build.commit and not build.disable:
1098                     raise MetaDataException("No commit specified for {0} in {1}"
1099                                             .format(build.version, linedesc))
1100
1101                 app.builds.append(build)
1102                 add_comments('build:' + build.vercode)
1103                 mode = 0
1104
1105         if mode == 0:
1106             if not line:
1107                 continue
1108             if line.startswith("#"):
1109                 curcomments.append(line[1:].strip())
1110                 continue
1111             try:
1112                 f, v = line.split(':', 1)
1113             except ValueError:
1114                 raise MetaDataException("Invalid metadata in " + linedesc)
1115
1116             # Translate obsolete fields...
1117             if f == 'Market Version':
1118                 f = 'Current Version'
1119             if f == 'Market Version Code':
1120                 f = 'Current Version Code'
1121
1122             ftype = fieldtype(f)
1123             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1124                 add_comments(f)
1125             if ftype == TYPE_MULTILINE:
1126                 mode = 1
1127                 if v:
1128                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1129             elif ftype == TYPE_STRING:
1130                 app.set_field(f, v)
1131             elif ftype == TYPE_LIST:
1132                 app.set_field(f, split_list_values(v))
1133             elif ftype == TYPE_BUILD:
1134                 if v.endswith("\\"):
1135                     mode = 2
1136                     del buildlines[:]
1137                     buildlines.append(v[:-1])
1138                 else:
1139                     build = parse_buildline([v])
1140                     app.builds.append(build)
1141                     add_comments('build:' + app.builds[-1].vercode)
1142             elif ftype == TYPE_BUILD_V2:
1143                 vv = v.split(',')
1144                 if len(vv) != 2:
1145                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1146                                             .format(v, linedesc))
1147                 build = Build()
1148                 build.version = vv[0]
1149                 build.vercode = vv[1]
1150                 if build.vercode in vc_seen:
1151                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1152                                             build.vercode, linedesc))
1153                 vc_seen.add(build.vercode)
1154                 del buildlines[:]
1155                 mode = 3
1156             elif ftype == TYPE_OBSOLETE:
1157                 pass        # Just throw it away!
1158             else:
1159                 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1160         elif mode == 1:     # Multiline field
1161             if line == '.':
1162                 mode = 0
1163                 app.set_field(f, '\n'.join(multiline_lines))
1164                 del multiline_lines[:]
1165             else:
1166                 multiline_lines.append(line)
1167         elif mode == 2:     # Line continuation mode in Build Version
1168             if line.endswith("\\"):
1169                 buildlines.append(line[:-1])
1170             else:
1171                 buildlines.append(line)
1172                 build = parse_buildline(buildlines)
1173                 app.builds.append(build)
1174                 add_comments('build:' + app.builds[-1].vercode)
1175                 mode = 0
1176     add_comments(None)
1177
1178     # Mode at end of file should always be 0
1179     if mode == 1:
1180         raise MetaDataException(f + " not terminated in " + mf.name)
1181     if mode == 2:
1182         raise MetaDataException("Unterminated continuation in " + mf.name)
1183     if mode == 3:
1184         raise MetaDataException("Unterminated build in " + mf.name)
1185
1186     return app
1187
1188
1189 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1190
1191     def w_comments(key):
1192         if key not in app.comments:
1193             return
1194         for line in app.comments[key]:
1195             w_comment(line)
1196
1197     def w_field_always(f, v=None):
1198         if v is None:
1199             v = app.get_field(f)
1200         w_comments(f)
1201         w_field(f, v)
1202
1203     def w_field_nonempty(f, v=None):
1204         if v is None:
1205             v = app.get_field(f)
1206         w_comments(f)
1207         if v:
1208             w_field(f, v)
1209
1210     w_field_nonempty('Disabled')
1211     w_field_nonempty('AntiFeatures')
1212     w_field_nonempty('Provides')
1213     w_field_always('Categories')
1214     w_field_always('License')
1215     w_field_nonempty('Author Name')
1216     w_field_nonempty('Author Email')
1217     w_field_always('Web Site')
1218     w_field_always('Source Code')
1219     w_field_always('Issue Tracker')
1220     w_field_nonempty('Changelog')
1221     w_field_nonempty('Donate')
1222     w_field_nonempty('FlattrID')
1223     w_field_nonempty('Bitcoin')
1224     w_field_nonempty('Litecoin')
1225     mf.write('\n')
1226     w_field_nonempty('Name')
1227     w_field_nonempty('Auto Name')
1228     w_field_always('Summary')
1229     w_field_always('Description', description_txt(app.Description))
1230     mf.write('\n')
1231     if app.RequiresRoot:
1232         w_field_always('Requires Root', 'yes')
1233         mf.write('\n')
1234     if app.RepoType:
1235         w_field_always('Repo Type')
1236         w_field_always('Repo')
1237         if app.Binaries:
1238             w_field_always('Binaries')
1239         mf.write('\n')
1240
1241     for build in app.builds:
1242
1243         if build.version == "Ignore":
1244             continue
1245
1246         w_comments('build:' + build.vercode)
1247         w_build(build)
1248         mf.write('\n')
1249
1250     if app.MaintainerNotes:
1251         w_field_always('Maintainer Notes', app.MaintainerNotes)
1252         mf.write('\n')
1253
1254     w_field_nonempty('Archive Policy')
1255     w_field_always('Auto Update Mode')
1256     w_field_always('Update Check Mode')
1257     w_field_nonempty('Update Check Ignore')
1258     w_field_nonempty('Vercode Operation')
1259     w_field_nonempty('Update Check Name')
1260     w_field_nonempty('Update Check Data')
1261     if app.CurrentVersion:
1262         w_field_always('Current Version')
1263         w_field_always('Current Version Code')
1264     if app.NoSourceSince:
1265         mf.write('\n')
1266         w_field_always('No Source Since')
1267     w_comments(None)
1268
1269
1270 # Write a metadata file in txt format.
1271 #
1272 # 'mf'      - Writer interface (file, StringIO, ...)
1273 # 'app'     - The app data
1274 def write_txt(mf, app):
1275
1276     def w_comment(line):
1277         mf.write("# %s\n" % line)
1278
1279     def w_field(f, v):
1280         t = fieldtype(f)
1281         if t == TYPE_LIST:
1282             v = ','.join(v)
1283         elif t == TYPE_MULTILINE:
1284             v = '\n' + v + '\n.'
1285         mf.write("%s:%s\n" % (f, v))
1286
1287     def w_build(build):
1288         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1289
1290         for f in build_flags_order:
1291             v = build.get_flag(f)
1292             if not v:
1293                 continue
1294
1295             t = flagtype(f)
1296             mf.write('    %s=' % f)
1297             if t == TYPE_STRING:
1298                 mf.write(v)
1299             elif t == TYPE_BOOL:
1300                 mf.write('yes')
1301             elif t == TYPE_SCRIPT:
1302                 first = True
1303                 for s in v.split(' && '):
1304                     if first:
1305                         first = False
1306                     else:
1307                         mf.write(' && \\\n        ')
1308                     mf.write(s)
1309             elif t == TYPE_LIST:
1310                 mf.write(','.join(v))
1311
1312             mf.write('\n')
1313
1314     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1315
1316
1317 def write_yaml(mf, app):
1318
1319     def w_comment(line):
1320         mf.write("# %s\n" % line)
1321
1322     def escape(v):
1323         if not v:
1324             return ''
1325         if any(c in v for c in [': ', '%', '@', '*']):
1326             return "'" + v.replace("'", "''") + "'"
1327         return v
1328
1329     def w_field(f, v, prefix='', t=None):
1330         if t is None:
1331             t = fieldtype(f)
1332         v = ''
1333         if t == TYPE_LIST:
1334             v = '\n'
1335             for e in v:
1336                 v += prefix + ' - ' + escape(e) + '\n'
1337         elif t == TYPE_MULTILINE:
1338             v = ' |\n'
1339             for l in v.splitlines():
1340                 if l:
1341                     v += prefix + '  ' + l + '\n'
1342                 else:
1343                     v += '\n'
1344         elif t == TYPE_BOOL:
1345             v = ' yes\n'
1346         elif t == TYPE_SCRIPT:
1347             cmds = [s + '&& \\' for s in v.split('&& ')]
1348             if len(cmds) > 0:
1349                 cmds[-1] = cmds[-1][:-len('&& \\')]
1350             w_field(f, cmds, prefix, 'multiline')
1351             return
1352         else:
1353             v = ' ' + escape(v) + '\n'
1354
1355         mf.write(prefix)
1356         mf.write(f)
1357         mf.write(":")
1358         mf.write(v)
1359
1360     global first_build
1361     first_build = True
1362
1363     def w_build(build):
1364         global first_build
1365         if first_build:
1366             mf.write("builds:\n")
1367             first_build = False
1368
1369         w_field('versionName', build.version, '  - ', TYPE_STRING)
1370         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1371         for f in build_flags_order:
1372             v = build.get_flag(f)
1373             if not v:
1374                 continue
1375
1376             w_field(f, v, '    ', flagtype(f))
1377
1378     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1379
1380
1381 def write_metadata(metadatapath, app):
1382     _, ext = fdroidserver.common.get_extension(metadatapath)
1383     accepted = fdroidserver.common.config['accepted_formats']
1384     if ext not in accepted:
1385         raise MetaDataException('Cannot write "%s", not an accepted format, use: %s' % (
1386             metadatapath, ', '.join(accepted)))
1387
1388     with open(metadatapath, 'w', encoding='utf8') as mf:
1389         if ext == 'txt':
1390             return write_txt(mf, app)
1391         elif ext == 'yml':
1392             return write_yaml(mf, app)
1393     raise MetaDataException('Unknown metadata format: %s' % metadatapath)