chiark / gitweb /
check whether metadata file is a duplicate before parsing it
[fdroidserver.git] / fdroidserver / metadata.py
index f034a6e3183e62328cf7d9a6f1855743eebcb608..9ba9b1fdef17d8a1ecb0acb9c2a9c5dd418d80c6 100644 (file)
@@ -1,4 +1,4 @@
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 #
 # metadata.py - part of the FDroid server tools
 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
 import json
 import os
 import re
-import sys
 import glob
 import cgi
 import logging
+import textwrap
+import io
 
 import yaml
 # use libyaml if it is available
@@ -37,9 +38,7 @@ except ImportError:
 # use the C implementation when available
 import xml.etree.cElementTree as ElementTree
 
-from collections import OrderedDict
-
-import common
+import fdroidserver.common
 
 srclibs = None
 
@@ -52,79 +51,341 @@ class MetaDataException(Exception):
     def __str__(self):
         return self.value
 
-# In the order in which they are laid out on files
-app_defaults = OrderedDict([
-    ('Disabled', None),
-    ('AntiFeatures', []),
-    ('Provides', None),
-    ('Categories', ['None']),
-    ('License', 'Unknown'),
-    ('Web Site', ''),
-    ('Source Code', ''),
-    ('Issue Tracker', ''),
-    ('Changelog', ''),
-    ('Donate', None),
-    ('FlattrID', None),
-    ('Bitcoin', None),
-    ('Litecoin', None),
-    ('Dogecoin', None),
-    ('Name', None),
-    ('Auto Name', ''),
-    ('Summary', ''),
-    ('Description', []),
-    ('Requires Root', False),
-    ('Repo Type', ''),
-    ('Repo', ''),
-    ('Binaries', None),
-    ('Maintainer Notes', []),
-    ('Archive Policy', None),
-    ('Auto Update Mode', 'None'),
-    ('Update Check Mode', 'None'),
-    ('Update Check Ignore', None),
-    ('Vercode Operation', None),
-    ('Update Check Name', None),
-    ('Update Check Data', None),
-    ('Current Version', ''),
-    ('Current Version Code', '0'),
-    ('No Source Since', ''),
+# To filter which ones should be written to the metadata files if
+# present
+app_fields = set([
+    'Disabled',
+    'AntiFeatures',
+    'Provides',
+    'Categories',
+    'License',
+    'Author Name',
+    'Author Email',
+    'Web Site',
+    'Source Code',
+    'Issue Tracker',
+    'Changelog',
+    'Donate',
+    'FlattrID',
+    'Bitcoin',
+    'Litecoin',
+    'Name',
+    'Auto Name',
+    'Summary',
+    'Description',
+    'Requires Root',
+    'Repo Type',
+    'Repo',
+    'Binaries',
+    'Maintainer Notes',
+    'Archive Policy',
+    'Auto Update Mode',
+    'Update Check Mode',
+    'Update Check Ignore',
+    'Vercode Operation',
+    'Update Check Name',
+    'Update Check Data',
+    'Current Version',
+    'Current Version Code',
+    'No Source Since',
+
+    'comments',  # For formats that don't do inline comments
+    'builds',    # For formats that do builds as a list
 ])
 
 
+class App():
+
+    def __init__(self):
+        self.Disabled = None
+        self.AntiFeatures = []
+        self.Provides = None
+        self.Categories = ['None']
+        self.License = 'Unknown'
+        self.AuthorName = None
+        self.AuthorEmail = None
+        self.WebSite = ''
+        self.SourceCode = ''
+        self.IssueTracker = ''
+        self.Changelog = ''
+        self.Donate = None
+        self.FlattrID = None
+        self.Bitcoin = None
+        self.Litecoin = None
+        self.Name = None
+        self.AutoName = ''
+        self.Summary = ''
+        self.Description = ''
+        self.RequiresRoot = False
+        self.RepoType = ''
+        self.Repo = ''
+        self.Binaries = None
+        self.MaintainerNotes = ''
+        self.ArchivePolicy = None
+        self.AutoUpdateMode = 'None'
+        self.UpdateCheckMode = 'None'
+        self.UpdateCheckIgnore = None
+        self.VercodeOperation = None
+        self.UpdateCheckName = None
+        self.UpdateCheckData = None
+        self.CurrentVersion = ''
+        self.CurrentVersionCode = None
+        self.NoSourceSince = ''
+
+        self.id = None
+        self.metadatapath = None
+        self.builds = []
+        self.comments = {}
+        self.added = None
+        self.lastupdated = None
+        self._modified = set()
+
+    # Translates human-readable field names to attribute names, e.g.
+    # 'Auto Name' to 'AutoName'
+    @classmethod
+    def field_to_attr(cls, f):
+        return f.replace(' ', '')
+
+    # Translates attribute names to human-readable field names, e.g.
+    # 'AutoName' to 'Auto Name'
+    @classmethod
+    def attr_to_field(cls, k):
+        if k in app_fields:
+            return k
+        f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
+        return f
+
+    # Constructs an old-fashioned dict with the human-readable field
+    # names. Should only be used for tests.
+    def field_dict(self):
+        d = {}
+        for k, v in self.__dict__.items():
+            if k == 'builds':
+                d['builds'] = []
+                for build in v:
+                    b = {k: v for k, v in build.__dict__.items() if not k.startswith('_')}
+                    d['builds'].append(b)
+            elif not k.startswith('_'):
+                f = App.attr_to_field(k)
+                d[f] = v
+        return d
+
+    # Gets the value associated to a field name, e.g. 'Auto Name'
+    def get_field(self, f):
+        if f not in app_fields:
+            raise MetaDataException('Unrecognised app field: ' + f)
+        k = App.field_to_attr(f)
+        return getattr(self, k)
+
+    # Sets the value associated to a field name, e.g. 'Auto Name'
+    def set_field(self, f, v):
+        if f not in app_fields:
+            raise MetaDataException('Unrecognised app field: ' + f)
+        k = App.field_to_attr(f)
+        self.__dict__[k] = v
+        self._modified.add(k)
+
+    # Appends to the value associated to a field name, e.g. 'Auto Name'
+    def append_field(self, f, v):
+        if f not in app_fields:
+            raise MetaDataException('Unrecognised app field: ' + f)
+        k = App.field_to_attr(f)
+        if k not in self.__dict__:
+            self.__dict__[k] = [v]
+        else:
+            self.__dict__[k].append(v)
+
+    # Like dict.update(), but using human-readable field names
+    def update_fields(self, d):
+        for f, v in d.items():
+            if f == 'builds':
+                for b in v:
+                    build = Build()
+                    build.update_flags(b)
+                    self.builds.append(build)
+            else:
+                self.set_field(f, v)
+
+TYPE_UNKNOWN = 0
+TYPE_OBSOLETE = 1
+TYPE_STRING = 2
+TYPE_BOOL = 3
+TYPE_LIST = 4
+TYPE_SCRIPT = 5
+TYPE_MULTILINE = 6
+TYPE_BUILD = 7
+TYPE_BUILD_V2 = 8
+
+fieldtypes = {
+    'Description': TYPE_MULTILINE,
+    'Maintainer Notes': TYPE_MULTILINE,
+    'Categories': TYPE_LIST,
+    'AntiFeatures': TYPE_LIST,
+    'Build Version': TYPE_BUILD,
+    'Build': TYPE_BUILD_V2,
+    'Use Built': TYPE_OBSOLETE,
+}
+
+
+def fieldtype(name):
+    if name in fieldtypes:
+        return fieldtypes[name]
+    return TYPE_STRING
+
+
 # In the order in which they are laid out on files
-# Sorted by their action and their place in the build timeline
-# These variables can have varying datatypes. For example, anything with
-# flagtype(v) == 'list' is inited as False, then set as a list of strings.
-flag_defaults = OrderedDict([
-    ('disable', False),
-    ('commit', None),
-    ('subdir', None),
-    ('submodules', False),
-    ('init', ''),
-    ('patch', []),
-    ('gradle', False),
-    ('maven', False),
-    ('kivy', False),
-    ('output', None),
-    ('srclibs', []),
-    ('oldsdkloc', False),
-    ('encoding', None),
-    ('forceversion', False),
-    ('forcevercode', False),
-    ('rm', []),
-    ('extlibs', []),
-    ('prebuild', ''),
-    ('update', ['auto']),
-    ('target', None),
-    ('scanignore', []),
-    ('scandelete', []),
-    ('build', ''),
-    ('buildjni', []),
-    ('ndk', 'r10e'),  # defaults to latest
-    ('preassemble', []),
-    ('gradleprops', []),
-    ('antcommands', None),
-    ('novcheck', False),
-])
+build_flags_order = [
+    'disable',
+    'commit',
+    'subdir',
+    'submodules',
+    'init',
+    'patch',
+    'gradle',
+    'maven',
+    'kivy',
+    'output',
+    'srclibs',
+    'oldsdkloc',
+    'encoding',
+    'forceversion',
+    'forcevercode',
+    'rm',
+    'extlibs',
+    'prebuild',
+    'update',
+    'target',
+    'scanignore',
+    'scandelete',
+    'build',
+    'buildjni',
+    'ndk',
+    'preassemble',
+    'gradleprops',
+    'antcommands',
+    'novcheck',
+]
+
+
+build_flags = set(build_flags_order + ['version', 'vercode'])
+
+
+class Build():
+
+    def __init__(self):
+        self.disable = False
+        self.commit = None
+        self.subdir = None
+        self.submodules = False
+        self.init = ''
+        self.patch = []
+        self.gradle = []
+        self.maven = False
+        self.kivy = False
+        self.output = None
+        self.srclibs = []
+        self.oldsdkloc = False
+        self.encoding = None
+        self.forceversion = False
+        self.forcevercode = False
+        self.rm = []
+        self.extlibs = []
+        self.prebuild = ''
+        self.update = []
+        self.target = None
+        self.scanignore = []
+        self.scandelete = []
+        self.build = ''
+        self.buildjni = []
+        self.ndk = None
+        self.preassemble = []
+        self.gradleprops = []
+        self.antcommands = []
+        self.novcheck = False
+
+        self._modified = set()
+
+    def get_flag(self, f):
+        if f not in build_flags:
+            raise MetaDataException('Unrecognised build flag: ' + f)
+        return getattr(self, f)
+
+    def set_flag(self, f, v):
+        if f == 'versionName':
+            f = 'version'
+        if f == 'versionCode':
+            f = 'vercode'
+        if f not in build_flags:
+            raise MetaDataException('Unrecognised build flag: ' + f)
+        self.__dict__[f] = v
+        self._modified.add(f)
+
+    def append_flag(self, f, v):
+        if f not in build_flags:
+            raise MetaDataException('Unrecognised build flag: ' + f)
+        if f not in self.__dict__:
+            self.__dict__[f] = [v]
+        else:
+            self.__dict__[f].append(v)
+
+    def build_method(self):
+        for f in ['maven', 'gradle', 'kivy']:
+            if self.get_flag(f):
+                return f
+        if self.output:
+            return 'raw'
+        return 'ant'
+
+    # like build_method, but prioritize output=
+    def output_method(self):
+        if self.output:
+            return 'raw'
+        for f in ['maven', 'gradle', 'kivy']:
+            if self.get_flag(f):
+                return f
+        return 'ant'
+
+    def ndk_path(self):
+        version = self.ndk
+        if not version:
+            version = 'r10e'  # falls back to latest
+        paths = fdroidserver.common.config['ndk_paths']
+        if version not in paths:
+            return ''
+        return paths[version]
+
+    def update_flags(self, d):
+        for f, v in d.items():
+            self.set_flag(f, v)
+
+flagtypes = {
+    'extlibs': TYPE_LIST,
+    'srclibs': TYPE_LIST,
+    'patch': TYPE_LIST,
+    'rm': TYPE_LIST,
+    'buildjni': TYPE_LIST,
+    'preassemble': TYPE_LIST,
+    'update': TYPE_LIST,
+    'scanignore': TYPE_LIST,
+    'scandelete': TYPE_LIST,
+    'gradle': TYPE_LIST,
+    'antcommands': TYPE_LIST,
+    'gradleprops': TYPE_LIST,
+    'init': TYPE_SCRIPT,
+    'prebuild': TYPE_SCRIPT,
+    'build': TYPE_SCRIPT,
+    'submodules': TYPE_BOOL,
+    'oldsdkloc': TYPE_BOOL,
+    'forceversion': TYPE_BOOL,
+    'forcevercode': TYPE_BOOL,
+    'novcheck': TYPE_BOOL,
+}
+
+
+def flagtype(name):
+    if name in flagtypes:
+        return flagtypes[name]
+    return TYPE_STRING
 
 
 # Designates a metadata field type and checks that it matches
@@ -133,142 +394,126 @@ flag_defaults = OrderedDict([
 # 'matching' - List of possible values or regex expression
 # 'sep'      - Separator to use if value may be a list
 # 'fields'   - Metadata fields (Field:Value) of this type
-# 'attrs'    - Build attributes (attr=value) of this type
+# 'flags'    - Build flags (flag=value) of this type
 #
 class FieldValidator():
 
-    def __init__(self, name, matching, sep, fields, attrs):
+    def __init__(self, name, matching, fields, flags):
         self.name = name
         self.matching = matching
-        if type(matching) is str:
-            self.compiled = re.compile(matching)
-        self.sep = sep
+        self.compiled = re.compile(matching)
         self.fields = fields
-        self.attrs = attrs
+        self.flags = flags
 
-    def _assert_regex(self, values, appid):
-        for v in values:
-            if not self.compiled.match(v):
-                raise MetaDataException("'%s' is not a valid %s in %s. "
-                                        % (v, self.name, appid) +
-                                        "Regex pattern: %s" % (self.matching))
-
-    def _assert_list(self, values, appid):
-        for v in values:
-            if v not in self.matching:
-                raise MetaDataException("'%s' is not a valid %s in %s. "
-                                        % (v, self.name, appid) +
-                                        "Possible values: %s" % (", ".join(self.matching)))
-
-    def check(self, value, appid):
-        if type(value) is not str or not value:
+    def check(self, v, appid):
+        if not v:
             return
-        if self.sep is not None:
-            values = value.split(self.sep)
-        else:
-            values = [value]
-        if type(self.matching) is list:
-            self._assert_list(values, appid)
+        if type(v) == list:
+            values = v
         else:
-            self._assert_regex(values, appid)
-
+            values = [v]
+        for v in values:
+            if not self.compiled.match(v):
+                raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
+                                        % (v, self.name, appid, self.matching))
 
 # Generic value types
 valuetypes = {
     FieldValidator("Integer",
-                   r'^[1-9][0-9]*$', None,
+                   r'^[1-9][0-9]*$',
                    [],
                    ['vercode']),
 
     FieldValidator("Hexadecimal",
-                   r'^[0-9a-f]+$', None,
+                   r'^[0-9a-f]+$',
                    ['FlattrID'],
                    []),
 
     FieldValidator("HTTP link",
-                   r'^http[s]?://', None,
-                   ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
+                   r'^http[s]?://',
+                   ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
+
+    FieldValidator("Email",
+                   r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
+                   ["AuthorEmail"], []),
 
     FieldValidator("Bitcoin address",
-                   r'^[a-zA-Z0-9]{27,34}$', None,
+                   r'^[a-zA-Z0-9]{27,34}$',
                    ["Bitcoin"],
                    []),
 
     FieldValidator("Litecoin address",
-                   r'^L[a-zA-Z0-9]{33}$', None,
+                   r'^L[a-zA-Z0-9]{33}$',
                    ["Litecoin"],
                    []),
 
-    FieldValidator("Dogecoin address",
-                   r'^D[a-zA-Z0-9]{33}$', None,
-                   ["Dogecoin"],
-                   []),
-
-    FieldValidator("bool",
-                   r'([Yy]es|[Nn]o|[Tt]rue|[Ff]alse)', None,
-                   ["Requires Root"],
-                   ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                    'novcheck']),
-
     FieldValidator("Repo Type",
-                   ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
-                   ["Repo Type"],
+                   r'^(git|git-svn|svn|hg|bzr|srclib)$',
+                   ["RepoType"],
                    []),
 
     FieldValidator("Binaries",
-                   r'^http[s]?://', None,
+                   r'^http[s]?://',
                    ["Binaries"],
                    []),
 
     FieldValidator("Archive Policy",
-                   r'^[0-9]+ versions$', None,
-                   ["Archive Policy"],
+                   r'^[0-9]+ versions$',
+                   ["ArchivePolicy"],
                    []),
 
     FieldValidator("Anti-Feature",
-                   ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
+                   r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
                    ["AntiFeatures"],
                    []),
 
     FieldValidator("Auto Update Mode",
-                   r"^(Version .+|None)$", None,
-                   ["Auto Update Mode"],
+                   r"^(Version .+|None)$",
+                   ["AutoUpdateMode"],
                    []),
 
     FieldValidator("Update Check Mode",
-                   r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
-                   ["Update Check Mode"],
+                   r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
+                   ["UpdateCheckMode"],
                    [])
 }
 
 
 # Check an app's metadata information for integrity errors
-def check_metadata(info):
+def check_metadata(app):
     for v in valuetypes:
-        for field in v.fields:
-            v.check(info[field], info['id'])
-        for build in info['builds']:
-            for attr in v.attrs:
-                v.check(build[attr], info['id'])
+        for k in v.fields:
+            if k not in app._modified:
+                continue
+            v.check(app.__dict__[k], app.id)
+        for build in app.builds:
+            for k in v.flags:
+                if k not in build._modified:
+                    continue
+                v.check(build.__dict__[k], app.id)
 
 
 # Formatter for descriptions. Create an instance, and call parseline() with
 # each line of the description source from the metadata. At the end, call
-# end() and then text_wiki and text_html will contain the result.
+# end() and then text_txt and text_html will contain the result.
 class DescriptionFormatter:
+
     stNONE = 0
     stPARA = 1
     stUL = 2
     stOL = 3
-    bold = False
-    ital = False
-    state = stNONE
-    text_wiki = ''
-    text_html = ''
-    text_txt = ''
-    linkResolver = None
 
     def __init__(self, linkres):
+        self.bold = False
+        self.ital = False
+        self.state = self.stNONE
+        self.laststate = self.stNONE
+        self.text_html = ''
+        self.text_txt = ''
+        self.html = io.StringIO()
+        self.text = io.StringIO()
+        self.para_lines = []
+        self.linkResolver = None
         self.linkResolver = linkres
 
     def endcur(self, notstates=None):
@@ -282,53 +527,63 @@ class DescriptionFormatter:
             self.endol()
 
     def endpara(self):
-        self.text_html += '</p>'
+        self.laststate = self.state
         self.state = self.stNONE
+        whole_para = ' '.join(self.para_lines)
+        self.addtext(whole_para)
+        wrapped = textwrap.fill(whole_para, 80,
+                                break_long_words=False,
+                                break_on_hyphens=False)
+        self.text.write(wrapped)
+        self.html.write('</p>')
+        del self.para_lines[:]
 
     def endul(self):
-        self.text_html += '</ul>'
+        self.html.write('</ul>')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def endol(self):
-        self.text_html += '</ol>'
+        self.html.write('</ol>')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def formatted(self, txt, html):
-        formatted = ''
+        res = ''
         if html:
             txt = cgi.escape(txt)
         while True:
             index = txt.find("''")
             if index == -1:
-                return formatted + txt
-            formatted += txt[:index]
+                return res + txt
+            res += txt[:index]
             txt = txt[index:]
             if txt.startswith("'''"):
                 if html:
                     if self.bold:
-                        formatted += '</b>'
+                        res += '</b>'
                     else:
-                        formatted += '<b>'
+                        res += '<b>'
                 self.bold = not self.bold
                 txt = txt[3:]
             else:
                 if html:
                     if self.ital:
-                        formatted += '</i>'
+                        res += '</i>'
                     else:
-                        formatted += '<i>'
+                        res += '<i>'
                 self.ital = not self.ital
                 txt = txt[2:]
 
     def linkify(self, txt):
-        linkified_plain = ''
-        linkified_html = ''
+        res_plain = ''
+        res_html = ''
         while True:
             index = txt.find("[")
             if index == -1:
-                return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
-            linkified_plain += self.formatted(txt[:index], False)
-            linkified_html += self.formatted(txt[:index], True)
+                return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
+            res_plain += self.formatted(txt[:index], False)
+            res_html += self.formatted(txt[:index], True)
             txt = txt[index:]
             if txt.startswith("[["):
                 index = txt.find("]]")
@@ -339,8 +594,8 @@ class DescriptionFormatter:
                     url, urltext = self.linkResolver(url)
                 else:
                     urltext = url
-                linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
-                linkified_plain += urltext
+                res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
+                res_plain += urltext
                 txt = txt[index + 2:]
             else:
                 index = txt.find("]")
@@ -355,55 +610,67 @@ class DescriptionFormatter:
                     url = url[:index2]
                     if url == urltxt:
                         raise MetaDataException("Url title is just the URL - use [url]")
-                linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
-                linkified_plain += urltxt
+                res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
+                res_plain += urltxt
                 if urltxt != url:
-                    linkified_plain += ' (' + url + ')'
+                    res_plain += ' (' + url + ')'
                 txt = txt[index + 1:]
 
     def addtext(self, txt):
         p, h = self.linkify(txt)
-        self.text_html += h
+        self.html.write(h)
 
     def parseline(self, line):
-        self.text_wiki += "%s\n" % line
-        self.text_txt += "%s\n" % line
         if not line:
             self.endcur()
         elif line.startswith('* '):
             self.endcur([self.stUL])
             if self.state != self.stUL:
-                self.text_html += '<ul>'
+                self.html.write('<ul>')
                 self.state = self.stUL
-            self.text_html += '<li>'
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+            else:
+                self.text.write('\n')
+            self.text.write(line)
+            self.html.write('<li>')
             self.addtext(line[1:])
-            self.text_html += '</li>'
+            self.html.write('</li>')
         elif line.startswith('# '):
             self.endcur([self.stOL])
             if self.state != self.stOL:
-                self.text_html += '<ol>'
+                self.html.write('<ol>')
                 self.state = self.stOL
-            self.text_html += '<li>'
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+            else:
+                self.text.write('\n')
+            self.text.write(line)
+            self.html.write('<li>')
             self.addtext(line[1:])
-            self.text_html += '</li>'
+            self.html.write('</li>')
         else:
+            self.para_lines.append(line)
             self.endcur([self.stPARA])
             if self.state == self.stNONE:
-                self.text_html += '<p>'
                 self.state = self.stPARA
-            elif self.state == self.stPARA:
-                self.text_html += ' '
-            self.addtext(line)
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+                self.html.write('<p>')
 
     def end(self):
         self.endcur()
+        self.text_txt = self.text.getvalue()
+        self.text_html = self.html.getvalue()
+        self.text.close()
+        self.html.close()
 
 
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in text format and wrapped to 80 columns.
-def description_txt(lines):
+def description_txt(s):
     ps = DescriptionFormatter(None)
-    for line in lines:
+    for line in s.splitlines():
         ps.parseline(line)
     ps.end()
     return ps.text_txt
@@ -412,19 +679,15 @@ def description_txt(lines):
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in wiki format. Used for the Maintainer Notes field as well,
 # because it's the same format.
-def description_wiki(lines):
-    ps = DescriptionFormatter(None)
-    for line in lines:
-        ps.parseline(line)
-    ps.end()
-    return ps.text_wiki
+def description_wiki(s):
+    return s
 
 
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in HTML format.
-def description_html(lines, linkres):
+def description_html(s, linkres):
     ps = DescriptionFormatter(linkres)
-    for line in lines:
+    for line in s.splitlines():
         ps.parseline(line)
     ps.end()
     return ps.text_html
@@ -443,7 +706,7 @@ def parse_srclib(metadatapath):
     if not os.path.exists(metadatapath):
         return thisinfo
 
-    metafile = open(metadatapath, "r")
+    metafile = open(metadatapath, "r", encoding='utf-8')
 
     n = 0
     for line in metafile:
@@ -453,14 +716,16 @@ def parse_srclib(metadatapath):
             continue
 
         try:
-            field, value = line.split(':', 1)
+            f, v = line.split(':', 1)
         except ValueError:
             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
 
-        if field == "Subdir":
-            thisinfo[field] = value.split(',')
+        if f == "Subdir":
+            thisinfo[f] = v.split(',')
         else:
-            thisinfo[field] = value
+            thisinfo[f] = v
+
+    metafile.close()
 
     return thisinfo
 
@@ -514,10 +779,16 @@ def read_metadata(xref=True):
     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
                                + glob.glob(os.path.join('metadata', '*.json'))
                                + glob.glob(os.path.join('metadata', '*.xml'))
-                               + glob.glob(os.path.join('metadata', '*.yaml'))):
-        appid, appinfo = parse_metadata(apps, metadatapath)
-        check_metadata(appinfo)
-        apps[appid] = appinfo
+                               + glob.glob(os.path.join('metadata', '*.yml'))
+                               + glob.glob('.fdroid.json')
+                               + glob.glob('.fdroid.xml')
+                               + glob.glob('.fdroid.yml')):
+        packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+        if packageName in apps:
+            raise MetaDataException("Found multiple metadata files for " + packageName)
+        app = parse_metadata(metadatapath)
+        check_metadata(app)
+        apps[app.id] = app
 
     if xref:
         # Parse all descriptions at load time, just to ensure cross-referencing
@@ -527,159 +798,101 @@ def read_metadata(xref=True):
                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
             raise MetaDataException("Cannot resolve app id " + appid)
 
-        for appid, app in apps.iteritems():
+        for appid, app in apps.items():
             try:
-                description_html(app['Description'], linkres)
-            except MetaDataException, e:
+                description_html(app.Description, linkres)
+            except MetaDataException as e:
                 raise MetaDataException("Problem with description of " + appid +
                                         " - " + str(e))
 
     return apps
 
-
-# Get the type expected for a given metadata field.
-def metafieldtype(name):
-    if name in ['Description', 'Maintainer Notes']:
-        return 'multiline'
-    if name in ['Categories', 'AntiFeatures']:
-        return 'list'
-    if name == 'Build Version':
-        return 'build'
-    if name == 'Build':
-        return 'buildv2'
-    if name == 'Use Built':
-        return 'obsolete'
-    if name not in app_defaults:
-        return 'unknown'
-    return 'string'
-
-
-def flagtype(name):
-    if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
-                'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
-                'gradleprops']:
-        return 'list'
-    if name in ['init', 'prebuild', 'build']:
-        return 'script'
-    if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                'novcheck']:
-        return 'bool'
-    return 'string'
-
-
-def fill_build_defaults(build):
-
-    def get_build_type():
-        for t in ['maven', 'gradle', 'kivy']:
-            if build[t]:
-                return t
-        if build['output']:
-            return 'raw'
-        return 'ant'
-
-    for flag, value in flag_defaults.iteritems():
-        if flag in build:
-            continue
-        build[flag] = value
-    build['type'] = get_build_type()
-    build['ndk_path'] = common.get_ndk_path(build['ndk'])
+# Port legacy ';' separators
+list_sep = re.compile(r'[,;]')
 
 
 def split_list_values(s):
