├── .travis.yml ├── src ├── main │ ├── resources │ │ ├── browscap-6001008.zip │ │ └── META-INF │ │ │ └── native-image │ │ │ └── com.blueconic │ │ │ └── browscap-java │ │ │ └── resource-config.json │ └── java │ │ └── com │ │ └── blueconic │ │ └── browscap │ │ ├── UserAgentParser.java │ │ ├── ParseException.java │ │ ├── Capabilities.java │ │ ├── BrowsCapField.java │ │ ├── impl │ │ ├── Mapper.java │ │ ├── CapabilitiesImpl.java │ │ ├── Rule.java │ │ ├── UserAgentParserImpl.java │ │ ├── UserAgentFileParser.java │ │ └── SearchableString.java │ │ └── UserAgentService.java └── test │ └── java │ └── com │ └── blueconic │ └── browscap │ └── impl │ ├── UserAgentFileParserTest.java │ ├── SearchableStringTest.java │ ├── UserAgentParserTest.java │ ├── RuleTest.java │ └── UserAgentServiceTest.java ├── .gitignore ├── .github └── workflows │ ├── maven.yml │ └── codeql-analysis.yml ├── LICENSE ├── data-preprocessor.groovy ├── README.md ├── CHANGELOG.txt └── pom.xml /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: java 3 | install: true 4 | script: mvn clean install -P default 5 | -------------------------------------------------------------------------------- /src/main/resources/browscap-6001008.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blueconic/browscap-java/HEAD/src/main/resources/browscap-6001008.zip -------------------------------------------------------------------------------- /src/main/resources/META-INF/native-image/com.blueconic/browscap-java/resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": { 3 | "includes": [ 4 | { 5 | "pattern": "browscap-\\d+.zip" 6 | } 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | 3 | # IntelliJ IDEA Java IDE 4 | .idea 5 | 6 | # Mobile Tools for Java (J2ME) 7 | .mtj.tmp/ 8 | 9 | # Package Files # 10 | *.jar 11 | *.war 12 | *.ear 13 | 14 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 15 | hs_err_pid* 16 | /target/ 17 | /.settings/ 18 | /.classpath 19 | /.project 20 | /bin/ -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/UserAgentParser.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap; 2 | 3 | public interface UserAgentParser { 4 | 5 | /** 6 | * Parses a User-Agent header value into a Capabilities object. 7 | * @param userAgent The user agent 8 | * @return The capabilities of the best matching rule 9 | */ 10 | Capabilities parse(String userAgent); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/ParseException.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap; 2 | 3 | /** 4 | * Exception which is thrown when a regular expression in the BrowsCap CSV cannot be parsed 5 | */ 6 | public class ParseException extends Exception { 7 | public ParseException(final String message) { 8 | super(message); 9 | } 10 | 11 | private static final long serialVersionUID = 1L; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v3 21 | with: 22 | java-version: '11' 23 | distribution: 'temurin' 24 | cache: maven 25 | - name: Build with Maven 26 | run: mvn -B clean install -P default --file pom.xml 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 BlueConic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/Capabilities.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap; 2 | 3 | import java.util.Map; 4 | 5 | public interface Capabilities { 6 | String UNKNOWN_BROWSCAP_VALUE = "Unknown"; 7 | 8 | /** 9 | * Returns the browser value (e.g. Chrome) 10 | * @return the browser 11 | */ 12 | String getBrowser(); 13 | 14 | /** 15 | * Returns the browser type (e.g. Browser or Application) 16 | * @return the browser type 17 | */ 18 | String getBrowserType(); 19 | 20 | /** 21 | * Returns the major version of the browser (e.g. 55 in case of Chrome) 22 | * @return the browser major version 23 | */ 24 | String getBrowserMajorVersion(); 25 | 26 | /** 27 | * Returns the platform name (e.g. Android, iOS, Win7, Win10) 28 | * @return the platform 29 | */ 30 | String getPlatform(); 31 | 32 | /** 33 | * Returns the platform version (e.g. 4.2, 10 depending on what the platform is) 34 | * @return the platform version 35 | */ 36 | String getPlatformVersion(); 37 | 38 | /** 39 | * Returns the device type (e.g. Mobile Phone, Desktop, Tablet, Console, TV Device) 40 | * @return the device type 41 | */ 42 | String getDeviceType(); 43 | 44 | /** 45 | * Returns the value for the specified field. 46 | * @param field The field for which the value should be returned. 47 | * @return the value for the specified field. 48 | */ 49 | String getValue(BrowsCapField field); 50 | 51 | /** 52 | * Returns the Map of values with the fields passed to the parser while loading 53 | * @return the map of values 54 | */ 55 | Map getValues(); 56 | } -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/BrowsCapField.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap; 2 | 3 | public enum BrowsCapField { 4 | 5 | IS_MASTER_PARENT, 6 | IS_LITE_MODE, 7 | PARENT, 8 | COMMENT, 9 | BROWSER(true), 10 | BROWSER_TYPE(true), 11 | BROWSER_BITS, 12 | BROWSER_MAKER, 13 | BROWSER_MODUS, 14 | BROWSER_VERSION, 15 | BROWSER_MAJOR_VERSION(true), 16 | BROWSER_MINOR_VERSION, 17 | PLATFORM(true), 18 | PLATFORM_VERSION(true), 19 | PLATFORM_DESCRIPTION, 20 | PLATFORM_BITS, 21 | PLATFORM_MAKER, 22 | IS_ALPHA, 23 | IS_BETA, 24 | IS_WIN16, 25 | IS_WIN32, 26 | IS_WIN64, 27 | IS_IFRAMES, 28 | IS_FRAMES, 29 | IS_TABLES, 30 | IS_COOKIES, 31 | IS_BACKGROUND_SOUNDS, 32 | IS_JAVASCRIPT, 33 | IS_VBSCRIPT, 34 | IS_JAVA_APPLETS, 35 | IS_ACTIVEX_CONTROLS, 36 | IS_MOBILE_DEVICE, 37 | IS_TABLET, 38 | IS_SYNDICATION_READER, 39 | IS_CRAWLER, 40 | IS_FAKE, 41 | IS_ANONYMIZED, 42 | IS_MODIFIED, 43 | CSS_VERSION, 44 | AOL_VERSION, 45 | DEVICE_NAME, 46 | DEVICE_MAKER, 47 | DEVICE_TYPE(true), 48 | DEVICE_POINTING_METHOD, 49 | DEVICE_CODE_NAME, 50 | DEVICE_BRAND_NAME, 51 | RENDERING_ENGINE_NAME, 52 | RENDERING_ENGINE_VERSION, 53 | RENDERING_ENGINE_DESCRIPTION, 54 | RENDERING_ENGINE_MAKER; 55 | 56 | private final boolean myIsDefault; 57 | 58 | BrowsCapField() { 59 | myIsDefault = false; 60 | } 61 | 62 | BrowsCapField(final boolean isDefault) { 63 | myIsDefault = isDefault; 64 | } 65 | 66 | public int getIndex() { 67 | return ordinal() + 1; 68 | } 69 | 70 | public boolean isDefault() { 71 | return myIsDefault; 72 | } 73 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '33 0 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'java' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/Mapper.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 4 | import static com.blueconic.browscap.BrowsCapField.BROWSER_MAJOR_VERSION; 5 | import static com.blueconic.browscap.BrowsCapField.BROWSER_TYPE; 6 | import static com.blueconic.browscap.BrowsCapField.DEVICE_TYPE; 7 | import static com.blueconic.browscap.BrowsCapField.PLATFORM; 8 | import static com.blueconic.browscap.BrowsCapField.PLATFORM_VERSION; 9 | import static com.blueconic.browscap.Capabilities.UNKNOWN_BROWSCAP_VALUE; 10 | 11 | import java.util.ArrayList; 12 | import java.util.Collection; 13 | import java.util.EnumMap; 14 | import java.util.HashSet; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Set; 18 | import java.util.Map.Entry; 19 | 20 | import com.blueconic.browscap.BrowsCapField; 21 | 22 | class Mapper { 23 | 24 | private final Map myIndices; 25 | 26 | Mapper(final Collection fields) { 27 | // Get all fields 28 | final Set all = new HashSet<>(fields); 29 | for (final BrowsCapField field : BrowsCapField.values()) { 30 | if (field.isDefault()) { 31 | all.add(field); 32 | } 33 | } 34 | 35 | // Get all unique values and keep a fixed order 36 | myIndices = new EnumMap<>(BrowsCapField.class); 37 | final List ordered = new ArrayList<>(all); 38 | for (int i = 0; i < ordered.size(); i++) { 39 | myIndices.put(ordered.get(i), i); 40 | } 41 | } 42 | 43 | String[] getValues(final Map values) { 44 | final String[] result = new String[myIndices.size()]; 45 | 46 | // default values first, for backwards compatibility 47 | put(result, BROWSER, "Default Browser"); 48 | put(result, BROWSER_TYPE, "Default Browser"); 49 | put(result, BROWSER_MAJOR_VERSION, UNKNOWN_BROWSCAP_VALUE); 50 | put(result, DEVICE_TYPE, UNKNOWN_BROWSCAP_VALUE); 51 | put(result, PLATFORM, UNKNOWN_BROWSCAP_VALUE); 52 | put(result, PLATFORM_VERSION, UNKNOWN_BROWSCAP_VALUE); 53 | 54 | for (final Entry entry : values.entrySet()) { 55 | put(result, entry.getKey(), entry.getValue()); 56 | } 57 | return result; 58 | } 59 | 60 | public Map getAll(final String[] values) { 61 | final Map result = new EnumMap<>(BrowsCapField.class); 62 | for (final BrowsCapField field : myIndices.keySet()) { 63 | result.put(field, getValue(values, field)); 64 | } 65 | return result; 66 | } 67 | 68 | String getValue(final String[] values, final BrowsCapField field) { 69 | final Integer index = myIndices.get(field); 70 | if (index != null) { 71 | return values[index]; 72 | } 73 | return null; 74 | } 75 | 76 | private void put(final String[] values, final BrowsCapField field, final String value) { 77 | final Integer index = myIndices.get(field); 78 | if (index != null) { 79 | values[index] = value; 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/CapabilitiesImpl.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 4 | import static com.blueconic.browscap.BrowsCapField.BROWSER_MAJOR_VERSION; 5 | import static com.blueconic.browscap.BrowsCapField.BROWSER_TYPE; 6 | import static com.blueconic.browscap.BrowsCapField.DEVICE_TYPE; 7 | import static com.blueconic.browscap.BrowsCapField.PLATFORM; 8 | import static com.blueconic.browscap.BrowsCapField.PLATFORM_VERSION; 9 | 10 | import java.util.Arrays; 11 | import java.util.Map; 12 | 13 | import com.blueconic.browscap.BrowsCapField; 14 | import com.blueconic.browscap.Capabilities; 15 | 16 | class CapabilitiesImpl implements Capabilities { 17 | 18 | private final String[] myValues; 19 | private final Mapper myMapper; 20 | 21 | CapabilitiesImpl(final String[] values, final Mapper mapper) { 22 | myValues = values; 23 | myMapper = mapper; 24 | } 25 | 26 | @Override 27 | public String getValue(final BrowsCapField field) { 28 | return myMapper.getValue(myValues, field); 29 | } 30 | 31 | /** 32 | * {@inheritDoc} 33 | */ 34 | @Override 35 | public String getBrowser() { 36 | return getValue(BROWSER); 37 | } 38 | 39 | /** 40 | * {@inheritDoc} 41 | */ 42 | @Override 43 | public String getBrowserType() { 44 | return getValue(BROWSER_TYPE); 45 | } 46 | 47 | /** 48 | * {@inheritDoc} 49 | */ 50 | @Override 51 | public String getBrowserMajorVersion() { 52 | return getValue(BROWSER_MAJOR_VERSION); 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | */ 58 | @Override 59 | public String getPlatform() { 60 | return getValue(PLATFORM); 61 | } 62 | 63 | /** 64 | * {@inheritDoc} 65 | */ 66 | @Override 67 | public String getPlatformVersion() { 68 | return getValue(PLATFORM_VERSION); 69 | } 70 | 71 | /** 72 | * {@inheritDoc} 73 | */ 74 | @Override 75 | public String getDeviceType() { 76 | return getValue(DEVICE_TYPE); 77 | } 78 | 79 | /** 80 | * {@inheritDoc} 81 | */ 82 | @Override 83 | public Map getValues() { 84 | return myMapper.getAll(myValues); 85 | } 86 | 87 | /** 88 | * {@inheritDoc} 89 | */ 90 | @Override 91 | public int hashCode() { 92 | return Arrays.hashCode(myValues); 93 | } 94 | 95 | /** 96 | * {@inheritDoc} 97 | */ 98 | @Override 99 | public boolean equals(final Object obj) { 100 | 101 | if (this == obj) { 102 | return true; 103 | } 104 | if (!(obj instanceof CapabilitiesImpl)) { 105 | return false; 106 | } 107 | 108 | final CapabilitiesImpl other = (CapabilitiesImpl) obj; 109 | if (myMapper != other.myMapper) { 110 | return false; 111 | } 112 | 113 | return Arrays.equals(myValues, other.myValues); 114 | } 115 | 116 | @Override 117 | public String toString() { 118 | return "CapabilitiesImpl [myValues=" + getValues() + "]"; 119 | } 120 | } -------------------------------------------------------------------------------- /src/test/java/com/blueconic/browscap/impl/UserAgentFileParserTest.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 4 | import static com.blueconic.browscap.impl.UserAgentFileParser.getParts; 5 | import static java.util.Arrays.asList; 6 | import static java.util.Collections.emptySet; 7 | import static java.util.Collections.singleton; 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | import com.blueconic.browscap.Capabilities; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class UserAgentFileParserTest { 14 | 15 | static final Capabilities DEFAULT = new UserAgentFileParser(singleton(BROWSER)).getDefaultCapabilities(); 16 | 17 | @Test 18 | void testGetParts() { 19 | assertEquals(asList("*", "a", "*"), getParts("*a*")); 20 | assertEquals(asList("*", "abc", "*"), getParts("*abc*")); 21 | assertEquals(asList("*"), getParts("*")); 22 | assertEquals(asList("a"), getParts("a")); 23 | } 24 | 25 | @Test 26 | void testLiteralException() { 27 | final UserAgentFileParser parser = new UserAgentFileParser(singleton(BROWSER)); 28 | assertThrows(IllegalStateException.class, () -> parser.createRule("", DEFAULT)); 29 | } 30 | 31 | @Test 32 | void testCreateRule() { 33 | final UserAgentFileParser parser = new UserAgentFileParser(singleton(BROWSER)); 34 | 35 | final Rule exact = parser.createRule("a", DEFAULT); 36 | validate(exact, "a", null, null); 37 | 38 | final Rule wildcard = parser.createRule("*", DEFAULT); 39 | validate(wildcard, null, new String[0], null); 40 | 41 | final Rule prefix = parser.createRule("abc*", DEFAULT); 42 | validate(prefix, "abc", new String[0], null); 43 | 44 | final Rule postfix = parser.createRule("*abc", DEFAULT); 45 | validate(postfix, null, new String[0], "abc"); 46 | 47 | final Rule prePost = parser.createRule("abc*def", DEFAULT); 48 | validate(prePost, "abc", new String[0], "def"); 49 | 50 | final Rule suffix = parser.createRule("*abc*", DEFAULT); 51 | validate(suffix, null, new String[]{"abc"}, null); 52 | 53 | final Rule expression = parser.createRule("*a*z*", DEFAULT); 54 | validate(expression, null, new String[]{"a", "z"}, null); 55 | } 56 | 57 | void validate(final Rule rule, final String prefix, final String[] subs, final String postfix) { 58 | validate(prefix, rule.getPrefix()); 59 | validate(postfix, rule.getPostfix()); 60 | 61 | if (subs == null) { 62 | assertNull(rule.getSuffixes()); 63 | } else { 64 | final Literal[] suffixes = rule.getSuffixes(); 65 | assertEquals(subs.length, suffixes.length); 66 | for (int i = 0; i < subs.length; i++) { 67 | validate(subs[i], suffixes[i]); 68 | } 69 | } 70 | } 71 | 72 | void validate(final String stringValue, final Literal literal) { 73 | if (stringValue == null) { 74 | assertNull(literal); 75 | } else { 76 | assertEquals(stringValue, literal.toString()); 77 | } 78 | } 79 | 80 | @Test 81 | void testGetValue() { 82 | 83 | final UserAgentFileParser parser = new UserAgentFileParser(emptySet()); 84 | 85 | // Test missing values 86 | assertEquals("Unknown", parser.getValue(null)); 87 | assertEquals("Unknown", parser.getValue("")); 88 | assertEquals("Unknown", parser.getValue(" ")); 89 | 90 | // Test trimming and interning 91 | final String input = "Test"; 92 | assertSame("Test", parser.getValue(input)); 93 | assertSame("Test", parser.getValue(input + " ")); 94 | assertSame("Test", parser.getValue(" " + input)); 95 | } 96 | } -------------------------------------------------------------------------------- /src/test/java/com/blueconic/browscap/impl/SearchableStringTest.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertFalse; 6 | import static org.junit.jupiter.api.Assertions.assertNull; 7 | import static org.junit.jupiter.api.Assertions.assertSame; 8 | import static org.junit.jupiter.api.Assertions.assertTrue; 9 | 10 | import com.blueconic.browscap.impl.SearchableString.Cache; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class SearchableStringTest { 14 | 15 | @Test 16 | void testSearchableString() { 17 | 18 | final LiteralDomain domain = new LiteralDomain(); 19 | 20 | final Literal abc = domain.createLiteral("abc"); 21 | final Literal ab = domain.createLiteral("ab"); 22 | 23 | final String stringValue = "abababc"; 24 | final SearchableString cache = domain.getSearchableString(stringValue); 25 | 26 | assertTrue(cache.startsWith(ab)); 27 | assertFalse(cache.startsWith(abc)); 28 | // Test caching path 29 | assertFalse(cache.startsWith(abc)); 30 | 31 | assertTrue(cache.endsWith(abc)); 32 | assertFalse(cache.endsWith(ab)); 33 | // Test caching path 34 | assertFalse(cache.endsWith(ab)); 35 | 36 | // Test simple methods 37 | assertEquals(stringValue, cache.toString()); 38 | assertEquals(stringValue.length(), cache.getSize()); 39 | } 40 | 41 | @Test 42 | void testGetIndices() { 43 | final LiteralDomain domain = new LiteralDomain(); 44 | 45 | final Literal abc = domain.createLiteral("abc"); 46 | final Literal ab = domain.createLiteral("ab"); 47 | final Literal anyChar = domain.createLiteral("?ab"); 48 | final Literal noMatch = domain.createLiteral("aaaaaaaaaaaaaaaaaa"); 49 | 50 | final SearchableString cache = domain.getSearchableString("abababc"); 51 | 52 | assertArrayEquals(new int[]{4}, cache.getIndices(abc)); 53 | assertArrayEquals(new int[]{0, 2, 4}, cache.getIndices(ab)); 54 | 55 | assertArrayEquals(new int[]{1, 3}, cache.getIndices(anyChar)); 56 | 57 | assertArrayEquals(new int[0], cache.getIndices(noMatch)); 58 | 59 | // Test caching 60 | final int[] indices = cache.getIndices(ab); 61 | assertSame(indices, cache.getIndices(ab)); 62 | } 63 | 64 | @Test 65 | void testCache() { 66 | final Cache cache = new Cache(); 67 | 68 | assertNull(cache.get(0)); 69 | cache.set(0, true); 70 | assertTrue(cache.get(0)); 71 | 72 | assertNull(cache.get(1)); 73 | cache.set(1, false); 74 | assertFalse(cache.get(1)); 75 | } 76 | 77 | @Test 78 | void testLiteralBasics() { 79 | 80 | final LiteralDomain domain = new LiteralDomain(); 81 | 82 | final String input = "abcdef"; 83 | final Literal literal = domain.createLiteral(input); 84 | assertEquals(input.length(), literal.getLength()); 85 | assertEquals('a', literal.getFirstChar()); 86 | assertEquals(input, literal.toString()); 87 | } 88 | 89 | @Test 90 | void testLiteralMatches() { 91 | 92 | final LiteralDomain domain = new LiteralDomain(); 93 | 94 | final String input = "def"; 95 | final Literal literal = domain.createLiteral(input); 96 | 97 | // Test for matches also with invalid bounds 98 | final char[] search = "abcdef".toCharArray(); 99 | assertTrue(literal.matches(search, 3)); 100 | assertFalse(literal.matches(search, 0)); 101 | assertFalse(literal.matches(search, 5)); 102 | assertFalse(literal.matches(search, -10)); 103 | assertFalse(literal.matches(search, 100)); 104 | 105 | final Literal joker = domain.createLiteral("d?f"); 106 | assertTrue(joker.matches(search, 3)); 107 | assertFalse(literal.matches(search, 0)); 108 | assertFalse(literal.matches(search, 5)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/test/java/com/blueconic/browscap/impl/UserAgentParserTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * $LastChangedBy$ 3 | * $LastChangedDate$ 4 | * $LastChangedRevision$ 5 | * $HeadURL$ 6 | * 7 | * Copyright 2014 BlueConic Inc./BlueConic B.V. All rights reserved. 8 | */ 9 | package com.blueconic.browscap.impl; 10 | 11 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 12 | import static com.blueconic.browscap.impl.UserAgentFileParserTest.DEFAULT; 13 | import static com.blueconic.browscap.impl.UserAgentParserImpl.getOrderedRules; 14 | import static java.util.Collections.singleton; 15 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.junit.jupiter.api.Assertions.assertFalse; 18 | import static org.junit.jupiter.api.Assertions.assertTrue; 19 | 20 | import java.util.Arrays; 21 | import java.util.BitSet; 22 | 23 | import com.blueconic.browscap.impl.UserAgentParserImpl.Filter; 24 | import org.junit.jupiter.api.BeforeEach; 25 | import org.junit.jupiter.api.Test; 26 | 27 | class UserAgentParserTest { 28 | 29 | private UserAgentFileParser myParser; 30 | 31 | @BeforeEach 32 | void setup() { 33 | myParser = new UserAgentFileParser(singleton(BROWSER)); 34 | } 35 | 36 | @Test 37 | void testExcludes() { 38 | 39 | final int length = 1017; 40 | final BitSet excludes = new BitSet(length); 41 | 42 | final BitSet excludeFilter1 = new BitSet(length); 43 | excludeFilter1.set(10); 44 | excludeFilter1.set(20); 45 | 46 | final BitSet excludeFilter2 = new BitSet(length); 47 | excludeFilter1.set(20); 48 | excludeFilter1.set(30); 49 | 50 | excludes.or(excludeFilter1); 51 | excludes.or(excludeFilter2); 52 | assertEquals(3, excludes.cardinality()); 53 | 54 | excludes.flip(0, length); 55 | assertEquals(length - 3, excludes.cardinality()); 56 | 57 | assertTrue(excludes.get(0)); 58 | assertTrue(excludes.get(length - 1)); 59 | assertFalse(excludes.get(10)); 60 | assertFalse(excludes.get(20)); 61 | assertFalse(excludes.get(30)); 62 | 63 | assertEquals(length - 1, excludes.nextSetBit(length - 1)); 64 | assertEquals(-1, excludes.nextSetBit(length)); 65 | } 66 | 67 | @Test 68 | void testGetIncludes() { 69 | final Rule a = getRule("test*123*abc*"); 70 | final Rule b = getRule("*test*abcd*"); 71 | final Rule c = getRule("*123*test"); 72 | final Rule d = getRule("*123*"); 73 | final Rule[] rules = {a, b, c, d}; 74 | 75 | final LiteralDomain domain = myParser.getDomain(); 76 | final UserAgentParserImpl parser = new UserAgentParserImpl(rules, domain, DEFAULT); 77 | 78 | final Filter startsWithTest = parser.createPrefixFilter("test"); 79 | final Filter containsTest = parser.createContainsFilter("test"); 80 | final Filter containsNumbers = parser.createContainsFilter("123"); 81 | final Filter[] filters = {startsWithTest, containsTest, containsNumbers}; 82 | 83 | final SearchableString useragent = domain.getSearchableString("useragent_test_string"); 84 | final BitSet includeRules = parser.getIncludeRules(useragent, Arrays.asList(filters)); 85 | 86 | // b should be checked 87 | assertEquals(1, includeRules.nextSetBit(0)); 88 | // No further rules to check 89 | assertEquals(-1, includeRules.nextSetBit(2)); 90 | 91 | final SearchableString numberString = domain.getSearchableString("123456"); 92 | final BitSet numberIncludes = parser.getIncludeRules(numberString, Arrays.asList(filters)); 93 | 94 | // Only d should be checked 95 | assertEquals(3, numberIncludes.nextSetBit(0)); 96 | // No further rules to check 97 | assertEquals(-1, numberIncludes.nextSetBit(4)); 98 | } 99 | 100 | @Test 101 | void testGetOrderedRules() { 102 | final Rule a = getRule("a"); 103 | final Rule b = getRule("b"); 104 | final Rule aa = getRule("aa"); 105 | final Rule bb = getRule("bb"); 106 | 107 | // Sort by size first, then lexicographical 108 | final Rule[] expected = {aa, bb, a, b}; 109 | 110 | final Rule[] rules = {bb, aa, b, a}; 111 | assertArrayEquals(expected, getOrderedRules(rules)); 112 | 113 | final Rule[] rulesAlt = {bb, a, b, aa}; 114 | assertArrayEquals(expected, getOrderedRules(rulesAlt)); 115 | } 116 | 117 | private Rule getRule(final String pattern) { 118 | final Rule rule = myParser.createRule(pattern, DEFAULT); 119 | assertEquals(pattern, rule.getPattern()); 120 | return rule; 121 | } 122 | } -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/UserAgentService.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap; 2 | 3 | import static java.nio.charset.StandardCharsets.UTF_8; 4 | import static java.util.stream.Collectors.toSet; 5 | 6 | import java.io.FileInputStream; 7 | import java.io.FileNotFoundException; 8 | import java.io.IOException; 9 | import java.io.InputStream; 10 | import java.io.InputStreamReader; 11 | import java.util.Collection; 12 | import java.util.Set; 13 | import java.util.stream.Stream; 14 | import java.util.zip.ZipEntry; 15 | import java.util.zip.ZipInputStream; 16 | 17 | import com.blueconic.browscap.impl.UserAgentFileParser; 18 | 19 | /** 20 | * Service that manages the creation of user agent parsers. In the feature, this might be expanded so it also supports 21 | */ 22 | public class UserAgentService { 23 | 24 | // The version of the browscap file this bundle depends on 25 | public static final int BUNDLED_BROWSCAP_VERSION = 6001008; 26 | private String myZipFilePath; 27 | private InputStream myZipFileStream; 28 | 29 | public UserAgentService() { 30 | // Default 31 | } 32 | 33 | /** 34 | * Creates a user agent service based on the Browscap CSV file in the given ZIP file 35 | * @param zipFilePath the zip file should contain the csv file. It will load the given zip file instead of the 36 | * bundled one 37 | */ 38 | public UserAgentService(final String zipFilePath) { 39 | myZipFilePath = zipFilePath; 40 | } 41 | 42 | /** 43 | * Creates a user agent service based on the Browscap CSV file in the given ZIP InputStream 44 | * @param zipFileStream the zip InputStream should contain the csv file. It will load the given zip InputStream 45 | * instead of the bundled zip file 46 | */ 47 | public UserAgentService(final InputStream zipFileStream) { 48 | myZipFileStream = zipFileStream; 49 | } 50 | 51 | /** 52 | * Returns a parser based on the bundled BrowsCap version 53 | * @return the user agent parser 54 | */ 55 | public UserAgentParser loadParser() throws IOException, ParseException { 56 | 57 | // Use all default fields 58 | final Set defaultFields = 59 | Stream.of(BrowsCapField.values()).filter(BrowsCapField::isDefault).collect(toSet()); 60 | 61 | return createParserWithFields(defaultFields); 62 | } 63 | 64 | /** 65 | * Returns a parser based on the bundled BrowsCap version 66 | * @param fields list 67 | * @return the user agent parser 68 | */ 69 | public UserAgentParser loadParser(final Collection fields) throws IOException, ParseException { 70 | return createParserWithFields(fields); 71 | } 72 | 73 | private UserAgentParser createParserWithFields(final Collection fields) 74 | throws IOException, ParseException { 75 | // http://browscap.org/version-number 76 | try (final InputStream zipStream = getCsvFileStream(); 77 | final ZipInputStream zipIn = new ZipInputStream(zipStream)) { 78 | // look for the first file that isn't a directory and a .csv 79 | // that should be a BrowsCap .csv file 80 | ZipEntry entry = null; 81 | do { 82 | entry = zipIn.getNextEntry(); 83 | } while (!(entry == null || entry.getName().endsWith(".csv"))); 84 | if (!(entry == null || entry.isDirectory())) { 85 | return UserAgentFileParser.parse(new InputStreamReader(zipIn, UTF_8), fields); 86 | } else { 87 | throw new IOException( 88 | "Unable to find the BrowsCap CSV file in the ZIP file"); 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * Returns the bundled ZIP file name 95 | * @return CSV file name 96 | */ 97 | public static String getBundledCsvFileName() { 98 | return "browscap-" + BUNDLED_BROWSCAP_VERSION + ".zip"; 99 | } 100 | 101 | /** 102 | * Returns the InputStream to the CSV file. This is either the bundled ZIP file or the one passed in the 103 | * constructor. 104 | * @return 105 | * @throws FileNotFoundException 106 | */ 107 | private InputStream getCsvFileStream() throws FileNotFoundException { 108 | if (myZipFileStream == null) { 109 | if (myZipFilePath == null) { 110 | final String csvFileName = getBundledCsvFileName(); 111 | return getClass().getClassLoader().getResourceAsStream(csvFileName); 112 | } else { 113 | return new FileInputStream(myZipFilePath); 114 | } 115 | } else { 116 | return myZipFileStream; 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /data-preprocessor.groovy: -------------------------------------------------------------------------------- 1 | log.info "Sorting input CSV for faster startup" 2 | 3 | import com.univocity.parsers.common.record.Record 4 | import com.univocity.parsers.csv.CsvParser 5 | import com.univocity.parsers.csv.CsvParserSettings 6 | import com.univocity.parsers.csv.CsvWriter 7 | import com.univocity.parsers.csv.CsvWriterSettings 8 | 9 | import java.nio.file.Files 10 | import java.security.DigestInputStream 11 | import java.security.DigestOutputStream 12 | import java.security.MessageDigest 13 | import java.util.zip.ZipEntry 14 | import java.util.zip.ZipFile 15 | import java.util.zip.ZipOutputStream 16 | 17 | import static java.nio.charset.StandardCharsets.UTF_8 18 | 19 | class Row { 20 | String pattern 21 | Record record 22 | 23 | Row(String pattern, Record record) { 24 | this.pattern = pattern 25 | this.record = record 26 | } 27 | } 28 | 29 | /* 30 | * Checking the resources for rules to sort 31 | */ 32 | def resources = new File("src/main/resources/"); 33 | if(resources.isDirectory()) { 34 | for(File resource in resources.listFiles()) { 35 | if(resource.getName().matches("browscap-\\d+\\.zip")) { 36 | sortRules(resource) 37 | } 38 | } 39 | } 40 | 41 | 42 | /** 43 | * Sorts the CSV files inside the given ZIP file based on specific rules and writes the sorted contents 44 | * back into the ZIP file. The sorting is performed in the following order: 45 | * 1. By the length of the "pattern" value in descending order. 46 | * 2. By the "pattern" value in ascending lexicographical order, as a secondary sorting criterion. 47 | * 48 | * @param rulesZip A {@link File} representing the ZIP file containing the input CSV files to sort. 49 | * @throws RuntimeException If an {@link IOException} occurs while processing the ZIP or CSV files. 50 | */ 51 | def sortRules(File rulesZip) { 52 | def settings = new CsvParserSettings(lineSeparatorDetectionEnabled: true) 53 | boolean hasChanges = false 54 | def sortedZip = File.createTempFile("browscap-", ".zip") 55 | try(def zip = new ZipFile(rulesZip)) { 56 | def entries = zip.entries() 57 | def entryMap = new HashMap>() 58 | def entryHashMap = new HashMap() 59 | while (entries.hasMoreElements()) { 60 | ZipEntry entry = entries.nextElement() 61 | if (entry.getName().endsWith(".csv") && !entry.isDirectory()) { 62 | log.info "\tSorting " + entry.getName() 63 | def rows = new ArrayList() 64 | MessageDigest md = MessageDigest.getInstance("MD5") 65 | try (DigestInputStream dis = new DigestInputStream(zip.getInputStream(entry), md) 66 | def reader = new InputStreamReader(dis, UTF_8) 67 | def br = new BufferedReader(reader)) { 68 | 69 | def csvParser = new CsvParser(settings) 70 | csvParser.beginParsing(br) 71 | Record record 72 | while ((record = csvParser.parseNextRecord()) != null) { 73 | rows.add(new Row(record.getString(0), record)) 74 | } 75 | } catch (IOException e) { 76 | throw new RuntimeException(e) 77 | } 78 | 79 | // Sorting rows by pattern length in descending order, then by the pattern value 80 | rows.sort { r1, r2 -> 81 | r2.pattern.length() <=> r1.pattern.length() ?: r1.pattern <=> r2.pattern 82 | } 83 | 84 | entryMap.put(entry.getName(), rows) 85 | entryHashMap.put(entry.getName(), md.digest()) 86 | } 87 | } 88 | def writerSettings = new CsvWriterSettings() 89 | MessageDigest md = MessageDigest.getInstance("MD5") 90 | 91 | try (def fos = new FileOutputStream(sortedZip) 92 | def zipOut = new ZipOutputStream(fos) 93 | def dig = new DigestOutputStream(zipOut, md) 94 | def writer = new OutputStreamWriter(dig, UTF_8)) { 95 | for (def csvEntry : entryMap.entrySet()) { 96 | def outCsv = new ZipEntry(csvEntry.getKey()) 97 | zipOut.putNextEntry(outCsv) 98 | 99 | def csvWriter = new CsvWriter(writer, writerSettings) 100 | csvEntry.getValue().each { row -> 101 | csvWriter.writeRow(row.record.values) 102 | } 103 | csvWriter.flush() 104 | if (!Arrays.equals(entryHashMap.get(csvEntry.getKey()), md.digest())) { 105 | hasChanges = true 106 | } 107 | } 108 | } 109 | } 110 | 111 | if(hasChanges) { 112 | if (rulesZip.isFile()) { 113 | Files.delete(rulesZip.toPath()) 114 | } 115 | Files.move(sortedZip.toPath(), rulesZip.toPath()) 116 | } else { 117 | sortedZip.delete() 118 | } 119 | } -------------------------------------------------------------------------------- /src/test/java/com/blueconic/browscap/impl/RuleTest.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 4 | import static com.blueconic.browscap.impl.UserAgentFileParserTest.DEFAULT; 5 | import static java.util.Collections.singleton; 6 | import static org.junit.jupiter.api.Assertions.assertEquals; 7 | import static org.junit.jupiter.api.Assertions.assertFalse; 8 | import static org.junit.jupiter.api.Assertions.assertTrue; 9 | 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class RuleTest { 14 | 15 | private UserAgentFileParser myParser; 16 | 17 | @BeforeEach 18 | void setup() { 19 | myParser = new UserAgentFileParser(singleton(BROWSER)); 20 | } 21 | 22 | @Test 23 | void testLiteralExpression() { 24 | final Rule rule = getRule("a"); 25 | 26 | assertTrue(matches(rule, "a")); 27 | 28 | assertFalse(matches(rule, "b")); 29 | assertFalse(matches(rule, "ab")); 30 | assertFalse(matches(rule, "ba")); 31 | } 32 | 33 | @Test 34 | void testLiteralQuestionMark() { 35 | final Rule literal = getRule("?a"); 36 | 37 | assertTrue(matches(literal, "aa")); 38 | assertTrue(matches(literal, "ba")); 39 | 40 | assertFalse(matches(literal, "ab")); 41 | assertFalse(matches(literal, "a")); 42 | assertFalse(matches(literal, "aaa")); 43 | 44 | final Rule literal2 = getRule("?a?"); 45 | 46 | assertTrue(matches(literal2, "bac")); 47 | assertFalse(matches(literal2, "a")); 48 | } 49 | 50 | @Test 51 | void testSuffix() { 52 | 53 | final Rule expression = getRule("*abc*"); 54 | 55 | assertTrue(matches(expression, "abc")); 56 | assertTrue(matches(expression, "1abc3")); 57 | assertTrue(matches(expression, "1abababc3")); 58 | 59 | assertFalse(matches(expression, "ab")); 60 | assertFalse(matches(expression, "1bc2")); 61 | assertFalse(matches(expression, "1ab2")); 62 | } 63 | 64 | @Test 65 | void testPrefix() { 66 | final Rule expression = getRule("abc*"); 67 | 68 | assertTrue(matches(expression, "abc")); 69 | assertTrue(matches(expression, "abcd")); 70 | 71 | assertFalse(matches(expression, "ab")); 72 | assertFalse(matches(expression, "1abc")); 73 | } 74 | 75 | @Test 76 | void testPostfix() { 77 | 78 | final Rule expression = getRule("*abc"); 79 | 80 | assertTrue(matches(expression, "abc")); 81 | assertTrue(matches(expression, "1abc")); 82 | 83 | assertFalse(matches(expression, "abcd")); 84 | assertFalse(matches(expression, "ab")); 85 | } 86 | 87 | @Test 88 | void testPreFixMultiple() { 89 | 90 | final Rule expression = getRule("a*z"); 91 | 92 | assertTrue(matches(expression, "az")); 93 | assertTrue(matches(expression, "alz")); 94 | 95 | assertFalse(matches(expression, "")); 96 | assertFalse(matches(expression, "ab")); 97 | 98 | final Rule multiple = getRule("a*b*z"); 99 | 100 | assertTrue(matches(multiple, "abz")); 101 | assertTrue(matches(multiple, "a_b_z")); 102 | assertTrue(matches(multiple, "ababz")); 103 | 104 | assertFalse(matches(multiple, "")); 105 | assertFalse(matches(multiple, "abz1")); 106 | 107 | // Test overlap 108 | assertFalse(matches(getRule("aa*aa"), "aaa")); 109 | assertFalse(matches(getRule("*aa*aa"), "aaa")); 110 | } 111 | 112 | @Test 113 | void testSuffixMultiple() { 114 | 115 | final Rule rule = getRule("*a*z*"); 116 | 117 | assertTrue(matches(rule, "az")); 118 | assertTrue(matches(rule, "alz")); 119 | assertTrue(matches(rule, "1alz3")); 120 | assertTrue(matches(rule, "AAAaAAAAzAAAA")); 121 | 122 | assertFalse(matches(rule, "")); 123 | assertFalse(matches(rule, "ab")); 124 | assertFalse(matches(rule, "za")); 125 | } 126 | 127 | @Test 128 | void testRequires() { 129 | final Rule rule = getRule("*abc*def*"); 130 | assertTrue(rule.requires("abc")); 131 | assertTrue(rule.requires("def")); 132 | assertTrue(rule.requires("bc")); 133 | assertFalse(rule.requires("abcdef")); 134 | 135 | final Rule prepost = getRule("abc*def"); 136 | assertTrue(prepost.requires("abc")); 137 | assertTrue(prepost.requires("def")); 138 | assertTrue(prepost.requires("bc")); 139 | } 140 | 141 | private Rule getRule(final String pattern) { 142 | final Rule rule = myParser.createRule(pattern, DEFAULT); 143 | assertEquals(pattern, rule.getPattern()); 144 | return rule; 145 | } 146 | 147 | private boolean matches(final Rule rule, final String useragent) { 148 | final SearchableString searchableString = myParser.getDomain().getSearchableString(useragent); 149 | return rule.matches(searchableString); 150 | } 151 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Java CI with Maven](https://github.com/blueconic/browscap-java/actions/workflows/maven.yml/badge.svg)](https://github.com/blueconic/browscap-java/actions/workflows/maven.yml) 2 | [![Project Map](https://sourcespy.com/shield.svg)](https://sourcespy.com/github/blueconicbrowscapjava/) 3 | 4 | # browscap-java 5 | A blazingly fast and memory efficient (thread-safe) Java client on top of the BrowsCap [CSV source files](https://github.com/browscap/browscap). 6 | The BrowsCap version currently shipped is: 6001008. 7 | 8 | ## Description 9 | This library can be used to parse useragent headers in order to extract information about the used browser, browser version, platform, platform version and device type. Very useful to determine if the client is a desktop, tablet or mobile device or to determine if the client is on Windows or Mac OS (just to name a few examples). 10 | 11 | ## Algorithm 12 | We got some questions on the how and why of our algorithm and why it is "blazingly fast and efficient". 13 | In short, this is how our algorithm works: 14 | 15 | 1. All CSV lines are read and parsed in our own data structures (e.g. "Rule" objects). 16 | -- This doesn't involve regular expressions (which are memory consuming and costly) but uses a smart way of doing substrings on the CSV expression. 17 | -- The results of the substring operations (startsWith, endsWith, findIndices in SearchableString) are cached, so subsequent calls are very fast. 18 | 2. When all rules are generated, they're sorted by size and alphabet, so the first match can be returned immediately. 19 | 3. When looking up a useragent, all rules are filtered based on the "parts" of an expression. Most rules can be easily discarded because they don't contain a specific substring. 20 | 4. The filtering mechanism is based on bitset operations, which are very fast for large data sets. 21 | 22 | ## Notes 23 | * Although this library is very fast, implementing a cache is advisable. Since cache strategies differ per usecase, this library doesn't ship with one out of the box. 24 | * All BrowsCap fields are available by configuration, but the following fields are loaded by default: 25 | * browser (e.g. Chrome) 26 | * browserType (e.g. Browser or Application) 27 | * browserMajorVersion (e.g. 80 in case of Chrome) 28 | * deviceType (e.g. Mobile Phone, Desktop, Tablet, Console, TV Device) 29 | * platform (e.g. Android, iOS, Win7, Win8, Win10) 30 | * platformVersion (e.g. 4.2, 10 depending on what the platform is) 31 | * The fields _are_ configurable by specifying a list of BrowsCapFields in the constructor of the UserAgentParser. 32 | * The CSV file is read in a streaming way, so it's processed line by line. This makes it more memory efficient than loading the whole into memory first. 33 | * 1000+ user agents are tested in the unit tests. 34 | * GraalVM Native Image is supported since 1.4.0 35 | 36 | ## Sorting the CSV file 37 | The BrowsCap source file is sorted when building via Maven, see `data-preprocessor.groovy`. 38 | When using your own source file, make sure to sort it as well to speed the startup performance. 39 | 40 | ## Future 41 | Possible new features we're thinking of (and are not yet present): 42 | * Auto-update the BrowsCap CSV 43 | 44 | ## Maven 45 | Add this to the dependencies in your pom.xml. 46 | 47 | ```xml 48 | 49 | com.blueconic 50 | browscap-java 51 | 1.5.1 52 | 53 | ``` 54 | 55 | ## Usage 56 | ```java 57 | import com.blueconic.browscap.BrowsCapField; 58 | import com.blueconic.browscap.Capabilities; 59 | import com.blueconic.browscap.ParseException; 60 | import com.blueconic.browscap.UserAgentParser; 61 | import com.blueconic.browscap.UserAgentService; 62 | 63 | // ... class definition 64 | 65 | // create a parser with the default fields 66 | final UserAgentParser parser = new UserAgentService().loadParser(); // handle IOException and ParseException 67 | 68 | // or create a parser with a custom defined field list 69 | // the list of available fields can be seen inthe BrowsCapField enum 70 | final UserAgentParser parser = 71 | new UserAgentService().loadParser(Arrays.asList(BrowsCapField.BROWSER, BrowsCapField.BROWSER_TYPE, 72 | BrowsCapField.BROWSER_MAJOR_VERSION, 73 | BrowsCapField.DEVICE_TYPE, BrowsCapField.PLATFORM, BrowsCapField.PLATFORM_VERSION, 74 | BrowsCapField.RENDERING_ENGINE_VERSION, BrowsCapField.RENDERING_ENGINE_NAME, 75 | BrowsCapField.PLATFORM_MAKER, BrowsCapField.RENDERING_ENGINE_MAKER)); 76 | 77 | // It's also possible to supply your own ZIP file by supplying a correct path to a ZIP file in the constructor. 78 | // This can be used when a new BrowsCap version is released which is not yet bundled in this package. 79 | // final UserAgentParser parser = new UserAgentService("E:\\anil\\browscap.zip").loadParser(); 80 | 81 | // parser can be re-used for multiple lookup calls 82 | final String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.81 Safari/537.36"; 83 | final Capabilities capabilities = parser.parse(userAgent); 84 | 85 | // the default fields have getters 86 | final String browser = capabilities.getBrowser(); 87 | final String browserType = capabilities.getBrowserType(); 88 | final String browserMajorVersion = capabilities.getBrowserMajorVersion(); 89 | final String deviceType = capabilities.getDeviceType(); 90 | final String platform = capabilities.getPlatform(); 91 | final String platformVersion = capabilities.getPlatformVersion(); 92 | 93 | // the custom defined fields are available 94 | final String renderingEngineMaker = capabilities.getValue(BrowsCapField.RENDERING_ENGINE_MAKER); 95 | 96 | // do something with the values 97 | 98 | ``` 99 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | 1.5.1 - 21 May 2025 2 | - Moved to Central Publishing, no code changes. 3 | 4 | 1.5.0 - 14 April 2025 5 | - Merged https://github.com/blueconic/browscap-java/pull/67, speeding up startup by using a sorted CSV. Thanks to @CaunCaran. 6 | 7 | 1.4.5 - 20 February 2025 8 | - Updated to browscap 6001008 (https://github.com/browscap/browscap/releases/tag/6.1.8) 9 | - Add recent and future chrome, edge and firefox based versions, up until 145, based on changes in PR 3132 10 | 11 | 1.4.4 - 17 June 2024 12 | - Updated to browscap 6001007 (https://github.com/browscap/browscap/releases/tag/6.1.7) 13 | - Add recent and future chrome, edge and firefox based versions, up until 135, based on changes in PR 2880 14 | 15 | 1.4.3 - 5 December 2023 16 | - Updated to browscap 6001006 (https://github.com/browscap/browscap/releases/tag/6.1.6) 17 | - Detection for GPTBot 18 | - Detection for ImagesiftBot 19 | - Added Seekport crawler 20 | - support to Opera until 115 21 | - support for Opera Mobile until 85 22 | - Android 14 detection 23 | - MacOS X until version 14 24 | - Improved BingBot detection 25 | - Skipped 1.4.2 due to Sonatype publishing issues 26 | 27 | 1.4.1 - 26 July 2023 28 | - Updated to browscap 6001005 (https://github.com/browscap/browscap/releases/tag/6.1.5) 29 | - Updated chrome versions up until 125 30 | 31 | 1.4.0 - 24 January 2023 32 | - Added support for GraalVM Native Image (https://github.com/blueconic/browscap-java/pull/51) 33 | 34 | 1.3.14 - 20 January 2023 35 | - Updated chrome versions 36 | - Updated Apple platform versions 37 | - Updated Android platform version 38 | - See https://github.com/browscap/browscap/pull/2771 39 | 40 | 1.3.13 - 4 October 2022 41 | - Updated to browscap 6001002 (https://github.com/browscap/browscap/releases/tag/6.1.2) 42 | -- iPadOS / iOS 16 43 | -- Safari 15.x and Safari 16.x 44 | -- Headless Chrome 45 | -- Samsung Browser 46 | -- Firefox for iOS 47 | 48 | 1.3.12 - 3 March 2022 49 | - Updated to browscap 6001000 (Safari 15.3, 15.4 and Chrome/FireFox versions updated until 107) 50 | 51 | 1.3.11 - 1 December 2021 52 | - Updated to browscap 6000051 53 | 54 | 1.3.10 - 25 November 2021 55 | - Updated to browscap 6000050 (Safari 15.2, Samsung browser). 56 | 57 | 1.3.9 - 18 November 2021 58 | - Updated to browscap 6000049 (Safari 15.1). 59 | 60 | 1.3.8 - 15 November 2021 61 | - Updated to browscap 6000048 (Apple platform version update mostly). 62 | 63 | 1.3.7 - 29 September 2021 64 | - Updated to browscap 6000046. 65 | 66 | 1.3.6 - 04 May 2021 67 | - Updated to browscap 6000045 (skipped 6000045 due to bug). 68 | - Updated unit tests accordingly (Future Chrome and FireFox versions). 69 | 70 | 1.3.5 - 22 April 2021 71 | - Fix for #36 72 | 73 | 1.3.4 - 22 April 2021 74 | - Updated to browscap 6000043. 75 | - Updated unit tests accordingly (new bots mostly). 76 | 77 | 1.3.3 - 7 December 2020 78 | - Updated to browscap 6000042. 79 | - Updated unit tests accordingly (mainly Safari 14). 80 | 81 | 1.3.2 - 4 November 2020 82 | - Updated to browscap 6000041. 83 | - Updated unit tests accordingly (Mostly Chrome and FireFox version updates for current and future versions). 84 | 85 | 1.3.1 - 23 July 2020 86 | - Updated to browscap 6000040. 87 | - Updated unit tests accordingly. 88 | 89 | 1.3.0 - 20 July 2020 90 | - Merged 31: updated CSV library 91 | 92 | 1.2.17 - 29 May 2020 93 | - Updated to browscap 6000039. 94 | - Updated unit tests accordingly. 95 | 96 | 1.2.16 - 10 April 2020 97 | - Merged PR #28, which allows using the default Browscap ZIP file which not only contains the CSV file. 98 | 99 | 1.2.15 - 18 March 2020 100 | - Updated to browscap 6000038. 101 | - Updated unit tests accordingly. 102 | 103 | 1.2.14 - 31 Januari 2020 104 | - Updated to browscap 6000037. 105 | - Updated unit tests accordingly. 106 | 107 | 1.2.13 - 18 October 2019 108 | - Updated to browscap 6000036. 109 | - Updated unit tests accordingly, also for 6000035. 110 | 111 | 1.2.12 - 6 October 2019 112 | - Updated to browscap 6000035, see https://github.com/blueconic/browscap-java/issues/26. 113 | - Unit tests follow later. 114 | 115 | 1.2.11 - 12 July 2019 116 | - #22 literal indexes increasing when reload 117 | 118 | 1.2.10 - 8 July 2019 119 | - Updated to browscap 6000034 120 | - Updated unit tests accordingly 121 | 122 | 1.2.9 - 15 May 2019 123 | - Updated to browscap 6000033 124 | - Updated unit tests accordingly 125 | 126 | 1.2.8 - 21 Mar 2019 127 | - Updated to browscap 6000032 128 | - Updated unit tests accordingly 129 | 130 | 1.2.7 - 29 Nov 2018 131 | - Updated to browscap 6000031 132 | - Updated unit tests accordingly 133 | 134 | 1.2.6 - 12 Nov 2018 135 | - Merged pull request https://github.com/blueconic/browscap-java/pull/16. 136 | Reduce dependencies by leveraging commons-csv. 137 | Startup performance is not affected. 138 | 139 | 1.2.5 - 21 Sept 2018 140 | - Merged pull request https://github.com/blueconic/browscap-java/pull/14. 141 | It is now possible to supply your own BrowsCap ZIP file as file input stream in the constructor. 142 | 143 | 1.2.4 - 15 August 2018 144 | - Updated to browscap 6000030 145 | - Updated unit tests accordingly 146 | 147 | 1.2.3 - 23 May 2018 148 | - Updated to browscap 6000029 149 | - Updated unit tests accordingly 150 | 151 | 1.2.2 - 3 March 2018 152 | - Updated to browscap 600028 153 | - Updated unit tests accordingly 154 | 155 | 1.2.1 - 2 Jan 2018 156 | Updated to BrowsCap 6027. 157 | Updated unit test accordingly. 158 | 159 | 1.2.0 - 29 Nov 2017 160 | Performance updates for memory footprint and startup time. 161 | 162 | 1.1.0 - 24 Nov 2017 163 | Integrated PR 2; the browscap fields are now configurable in the constructor of the parser. 164 | Updated documentation and unit tests. 165 | 166 | 1.0.4 - 5 Oct 2017 167 | Updated to BrowsCap 6026. 168 | Updated unit test accordingly. 169 | 170 | 1.0.3 - 31 Aug 2017 171 | Merged pull request https://github.com/blueconic/browscap-java/pull/1. 172 | It is now possible to supply your own BrowsCap ZIP file in the constructor. 173 | 174 | 1.0.2 - 31 Aug 2017 175 | Updated to BrowsCap 6024. 176 | Updated unit test accordingly. 177 | 178 | 1.0.1 - 5 May 2017 179 | Updated to BrowsCap 6023. 180 | Updated unit test accordingly. 181 | 182 | 1.0.0 - 3 May 2017 183 | Initial version with BrowsCap 6022. 184 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/Rule.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import com.blueconic.browscap.Capabilities; 4 | 5 | /** 6 | * Instances of this class represent a line of the browscap data. This class is responsible for checking a potential 7 | * match and for supplying the corresponding browser properties. 8 | */ 9 | class Rule { 10 | 11 | // The properties of the matching pattern 12 | private final Literal myPrefix; 13 | private final Literal[] mySuffixes; 14 | private final Literal myPostfix; 15 | 16 | // The size of the pattern 17 | private final int mySize; 18 | 19 | // The browser properties 20 | private final Capabilities myCapabilities; 21 | 22 | /** 23 | * Creates a new rule. 24 | * @param prefix The prefix of the matching pattern, potentially null 25 | * @param suffixes The required substrings separated by wildcards, potentially null to indicate no 26 | * wildcards 27 | * @param postfix The postfix of the matching pattern, potentially null 28 | * @param pattern The original string representation of the matching pattern 29 | * @param capabilities The browser properties for this rule 30 | */ 31 | Rule(final Literal prefix, final Literal[] suffixes, final Literal postfix, final String pattern, 32 | final Capabilities capabilities) { 33 | myPrefix = prefix; 34 | mySuffixes = suffixes; 35 | myPostfix = postfix; 36 | myCapabilities = capabilities; 37 | mySize = pattern.length(); 38 | } 39 | 40 | /** 41 | * Return the prefix. 42 | * @return the prefix, possibly null 43 | */ 44 | Literal getPrefix() { 45 | return myPrefix; 46 | } 47 | 48 | /** 49 | * The required substrings separated by wildcards, potentially null to indicate no wildcards 50 | * @return The required substrings. 51 | */ 52 | Literal[] getSuffixes() { 53 | return mySuffixes; 54 | } 55 | 56 | /** 57 | * Return the postfix. 58 | * @return the postfix, possibly null 59 | */ 60 | Literal getPostfix() { 61 | return myPostfix; 62 | } 63 | 64 | /** 65 | * Tests whether this rule needs a specific string in the useragent to match. 66 | * @return true if this rule can't match without the specific substring, false otherwise. 67 | */ 68 | boolean requires(final String value) { 69 | if (requires(myPrefix, value) || requires(myPostfix, value)) { 70 | return true; 71 | } 72 | 73 | if (mySuffixes == null) { 74 | return false; 75 | } 76 | for (final Literal suffix : mySuffixes) { 77 | if (requires(suffix, value)) { 78 | return true; 79 | } 80 | } 81 | return false; 82 | } 83 | 84 | private static boolean requires(final Literal literal, final String value) { 85 | return literal != null && literal.requires(value); 86 | } 87 | 88 | Capabilities getCapabilities() { 89 | return myCapabilities; 90 | } 91 | 92 | int getSize() { 93 | return mySize; 94 | } 95 | 96 | final boolean matches(final SearchableString value) { 97 | 98 | // Inclusive 99 | final int start; 100 | if (myPrefix == null) { 101 | start = 0; 102 | } else if (value.startsWith(myPrefix)) { 103 | start = myPrefix.getLength(); 104 | } else { 105 | return false; 106 | } 107 | 108 | // Inclusive 109 | final int end; 110 | if (myPostfix == null) { 111 | end = value.getSize() - 1; 112 | } else if (value.endsWith(myPostfix)) { 113 | end = value.getSize() - 1 - myPostfix.getLength(); 114 | } else { 115 | return false; 116 | } 117 | 118 | return checkWildCards(value, mySuffixes, start, end); 119 | } 120 | 121 | // Static for inline (2x) 122 | static boolean checkWildCards(final SearchableString value, final Literal[] suffixes, final int start, 123 | final int end) { 124 | 125 | if (suffixes == null) { 126 | // No wildcards 127 | return start == end + 1; 128 | } 129 | 130 | // One wildcard 131 | if (suffixes.length == 0) { 132 | return start <= end + 1; 133 | } 134 | 135 | int from = start; 136 | for (final Literal suffix : suffixes) { 137 | 138 | final int match = checkWildCard(value, suffix, from); 139 | if (match == -1) { 140 | return false; 141 | } 142 | 143 | from = suffix.getLength() + match; 144 | if (from > end + 1) { 145 | return false; 146 | } 147 | } 148 | 149 | return true; 150 | } 151 | 152 | // Return found index or -1 153 | private static int checkWildCard(final SearchableString value, final Literal suffix, final int start) { 154 | for (final int index : value.getIndices(suffix)) { 155 | if (index >= start) { 156 | return index; 157 | } 158 | } 159 | return -1; 160 | } 161 | 162 | /** 163 | * Returns the reconstructed original pattern. 164 | * @return the reconstructed original pattern 165 | */ 166 | String getPattern() { 167 | final StringBuilder result = new StringBuilder(); 168 | 169 | if (myPrefix != null) { 170 | result.append(myPrefix); 171 | } 172 | if (mySuffixes != null) { 173 | result.append("*"); 174 | for (final Literal sub : mySuffixes) { 175 | result.append(sub); 176 | result.append("*"); 177 | } 178 | } 179 | if (myPostfix != null) { 180 | result.append(myPostfix); 181 | } 182 | return result.toString(); 183 | } 184 | 185 | /** 186 | * {@inheritDoc} 187 | */ 188 | @Override 189 | public String toString() { 190 | return getPattern() + " : " + myCapabilities; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/UserAgentParserImpl.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static java.util.Arrays.parallelSort; 4 | import static java.util.Arrays.sort; 5 | import static java.util.stream.Collectors.toList; 6 | 7 | import java.util.ArrayList; 8 | import java.util.Arrays; 9 | import java.util.BitSet; 10 | import java.util.Comparator; 11 | import java.util.List; 12 | import java.util.function.Predicate; 13 | import java.util.stream.Stream; 14 | 15 | import com.blueconic.browscap.Capabilities; 16 | import com.blueconic.browscap.UserAgentParser; 17 | 18 | /** 19 | * This class is responsible for determining the best matching useragent rule to determine the properties for a 20 | * useragent string. 21 | */ 22 | class UserAgentParserImpl implements UserAgentParser { 23 | 24 | // Common substrings to filter irrelevant rules and speed up processing 25 | static final String[] COMMON = {"-", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "profile", "player", 26 | "compatible", "android", "google", "tab", "transformer", "lenovo", "micro", "edge", "safari", "opera", 27 | "chrome", "firefox", "msie", "chromium", "cpu os ", "cpu iphone os ", "windows nt ", "mac os x ", "linux", 28 | "bsd", "windows phone", "iphone", "pad", "blackberry", "nokia", "alcatel", "ucbrowser", "mobile", "ie", 29 | "mercury", "samsung", "browser", "wow64", "silk", "lunascape", "crios", "epiphany", "konqueror", "version", 30 | "rv:", "build", "bot", "like gecko", "applewebkit", "trident", "mozilla", "windows nt 4", "windows nt 5.0", 31 | "windows nt 5.1", "windows nt 5.2", "windows nt 6.0", "windows nt 6.1", "windows nt 6.2", "windows nt 6.3", 32 | "windows nt 10.0", "android?4.0", "android?4.1", "android?4.2", "android?4.3", "android?4.4", "android?2.3", 33 | "android?5"}; 34 | 35 | // Common prefixes to filter irrelevant rules and speed up processing 36 | static final String[] FILTER_PREFIXES = {"mozilla/5.0", "mozilla/4"}; 37 | 38 | // All useragent rule ordered by size and alphabetically 39 | private final Rule[] myRules; 40 | 41 | // Filters for filtering irrelevant rules and speed up processing 42 | private final List myFilters; 43 | 44 | // The default Capabilities 45 | private final Capabilities myDefaultCapabilities; 46 | 47 | // The domain of literals for this parser 48 | private final LiteralDomain myDomain; 49 | 50 | /** 51 | * Creates a new parser based on a collection of rules. 52 | * @param rules The rules, ordered by priority 53 | * @param domain The domain of literals 54 | * @param defaultCapabilities The default capabilities 55 | */ 56 | UserAgentParserImpl(final Rule[] rules, final LiteralDomain domain, final Capabilities defaultCapabilities) { 57 | myDomain = domain; 58 | myRules = getOrderedRules(rules); 59 | myFilters = buildFilters(); 60 | myDefaultCapabilities = defaultCapabilities; 61 | } 62 | 63 | /** 64 | * {@inheritDoc} 65 | */ 66 | @Override 67 | public Capabilities parse(final String userAgent) { 68 | if (userAgent == null || userAgent.isEmpty()) { 69 | return myDefaultCapabilities; 70 | } 71 | 72 | final SearchableString searchString = myDomain.getSearchableString(userAgent.toLowerCase()); 73 | 74 | final BitSet includes = getIncludeRules(searchString, myFilters); 75 | 76 | for (int i = includes.nextSetBit(0); i >= 0; i = includes.nextSetBit(i + 1)) { 77 | final Rule rule = myRules[i]; 78 | if (rule.matches(searchString)) { 79 | return rule.getCapabilities(); 80 | } 81 | } 82 | 83 | return myDefaultCapabilities; 84 | } 85 | 86 | BitSet getIncludeRules(final SearchableString searchString, final List filters) { 87 | final BitSet excludes = new BitSet(myRules.length); 88 | for (final Filter filter : filters) { 89 | filter.applyExcludes(searchString, excludes); 90 | } 91 | 92 | // Convert flip the excludes to determine the includes 93 | excludes.flip(0, myRules.length); 94 | return excludes; 95 | } 96 | 97 | // Sort by size and alphabet, so the first match can be returned immediately 98 | static Rule[] getOrderedRules(final Rule[] rules) { 99 | 100 | final Comparator c = Comparator.comparing(Rule::getSize).reversed().thenComparing(Rule::getPattern); 101 | 102 | final Rule[] result = Arrays.copyOf(rules, rules.length); 103 | sort(result, c); 104 | return result; 105 | } 106 | 107 | List buildFilters() { 108 | final List result = new ArrayList<>(); 109 | 110 | // Build filters for specific prefix constraints 111 | for (final String pattern : FILTER_PREFIXES) { 112 | result.add(createPrefixFilter(pattern)); 113 | } 114 | 115 | // Build filters for specific contains constraints 116 | Stream.of(COMMON).map(this::createContainsFilter).forEach(result::add); 117 | 118 | return result; 119 | } 120 | 121 | Filter createContainsFilter(final String pattern) { 122 | final Literal literal = myDomain.createLiteral(pattern); 123 | 124 | final Predicate pred = c -> c.getIndices(literal).length > 0; 125 | 126 | final Predicate matches = rule -> rule.requires(pattern); 127 | 128 | return new Filter(pred, matches); 129 | } 130 | 131 | Filter createPrefixFilter(final String pattern) { 132 | final Literal literal = myDomain.createLiteral(pattern); 133 | 134 | final Predicate pred = s -> s.startsWith(literal); 135 | 136 | final Predicate matches = rule -> { 137 | final Literal prefix = rule.getPrefix(); 138 | return prefix != null && prefix.toString().startsWith(pattern); 139 | }; 140 | 141 | return new Filter(pred, matches); 142 | } 143 | 144 | /** 145 | * Filter expression to can exclude a number of rules if a useragent doesn't meet it's predicate. 146 | */ 147 | class Filter { 148 | 149 | private final Predicate myUserAgentPredicate; 150 | private final BitSet myMask; 151 | 152 | /** 153 | * Creates a filter. 154 | * @param userAgentPredicate The predicate for matching user agents. 155 | * @param patternPredicate The corresponding predicate for matching rule 156 | */ 157 | Filter(final Predicate userAgentPredicate, final Predicate patternPredicate) { 158 | myUserAgentPredicate = userAgentPredicate; 159 | myMask = new BitSet(myRules.length); 160 | for (int i = 0; i < myRules.length; i++) { 161 | if (patternPredicate.test(myRules[i])) { 162 | myMask.set(i); 163 | } 164 | } 165 | } 166 | 167 | void applyExcludes(final SearchableString userAgent, final BitSet resultExcludes) { 168 | if (!myUserAgentPredicate.test(userAgent)) { 169 | resultExcludes.or(myMask); 170 | } 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/test/java/com/blueconic/browscap/impl/UserAgentServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.BrowsCapField.BROWSER; 4 | import static com.blueconic.browscap.BrowsCapField.BROWSER_MAJOR_VERSION; 5 | import static com.blueconic.browscap.BrowsCapField.BROWSER_TYPE; 6 | import static com.blueconic.browscap.BrowsCapField.DEVICE_TYPE; 7 | import static com.blueconic.browscap.BrowsCapField.PLATFORM; 8 | import static com.blueconic.browscap.BrowsCapField.PLATFORM_MAKER; 9 | import static com.blueconic.browscap.BrowsCapField.PLATFORM_VERSION; 10 | import static com.blueconic.browscap.BrowsCapField.RENDERING_ENGINE_MAKER; 11 | import static com.blueconic.browscap.BrowsCapField.RENDERING_ENGINE_NAME; 12 | import static com.blueconic.browscap.BrowsCapField.RENDERING_ENGINE_VERSION; 13 | import static java.util.Arrays.asList; 14 | import static org.junit.jupiter.api.Assertions.assertEquals; 15 | 16 | import java.io.BufferedReader; 17 | import java.io.FileInputStream; 18 | import java.io.IOException; 19 | import java.io.InputStream; 20 | import java.io.InputStreamReader; 21 | import java.nio.file.Path; 22 | import java.nio.file.Paths; 23 | import java.util.Collection; 24 | 25 | import org.junit.jupiter.api.Test; 26 | 27 | import com.blueconic.browscap.BrowsCapField; 28 | import com.blueconic.browscap.Capabilities; 29 | import com.blueconic.browscap.ParseException; 30 | import com.blueconic.browscap.UserAgentParser; 31 | import com.blueconic.browscap.UserAgentService; 32 | 33 | class UserAgentServiceTest { 34 | 35 | @Test 36 | void testUserAgentsFromExternalFile() throws IOException, ParseException { 37 | final int ITERATIONS = 10; 38 | 39 | final Path path = Paths.get("src", "main", "resources", UserAgentService.getBundledCsvFileName()); 40 | final UserAgentService uas = new UserAgentService(path.toString()); 41 | 42 | final UserAgentParser parser = uas.loadParser(); 43 | 44 | int counter = 0; 45 | for (int i = 0; i < ITERATIONS; i++) { 46 | counter += processUserAgentFile(parser); 47 | } 48 | System.out.print("Processed " + counter + " items\n"); 49 | } 50 | 51 | @Test 52 | void testUserAgentsFromExternalInputStream() throws IOException, ParseException { 53 | final int ITERATIONS = 10; 54 | 55 | final Path path = Paths.get("src", "main", "resources", UserAgentService.getBundledCsvFileName()); 56 | final InputStream fileStream = new FileInputStream(path.toString()); 57 | final UserAgentService uas = new UserAgentService(fileStream); 58 | 59 | final UserAgentParser parser = uas.loadParser(); 60 | 61 | int counter = 0; 62 | for (int i = 0; i < ITERATIONS; i++) { 63 | counter += processUserAgentFile(parser); 64 | } 65 | System.out.print("Processed " + counter + " items\n"); 66 | } 67 | 68 | @Test 69 | void testUserAgentsFromBundledFile() throws IOException, ParseException { 70 | final int ITERATIONS = 10; 71 | 72 | final UserAgentService uas = new UserAgentService(); 73 | final UserAgentParser parser = uas.loadParser(); 74 | 75 | int counter = 0; 76 | for (int i = 0; i < ITERATIONS; i++) { 77 | counter += processUserAgentFile(parser); 78 | } 79 | System.out.print("Processed " + counter + " items\n"); 80 | } 81 | 82 | private int processUserAgentFile(final UserAgentParser parser) throws IOException { 83 | final InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("useragents.txt"); 84 | 85 | final BufferedReader in = new BufferedReader(new InputStreamReader(resourceAsStream)); 86 | String line = null; 87 | int x = 0; 88 | 89 | while ((line = in.readLine()) != null) { 90 | if (!"".equals(line)) { 91 | final String[] properties = line.split(" "); 92 | if (properties.length < 5) { 93 | continue; 94 | } 95 | final Capabilities result = parser.parse(properties[5]); // check the values 96 | 97 | int y = 0; 98 | // System.out.println(result + "===" + properties[5]); 99 | 100 | assertEquals(properties[y++], result.getBrowser()); 101 | assertEquals(properties[y++], result.getBrowserMajorVersion()); 102 | assertEquals(properties[y++], result.getPlatform()); 103 | assertEquals(properties[y++], result.getPlatformVersion()); 104 | assertEquals(properties[y], result.getDeviceType()); 105 | 106 | x++; 107 | } 108 | } 109 | return x; 110 | } 111 | 112 | @Test 113 | void testUserAgentsFromBundledFileWithCustomFields() throws IOException, ParseException { 114 | 115 | final Collection fields = 116 | asList(BROWSER, BROWSER_TYPE, BROWSER_MAJOR_VERSION, DEVICE_TYPE, PLATFORM, 117 | PLATFORM_VERSION, RENDERING_ENGINE_VERSION, RENDERING_ENGINE_NAME, PLATFORM_MAKER, 118 | RENDERING_ENGINE_MAKER); 119 | 120 | final UserAgentService uas = new UserAgentService(); 121 | final UserAgentParser parser = uas.loadParser(fields); 122 | 123 | final int counter = processCustomUserAgentFile(parser); 124 | System.out.print("Processed " + counter + " items"); 125 | } 126 | 127 | private int processCustomUserAgentFile(final UserAgentParser parser) throws IOException { 128 | final InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("useragents_2.txt"); 129 | final BufferedReader in = new BufferedReader(new InputStreamReader(resourceAsStream)); 130 | String line = null; 131 | int x = 0; 132 | 133 | while ((line = in.readLine()) != null) { 134 | if (!"".equals(line)) { 135 | final String[] properties = line.split(" "); 136 | if (properties.length < 10) { 137 | continue; 138 | } 139 | final Capabilities result = parser.parse(properties[10]); // check the values 140 | 141 | int y = 0; 142 | // System.out.println(result + "===" + properties[10] + "\n"); 143 | // System.out.println("Custom Fields" + result.getValues() + "===" + properties[10] + "\n"); 144 | 145 | assertEquals(properties[y++], result.getBrowser()); 146 | assertEquals(properties[y++], result.getBrowserType()); 147 | assertEquals(properties[y++], result.getBrowserMajorVersion()); 148 | assertEquals(properties[y++], result.getPlatform()); 149 | assertEquals(properties[y++], result.getValues().get(PLATFORM_MAKER)); 150 | assertEquals(properties[y++], result.getPlatformVersion()); 151 | assertEquals(properties[y++], result.getDeviceType()); 152 | assertEquals(properties[y++], result.getValues().get(RENDERING_ENGINE_NAME)); 153 | assertEquals(properties[y++], result.getValues().get(RENDERING_ENGINE_MAKER)); 154 | assertEquals(properties[y], result.getValues().get(RENDERING_ENGINE_VERSION)); 155 | x++; 156 | } 157 | } 158 | return x; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | com.blueconic 5 | browscap-java 6 | jar 7 | 1.5.1 8 | browscap-java 9 | A blazingly fast and memory efficient Java client on top of the BrowsCap CSV source files. 10 | 11 | 12 | 13 | MIT 14 | https://opensource.org/licenses/MIT 15 | 16 | 17 | 18 | 19 | 20 | Paul Rütter 21 | paul@blueconic.com 22 | BlueConic 23 | https://www.blueconic.com 24 | 25 | 26 | 27 | 28 | scm:git:git@github.com:blueconic/browscap-java.git 29 | scm:git:git@github.com:blueconic/browscap-java.git 30 | https://github.com/blueconic/browscap-java 31 | 32 | 33 | 34 | 35 | ossrh 36 | https://s01.oss.sonatype.org/content/repositories/snapshots 37 | 38 | 39 | ossrh 40 | https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ 41 | 42 | 43 | 44 | http://www.blueconic.com 45 | 46 | UTF-8 47 | 1.8 48 | 1.8 49 | 50 | 51 | 52 | com.univocity 53 | univocity-parsers 54 | 2.9.1 55 | 56 | 57 | org.junit.jupiter 58 | junit-jupiter 59 | 5.9.2 60 | test 61 | 62 | 63 | 64 | 65 | 66 | maven-surefire-plugin 67 | 2.22.2 68 | 69 | 70 | 72 | org.codehaus.gmavenplus 73 | gmavenplus-plugin 74 | 4.1.1 75 | 76 | 77 | sort-rules 78 | initialize 79 | 80 | execute 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | org.apache.groovy 92 | groovy 93 | 4.0.15 94 | runtime 95 | 96 | 97 | com.univocity 98 | univocity-parsers 99 | 2.9.1 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | default 108 | 109 | true 110 | 111 | 112 | 113 | 114 | org.apache.maven.plugins 115 | maven-javadoc-plugin 116 | 2.10.4 117 | 118 | 119 | attach-javadocs 120 | 121 | jar 122 | 123 | 124 | 125 | 126 | -Xdoclint:none 127 | 128 | 129 | 130 | 131 | 132 | 133 | deploy 134 | 135 | false 136 | 137 | 138 | 139 | 140 | org.apache.maven.plugins 141 | maven-gpg-plugin 142 | 1.6 143 | 144 | 145 | sign-artifacts 146 | verify 147 | 148 | sign 149 | 150 | 151 | 152 | 153 | 154 | org.sonatype.central 155 | central-publishing-maven-plugin 156 | 0.7.0 157 | true 158 | 159 | central 160 | true 161 | 162 | 163 | 164 | org.apache.maven.plugins 165 | maven-source-plugin 166 | 3.0.1 167 | 168 | 169 | attach-sources 170 | 171 | jar 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/UserAgentFileParser.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import static com.blueconic.browscap.Capabilities.UNKNOWN_BROWSCAP_VALUE; 4 | import static java.util.Collections.singleton; 5 | 6 | import java.io.IOException; 7 | import java.io.Reader; 8 | import java.util.ArrayList; 9 | import java.util.Collection; 10 | import java.util.EnumMap; 11 | import java.util.HashMap; 12 | import java.util.HashSet; 13 | import java.util.LinkedList; 14 | import java.util.List; 15 | import java.util.Map; 16 | import java.util.Set; 17 | 18 | import com.univocity.parsers.common.record.Record; 19 | import com.univocity.parsers.csv.CsvParser; 20 | import com.univocity.parsers.csv.CsvParserSettings; 21 | 22 | import com.blueconic.browscap.BrowsCapField; 23 | import com.blueconic.browscap.Capabilities; 24 | import com.blueconic.browscap.ParseException; 25 | import com.blueconic.browscap.UserAgentParser; 26 | 27 | /** 28 | * This class is responsible for parsing rules and creating the efficient java representation. 29 | */ 30 | public class UserAgentFileParser { 31 | 32 | // Mapping substrings to unique literal for caching of lookups 33 | private final Map myUniqueLiterals = new HashMap<>(); 34 | 35 | private final Map myCache = new HashMap<>(); 36 | 37 | private final Map myStrings = new HashMap<>(); 38 | 39 | private final Mapper myMapper; 40 | 41 | private final Set myFields; 42 | 43 | private final LiteralDomain myDomain = new LiteralDomain(); 44 | 45 | UserAgentFileParser(final Collection fields) { 46 | myFields = new HashSet<>(fields); 47 | myMapper = new Mapper(myFields); 48 | } 49 | 50 | /** 51 | * Parses a csv stream of rules. 52 | * @param input The input stream 53 | * @param fields The fields that should be stored during parsing 54 | * @return a UserAgentParser based on the read rules 55 | * @throws IOException If reading the stream failed. 56 | * @throws ParseException 57 | */ 58 | public static UserAgentParser parse(final Reader input, final Collection fields) 59 | throws IOException, ParseException { 60 | return new UserAgentFileParser(fields).parse(input); 61 | } 62 | 63 | private UserAgentParser parse(final Reader input) throws ParseException { 64 | final List rules = new ArrayList<>(); 65 | 66 | final CsvParserSettings settings = new CsvParserSettings(); 67 | final CsvParser csvParser = new CsvParser(settings); 68 | csvParser.beginParsing(input); 69 | Record record; 70 | while ((record = csvParser.parseNextRecord()) != null) { 71 | final Rule rule = getRule(record); 72 | if (rule != null) { 73 | rules.add(rule); 74 | } 75 | } 76 | return new UserAgentParserImpl(rules.toArray(new Rule[0]), myDomain, getDefaultCapabilities()); 77 | } 78 | 79 | Capabilities getDefaultCapabilities() { 80 | final Map result = new EnumMap<>(BrowsCapField.class); 81 | for (final BrowsCapField field : myFields) { 82 | result.put(field, UNKNOWN_BROWSCAP_VALUE); 83 | } 84 | return getCapabilities(result); 85 | } 86 | 87 | private Rule getRule(final Record record) throws ParseException { 88 | if (record.getValues().length <= 47) { 89 | return null; 90 | } 91 | 92 | // Normalize: lowercase and remove duplicate wildcards 93 | final String pattern = normalizePattern(record.getString(0)); 94 | try { 95 | final Map values = getBrowsCapFields(record); 96 | final Capabilities capabilities = getCapabilities(values); 97 | final Rule rule = createRule(pattern, capabilities); 98 | 99 | // Check reconstructing the pattern 100 | if (!pattern.equals(rule.getPattern())) { 101 | throw new ParseException("Unable to parse " + pattern); 102 | } 103 | return rule; 104 | 105 | } catch (final IllegalStateException e) { 106 | throw new ParseException("Unable to parse " + pattern); 107 | } 108 | } 109 | 110 | private static String normalizePattern(final String pattern) { 111 | 112 | final String lowerCase = pattern.toLowerCase(); 113 | if (lowerCase.contains("**")) { 114 | return lowerCase.replaceAll("\\*+", "*"); 115 | } 116 | return lowerCase; 117 | } 118 | 119 | private Map getBrowsCapFields(final Record record) { 120 | final Map values = new EnumMap<>(BrowsCapField.class); 121 | for (final BrowsCapField field : myFields) { 122 | values.put(field, getValue(record.getString(field.getIndex()))); 123 | } 124 | return values; 125 | } 126 | 127 | String getValue(final String value) { 128 | if (value == null) { 129 | return UNKNOWN_BROWSCAP_VALUE; 130 | } 131 | 132 | final String trimmed = value.trim(); 133 | if (trimmed.isEmpty()) { 134 | return UNKNOWN_BROWSCAP_VALUE; 135 | } 136 | 137 | final String cached = myStrings.get(trimmed); 138 | if (cached != null) { 139 | return cached; 140 | } 141 | myStrings.put(trimmed, trimmed); 142 | return trimmed; 143 | } 144 | 145 | Capabilities getCapabilities(final Map values) { 146 | 147 | final CapabilitiesImpl result = new CapabilitiesImpl(myMapper.getValues(values), myMapper); 148 | final Capabilities fromCache = myCache.get(result); 149 | if (fromCache != null) { 150 | return fromCache; 151 | } 152 | 153 | myCache.put(result, result); 154 | return result; 155 | } 156 | 157 | Literal getLiteral(final String value) { 158 | return myUniqueLiterals.computeIfAbsent(value, myDomain::createLiteral); 159 | } 160 | 161 | LiteralDomain getDomain() { 162 | return myDomain; 163 | } 164 | 165 | Rule createRule(final String pattern, final Capabilities capabilities) { 166 | 167 | final List parts = getParts(pattern); 168 | if (parts.isEmpty()) { 169 | throw new IllegalStateException(); 170 | } 171 | 172 | final String first = parts.get(0); 173 | if (parts.size() == 1) { 174 | if ("*".equals(first)) { 175 | return getWildCardRule(); 176 | } 177 | return new Rule(getLiteral(first), null, null, pattern, capabilities); 178 | } 179 | 180 | final LinkedList suffixes = new LinkedList<>(parts); 181 | 182 | Literal prefix = null; 183 | if (!"*".equals(first)) { 184 | prefix = getLiteral(first); 185 | suffixes.remove(0); 186 | } 187 | 188 | final String last = parts.get(parts.size() - 1); 189 | Literal postfix = null; 190 | if (!"*".equals(last)) { 191 | postfix = getLiteral(last); 192 | suffixes.removeLast(); 193 | } 194 | suffixes.removeAll(singleton("*")); 195 | final Literal[] suffixArray = new Literal[suffixes.size()]; 196 | for (int i = 0; i < suffixArray.length; i++) { 197 | suffixArray[i] = getLiteral(suffixes.get(i)); 198 | } 199 | 200 | return new Rule(prefix, suffixArray, postfix, pattern, capabilities); 201 | } 202 | 203 | private Rule getWildCardRule() { 204 | // The default match all pattern 205 | final Map fieldValues = new EnumMap<>(BrowsCapField.class); 206 | for (final BrowsCapField field : myFields) { 207 | if (!field.isDefault()) { 208 | fieldValues.put(field, UNKNOWN_BROWSCAP_VALUE); 209 | } 210 | } 211 | final Capabilities capabilities = getCapabilities(fieldValues); 212 | return new Rule(null, new Literal[0], null, "*", capabilities); 213 | } 214 | 215 | static List getParts(final String pattern) { 216 | 217 | final List parts = new ArrayList<>(); 218 | 219 | final StringBuilder literal = new StringBuilder(); 220 | for (final char c : pattern.toCharArray()) { 221 | if (c == '*') { 222 | if (literal.length() != 0) { 223 | parts.add(literal.toString()); 224 | literal.setLength(0); 225 | } 226 | parts.add(String.valueOf(c)); 227 | 228 | } else { 229 | literal.append(c); 230 | } 231 | } 232 | if (literal.length() != 0) { 233 | parts.add(literal.toString()); 234 | literal.setLength(0); 235 | } 236 | 237 | return parts; 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/main/java/com/blueconic/browscap/impl/SearchableString.java: -------------------------------------------------------------------------------- 1 | package com.blueconic.browscap.impl; 2 | 3 | import java.util.BitSet; 4 | import java.util.HashSet; 5 | import java.util.Set; 6 | import java.util.concurrent.atomic.AtomicInteger; 7 | 8 | /** 9 | * This class represents a searchable useragent strings. It relies and simple char arrays for low memory use and fast 10 | * operations. It provided methods for finding substrings and provides caches for better performance. 11 | */ 12 | class SearchableString { 13 | 14 | private static final int[] EMPTY = new int[0]; 15 | private static final int[][] SINGLE_VALUES = getSingleValues(); 16 | 17 | private final char[] myChars; 18 | private final int[][] myIndices; 19 | private final Cache myPrefixCache = new Cache(); 20 | private final Cache myPostfixCache = new Cache(); 21 | 22 | // Reusable buffer for findIndices 23 | private final int[] myBuffer; 24 | 25 | /** 26 | * Creates a new instance for the specified string value. 27 | * @param stringValue The user agent string 28 | * @param maxIndex The number of unique literals 29 | */ 30 | SearchableString(final String stringValue, final int maxIndex) { 31 | myChars = stringValue.toCharArray(); 32 | myIndices = new int[maxIndex][]; 33 | myBuffer = new int[myChars.length]; 34 | } 35 | 36 | /** 37 | * Returns the size of this instance. 38 | * @return The size 39 | */ 40 | int getSize() { 41 | return myChars.length; 42 | } 43 | 44 | /** 45 | * Indicates whether this instance starts with the specified prefix. 46 | * @param literal The prefix that should be tested 47 | * @return true if the argument represents the prefix of this instance, false otherwise. 48 | */ 49 | boolean startsWith(final Literal literal) { 50 | 51 | // Check whether the answer is already in the cache 52 | final int index = literal.getIndex(); 53 | final Boolean cached = myPrefixCache.get(index); 54 | if (cached != null) { 55 | return cached.booleanValue(); 56 | } 57 | 58 | // Get the answer and cache the result 59 | final boolean result = literal.matches(myChars, 0); 60 | myPrefixCache.set(index, result); 61 | return result; 62 | } 63 | 64 | /** 65 | * Indicates whether this instance ends with the specified postfix. 66 | * @param literal The postfix that should be tested 67 | * @return true if the argument represents the postfix of this instance, false otherwise. 68 | */ 69 | boolean endsWith(final Literal literal) { 70 | 71 | // Check whether the answer is already in the cache 72 | final int index = literal.getIndex(); 73 | final Boolean cached = myPostfixCache.get(index); 74 | if (cached != null) { 75 | return cached.booleanValue(); 76 | } 77 | 78 | // Get the answer and cache the result 79 | final boolean result = literal.matches(myChars, myChars.length - literal.getLength()); 80 | myPostfixCache.set(index, result); 81 | return result; 82 | } 83 | 84 | /** 85 | * Returns all indices where the literal argument can be found in this String. Results are cached for better 86 | * performance. 87 | * @param literal The string that should be found 88 | * @return all indices where the literal argument can be found in this String. 89 | */ 90 | int[] getIndices(final Literal literal) { 91 | 92 | // Check whether the answer is already in the cache 93 | final int index = literal.getIndex(); 94 | final int[] cached = myIndices[index]; 95 | if (cached != null) { 96 | return cached; 97 | } 98 | 99 | // Find all indices 100 | final int[] values = findIndices(literal); 101 | myIndices[index] = values; 102 | return values; 103 | } 104 | 105 | /** 106 | * Returns all indices where the literal argument can be found in this String. 107 | * @param literal The string that should be found 108 | * @return all indices where the literal argument can be found in this String. 109 | */ 110 | private int[] findIndices(final Literal literal) { 111 | 112 | int count = 0; 113 | final char s = literal.getFirstChar(); 114 | for (int i = 0; i < myChars.length; i++) { 115 | 116 | // Check the first char for better performance and check the complete string 117 | if ((myChars[i] == s || s == '?') && literal.matches(myChars, i)) { 118 | 119 | // This index matches 120 | myBuffer[count] = i; 121 | count++; 122 | } 123 | } 124 | 125 | // Check whether any match has been found 126 | if (count == 0) { 127 | return EMPTY; 128 | } 129 | 130 | // Use an existing array 131 | if (count == 1 && myBuffer[0] < SINGLE_VALUES.length) { 132 | final int index = myBuffer[0]; 133 | return SINGLE_VALUES[index]; 134 | } 135 | 136 | // Copy the values 137 | final int[] values = new int[count]; 138 | for (int i = 0; i < count; i++) { 139 | values[i] = myBuffer[i]; 140 | } 141 | return values; 142 | } 143 | 144 | /** 145 | * {@inheritDoc} 146 | */ 147 | @Override 148 | public String toString() { 149 | return new String(myChars); 150 | } 151 | 152 | private static int[][] getSingleValues() { 153 | final int[][] result = new int[1024][]; 154 | for (int i = 0; i < result.length; i++) { 155 | result[i] = new int[]{i}; 156 | } 157 | return result; 158 | } 159 | 160 | /** Compact cache for boolean values. */ 161 | static class Cache { 162 | 163 | // The boolean values 164 | private final BitSet myValues = new BitSet(); 165 | 166 | // Indicates whether a value has been stored for an index 167 | private final BitSet myIsKnown = new BitSet(); 168 | 169 | /** 170 | * Gets the cached value for the specified index. 171 | * @param index The index of the requested value 172 | * @return The cached boolean value, or null if no value is present in the cache. 173 | */ 174 | Boolean get(final int index) { 175 | 176 | // Check whether a true value has been set 177 | if (myValues.get(index)) { 178 | return Boolean.TRUE; 179 | } 180 | 181 | // Check whether any value has been stored 182 | if (myIsKnown.get(index)) { 183 | return Boolean.FALSE; 184 | } 185 | 186 | // No value found 187 | return null; 188 | } 189 | 190 | /** 191 | * Set the value in the cache. 192 | * @param index The index for the stored value 193 | * @param flag The actual value 194 | */ 195 | void set(final int index, final boolean flag) { 196 | 197 | // Store the value 198 | myValues.set(index, flag); 199 | 200 | // Store the fact the value has been stored 201 | myIsKnown.set(index, true); 202 | } 203 | } 204 | } 205 | 206 | /** 207 | * This combines a String value with a unique int value. The int value is used for caching of results. 208 | */ 209 | class Literal { 210 | 211 | // The actual string data 212 | private final String myString; 213 | private final char[] myCharacters; 214 | 215 | // The unique index for this instance 216 | private final int myIndex; 217 | 218 | /** 219 | * Creates a new instance with the specified non-empty value. 220 | * @param value The String value 221 | * @param index The unique index for this instance 222 | */ 223 | Literal(final String value, final int index) { 224 | myString = value; 225 | myCharacters = value.toCharArray(); 226 | myIndex = index; 227 | } 228 | 229 | /** 230 | * Returns the first character for quick checks. 231 | * @return the first character 232 | */ 233 | char getFirstChar() { 234 | return myCharacters[0]; 235 | } 236 | 237 | /** 238 | * Returns the size of this instance. 239 | * @return The size of this instance 240 | */ 241 | int getLength() { 242 | return myCharacters.length; 243 | } 244 | 245 | /** 246 | * Checks whether the value represents a complete substring from the from index. 247 | * @param from The start index of the potential substring 248 | * @return true If the arguments represent a valid substring, false otherwise. 249 | */ 250 | boolean matches(final char[] value, final int from) { 251 | // Check the bounds 252 | final int len = myCharacters.length; 253 | if (len + from > value.length || from < 0) { 254 | return false; 255 | } 256 | 257 | // Bounds are ok, check all characters. 258 | // Allow question marks to match any character 259 | for (int i = 0; i < len; i++) { 260 | if (myCharacters[i] != value[i + from] && myCharacters[i] != '?') { 261 | return false; 262 | } 263 | } 264 | 265 | // All characters match 266 | return true; 267 | } 268 | 269 | /** 270 | * Returns the unique index of this instance. 271 | * @return the unique index of this instance. 272 | */ 273 | final int getIndex() { 274 | return myIndex; 275 | } 276 | 277 | private static boolean contains(final char[] characters, final char value) { 278 | for (int i = 0; i < characters.length; i++) { 279 | if (characters[i] == value) { 280 | return true; 281 | } 282 | } 283 | return false; 284 | } 285 | 286 | boolean requires(final String value) { 287 | final int len = value.length(); 288 | if (len == 1) { 289 | return contains(myCharacters, value.charAt(0)); 290 | } 291 | 292 | if (len > myCharacters.length) { 293 | return false; 294 | } 295 | 296 | return myString.contains(value); 297 | } 298 | 299 | /** 300 | * {@inheritDoc} 301 | */ 302 | @Override 303 | public String toString() { 304 | return myString; 305 | } 306 | } 307 | 308 | class LiteralDomain { 309 | 310 | // Keep track of the total number of instances 311 | private final AtomicInteger myNrOfInstances = new AtomicInteger(); 312 | 313 | Literal createLiteral(final String contents) { 314 | return new Literal(contents, myNrOfInstances.getAndAdd(1)); 315 | } 316 | 317 | SearchableString getSearchableString(final String contents) { 318 | final int maxIndex = myNrOfInstances.get() + 1; 319 | 320 | return new SearchableString(contents, maxIndex); 321 | } 322 | } 323 | --------------------------------------------------------------------------------