nl.basjes.parse.useragent.UserAgentAnalyzer.java Source code

Java tutorial

Introduction

Here is the source code for nl.basjes.parse.useragent.UserAgentAnalyzer.java

Source

/*
 * Yet Another UserAgent Analyzer
 * Copyright (C) 2013-2017 Niels Basjes
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package nl.basjes.parse.useragent;

import nl.basjes.parse.useragent.analyze.Analyzer;
import nl.basjes.parse.useragent.analyze.InvalidParserConfigurationException;
import nl.basjes.parse.useragent.analyze.Matcher;
import nl.basjes.parse.useragent.analyze.MatcherAction;
import nl.basjes.parse.useragent.analyze.UselessMatcherException;
import nl.basjes.parse.useragent.parse.UserAgentTreeFlattener;
import nl.basjes.parse.useragent.utils.Normalize;
import nl.basjes.parse.useragent.utils.VersionSplitter;
import org.antlr.v4.runtime.tree.ParseTree;
import org.apache.commons.collections4.map.LRUMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Formatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

import static nl.basjes.parse.useragent.UserAgent.AGENT_CLASS;
import static nl.basjes.parse.useragent.UserAgent.AGENT_NAME;
import static nl.basjes.parse.useragent.UserAgent.AGENT_VERSION;
import static nl.basjes.parse.useragent.UserAgent.AGENT_VERSION_MAJOR;
import static nl.basjes.parse.useragent.UserAgent.DEVICE_BRAND;
import static nl.basjes.parse.useragent.UserAgent.DEVICE_CLASS;
import static nl.basjes.parse.useragent.UserAgent.DEVICE_NAME;
import static nl.basjes.parse.useragent.UserAgent.DEVICE_VERSION;
import static nl.basjes.parse.useragent.UserAgent.LAYOUT_ENGINE_CLASS;
import static nl.basjes.parse.useragent.UserAgent.LAYOUT_ENGINE_NAME;
import static nl.basjes.parse.useragent.UserAgent.LAYOUT_ENGINE_VERSION;
import static nl.basjes.parse.useragent.UserAgent.LAYOUT_ENGINE_VERSION_MAJOR;
import static nl.basjes.parse.useragent.UserAgent.OPERATING_SYSTEM_CLASS;
import static nl.basjes.parse.useragent.UserAgent.OPERATING_SYSTEM_NAME;
import static nl.basjes.parse.useragent.UserAgent.OPERATING_SYSTEM_VERSION;
import static nl.basjes.parse.useragent.UserAgent.PRE_SORTED_FIELDS_LIST;
import static nl.basjes.parse.useragent.UserAgent.SET_ALL_FIELDS;
import static nl.basjes.parse.useragent.UserAgent.SYNTAX_ERROR;

public class UserAgentAnalyzer extends Analyzer {

    private static final int INFORM_ACTIONS_HASHMAP_SIZE = 300000;
    private static final int DEFAULT_PARSE_CACHE_SIZE = 10000;

    private static final Logger LOG = LoggerFactory.getLogger(UserAgentAnalyzer.class);
    protected List<Matcher> allMatchers = new ArrayList<>();
    private Map<String, Set<MatcherAction>> informMatcherActions = new HashMap<>(INFORM_ACTIONS_HASHMAP_SIZE);
    private final Map<String, List<Map<String, List<String>>>> matcherConfigs = new HashMap<>(64);

    private boolean doingOnlyASingleTest = false;

    // If we want ALL fields this is null. If we only want specific fields this is a list of names.
    protected Set<String> wantedFieldNames = null;

    protected final List<Map<String, Map<String, String>>> testCases = new ArrayList<>(2048);
    private Map<String, Map<String, String>> lookups = new HashMap<>(128);

    protected UserAgentTreeFlattener flattener;

    private Yaml yaml;

    private LRUMap<String, UserAgent> parseCache = new LRUMap<>(DEFAULT_PARSE_CACHE_SIZE);

    public UserAgentAnalyzer() {
        this(true);
    }

    protected UserAgentAnalyzer(boolean initialize) {
        if (initialize) {
            initialize(true);
        }
    }

    protected void initialize(boolean showMatcherStats) {
        logVersion();
        loadResources("classpath*:UserAgents/**/*.yaml", showMatcherStats);
    }

    public UserAgentAnalyzer(String resourceString) {
        loadResources(resourceString, true);
    }

    public static void logVersion() {
        String[] lines = { "For more information: https://github.com/nielsbasjes/yauaa",
                "Copyright (C) 2013-2017 Niels Basjes - License Apache 2.0" };
        String version = getVersion();
        int width = version.length();
        for (String line : lines) {
            width = Math.max(width, line.length());
        }

        LOG.info("");
        LOG.info("/-{}-\\", padding('-', width));
        logLine(version, width);
        LOG.info("+-{}-+", padding('-', width));
        for (String line : lines) {
            logLine(line, width);
        }
        LOG.info("\\-{}-/", padding('-', width));
        LOG.info("");
    }

    private static String padding(char letter, int count) {
        StringBuilder sb = new StringBuilder(128);
        for (int i = 0; i < count; i++) {
            sb.append(letter);
        }
        return sb.toString();
    }

    private static void logLine(String line, int width) {
        LOG.info("| {}{} |", line, padding(' ', width - line.length()));
    }

    // --------------------------------------------

    public static String getVersion() {
        return "Yauaa " + Version.getProjectVersion() + " (" + Version.getGitCommitIdDescribeShort() + " @ "
                + Version.getBuildTimestamp() + ")";
    }

    public void loadResources(String resourceString, boolean showMatcherStats) {
        LOG.info("Loading from: \"{}\"", resourceString);

        flattener = new UserAgentTreeFlattener(this);
        yaml = new Yaml();

        Map<String, Resource> resources = new TreeMap<>();
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        try {
            Resource[] resourceArray = resolver.getResources(resourceString);
            for (Resource resource : resourceArray) {
                resources.put(resource.getFilename(), resource);
            }
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }
        doingOnlyASingleTest = false;
        int maxFilenameLength = 0;

        if (resources.isEmpty()) {
            throw new InvalidParserConfigurationException("Unable to find ANY config files");
        }

        for (Map.Entry<String, Resource> resourceEntry : resources.entrySet()) {
            try {
                Resource resource = resourceEntry.getValue();
                String filename = resource.getFilename();
                maxFilenameLength = Math.max(maxFilenameLength, filename.length());
                loadResource(resource.getInputStream(), filename);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        LOG.info("Loaded {} files", resources.size());

        if (lookups != null && !lookups.isEmpty()) {
            // All compares are done in a case insensitive way. So we lowercase ALL keys of the lookups beforehand.
            Map<String, Map<String, String>> cleanedLookups = new HashMap<>(lookups.size());
            for (Map.Entry<String, Map<String, String>> lookupsEntry : lookups.entrySet()) {
                Map<String, String> cleanedLookup = new HashMap<>(lookupsEntry.getValue().size());
                for (Map.Entry<String, String> entry : lookupsEntry.getValue().entrySet()) {
                    cleanedLookup.put(entry.getKey().toLowerCase(), entry.getValue());
                }
                cleanedLookups.put(lookupsEntry.getKey(), cleanedLookup);
            }
            lookups = cleanedLookups;
        }

        LOG.info("Building all matchers");
        int totalNumberOfMatchers = 0;
        int skippedMatchers = 0;
        if (matcherConfigs != null) {
            long fullStart = System.nanoTime();
            for (Map.Entry<String, Resource> resourceEntry : resources.entrySet()) {
                Resource resource = resourceEntry.getValue();
                String configFilename = resource.getFilename();
                List<Map<String, List<String>>> matcherConfig = matcherConfigs.get(configFilename);
                if (matcherConfig == null) {
                    continue; // No matchers in this file (probably only lookups and/or tests)
                }

                long start = System.nanoTime();
                int startSize = informMatcherActions.size();
                for (Map<String, List<String>> map : matcherConfig) {
                    try {
                        allMatchers.add(new Matcher(this, lookups, wantedFieldNames, map));
                        totalNumberOfMatchers++;
                    } catch (UselessMatcherException ume) {
                        skippedMatchers++;
                    }
                }
                long stop = System.nanoTime();
                int stopSize = informMatcherActions.size();

                if (showMatcherStats) {
                    Formatter msg = new Formatter(Locale.ENGLISH);
                    msg.format(
                            "Building %4d matchers from %-" + maxFilenameLength
                                    + "s took %5d msec resulted in %8d extra hashmap entries",
                            matcherConfig.size(), configFilename, (stop - start) / 1000000, stopSize - startSize);
                    LOG.info(msg.toString());
                }
            }
            long fullStop = System.nanoTime();

            Formatter msg = new Formatter(Locale.ENGLISH);
            msg.format(
                    "Building %4d (dropped %4d) matchers from %4d files took %5d msec resulted in %8d hashmap entries",
                    totalNumberOfMatchers, skippedMatchers, matcherConfigs.size(), (fullStop - fullStart) / 1000000,
                    informMatcherActions.size());
            LOG.info(msg.toString());

        }
        LOG.info("Analyzer stats");
        LOG.info("Lookups      : {}", (lookups == null) ? 0 : lookups.size());
        LOG.info("Matchers     : {} (total:{} ; dropped: {})", allMatchers.size(), totalNumberOfMatchers,
                skippedMatchers);
        LOG.info("Hashmap size : {}", informMatcherActions.size());
        LOG.info("Testcases    : {}", testCases.size());
        //        LOG.info("All possible field names:");
        //        int count = 1;
        //        for (String fieldName : getAllPossibleFieldNames()) {
        //            LOG.info("- {}: {}", count++, fieldName);
        //        }
    }

    /**
     * Used by some unit tests to get rid of all the standard tests and focus on the experiment at hand.
     */
    public void eraseTestCases() {
        testCases.clear();
    }

    public Set<String> getAllPossibleFieldNames() {
        Set<String> results = new TreeSet<>();
        results.addAll(HARD_CODED_GENERATED_FIELDS);
        for (Matcher matcher : allMatchers) {
            results.addAll(matcher.getAllPossibleFieldNames());
        }
        return results;
    }

    public List<String> getAllPossibleFieldNamesSorted() {
        List<String> fieldNames = new ArrayList<>(getAllPossibleFieldNames());
        Collections.sort(fieldNames);

        List<String> result = new ArrayList<>();
        for (String fieldName : PRE_SORTED_FIELDS_LIST) {
            fieldNames.remove(fieldName);
            result.add(fieldName);
        }
        for (String fieldName : fieldNames) {
            result.add(fieldName);
        }
        return result;
    }

    /*
    Example of the structure of the yaml file:
    ----------------------------
    config:
      - lookup:
        name: 'lookupname'
        map:
    "From1" : "To1"
    "From2" : "To2"
    "From3" : "To3"
      - matcher:
          options:
    - 'verbose'
    - 'init'
          require:
    - 'Require pattern'
    - 'Require pattern'
          extract:
    - 'Extract pattern'
    - 'Extract pattern'
      - test:
          input:
    user_agent_string: 'Useragent'
          expected:
    FieldName     : 'ExpectedValue'
    FieldName     : 'ExpectedValue'
    FieldName     : 'ExpectedValue'
    ----------------------------
    */

    private void loadResource(InputStream yamlStream, String filename) {
        Object loadedYaml;
        try {
            loadedYaml = yaml.load(yamlStream);
        } catch (Exception e) {
            LOG.error("Caught exception during parse of file {}", filename);
            throw e;
        }

        if (!(loadedYaml instanceof Map)) {
            throw new InvalidParserConfigurationException("Yaml config  (" + filename + "): File must be a Map");
        }

        @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
        Object rawConfig = ((Map<String, Object>) loadedYaml).get("config");
        if (rawConfig == null) {
            throw new InvalidParserConfigurationException(
                    "Yaml config (" + filename + "): Missing 'config' top level entry");
        }
        if (!(rawConfig instanceof List)) {
            throw new InvalidParserConfigurationException(
                    "Yaml config (" + filename + "): Top level 'config' must be a Map");
        }

        @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
        List<Object> configList = (List<Object>) rawConfig;
        int entryCount = 0;
        for (Object configEntry : configList) {
            entryCount++;
            if (!(configEntry instanceof Map)) {
                throw new InvalidParserConfigurationException(
                        "Yaml config (" + filename + " [" + entryCount + "]): Entry must be a Map");
            }
            @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
            Map<String, Object> entry = (Map<String, Object>) configEntry;
            if (entry.size() != 1) {
                StringBuilder sb = new StringBuilder();
                for (String key : entry.keySet()) {
                    sb.append('"').append(key).append("\" ");
                }
                throw new InvalidParserConfigurationException("Yaml config (" + filename + " [" + entryCount
                        + "]): Entry has more than one child: " + sb.toString());
            }

            Map.Entry<String, Object> onlyEntry = entry.entrySet().iterator().next();
            String key = onlyEntry.getKey();
            Object value = onlyEntry.getValue();
            switch (key) {

            case "lookup":
                if (!(value instanceof Map)) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + " [" + entryCount + "]): Entry 'lookup' must be a Map");
                }

                @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
                Map<String, Object> newLookup = (Map<String, Object>) value;
                Object rawName = newLookup.get("name");
                if (rawName == null) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + " [" + entryCount + "]): Lookup does not have 'name'");
                }
                if (!(rawName instanceof String)) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + " [" + entryCount + "]): Lookup 'name' must be a String");
                }

                Object rawMap = newLookup.get("map");
                if (rawMap == null) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + " [" + entryCount + "]): Lookup does not have 'map'");
                }
                if (!(rawMap instanceof Map)) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + " [" + entryCount + "]): Lookup 'map' must be a Map");
                }

                @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
                Map<String, String> map = (Map<String, String>) rawMap;
                lookups.put((String) rawName, map);
                break;

            case "matcher":
                if (!(value instanceof Map)) {
                    throw new InvalidParserConfigurationException(
                            "Yaml config (" + filename + "): Entry 'matcher' must be a Map");
                }
                @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
                Map<String, List<String>> matcherConfig = (Map<String, List<String>>) value;

                List<Map<String, List<String>>> matcherConfigList = matcherConfigs.get(filename);
                if (matcherConfigList == null) {
                    matcherConfigList = new ArrayList<>(32);
                    matcherConfigs.put(filename, matcherConfigList);
                }
                matcherConfigList.add(matcherConfig);
                break;

            case "test":
                if (!doingOnlyASingleTest) {
                    if (!(value instanceof Map)) {
                        throw new InvalidParserConfigurationException(
                                "Yaml config (" + filename + "): Entry 'testcase' must be a Map");
                    }
                    @SuppressWarnings({ "unchecked" }) // Ignoring the possibly wrong generic here
                    Map<String, Map<String, String>> testCase = (Map<String, Map<String, String>>) value;
                    Map<String, String> metaData = testCase.get("metaData");
                    if (metaData == null) {
                        metaData = new HashMap<>();
                        testCase.put("metaData", metaData);
                    }
                    metaData.put("filename", filename);
                    metaData.put("fileentry", String.valueOf(entryCount));

                    @SuppressWarnings("unchecked")
                    List<String> options = (List<String>) testCase.get("options");
                    Map<String, String> expected = testCase.get("expected");
                    if (options != null) {
                        if (options.contains("only")) {
                            doingOnlyASingleTest = true;
                            testCases.clear();
                        }
                    }
                    if (expected == null || expected.isEmpty()) {
                        doingOnlyASingleTest = true;
                        testCases.clear();
                    }

                    testCases.add(testCase);
                }
                break;

            default:
                throw new InvalidParserConfigurationException(
                        "Yaml config (" + filename + "): Found unexpected config entry: " + key
                                + ", allowed are 'lookup, 'matcher' and 'test'");
            }
        }

    }

    public void informMeAbout(MatcherAction matcherAction, String keyPattern) {
        String hashKey = keyPattern.toLowerCase();
        Set<MatcherAction> analyzerSet = informMatcherActions.get(hashKey);
        if (analyzerSet == null) {
            analyzerSet = new HashSet<>();
            informMatcherActions.put(hashKey, analyzerSet);
        }
        analyzerSet.add(matcherAction);
    }

    private boolean verbose = false;

    public void setVerbose(boolean newVerbose) {
        this.verbose = newVerbose;
        flattener.setVerbose(newVerbose);
    }

    public UserAgent parse(String userAgentString) {
        UserAgent userAgent = new UserAgent(userAgentString);
        return cachedParse(userAgent);
    }

    public UserAgent parse(UserAgent userAgent) {
        userAgent.reset();
        return cachedParse(userAgent);
    }

    public void disableCaching() {
        setCacheSize(0);
    }

    /**
     * Sets the new size of the parsing cache.
     * Note that this will also wipe the existing cache.
     * @param newCacheSize The size of the new LRU cache. As size of 0 will disable caching.
     */
    public void setCacheSize(int newCacheSize) {
        if (newCacheSize >= 1) {
            parseCache = new LRUMap<>(newCacheSize);
        } else {
            parseCache = null;
        }
    }

    public int getCacheSize() {
        if (parseCache == null) {
            return 0;
        }
        return parseCache.maxSize();
    }

    private synchronized UserAgent cachedParse(UserAgent userAgent) {
        if (parseCache == null) {
            return nonCachedParse(userAgent);
        }

        String userAgentString = userAgent.getUserAgentString();
        UserAgent cachedValue = parseCache.get(userAgentString);
        if (cachedValue != null) {
            userAgent.clone(cachedValue);
        } else {
            cachedValue = new UserAgent(nonCachedParse(userAgent));
            parseCache.put(userAgentString, cachedValue);
        }
        // We have our answer.
        return userAgent;
    }

    private UserAgent nonCachedParse(UserAgent userAgent) {

        boolean setVerboseTemporarily = userAgent.isDebug();

        // Reset all Matchers
        for (Matcher matcher : allMatchers) {
            matcher.reset(setVerboseTemporarily);
        }

        userAgent = flattener.parse(userAgent);

        // Fire all Analyzers
        for (Matcher matcher : allMatchers) {
            matcher.analyze(userAgent);
        }

        userAgent.processSetAll();
        return hardCodedPostProcessing(userAgent);
    }

    private static final List<String> HARD_CODED_GENERATED_FIELDS = new ArrayList<>();
    static {
        HARD_CODED_GENERATED_FIELDS.add(SYNTAX_ERROR);
        HARD_CODED_GENERATED_FIELDS.add(AGENT_VERSION_MAJOR);
        HARD_CODED_GENERATED_FIELDS.add(LAYOUT_ENGINE_VERSION_MAJOR);
        HARD_CODED_GENERATED_FIELDS.add("AgentNameVersion");
        HARD_CODED_GENERATED_FIELDS.add("AgentNameVersionMajor");
        HARD_CODED_GENERATED_FIELDS.add("LayoutEngineNameVersion");
        HARD_CODED_GENERATED_FIELDS.add("LayoutEngineNameVersionMajor");
        HARD_CODED_GENERATED_FIELDS.add("OperatingSystemNameVersion");
        HARD_CODED_GENERATED_FIELDS.add("WebviewAppVersionMajor");
        HARD_CODED_GENERATED_FIELDS.add("WebviewAppNameVersionMajor");
    }

    private UserAgent hardCodedPostProcessing(UserAgent userAgent) {
        // If it is really really bad ... then it is a Hacker.
        if ("true".equals(userAgent.getValue(SYNTAX_ERROR))) {
            if (userAgent.get(DEVICE_CLASS).getConfidence() == -1
                    && userAgent.get(OPERATING_SYSTEM_CLASS).getConfidence() == -1
                    && userAgent.get(LAYOUT_ENGINE_CLASS).getConfidence() == -1) {

                userAgent.set(DEVICE_CLASS, "Hacker", 10);
                userAgent.set(DEVICE_BRAND, "Hacker", 10);
                userAgent.set(DEVICE_NAME, "Hacker", 10);
                userAgent.set(DEVICE_VERSION, "Hacker", 10);
                userAgent.set(OPERATING_SYSTEM_CLASS, "Hacker", 10);
                userAgent.set(OPERATING_SYSTEM_NAME, "Hacker", 10);
                userAgent.set(OPERATING_SYSTEM_VERSION, "Hacker", 10);
                userAgent.set(LAYOUT_ENGINE_CLASS, "Hacker", 10);
                userAgent.set(LAYOUT_ENGINE_NAME, "Hacker", 10);
                userAgent.set(LAYOUT_ENGINE_VERSION, "Hacker", 10);
                userAgent.set(LAYOUT_ENGINE_VERSION_MAJOR, "Hacker", 10);
                userAgent.set(AGENT_CLASS, "Hacker", 10);
                userAgent.set(AGENT_NAME, "Hacker", 10);
                userAgent.set(AGENT_VERSION, "Hacker", 10);
                userAgent.set(AGENT_VERSION_MAJOR, "Hacker", 10);
                userAgent.set("HackerToolkit", "Unknown", 10);
                userAgent.set("HackerAttackVector", "Unknown", 10);
            }
        }

        // !!!!!!!!!! NOTE !!!!!!!!!!!!
        // IF YOU ADD ANY EXTRA FIELDS YOU MUST ADD THEM TO THE BUILDER TOO !!!!
        // TODO: Perhaps this should be more generic. Like a "Post processor"  (Generic: Create fields from fields)?
        addMajorVersionField(userAgent, AGENT_VERSION, AGENT_VERSION_MAJOR);
        addMajorVersionField(userAgent, LAYOUT_ENGINE_VERSION, LAYOUT_ENGINE_VERSION_MAJOR);
        addMajorVersionField(userAgent, "WebviewAppVersion", "WebviewAppVersionMajor");

        concatFieldValuesNONDuplicated(userAgent, "AgentNameVersion", AGENT_NAME, AGENT_VERSION);
        concatFieldValuesNONDuplicated(userAgent, "AgentNameVersionMajor", AGENT_NAME, AGENT_VERSION_MAJOR);
        concatFieldValuesNONDuplicated(userAgent, "WebviewAppNameVersionMajor", "WebviewAppName",
                "WebviewAppVersionMajor");
        concatFieldValuesNONDuplicated(userAgent, "LayoutEngineNameVersion", LAYOUT_ENGINE_NAME,
                LAYOUT_ENGINE_VERSION);
        concatFieldValuesNONDuplicated(userAgent, "LayoutEngineNameVersionMajor", LAYOUT_ENGINE_NAME,
                LAYOUT_ENGINE_VERSION_MAJOR);
        concatFieldValuesNONDuplicated(userAgent, "OperatingSystemNameVersion", OPERATING_SYSTEM_NAME,
                OPERATING_SYSTEM_VERSION);

        // The device brand field is a mess.
        UserAgent.AgentField deviceBrand = userAgent.get(DEVICE_BRAND);
        if (deviceBrand.getConfidence() >= 0) {
            userAgent.set(DEVICE_BRAND, Normalize.brand(deviceBrand.getValue()), deviceBrand.getConfidence() + 1);
        }

        // The email address is a mess
        UserAgent.AgentField email = userAgent.get("AgentInformationEmail");
        if (email != null && email.getConfidence() >= 0) {
            userAgent.set("AgentInformationEmail", Normalize.email(email.getValue()), email.getConfidence() + 1);
        }

        return userAgent;
    }

    private void concatFieldValuesNONDuplicated(UserAgent userAgent, String targetName, String firstName,
            String secondName) {
        UserAgent.AgentField firstField = userAgent.get(firstName);
        UserAgent.AgentField secondField = userAgent.get(secondName);

        String first = null;
        long firstConfidence = -1;
        String second = null;
        long secondConfidence = -1;

        if (firstField != null) {
            first = firstField.getValue();
            firstConfidence = firstField.getConfidence();
        }
        if (secondField != null) {
            second = secondField.getValue();
            secondConfidence = secondField.getConfidence();
        }

        if (first == null && second == null) {
            return; // Nothing to do
        }

        if (second == null) {
            if (firstConfidence >= 0) {
                userAgent.set(targetName, first, firstConfidence);
                return;
            }
            return; // Nothing to do
        } else {
            if (first == null) {
                if (secondConfidence >= 0) {
                    userAgent.set(targetName, second, secondConfidence);
                    return;
                }
                return;
            }
        }

        if (first.equals(second)) {
            userAgent.set(targetName, first, firstConfidence);
        } else {
            if (second.startsWith(first)) {
                userAgent.set(targetName, second, secondConfidence);
            } else {
                userAgent.set(targetName, first + " " + second,
                        Math.max(firstField.getConfidence(), secondField.getConfidence()));
            }
        }
    }

    private void addMajorVersionField(UserAgent userAgent, String versionName, String majorVersionName) {
        UserAgent.AgentField agentVersionMajor = userAgent.get(majorVersionName);
        if (agentVersionMajor == null || agentVersionMajor.getConfidence() == -1) {
            UserAgent.AgentField agentVersion = userAgent.get(versionName);
            if (agentVersion != null) {
                userAgent.set(majorVersionName, VersionSplitter.getSingleVersion(agentVersion.getValue(), 1),
                        agentVersion.getConfidence());
            }
        }
    }

    public void inform(String key, String value, ParseTree ctx) {
        inform(key, key, value, ctx);
        inform(key + "=\"" + value + '"', key, value, ctx);
    }

    private void inform(String match, String key, String value, ParseTree ctx) {
        Set<MatcherAction> relevantActions = informMatcherActions.get(match.toLowerCase());
        if (verbose) {
            if (relevantActions == null) {
                LOG.info("--- Have (0): {}", match);
            } else {
                LOG.info("+++ Have ({}): {}", relevantActions.size(), match);

                int count = 1;
                for (MatcherAction action : relevantActions) {
                    LOG.info("+++ -------> ({}): {}", count, action.toString());
                    count++;
                }
            }
        }

        if (relevantActions != null) {
            for (MatcherAction matcherAction : relevantActions) {
                matcherAction.inform(key, value, ctx);
            }
        }
    }

    // ===============================================================================================================

    public static class GetAllPathsAnalyzer extends Analyzer {
        final List<String> values = new ArrayList<>(128);
        final UserAgentTreeFlattener flattener;

        private final UserAgent result;

        GetAllPathsAnalyzer(String useragent) {
            flattener = new UserAgentTreeFlattener(this);
            result = flattener.parse(useragent);
        }

        public List<String> getValues() {
            return values;
        }

        public UserAgent getResult() {
            return result;
        }

        public void inform(String path, String value, ParseTree ctx) {
            values.add(path);
            values.add(path + "=\"" + value + "\"");
        }

        public void informMeAbout(MatcherAction matcherAction, String keyPattern) {
        }
    }

    @SuppressWarnings({ "unused" })
    public static List<String> getAllPaths(String agent) {
        return new GetAllPathsAnalyzer(agent).getValues();
    }

    public static GetAllPathsAnalyzer getAllPathsAnalyzer(String agent) {
        return new GetAllPathsAnalyzer(agent);
    }

    // ===============================================================================================================

    public static Builder newBuilder() {
        return new Builder();
    }

    public static class Builder {
        private final UserAgentAnalyzer uaa;

        protected Builder() {
            this.uaa = new UserAgentAnalyzer(false);
        }

        protected Builder(UserAgentAnalyzer forceAnalyzer) {
            this.uaa = forceAnalyzer;
        }

        public Builder withCache(int cacheSize) {
            uaa.setCacheSize(cacheSize);
            return this;
        }

        public Builder withoutCache() {
            uaa.setCacheSize(0);
            return this;
        }

        public Builder withField(String fieldName) {
            if (uaa.wantedFieldNames == null) {
                uaa.wantedFieldNames = new HashSet<>(32);
            }
            uaa.wantedFieldNames.add(fieldName);
            return this;
        }

        public Builder withFields(Collection<String> fieldNames) {
            if (fieldNames == null) {
                return this;
            }
            for (String fieldName : fieldNames) {
                withField(fieldName);
            }
            return this;
        }

        public Builder withAllFields() {
            uaa.wantedFieldNames = null;
            return this;
        }

        boolean showMatcherLoadStats = true;

        public Builder showMatcherLoadStats() {
            showMatcherLoadStats = true;
            return this;
        }

        public Builder hideMatcherLoadStats() {
            showMatcherLoadStats = false;
            return this;
        }

        private void addGeneratedFields(String result, String... dependencies) {
            if (uaa.wantedFieldNames.contains(result)) {
                Collections.addAll(uaa.wantedFieldNames, dependencies);
            }
        }

        public UserAgentAnalyzer build() {
            if (uaa.wantedFieldNames != null) {
                addGeneratedFields("AgentNameVersion", AGENT_NAME, AGENT_VERSION);
                addGeneratedFields("AgentNameVersionMajor", AGENT_NAME, AGENT_VERSION_MAJOR);
                addGeneratedFields("WebviewAppNameVersionMajor", "WebviewAppName", "WebviewAppVersionMajor");
                addGeneratedFields("LayoutEngineNameVersion", LAYOUT_ENGINE_NAME, LAYOUT_ENGINE_VERSION);
                addGeneratedFields("LayoutEngineNameVersionMajor", LAYOUT_ENGINE_NAME, LAYOUT_ENGINE_VERSION_MAJOR);
                addGeneratedFields("OperatingSystemNameVersion", OPERATING_SYSTEM_NAME, OPERATING_SYSTEM_VERSION);

                addGeneratedFields(AGENT_VERSION_MAJOR, AGENT_VERSION);
                addGeneratedFields(LAYOUT_ENGINE_VERSION_MAJOR, LAYOUT_ENGINE_VERSION);
                addGeneratedFields("WebviewAppVersionMajor", "WebviewAppVersion");

                // Special field that affects ALL fields.
                uaa.wantedFieldNames.add(SET_ALL_FIELDS);
            }
            uaa.initialize(showMatcherLoadStats);
            return uaa;
        }

    }
}