-    # Port legacy ';' separators
-    l = [v.strip() for v in s.replace(';', ',').split(',')]
-    return [v for v in l if v]
+    res = []
+    for v in re.split(list_sep, s):
+        if not v:
+            continue
+        v = v.strip()
+        if not v:
+            continue
+        res.append(v)
+    return res
 
 
-def get_default_app_info_list(apps, metadatapath=None):
+def get_default_app_info(metadatapath=None):
     if metadatapath is None:
         appid = None
     else:
-        appid = os.path.splitext(os.path.basename(metadatapath))[0]
-    if appid in apps:
-        logging.critical("'%s' is a duplicate! '%s' is already provided by '%s'"
-                         % (metadatapath, appid, apps[appid]['metadatapath']))
-        sys.exit(1)
+        appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
 
-    thisinfo = {}
-    thisinfo.update(app_defaults)
-    thisinfo['metadatapath'] = metadatapath
+    if appid == '.fdroid':  # we have local metadata in the app's source
+        if os.path.exists('AndroidManifest.xml'):
+            manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
+        else:
+            pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
+            for root, dirs, files in os.walk(os.getcwd()):
+                if 'build.gradle' in files:
+                    p = os.path.join(root, 'build.gradle')
+                    with open(p, 'rb') as f:
+                        data = f.read()
+                    m = pattern.search(data)
+                    if m:
+                        logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
+                        manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
+                        break
+        if manifestroot is None:
+            raise MetaDataException("Cannot find a packageName for {0}!".format(metadatapath))
+        appid = manifestroot.attrib['package']
+
+    app = App()
+    app.metadatapath = metadatapath
     if appid is not None:
