Skip to content

Commit 1e84280

Browse files
author
Vadim Belov
committed
Enhance Android device detection, add Samsung model mapping
Significantly improve device detection for Android user agents, especially Samsung, by introducing a comprehensive KnownDeviceCodes map for model-to-device info mapping. Expand UserAgentDeviceType with Samsung, Google, OnePlus, Xiaomi, and Apple device types. Refactor detection logic to use regex and heuristics for Samsung models. Update and expand tests for more accurate and granular device identification, including Samsung watches.
1 parent 83da185 commit 1e84280

4 files changed

Lines changed: 194 additions & 9 deletions

File tree

Sources/EasyExtensions.Tests/UserAgentHelpersTests.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@ public void GetDevice_Identifies_iOS(string ua, string expected)
8080
[TestCase("Mozilla/5.0 (Linux; Android 12; Pixel 6 Build/SP1A.210812.016; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/94.0.4606.61 Mobile Safari/537.36", "Android Phone (Pixel 6)")]
8181
[TestCase("Mozilla/5.0 (Linux; Android 10; SM-G973F Build/QP1A.190711.020) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36", "Android Phone (SM-G973F)")]
8282
[TestCase("Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36", "Android Phone")]
83-
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Android Phone (Samsung Galaxy S20)")]
84-
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Android Phone (Samsung Galaxy S23 Ultra)")]
83+
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Samsung Galaxy S20")]
84+
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Samsung Galaxy S23 Ultra")]
85+
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Samsung SM-A556B")]
86+
[TestCase("Mozilla/5.0 (Linux; Android 13; SM-R960) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36", "Samsung SM-R960")]
8587
[TestCase("Mozilla/5.0 (Linux; Android 11; SM-T970 Build/RP1A.200720.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.93 Safari/537.36", "Android Tablet (SM-T970)")]
8688
[TestCase("Mozilla/5.0 (Linux; Android 13; Tablet; rv:102.0) Gecko/102.0 Firefox/102.0", "Android Tablet")]
8789
public void GetDevice_Identifies_Android(string ua, string expected)
@@ -109,5 +111,13 @@ public void GetDevice_Fallbacks(string ua, string expected)
109111
{
110112
Assert.That(UserAgentHelpers.GetDevice(ua), Is.EqualTo(expected));
111113
}
114+
115+
[Test]
116+
public void GetDeviceInfo_Identifies_SamsungWatch_Enum()
117+
{
118+
const string ua = "Mozilla/5.0 (Linux; Android 13; SM-R960) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36";
119+
var info = UserAgentHelpers.GetDeviceInfo(ua);
120+
Assert.That(info.Type, Is.EqualTo(UserAgentDeviceType.SamsungWatch));
121+
}
112122
}
113123
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2025–2026 Vadim Belov <https://belov.us>
3+
4+
using System;
5+
using System.Collections.Generic;
6+
7+
namespace EasyExtensions.Helpers
8+
{
9+
internal static class KnownDeviceCodes
10+
{
11+
internal static readonly Dictionary<string, UserAgentDeviceInfo> Map = new Dictionary<string, UserAgentDeviceInfo>(StringComparer.OrdinalIgnoreCase)
12+
{
13+
// =========================
14+
// Samsung phones (flagships)
15+
// =========================
16+
["SM-G981B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-G981B", "Samsung Galaxy S20"),
17+
["SM-G991B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-G991B", "Samsung Galaxy S21"),
18+
["SM-G996B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-G996B", "Samsung Galaxy S21+"),
19+
["SM-G998B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-G998B", "Samsung Galaxy S21 Ultra"),
20+
21+
["SM-S901B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S901B", "Samsung Galaxy S22"),
22+
["SM-S906B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S906B", "Samsung Galaxy S22+"),
23+
["SM-S908B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S908B", "Samsung Galaxy S22 Ultra"),
24+
25+
["SM-S911B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S911B", "Samsung Galaxy S23"),
26+
["SM-S916B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S916B", "Samsung Galaxy S23+"),
27+
["SM-S918B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S918B", "Samsung Galaxy S23 Ultra"),
28+
29+
["SM-S921B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S921B", "Samsung Galaxy S24"),
30+
["SM-S926B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S926B", "Samsung Galaxy S24+"),
31+
["SM-S928B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S928B", "Samsung Galaxy S24 Ultra"),
32+
["SM-S928U1"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S928U1", "Samsung Galaxy S24 Ultra (US Unlocked)"),
33+
34+
// S25 family (????? ??????????? ? ?????/?????????; ???????????? ???????? ???? ??????)
35+
["SM-S931B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S931B", "Samsung Galaxy S25"),
36+
["SM-S936B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S936B", "Samsung Galaxy S25+"),
37+
["SM-S938B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-S938B", "Samsung Galaxy S25 Ultra"),
38+
39+
// =========================
40+
// Samsung foldables
41+
// =========================
42+
["SM-F936B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-F936B", "Samsung Galaxy Z Fold4"),
43+
["SM-F731B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-F731B", "Samsung Galaxy Z Flip5"),
44+
["SM-F946B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungPhone, "SM-F946B", "Samsung Galaxy Z Fold5"),
45+
46+
// =========================
47+
// Samsung tablets (Tab S-series)
48+
// =========================
49+
["SM-X710"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungTablet, "SM-X710", "Samsung Galaxy Tab S9 (Wi-Fi)"),
50+
["SM-X716B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungTablet, "SM-X716B", "Samsung Galaxy Tab S9 (5G)"),
51+
52+
["SM-X910"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungTablet, "SM-X910", "Samsung Galaxy Tab S9 Ultra (Wi-Fi)"),
53+
["SM-X916B"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungTablet, "SM-X916B", "Samsung Galaxy Tab S9 Ultra (5G)"),
54+
55+
["SM-X920"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungTablet, "SM-X920", "Samsung Galaxy Tab S10 Ultra"),
56+
57+
// =========================
58+
// Samsung watches
59+
// =========================
60+
["SM-R930"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungWatch, "SM-R930", "Samsung Galaxy Watch6 40mm"),
61+
["SM-R940"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungWatch, "SM-R940", "Samsung Galaxy Watch6 44mm"),
62+
["SM-R960"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungWatch, "SM-R960", "Samsung Galaxy Watch6 Classic 47mm"),
63+
64+
["SM-L310"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungWatch, "SM-L310", "Samsung Galaxy Watch7 (Bluetooth)"),
65+
["SM-L315"] = new UserAgentDeviceInfo(UserAgentDeviceType.SamsungWatch, "SM-L315", "Samsung Galaxy Watch7 (LTE)"),
66+
67+
// ============================================================
68+
// Google Pixel (????? ??????????? ??? device/sku codes ? UA/app)
69+
// ============================================================
70+
["G9BQD"] = new UserAgentDeviceInfo(UserAgentDeviceType.GooglePhone, "G9BQD", "Google Pixel 8"),
71+
["GKWS6"] = new UserAgentDeviceInfo(UserAgentDeviceType.GooglePhone, "GKWS6", "Google Pixel 8 (variant)"),
72+
73+
// =========================
74+
// OnePlus (????? CPH****)
75+
// =========================
76+
["CPH2581"] = new UserAgentDeviceInfo(UserAgentDeviceType.OnePlusPhone, "CPH2581", "OnePlus 12 (Global)"),
77+
["CPH2609"] = new UserAgentDeviceInfo(UserAgentDeviceType.OnePlusPhone, "CPH2609", "OnePlus 12R (Global)"),
78+
79+
// =========================
80+
// Xiaomi / Redmi / POCO (????? "M****" ??? "231***")
81+
// =========================
82+
["23127PN0CG"] = new UserAgentDeviceInfo(UserAgentDeviceType.XiaomiPhone, "23127PN0CG", "Xiaomi 14 (Global)"),
83+
["23090RA98G"] = new UserAgentDeviceInfo(UserAgentDeviceType.XiaomiPhone, "23090RA98G", "POCO X6 Pro (Global)"),
84+
85+
// ============================================================
86+
// Apple iPhone / iPad / Watch (?????? machine identifiers)
87+
// ?????: ? Safari UA ????? ?????? ???. ??? ??????? ??? SDK/?????.
88+
// ============================================================
89+
["iPhone15,2"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone15,2", "iPhone 14 Pro"),
90+
["iPhone15,3"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone15,3", "iPhone 14 Pro Max"),
91+
["iPhone16,1"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone16,1", "iPhone 15 Pro"),
92+
["iPhone16,2"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone16,2", "iPhone 15 Pro Max"),
93+
["iPhone17,1"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone17,1", "iPhone 16 Pro"),
94+
["iPhone17,2"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone17,2", "iPhone 16 Pro Max"),
95+
["iPhone17,3"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone17,3", "iPhone 16"),
96+
["iPhone17,4"] = new UserAgentDeviceInfo(UserAgentDeviceType.ApplePhone, "iPhone17,4", "iPhone 16 Plus"),
97+
};
98+
}
99+
}

Sources/EasyExtensions/Helpers/UserAgentDeviceType.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ public enum UserAgentDeviceType
6363
/// </summary>
6464
AndroidTablet,
6565

66+
/// <summary>
67+
/// A Samsung phone device.
68+
/// </summary>
69+
SamsungPhone,
70+
71+
/// <summary>
72+
/// A Samsung tablet device.
73+
/// </summary>
74+
SamsungTablet,
75+
76+
/// <summary>
77+
/// A Samsung watch device.
78+
/// </summary>
79+
SamsungWatch,
80+
81+
/// <summary>
82+
/// A Google phone device.
83+
/// </summary>
84+
GooglePhone,
85+
86+
/// <summary>
87+
/// A OnePlus phone device.
88+
/// </summary>
89+
OnePlusPhone,
90+
91+
/// <summary>
92+
/// A Xiaomi/Redmi/POCO phone device.
93+
/// </summary>
94+
XiaomiPhone,
95+
96+
/// <summary>
97+
/// An Apple device identified by machine identifier (for example, iPhone16,1).
98+
/// </summary>
99+
ApplePhone,
100+
66101
/// <summary>
67102
/// A ChromeOS device (Chromebook).
68103
/// </summary>

Sources/EasyExtensions/Helpers/UserAgentHelpers.cs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,7 @@ public static class UserAgentHelpers
3030
TryParseServerFallback,
3131
};
3232

33-
private static readonly Dictionary<string, string> _androidModelAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
34-
{
35-
["SM-G981B"] = "Samsung S20",
36-
["SM-S918B"] = "Samsung S23 Ultra",
37-
};
33+
private static readonly Regex _samsungModelRegex = new Regex(@"\bSM-[A-Z]\d{3}[A-Z0-9]?\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
3834

3935
/// <summary>
4036
/// Determines the type of device based on the provided user agent string.
@@ -143,9 +139,19 @@ private static bool ContainsAny(string haystack, params string[] needles)
143139

144140
var isMobile = ual.Contains("mobile");
145141
var model = TryExtractAndroidModel(ua);
146-
if (model != null && _androidModelAliases.TryGetValue(model, out var alias))
142+
143+
var knownCode = model != null ? TryGetFirstKnownDeviceCode(model) : null;
144+
if (knownCode != null)
145+
{
146+
return knownCode;
147+
}
148+
149+
// Samsung detection (SM-*) fallback
150+
var samsungCode = model != null ? GetFirstSamsungModelCode(model) : null;
151+
if (samsungCode != null)
147152
{
148-
model = alias;
153+
var samsungType = ResolveSamsungType(samsungCode);
154+
return new UserAgentDeviceInfo(samsungType, samsungCode, "Samsung " + samsungCode);
149155
}
150156

151157
if (isMobile)
@@ -160,6 +166,41 @@ private static bool ContainsAny(string haystack, params string[] needles)
160166
: new UserAgentDeviceInfo(UserAgentDeviceType.AndroidTablet, null, "Android Tablet");
161167
}
162168

169+
private static string? GetFirstSamsungModelCode(string value)
170+
{
171+
var m = _samsungModelRegex.Match(value);
172+
return m.Success ? m.Value : null;
173+
}
174+
175+
private static UserAgentDeviceInfo? TryGetFirstKnownDeviceCode(string value)
176+
{
177+
// Currently we support Samsung-style model codes, but this can be expanded.
178+
var samsungCode = GetFirstSamsungModelCode(value);
179+
if (samsungCode != null && KnownDeviceCodes.Map.TryGetValue(samsungCode, out var knownSamsung))
180+
{
181+
return knownSamsung;
182+
}
183+
return null;
184+
}
185+
186+
private static UserAgentDeviceType ResolveSamsungType(string samsungModelCode)
187+
{
188+
// Heuristics based on common Samsung model code families.
189+
// This is intentionally simple and can be extended with more rules.
190+
if (samsungModelCode.StartsWith("SM-T", StringComparison.OrdinalIgnoreCase) || samsungModelCode.StartsWith("SM-X", StringComparison.OrdinalIgnoreCase))
191+
{
192+
return UserAgentDeviceType.SamsungTablet;
193+
}
194+
195+
if (samsungModelCode.StartsWith("SM-R", StringComparison.OrdinalIgnoreCase) || samsungModelCode.StartsWith("SM-W", StringComparison.OrdinalIgnoreCase))
196+
{
197+
return UserAgentDeviceType.SamsungWatch;
198+
}
199+
200+
// Default to phone (most SM-* are phones).
201+
return UserAgentDeviceType.SamsungPhone;
202+
}
203+
163204
private static string? TryExtractAndroidModel(string ua)
164205
{
165206
// Pattern 1: ...; <model> Build/...

0 commit comments

Comments
 (0)