diff --git a/flow-server/src/main/java/com/vaadin/flow/server/WebBrowser.java b/flow-server/src/main/java/com/vaadin/flow/server/WebBrowser.java index 9f7b1529ef2..6d655169c91 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/WebBrowser.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/WebBrowser.java @@ -19,6 +19,8 @@ import java.io.Serializable; import java.util.Locale; +import org.slf4j.LoggerFactory; + import com.vaadin.flow.shared.BrowserDetails; /** @@ -65,7 +67,13 @@ public class WebBrowser implements Serializable { if (agent != null) { browserApplication = agent; - browserDetails = new BrowserDetails(agent); + browserDetails = new BrowserDetails(agent) { + @Override + protected void log(String error, Exception e) { + LoggerFactory.getLogger(BrowserDetails.class).error(error, + e); + } + }; } } diff --git a/flow-server/src/main/java/com/vaadin/flow/shared/BrowserDetails.java b/flow-server/src/main/java/com/vaadin/flow/shared/BrowserDetails.java index 5fbcc9ad88b..1f20da5851b 100644 --- a/flow-server/src/main/java/com/vaadin/flow/shared/BrowserDetails.java +++ b/flow-server/src/main/java/com/vaadin/flow/shared/BrowserDetails.java @@ -82,16 +82,19 @@ public BrowserDetails(String userAgent) { isWebKit = !isTrident && userAgent.contains("applewebkit"); // browser name - isChrome = userAgent.contains(CHROME) || userAgent.contains(" crios/") - || userAgent.contains(HEADLESSCHROME); - isOpera = userAgent.contains("opera"); + isChrome = (userAgent.contains(CHROME) || userAgent.contains(" crios/") + || userAgent.contains(HEADLESSCHROME)) + && !userAgent.contains(" opr/"); + isOpera = userAgent.contains("opera") || userAgent.contains(" opr/"); isIE = userAgent.contains("msie") && !isOpera && !userAgent.contains("webtv"); // IE 11 no longer contains MSIE in the user agent isIE = isIE || isTrident; - isSafari = !isChrome && !isIE && userAgent.contains("safari"); - isFirefox = userAgent.contains(" firefox/"); + isSafari = !isChrome && !isIE && !isOpera + && userAgent.contains("safari"); + isFirefox = userAgent.contains(" firefox/") + || userAgent.contains("fxios/"); if (userAgent.contains(" edge/") || userAgent.contains(" edg/") || userAgent.contains(" edga/") || userAgent.contains(" edgios/")) { @@ -148,7 +151,7 @@ public BrowserDetails(String userAgent) { if (rvPos >= 0) { String tmp = userAgent.substring(rvPos + 3); tmp = tmp.replaceFirst("(\\.[0-9]+).+", "$1"); - parseVersionString(tmp); + parseVersionString(tmp, userAgent); } } else if (isTrident) { // potentially IE 11 in compatibility mode @@ -161,18 +164,30 @@ public BrowserDetails(String userAgent) { .substring(userAgent.indexOf("msie ") + 5); ieVersionString = safeSubstring(ieVersionString, 0, ieVersionString.indexOf(';')); - parseVersionString(ieVersionString); + parseVersionString(ieVersionString, userAgent); } } else if (isFirefox) { - int i = userAgent.indexOf(" firefox/") + 9; - parseVersionString(safeSubstring(userAgent, i, i + 5)); + int i = userAgent.indexOf(" fxios/"); + if (i != -1) { + // Version present in Opera 10 and newer + i = userAgent.indexOf(" fxios/") + 7; + } else { + i = userAgent.indexOf(" firefox/") + 9; + } + parseVersionString( + safeSubstring(userAgent, i, + i + getVersionStringLength(userAgent, i)), + userAgent); } else if (isChrome) { parseChromeVersion(userAgent); } else if (isSafari) { int i = userAgent.indexOf(" version/"); if (i >= 0) { i += 9; - parseVersionString(safeSubstring(userAgent, i, i + 5)); + parseVersionString( + safeSubstring(userAgent, i, + i + getVersionStringLength(userAgent, i)), + userAgent); } else { int engineVersion = (int) (browserEngineVersion * 10); if (engineVersion >= 6010 && engineVersion < 6015) { @@ -206,10 +221,15 @@ public BrowserDetails(String userAgent) { if (i != -1) { // Version present in Opera 10 and newer i += 9; // " version/".length + } else if (userAgent.contains(" opr/")) { + i = userAgent.indexOf(" opr/") + 5; } else { i = userAgent.indexOf("opera/") + 6; } - parseVersionString(safeSubstring(userAgent, i, i + 5)); + parseVersionString( + safeSubstring(userAgent, i, + i + getVersionStringLength(userAgent, i)), + userAgent); } else if (isEdge) { int i = userAgent.indexOf(" edge/") + 6; if (userAgent.contains(" edg/")) { @@ -220,7 +240,10 @@ public BrowserDetails(String userAgent) { i = userAgent.indexOf(" edgios/") + 8; } - parseVersionString(safeSubstring(userAgent, i, i + 8)); + parseVersionString( + safeSubstring(userAgent, i, + i + getVersionStringLength(userAgent, i)), + userAgent); } } catch (Exception e) { // Browser version parsing failed @@ -274,16 +297,16 @@ private void parseChromeOSVersion(String userAgent) { } String osVersionString = userAgent.substring(cur + 1, end); String[] parts = osVersionString.split("\\."); - parseChromeOsVersionParts(parts); + parseChromeOsVersionParts(parts, userAgent); } - private void parseChromeOsVersionParts(String[] parts) { + private void parseChromeOsVersionParts(String[] parts, String userAgent) { osMajorVersion = -1; osMinorVersion = -1; if (parts.length > 2) { - osMajorVersion = parseVersionPart(parts[0], OS_MAJOR); - osMinorVersion = parseVersionPart(parts[1], OS_MINOR); + osMajorVersion = parseVersionPart(parts[0], OS_MAJOR, userAgent); + osMinorVersion = parseVersionPart(parts[1], OS_MINOR, userAgent); } } @@ -298,11 +321,13 @@ private void parseChromeVersion(String userAgent) { i += CHROME.length(); } int versionBreak = getVersionStringLength(userAgent, i); - parseVersionString(safeSubstring(userAgent, i, i + versionBreak)); + parseVersionString(safeSubstring(userAgent, i, i + versionBreak), + userAgent); } else { i += crios.length(); // move index to version string start int versionBreak = getVersionStringLength(userAgent, i); - parseVersionString(safeSubstring(userAgent, i, i + versionBreak)); + parseVersionString(safeSubstring(userAgent, i, i + versionBreak), + userAgent); } } @@ -327,7 +352,7 @@ private static int getVersionStringLength(String userAgent, private void parseAndroidVersion(String userAgent) { // Android 5.1; - if (!userAgent.contains("android")) { + if (!userAgent.contains("android ")) { return; } @@ -337,7 +362,7 @@ private void parseAndroidVersion(String userAgent) { osVersionString = safeSubstring(osVersionString, 0, osVersionString.indexOf(";")); String[] parts = osVersionString.split("\\."); - parseOsVersion(parts); + parseOsVersion(parts, userAgent); } private void parseIOSVersion(String userAgent) { @@ -349,35 +374,43 @@ private void parseIOSVersion(String userAgent) { String osVersionString = safeSubstring(userAgent, userAgent.indexOf("os ") + 3, userAgent.indexOf(" like mac")); String[] parts = osVersionString.split("_"); - parseOsVersion(parts); + parseOsVersion(parts, userAgent); } - private void parseOsVersion(String[] parts) { + private void parseOsVersion(String[] parts, String userAgent) { osMajorVersion = -1; osMinorVersion = -1; if (parts.length >= 1) { - osMajorVersion = parseVersionPart(parts[0], OS_MAJOR); + osMajorVersion = parseVersionPart(parts[0], OS_MAJOR, userAgent); } if (parts.length >= 2) { // Some Androids report version numbers as "2.1-update1" int dashIndex = parts[1].indexOf('-'); if (dashIndex > -1) { String dashlessVersion = parts[1].substring(0, dashIndex); - osMinorVersion = parseVersionPart(dashlessVersion, OS_MINOR); + osMinorVersion = parseVersionPart(dashlessVersion, OS_MINOR, + userAgent); } else { - osMinorVersion = parseVersionPart(parts[1], OS_MINOR); + osMinorVersion = parseVersionPart(parts[1], OS_MINOR, + userAgent); } } } - private void parseVersionString(String versionString) { + private void parseVersionString(String versionString, String userAgent) { int idx = versionString.indexOf('.'); if (idx < 0) { idx = versionString.length(); } String majorVersionPart = safeSubstring(versionString, 0, idx); - browserMajorVersion = parseVersionPart(majorVersionPart, BROWSER_MAJOR); + browserMajorVersion = parseVersionPart(majorVersionPart, BROWSER_MAJOR, + userAgent); + + if (browserMajorVersion == -1) { + // no need to scan for minor if major version scanning failed. + return; + } int idx2 = versionString.indexOf('.', idx + 1); if (idx2 < 0) { @@ -390,7 +423,8 @@ private void parseVersionString(String versionString) { } String minorVersionPart = safeSubstring(versionString, idx + 1, idx2) .replaceAll("[^0-9].*", ""); - browserMinorVersion = parseVersionPart(minorVersionPart, BROWSER_MINOR); + browserMinorVersion = parseVersionPart(minorVersionPart, BROWSER_MINOR, + userAgent); } private static String safeSubstring(String string, int beginIndex, @@ -410,11 +444,13 @@ private static String safeSubstring(String string, int beginIndex, return string.substring(trimmedStart, trimmedEnd); } - private int parseVersionPart(String versionString, String partName) { + private int parseVersionPart(String versionString, String partName, + String userAgent) { try { return Integer.parseInt(versionString); } catch (Exception e) { - log(partName + " version parsing failed for: " + versionString, e); + log(partName + " version parsing failed for: " + versionString + + "\nWith userAgent: " + userAgent, e); } return -1; } @@ -598,6 +634,15 @@ public boolean isIPhone() { return isIPhone; } + /** + * Tests if the browser is run on iPad. + * + * @return true if run on iPad, false otherwise + */ + public boolean isIPad() { + return isIPad; + } + /** * Tests if the browser is run on Chrome OS (e.g. a Chromebook). * @@ -667,10 +712,10 @@ && getOperatingSystemMinorVersion() >= 7))) { return false; } - private static void log(String error, Exception e) { + protected void log(String error, Exception e) { // "Logs" to stdout so the problem can be found but does not prevent // using the app. As this class is shared, we do not use - // java.util.logging + // slf4j for logging as normal. System.err.println(error + ' ' + e.getMessage()); } diff --git a/flow-server/src/test/java/com/vaadin/flow/server/WebBrowserTest.java b/flow-server/src/test/java/com/vaadin/flow/server/WebBrowserTest.java index 73682141236..55664d2824b 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/WebBrowserTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/WebBrowserTest.java @@ -15,8 +15,12 @@ */ package com.vaadin.flow.server; +import java.util.Locale; + +import jdk.jfr.ValueDescriptor; import org.junit.Assert; import org.junit.Test; +import org.mockito.Mockito; public class WebBrowserTest { @@ -56,4 +60,53 @@ public void isIPhone_noDetails_returnsFalse() { public void isChromeOS_noDetails_returnsFalse() { Assert.assertFalse(browser.isChromeOS()); } + + @Test + public void isSafariOnMac_userDetails_returnsTrue() { + VaadinRequest request = initRequest( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_2) AppleWebKit/611.3.10.1.5 (KHTML, like Gecko) Version/14.1.2 Safari/611.3.10.1.5"); + + browser = new WebBrowser(request); + Assert.assertTrue(browser.isSafari()); + Assert.assertTrue(browser.isMacOSX()); + } + + @Test + public void isChromeOnWindows_userDetails_returnsTrue() { + VaadinRequest request = initRequest( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + + browser = new WebBrowser(request); + Assert.assertTrue(browser.isChrome()); + Assert.assertTrue(browser.isWindows()); + } + + @Test + public void isOperaOnWindows_userDetails_returnsTrue() { + VaadinRequest request = initRequest( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0"); + + browser = new WebBrowser(request); + Assert.assertTrue(browser.isOpera()); + Assert.assertTrue(browser.isWindows()); + } + + @Test + public void isFirefoxOnAndroid_userDetails_returnsTrue() { + VaadinRequest request = initRequest( + "Mozilla/5.0 (Android; Tablet; rv:33.0) Gecko/33.0 Firefox/33.0"); + + browser = new WebBrowser(request); + Assert.assertTrue(browser.isFirefox()); + Assert.assertTrue(browser.isAndroid()); + } + + private static VaadinRequest initRequest(String userAgent) { + VaadinRequest request = Mockito.mock(VaadinRequest.class); + Mockito.when(request.getLocale()).thenReturn(Locale.ENGLISH); + Mockito.when(request.getRemoteAddr()).thenReturn("0.0.0.0"); + Mockito.when(request.isSecure()).thenReturn(false); + Mockito.when(request.getHeader("User-Agent")).thenReturn(userAgent); + return request; + } } diff --git a/flow-server/src/test/java/com/vaadin/flow/shared/BrowserDetailsTest.java b/flow-server/src/test/java/com/vaadin/flow/shared/BrowserDetailsTest.java index aaeeb6ec393..0db4e9e6677 100644 --- a/flow-server/src/test/java/com/vaadin/flow/shared/BrowserDetailsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/shared/BrowserDetailsTest.java @@ -16,9 +16,17 @@ package com.vaadin.flow.shared; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import com.fasterxml.jackson.databind.ObjectMapper; import junit.framework.TestCase; +import org.apache.commons.io.IOUtils; import org.junit.Assert; +import com.vaadin.flow.server.frontend.TaskGenerateTsConfigTest; + public class BrowserDetailsTest extends TestCase { private static final String FIREFOX30_WINDOWS = "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-GB; rv:1.9.0.6) Gecko/2009011913 Firefox/3.0.6"; @@ -52,6 +60,7 @@ public class BrowserDetailsTest extends TestCase { private static final String OPERA964_WINDOWS = "Opera/9.64(Windows NT 5.1; U; en) Presto/2.1.1"; private static final String OPERA1010_WINDOWS = "Opera/9.80 (Windows NT 5.1; U; en) Presto/2.2.15 Version/10.10"; private static final String OPERA1050_WINDOWS = "Opera/9.80 (Windows NT 5.1; U; en) Presto/2.5.22 Version/10.50"; + private static final String OPERA115_WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0"; private static final String CHROME3_MAC = "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-US) AppleWebKit/532.0 (KHTML, like Gecko) Chrome/3.0.198 Safari/532.0"; private static final String CHROME4_WINDOWS = "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.5 (KHTML, like Gecko) Chrome/4.0.249.89 Safari/532.5"; @@ -690,7 +699,17 @@ public void testHeadlessChrome() { assertBrowserMinorVersion(bd, 0); assertEngineVersion(bd, 537.36f); assertLinux(bd); + } + public void testOpera65() { + String userAgent = OPERA115_WINDOWS; + BrowserDetails bd = new BrowserDetails(userAgent); + assertWebKit(bd); + assertOpera(bd); + assertBrowserMajorVersion(bd, 115); + assertBrowserMinorVersion(bd, 0); + assertEngineVersion(bd, 537.36f); + assertWindows(bd); } public void testIos11FacebookBrowser() { @@ -705,6 +724,88 @@ public void testIos11Firefox() { assertEngineVersion(bd, 604.3f); } + public void testCommonDesktopUserAgents() throws IOException { + UserAgent[] agents = getUserAgentDetails( + "common-desktop-useragents.json"); + + assertAgentDetails(agents); + } + + public void testMobileUserAgents() throws IOException { + UserAgent[] agents = getUserAgentDetails("mobile-useragents.json"); + + assertAgentDetails(agents); + } + + private static UserAgent[] getUserAgentDetails(String agentFile) + throws IOException { + String userAgents = IOUtils.toString( + Objects.requireNonNull(TaskGenerateTsConfigTest.class + .getClassLoader().getResourceAsStream(agentFile)), + StandardCharsets.UTF_8); + ObjectMapper mapper = new ObjectMapper(); + + UserAgent agents[] = mapper.readValue(userAgents, UserAgent[].class); + return agents; + } + + private void assertAgentDetails(UserAgent[] agents) { + for (UserAgent agent : agents) { + BrowserDetails bd = new BrowserDetails(agent.ua); + assertOs(bd, agent.os); + BrowserVersion versions = getMinorMajorVersion( + agent.browserVersion); + Assert.assertEquals( + "Major version differs on userAgent " + agent.ua, + versions.browserMajorVersion, bd.getBrowserMajorVersion()); + Assert.assertEquals( + "Minor version differs on userAgent " + agent.ua, + versions.browserMinorVersion, bd.getBrowserMinorVersion()); + } + } + + private BrowserVersion getMinorMajorVersion(String browserVersion) { + final String[] digits = browserVersion.split("[-.]", 4); + + int major = Integer.parseInt(digits[0]); + int minor = -1; + if (digits.length >= 2) { + minor = Integer.parseInt(digits[1]); + } + return new BrowserVersion(major, minor); + } + + private void assertOs(BrowserDetails bd, String os) { + switch (os) { + case "LINUX": + assertLinux(bd); + break; + case "WINDOWS": + assertWindows(bd); + break; + case "MACOSX": + assertMacOSX(bd); + break; + case "IPAD": + assertIPad(bd); + break; + case "IPHONE": + assertIPhone(bd); + break; + case "ANDROID": + assertAndroid(bd); + break; + } + } + + private record BrowserVersion(int browserMajorVersion, + int browserMinorVersion) { + } + + private record UserAgent(String ua, String browser, String browserVersion, + String os, String device) { + } + /* * Helper methods below */ @@ -827,13 +928,17 @@ private void assertMacOSX(BrowserDetails browserDetails) { assertFalse(browserDetails.isChromeOS()); } - private void assertAndroid(BrowserDetails browserDetails, int majorVersion, - int minorVersion) { + private void assertAndroid(BrowserDetails browserDetails) { assertFalse(browserDetails.isLinux()); assertFalse(browserDetails.isWindows()); assertFalse(browserDetails.isMacOSX()); assertTrue(browserDetails.isAndroid()); assertFalse(browserDetails.isChromeOS()); + } + + private void assertAndroid(BrowserDetails browserDetails, int majorVersion, + int minorVersion) { + assertAndroid(browserDetails); assertOSMajorVersion(browserDetails, majorVersion); assertOSMinorVersion(browserDetails, minorVersion); @@ -843,6 +948,10 @@ private void assertIPhone(BrowserDetails browserDetails) { assertTrue(browserDetails.isIPhone()); } + private void assertIPad(BrowserDetails browserDetails) { + assertTrue(browserDetails.isIPad()); + } + private void assertWindows(BrowserDetails browserDetails) { assertWindows(browserDetails, false); } diff --git a/flow-server/src/test/resources/common-desktop-useragents.json b/flow-server/src/test/resources/common-desktop-useragents.json new file mode 100644 index 00000000000..46426187959 --- /dev/null +++ b/flow-server/src/test/resources/common-desktop-useragents.json @@ -0,0 +1,62 @@ +[ + { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1", + "browser": "Safari", + "browserVersion": "17.6", + "os": "MACOSX" + }, + { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.3", + "browser": "Chrome", + "browserVersion": "113.0.0", + "os": "MACOSX" + }, + { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.3", + "browser": "Chrome", + "browserVersion": "130.0.0", + "os": "WINDOWS" + }, + { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.3", + "browser": "Chrome", + "browserVersion": "130.0.0", + "os": "MACOSX" + }, + { + "ua": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "browser": "Firefox", + "browserVersion": "115.0", + "os": "WINDOWS" + }, + { + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.3", + "browser": "Chrome", + "browserVersion": "130.0.0", + "os": "LINUX" + }, + { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "browser": "Firefox", + "browserVersion": "132.0", + "os": "WINDOWS" + }, + { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.1958", + "browser": "Edge", + "browserVersion": "18.1958", + "os": "WINDOWS" + }, + { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/117.0", + "browser": "Firefox", + "browserVersion": "117.0", + "os": "WINDOWS" + }, + { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.", + "browser": "Opera", + "browserVersion": "114.0.0", + "os": "WINDOWS" + } +] \ No newline at end of file diff --git a/flow-server/src/test/resources/mobile-useragents.json b/flow-server/src/test/resources/mobile-useragents.json new file mode 100644 index 00000000000..3b9ecf2ca45 --- /dev/null +++ b/flow-server/src/test/resources/mobile-useragents.json @@ -0,0 +1,73 @@ +[ + { + "ua": "Mozilla/5.0 (iPad; CPU OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.0 Mobile/15E148 Safari/605.1.15", + "browser": "Firefox", + "browserVersion": "132.0", + "os": "IPAD" + }, + { + "ua": "Mozilla/5.0 (iPad; CPU OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "browser": "Safari", + "browserVersion": "18.0", + "os": "IPAD" + }, + { + "ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.102 Mobile Safari/537.36", + "browser": "Chrome", + "browserVersion": "130.0.6723.102", + "os": "ANDROID", + "device": "K" + }, + { + "ua": "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0", + "browser": "Firefox", + "browserVersion": "132.0", + "os": "ANDROID", + "device": "Generic android" + }, + { + "ua": "Mozilla/5.0 (Linux; Android 10; VOG-L29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.102 Mobile Safari/537.36 OPR/76.2.4027.73374", + "browser": "Opera", + "browserVersion": "76.2.4027.73374", + "os": "ANDROID", + "device": "Huawei" + }, + { + "ua": "Mozilla/5.0 (Linux; Android 10; SM-G970F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.102 Mobile Safari/537.36 OPR/76.2.4027.73374", + "browser": "Opera", + "browserVersion": "76.2.4027.73374", + "os": "ANDROID", + "device": "Samsung" + }, + { + "ua": "Mozilla/5.0 (Linux; Android 10; SM-N975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.102 Mobile Safari/537.36 OPR/76.2.4027.73374", + "browser": "Opera", + "browserVersion": "76.2.4027.73374", + "os": "ANDROID", + "device": "Samsung" + }, + { + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.31 Mobile/15E148 Safari/604.1", + "browser": "Chrome", + "browserVersion": "131.0.6778", + "os": "IPHONE" + }, + { + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/130.2849.68 Mobile/15E148 Safari/605.1.15", + "browser": "Edge", + "browserVersion": "130.2849.68", + "os": "IPHONE" + }, + { + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/132.0 Mobile/15E148 Safari/605.1.15", + "browser": "Firefox", + "browserVersion": "132.0", + "os": "IPHONE" + }, + { + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "browser": "Safari", + "browserVersion": "18.0", + "os": "IPHONE" + } +] \ No newline at end of file