-        thisinfo['id'] = appid
-
-    # General defaults...
-    thisinfo['builds'] = []
-    thisinfo['comments'] = []
+        app.id = appid
 
-    return appid, thisinfo
+    return app
 
 
 def sorted_builds(builds):
-    return sorted(builds, key=lambda build: int(build['vercode']))
+    return sorted(builds, key=lambda build: int(build.vercode))
+
 
+esc_newlines = re.compile(r'\\( |\n)')
 
-def post_metadata_parse(thisinfo):
 
-    supported_metadata = app_defaults.keys() + ['comments', 'builds', 'id', 'metadatapath']
-    for k, v in thisinfo.iteritems():
-        if k not in supported_metadata:
-            raise MetaDataException("Unrecognised metadata: {0}: {1}"
-                                    .format(k, v))
+# This function uses __dict__ to be faster
+def post_metadata_parse(app):
+
+    for k in app._modified:
+        v = app.__dict__[k]
         if type(v) in (float, int):
-            thisinfo[k] = str(v)
-
-    # convert to the odd internal format
-    for k in ('Description', 'Maintainer Notes'):
-        if isinstance(thisinfo[k], basestring):
-            text = thisinfo[k].rstrip().lstrip()
-            thisinfo[k] = text.split('\n')
-
-    supported_flags = (flag_defaults.keys()
-                       + ['vercode', 'version', 'versionCode', 'versionName'])
-    esc_newlines = re.compile('\\\\( |\\n)')
-
-    for build in thisinfo['builds']:
-        for k, v in build.items():
-            if k not in supported_flags:
-                raise MetaDataException("Unrecognised build flag: {0}={1}"
-                                        .format(k, v))
-
-            if k == 'versionCode':
-                build['vercode'] = str(v)
-                del build['versionCode']
-            elif k == 'versionName':
-                build['version'] = str(v)
-                del build['versionName']
-            elif type(v) in (float, int):
-                build[k] = str(v)
-            else:
-                keyflagtype = flagtype(k)
-                if keyflagtype == 'list':
-                    # these can be bools, strings or lists, but ultimately are lists
-                    if isinstance(v, basestring):
-                        build[k] = [v]
-                    elif isinstance(v, bool):
-                        if v:
-                            build[k] = ['yes']
-                        else:
-                            build[k] = ['no']
-                elif keyflagtype == 'script':
-                    build[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
-                elif keyflagtype == 'bool':
-                    # TODO handle this using <xsd:element type="xsd:boolean> in a schema
-                    if isinstance(v, basestring):
-                        if v == 'true':
-                            build[k] = True
-                        else:
-                            build[k] = False
-
-    if not thisinfo['Description']:
-        thisinfo['Description'].append('No description available')
-
-    for build in thisinfo['builds']:
-        fill_build_defaults(build)
-
-    thisinfo['builds'] = sorted_builds(thisinfo['builds'])
+            app.__dict__[k] = str(v)
+
+    for build in app.builds:
+        for k in build._modified:
+            v = build.__dict__[k]
+            if type(v) in (float, int):
+                build.__dict__[k] = str(v)
+                continue
+            ftype = flagtype(k)
+
+            if ftype == TYPE_SCRIPT:
+                build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
+            elif ftype == TYPE_BOOL:
+                # TODO handle this using <xsd:element type="xsd:boolean> in a schema
+                if isinstance(v, str):
+                    build.__dict__[k] = _decode_bool(v)
+            elif ftype == TYPE_STRING:
+                if isinstance(v, bool) and v:
+                    build.__dict__[k] = 'yes'
+
+    if not app.Description:
+        app.Description = 'No description available'
+
+    app.builds = sorted_builds(app.builds)
 
 
 # Parse metadata for a single application.
@@ -708,142 +921,106 @@ def post_metadata_parse(thisinfo):
 #
 
 
-def _decode_list(data):
-    '''convert items in a list from unicode to basestring'''
-    rv = []
-    for item in data:
-        if isinstance(item, unicode):
-            item = item.encode('utf-8')
-        elif isinstance(item, list):
-            item = _decode_list(item)
-        elif isinstance(item, dict):
-            item = _decode_dict(item)
-        rv.append(item)
-    return rv
-
-
-def _decode_dict(data):
-    '''convert items in a dict from unicode to basestring'''
-    rv = {}
-    for key, value in data.iteritems():
-        if isinstance(key, unicode):
-            key = key.encode('utf-8')
-        if isinstance(value, unicode):
-            value = value.encode('utf-8')
-        elif isinstance(value, list):
-            value = _decode_list(value)
-        elif isinstance(value, dict):
-            value = _decode_dict(value)
-        rv[key] = value
-    return rv
-
-
-def parse_metadata(apps, metadatapath):
-    root, ext = os.path.splitext(metadatapath)
-    metadataformat = ext[1:]
-    accepted = common.config['accepted_formats']
-    if metadataformat not in accepted:
-        logging.critical('"' + metadatapath
-                         + '" is not in an accepted format, '
-                         + 'convert to: ' + ', '.join(accepted))
-        sys.exit(1)
-
-    if metadataformat == 'txt':
-        return parse_txt_metadata(apps, metadatapath)
-    elif metadataformat == 'json':
-        return parse_json_metadata(apps, metadatapath)
-    elif metadataformat == 'xml':
-        return parse_xml_metadata(apps, metadatapath)
-    elif metadataformat == 'yaml':
-        return parse_yaml_metadata(apps, metadatapath)
-    else:
-        logging.critical('Unknown metadata format: ' + metadatapath)
-        sys.exit(1)
+bool_true = re.compile(r'([Yy]es|[Tt]rue)')
+bool_false = re.compile(r'([Nn]o|[Ff]alse)')
 
 
-def parse_json_metadata(apps, metadatapath):
+def _decode_bool(s):
+    if bool_true.match(s):
+        return True
+    if bool_false.match(s):
+        return False
+    raise MetaDataException("Invalid bool '%s'" % s)
 
-    appid, thisinfo = get_default_app_info_list(apps, metadatapath)
 
-    # fdroid metadata is only strings and booleans, no floats or ints. And
-    # json returns unicode, and fdroidserver still uses plain python strings
-    # TODO create schema using https://pypi.python.org/pypi/jsonschema
-    jsoninfo = json.load(open(metadatapath, 'r'),
-                         object_hook=_decode_dict,
-                         parse_int=lambda s: s,
-                         parse_float=lambda s: s)
-    thisinfo.update(jsoninfo)
-    post_metadata_parse(thisinfo)
+def parse_metadata(metadatapath):
+    _, ext = fdroidserver.common.get_extension(metadatapath)
+    accepted = fdroidserver.common.config['accepted_formats']
+    if ext not in accepted:
+        raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
+            metadatapath, ', '.join(accepted)))
 
-    return (appid, thisinfo)
+    app = App()
+    app.metadatapath = metadatapath
+    app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+
+    with open(metadatapath, 'r', encoding='utf-8') as mf:
+        if ext == 'txt':
+            parse_txt_metadata(mf, app)
+        elif ext == 'json':
+            parse_json_metadata(mf, app)
+        elif ext == 'xml':
+            parse_xml_metadata(mf, app)
+        elif ext == 'yml':
+            parse_yaml_metadata(mf, app)
+        else:
+            raise MetaDataException('Unknown metadata format: %s' % metadatapath)
 
+    post_metadata_parse(app)
+    return app
 
-def parse_xml_metadata(apps, metadatapath):
 
-    appid, thisinfo = get_default_app_info_list(apps, metadatapath)
+def parse_json_metadata(mf, app):
+
+    # fdroid metadata is only strings and booleans, no floats or ints.
+    # TODO create schema using https://pypi.python.org/pypi/jsonschema
+    jsoninfo = json.load(mf, parse_int=lambda s: s,
+                         parse_float=lambda s: s)
+    app.update_fields(jsoninfo)
+    for f in ['Description', 'Maintainer Notes']:
+        v = app.get_field(f)
+        app.set_field(f, '\n'.join(v))
+    return app
 
-    tree = ElementTree.ElementTree(file=metadatapath)
+
+def parse_xml_metadata(mf, app):
+
+    tree = ElementTree.ElementTree(file=mf)
     root = tree.getroot()
 
     if root.tag != 'resources':
-        logging.critical(metadatapath + ' does not have root as <resources></resources>!')
-        sys.exit(1)
+        raise MetaDataException('resources file does not have root element <resources/>')
 
-    supported_metadata = app_defaults.keys()
     for child in root:
         if child.tag != 'builds':
             # builds does not have name="" attrib
             name = child.attrib['name']
-            if name not in supported_metadata:
-                raise MetaDataException("Unrecognised metadata: <"
-                                        + child.tag + ' name="' + name + '">'
-                                        + child.text
-                                        + "</" + child.tag + '>')
 
         if child.tag == 'string':
-            thisinfo[name] = child.text
+            app.set_field(name, child.text)
         elif child.tag == 'string-array':
-            items = []
             for item in child:
-                items.append(item.text)
-            thisinfo[name] = items
+                app.append_field(name, item.text)
         elif child.tag == 'builds':
-            builds = []
-            for build in child:
-                builddict = dict()
-                for key in build:
-                    builddict[key.tag] = key.text
-                builds.append(builddict)
-            thisinfo['builds'] = builds
+            for b in child:
+                build = Build()
+                for key in b:
+                    build.set_flag(key.tag, key.text)
+                app.builds.append(build)
 
     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
-    if not isinstance(thisinfo['Requires Root'], bool):
-        if thisinfo['Requires Root'] == 'true':
-            thisinfo['Requires Root'] = True
-        else:
-            thisinfo['Requires Root'] = False
-
-    post_metadata_parse(thisinfo)
+    if not isinstance(app.RequiresRoot, bool):
+        app.RequiresRoot = app.RequiresRoot == 'true'
 
-    return (appid, thisinfo)
+    return app
 
 
-def parse_yaml_metadata(apps, metadatapath):
+def parse_yaml_metadata(mf, app):
 
-    appid, thisinfo = get_default_app_info_list(apps, metadatapath)
+    yamlinfo = yaml.load(mf, Loader=YamlLoader)
+    app.update_fields(yamlinfo)
+    return app
 
-    yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
-    thisinfo.update(yamlinfo)
-    post_metadata_parse(thisinfo)
 
-    return (appid, thisinfo)
+build_line_sep = re.compile(r'(?<!\\),')
+build_cont = re.compile(r'^[ \t]')
 
 
-def parse_txt_metadata(apps, metadatapath):
+def parse_txt_metadata(mf, app):
 
     linedesc = None
 
-    def add_buildflag(p, thisbuild):
+    def add_buildflag(p, build):
         if not p.strip():
             raise MetaDataException("Empty build flag at {1}"
                                     .format(buildlines[0], linedesc))
@@ -851,311 +1028,366 @@ def parse_txt_metadata(apps, metadatapath):
         if len(bv) != 2:
             raise MetaDataException("Invalid build flag at {0} in {1}"
                                     .format(buildlines[0], linedesc))
-        pk, pv = bv
-        if pk in thisbuild:
-            raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
-                                    .format(pk, thisbuild['version'], linedesc))
 
+        pk, pv = bv
         pk = pk.lstrip()
-        if pk not in flag_defaults:
-            raise MetaDataException("Unrecognised build flag at {0} in {1}"
-                                    .format(p, linedesc))
         t = flagtype(pk)
-        if t == 'list':
+        if t == TYPE_LIST:
             pv = split_list_values(pv)
-            if pk == 'gradle':
-                if len(pv) == 1 and pv[0] in ['main', 'yes']:
-                    pv = ['yes']
-            thisbuild[pk] = pv
-        elif t == 'string' or t == 'script':
-            thisbuild[pk] = pv
-        elif t == 'bool':
-            value = pv == 'yes'
-            if value:
-                thisbuild[pk] = True
-            else:
-                logging.debug("...ignoring bool flag %s" % p)
-
-        else:
-            raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
-                                    % (t, p, linedesc))
+            build.set_flag(pk, pv)
+        elif t == TYPE_STRING or t == TYPE_SCRIPT:
+            build.set_flag(pk, pv)
+        elif t == TYPE_BOOL:
+            build.set_flag(pk, _decode_bool(pv))
 
     def parse_buildline(lines):
-        value = "".join(lines)
-        parts = [p.replace("\\,", ",")
-                 for p in re.split(r"(?<!\\),", value)]
+        v = "".join(lines)
+        parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
         if len(parts) < 3:
-            raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
-        thisbuild = {}
-        thisbuild['origlines'] = lines
-        thisbuild['version'] = parts[0]
-        thisbuild['vercode'] = parts[1]
+            raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
+        build = Build()
+        build.version = parts[0]
+        build.vercode = parts[1]
         if parts[2].startswith('!'):
             # For backwards compatibility, handle old-style disabling,
             # including attempting to extract the commit from the message
-            thisbuild['disable'] = parts[2][1:]
+            build.disable = parts[2][1:]
             commit = 'unknown - see disabled'
             index = parts[2].rfind('at ')
             if index != -1:
                 commit = parts[2][index + 3:]
                 if commit.endswith(')'):
                     commit = commit[:-1]
-            thisbuild['commit'] = commit
+            build.commit = commit
         else:
-            thisbuild['commit'] = parts[2]
+            build.commit = parts[2]
         for p in parts[3:]:
-            add_buildflag(p, thisbuild)
+            add_buildflag(p, build)
 
-        return thisbuild
+        return build
 
     def add_comments(key):
         if not curcomments:
             return
-        for comment in curcomments:
-            thisinfo['comments'].append([key, comment])
+        app.comments[key] = list(curcomments)
         del curcomments[:]
 
-    appid, thisinfo = get_default_app_info_list(apps, metadatapath)
-    metafile = open(metadatapath, "r")
-
     mode = 0
     buildlines = []
+    multiline_lines = []
     curcomments = []
-    curbuild = None
-    vc_seen = {}
+    build = None
+    vc_seen = set()
 
     c = 0
-    for line in metafile:
+    for line in mf:
         c += 1
-        linedesc = "%s:%d" % (metafile.name, c)
+        linedesc = "%s:%d" % (mf.name, c)
         line = line.rstrip('\r\n')
         if mode == 3:
-            if not any(line.startswith(s) for s in (' ', '\t')):
-                commit = curbuild['commit'] if 'commit' in curbuild else None
-                if not commit and 'disable' not in curbuild:
-                    raise MetaDataException("No commit specified for {0} in {1}"
-                                            .format(curbuild['version'], linedesc))
-
-                thisinfo['builds'].append(curbuild)
-                add_comments('build:' + curbuild['vercode'])
-                mode = 0
-            else:
+            if build_cont.match(line):
                 if line.endswith('\\'):
                     buildlines.append(line[:-1].lstrip())
                 else:
                     buildlines.append(line.lstrip())
                     bl = ''.join(buildlines)
-                    add_buildflag(bl, curbuild)
-                    buildlines = []
+                    add_buildflag(bl, build)
+                    del buildlines[:]
+            else:
+                if not build.commit and not build.disable:
+                    raise MetaDataException("No commit specified for {0} in {1}"
+                                            .format(build.version, linedesc))
+
+                app.builds.append(build)
+                add_comments('build:' + build.vercode)
+                mode = 0
 
         if mode == 0:
             if not line:
                 continue
             if line.startswith("#"):
-                curcomments.append(line)
+                curcomments.append(line[1:].strip())
                 continue
             try:
-                field, value = line.split(':', 1)
+                f, v = line.split(':', 1)
             except ValueError:
                 raise MetaDataException("Invalid metadata in " + linedesc)
-            if field != field.strip() or value != value.strip():
-                raise MetaDataException("Extra spacing found in " + linedesc)
 
             # Translate obsolete fields...
-            if field == 'Market Version':
-                field = 'Current Version'
-            if field == 'Market Version Code':
-                field = 'Current Version Code'
-
-            fieldtype = metafieldtype(field)
-            if fieldtype not in ['build', 'buildv2']:
-                add_comments(field)
-            if fieldtype == 'multiline':
+            if f == 'Market Version':
+                f = 'Current Version'
+            if f == 'Market Version Code':
+                f = 'Current Version Code'
+
+            ftype = fieldtype(f)
+            if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
+                add_comments(f)
+            if ftype == TYPE_MULTILINE:
                 mode = 1
-                thisinfo[field] = []
-                if value:
-                    raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
-            elif fieldtype == 'string':
-                thisinfo[field] = value
-            elif fieldtype == 'list':
-                thisinfo[field] = split_list_values(value)
-            elif fieldtype == 'build':
-                if value.endswith("\\"):
+                if v:
+                    raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
+            elif ftype == TYPE_STRING:
+                app.set_field(f, v)
+            elif ftype == TYPE_LIST:
+                app.set_field(f, split_list_values(v))
+            elif ftype == TYPE_BUILD:
+                if v.endswith("\\"):
                     mode = 2
-                    buildlines = [value[:-1]]
+                    del buildlines[:]
+                    buildlines.append(v[:-1])
                 else:
-                    curbuild = parse_buildline([value])
-                    thisinfo['builds'].append(curbuild)
-                    add_comments('build:' + thisinfo['builds'][-1]['vercode'])
-            elif fieldtype == 'buildv2':
-                curbuild = {}
-                vv = value.split(',')
+                    build = parse_buildline([v])
+                    app.builds.append(build)
+                    add_comments('build:' + app.builds[-1].vercode)
+            elif ftype == TYPE_BUILD_V2:
+                vv = v.split(',')
                 if len(vv) != 2:
                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
-                                            .format(value, linedesc))
-                curbuild['version'] = vv[0]
-                curbuild['vercode'] = vv[1]
-                if curbuild['vercode'] in vc_seen:
+                                            .format(v, linedesc))
+                build = Build()
+                build.version = vv[0]
+                build.vercode = vv[1]
+                if build.vercode in vc_seen:
                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
-                                            curbuild['vercode'], linedesc))
-                vc_seen[curbuild['vercode']] = True
-                buildlines = []
+                                            build.vercode, linedesc))
+                vc_seen.add(build.vercode)
+                del buildlines[:]
                 mode = 3
-            elif fieldtype == 'obsolete':
+            elif ftype == TYPE_OBSOLETE:
                 pass        # Just throw it away!
             else:
-                raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
+                raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
         elif mode == 1:     # Multiline field
             if line == '.':
                 mode = 0
+                app.set_field(f, '\n'.join(multiline_lines))
+                del multiline_lines[:]
             else:
-                thisinfo[field].append(line)
+                multiline_lines.append(line)
         elif mode == 2:     # Line continuation mode in Build Version
             if line.endswith("\\"):
                 buildlines.append(line[:-1])
             else:
                 buildlines.append(line)
-                curbuild = parse_buildline(buildlines)
-                thisinfo['builds'].append(curbuild)
-                add_comments('build:' + thisinfo['builds'][-1]['vercode'])
+                build = parse_buildline(buildlines)
+                app.builds.append(build)
+                add_comments('build:' + app.builds[-1].vercode)
                 mode = 0
     add_comments(None)
 
-    # Mode at end of file should always be 0...
+    # Mode at end of file should always be 0
     if mode == 1:
-        raise MetaDataException(field + " not terminated in " + metafile.name)
-    elif mode == 2:
-        raise MetaDataException("Unterminated continuation in " + metafile.name)
-    elif mode == 3:
-        raise MetaDataException("Unterminated build in " + metafile.name)
+        raise MetaDataException(f + " not terminated in " + mf.name)
+    if mode == 2:
+        raise MetaDataException("Unterminated continuation in " + mf.name)
+    if mode == 3:
+        raise MetaDataException("Unterminated build in " + mf.name)
 
-    post_metadata_parse(thisinfo)
+    return app
 
-    return (appid, thisinfo)
 
+def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
 
-# Write a metadata file.
-#
-# 'dest'    - The path to the output file
-# 'app'     - The app data
-def write_metadata(dest, app):
-
-    def writecomments(key):
-        written = 0
-        for pf, comment in app['comments']:
-            if pf == key:
-                mf.write("%s\n" % comment)
-                written += 1
-        if written > 0:
-            logging.debug("...writing comments for " + (key or 'EOF'))
-
-    def writefield(field, value=None):
-        writecomments(field)
-        if value is None:
-            value = app[field]
-        t = metafieldtype(field)
-        if t == 'list':
-            value = ','.join(value)
-        elif t == 'multiline':
-            if type(value) == list:
-                value = '\n' + '\n'.join(value) + '\n.'
-            else:
-                value = '\n' + value + '.'
-        mf.write("%s:%s\n" % (field, value))
-
-    def writefield_nonempty(field, value=None):
-        if value is None:
-            value = app[field]
-        if value:
-            writefield(field, value)
-
-    mf = open(dest, 'w')
-    writefield_nonempty('Disabled')
-    if app['AntiFeatures']:
-        writefield('AntiFeatures')
-    writefield_nonempty('Provides')
-    writefield('Categories')
-    writefield('License')
-    writefield('Web Site')
-    writefield('Source Code')
-    writefield('Issue Tracker')
-    writefield_nonempty('Changelog')
-    writefield_nonempty('Donate')
-    writefield_nonempty('FlattrID')
-    writefield_nonempty('Bitcoin')
-    writefield_nonempty('Litecoin')
-    writefield_nonempty('Dogecoin')
+    def w_comments(key):
+        if key not in app.comments:
+            return
+        for line in app.comments[key]:
+            w_comment(line)
+
+    def w_field_always(f, v=None):
+        if v is None:
+            v = app.get_field(f)
+        w_comments(f)
+        w_field(f, v)
+
+    def w_field_nonempty(f, v=None):
+        if v is None:
+            v = app.get_field(f)
+        w_comments(f)
+        if v:
+            w_field(f, v)
+
+    w_field_nonempty('Disabled')
+    w_field_nonempty('AntiFeatures')
+    w_field_nonempty('Provides')
+    w_field_always('Categories')
+    w_field_always('License')
+    w_field_nonempty('Author Name')
+    w_field_nonempty('Author Email')
+    w_field_always('Web Site')
+    w_field_always('Source Code')
+    w_field_always('Issue Tracker')
+    w_field_nonempty('Changelog')
+    w_field_nonempty('Donate')
+    w_field_nonempty('FlattrID')
+    w_field_nonempty('Bitcoin')
+    w_field_nonempty('Litecoin')
     mf.write('\n')
-    writefield_nonempty('Name')
-    writefield_nonempty('Auto Name')
-    writefield('Summary')
-    writefield('Description', description_txt(app['Description']))
+    w_field_nonempty('Name')
+    w_field_nonempty('Auto Name')
+    w_field_always('Summary')
+    w_field_always('Description', description_txt(app.Description))
     mf.write('\n')
-    if app['Requires Root']:
-        writefield('Requires Root', 'yes')
+    if app.RequiresRoot:
+        w_field_always('Requires Root', 'yes')
         mf.write('\n')
-    if app['Repo Type']:
-        writefield('Repo Type')
-        writefield('Repo')
-        if app['Binaries']:
-            writefield('Binaries')
+    if app.RepoType:
+        w_field_always('Repo Type')
+        w_field_always('Repo')
+        if app.Binaries:
+            w_field_always('Binaries')
         mf.write('\n')
-    for build in sorted_builds(app['builds']):
 
-        if build['version'] == "Ignore":
+    for build in app.builds:
+
+        if build.version == "Ignore":
             continue
 
-        writecomments('build:' + build['vercode'])
-        mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
+        w_comments('build:' + build.vercode)
+        w_build(build)
+        mf.write('\n')
 
-        def write_builditem(key, value):
+    if app.MaintainerNotes:
+        w_field_always('Maintainer Notes', app.MaintainerNotes)
+        mf.write('\n')
 
-            if key in ['version', 'vercode']:
-                return
+    w_field_nonempty('Archive Policy')
+    w_field_always('Auto Update Mode')
+    w_field_always('Update Check Mode')
+    w_field_nonempty('Update Check Ignore')
+    w_field_nonempty('Vercode Operation')
+    w_field_nonempty('Update Check Name')
+    w_field_nonempty('Update Check Data')
+    if app.CurrentVersion:
+        w_field_always('Current Version')
+        w_field_always('Current Version Code')
+    if app.NoSourceSince:
+        mf.write('\n')
+        w_field_always('No Source Since')
+    w_comments(None)
 
-            if value == flag_defaults[key]:
-                return
 
-            t = flagtype(key)
+# Write a metadata file in txt format.
+#
+# 'mf'      - Writer interface (file, StringIO, ...)
+# 'app'     - The app data
+def write_txt(mf, app):
 
-            logging.debug("...writing {0} : {1}".format(key, value))
-            outline = '    %s=' % key
+    def w_comment(line):
+        mf.write("# %s\n" % line)
 
-            if t == 'string':
-                outline += value
-            elif t == 'bool':
-                outline += 'yes'
-            elif t == 'script':
-                outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
-            elif t == 'list':
-                outline += ','.join(value) if type(value) == list else value
+    def w_field(f, v):
+        t = fieldtype(f)
+        if t == TYPE_LIST:
+            v = ','.join(v)
+        elif t == TYPE_MULTILINE:
+            v = '\n' + v + '\n.'
+        mf.write("%s:%s\n" % (f, v))
 
-            outline += '\n'
-            mf.write(outline)
+    def w_build(build):
+        mf.write("Build:%s,%s\n" % (build.version, build.vercode))
 
-        for flag in flag_defaults:
-            value = build[flag]
-            if value:
-                write_builditem(flag, value)
-        mf.write('\n')
+        for f in build_flags_order:
+            v = build.get_flag(f)
+            if not v:
+                continue
 
-    if app['Maintainer Notes']:
-        writefield('Maintainer Notes', app['Maintainer Notes'])
-        mf.write('\n')
+            t = flagtype(f)
+            mf.write('    %s=' % f)
+            if t == TYPE_STRING:
+                mf.write(v)
+            elif t == TYPE_BOOL:
+                mf.write('yes')
+            elif t == TYPE_SCRIPT:
+                first = True
+                for s in v.split(' && '):
+                    if first:
+                        first = False
+                    else:
+                        mf.write(' && \\\n        ')
+                    mf.write(s)
+            elif t == TYPE_LIST:
+                mf.write(','.join(v))
+
+            mf.write('\n')
+
+    write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
+
+
+def write_yaml(mf, app):
+
+    def w_comment(line):
+        mf.write("# %s\n" % line)
+
+    def escape(v):
+        if not v:
+            return ''
+        if any(c in v for c in [': ', '%', '@', '*']):
+            return "'" + v.replace("'", "''") + "'"
+        return v
+
+    def w_field(f, v, prefix='', t=None):
+        if t is None:
+            t = fieldtype(f)
+        v = ''
+        if t == TYPE_LIST:
+            v = '\n'
+            for e in v:
+                v += prefix + ' - ' + escape(e) + '\n'
+        elif t == TYPE_MULTILINE:
+            v = ' |\n'
+            for l in v.splitlines():
+                if l:
+                    v += prefix + '  ' + l + '\n'
+                else:
+                    v += '\n'
+        elif t == TYPE_BOOL:
+            v = ' yes\n'
+        elif t == TYPE_SCRIPT:
+            cmds = [s + '&& \\' for s in v.split('&& ')]
+            if len(cmds) > 0:
+                cmds[-1] = cmds[-1][:-len('&& \\')]
+            w_field(f, cmds, prefix, 'multiline')
+            return
+        else:
+            v = ' ' + escape(v) + '\n'
+
+        mf.write(prefix)
+        mf.write(f)
+        mf.write(":")
+        mf.write(v)
+
+    global first_build
+    first_build = True
+
+    def w_build(build):
+        global first_build
+        if first_build:
+            mf.write("builds:\n")
+            first_build = False
+
+        w_field('versionName', build.version, '  - ', TYPE_STRING)
+        w_field('versionCode', build.vercode, '    ', TYPE_STRING)
+        for f in build_flags_order:
+            v = build.get_flag(f)
+            if not v:
+                continue
 
-    writefield_nonempty('Archive Policy')
-    writefield('Auto Update Mode')
-    writefield('Update Check Mode')
-    writefield_nonempty('Update Check Ignore')
-    writefield_nonempty('Vercode Operation')
-    writefield_nonempty('Update Check Name')
-    writefield_nonempty('Update Check Data')
-    if app['Current Version']:
-        writefield('Current Version')
-        writefield('Current Version Code')
-    mf.write('\n')
-    if app['No Source Since']:
-        writefield('No Source Since')
-        mf.write('\n')
-    writecomments(None)
-    mf.close()
+            w_field(f, v, '    ', flagtype(f))
+
+    write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
+
+
+def write_metadata(metadatapath, app):
+    _, ext = fdroidserver.common.get_extension(metadatapath)
+    accepted = fdroidserver.common.config['accepted_formats']
+    if ext not in accepted:
+        raise MetaDataException('Cannot write "%s", not an accepted format, use: %s' % (
+            metadatapath, ', '.join(accepted)))
+
+    with open(metadatapath, 'w', encoding='utf8') as mf:
+        if ext == 'txt':
+            return write_txt(mf, app)
+        elif ext == 'yml':
+            return write_yaml(mf, app)
+    raise MetaDataException('Unknown metadata format: %s' % metadatapath)