1<?php
2
3/**
4 * Device Detector - The Universal Device Detection library for parsing User Agents
5 *
6 * @link https://matomo.org
7 *
8 * @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
9 */
10
11declare(strict_types=1);
12
13namespace DeviceDetector;
14
15use DeviceDetector\Cache\CacheInterface;
16use DeviceDetector\Cache\StaticCache;
17use DeviceDetector\Parser\AbstractBotParser;
18use DeviceDetector\Parser\Bot;
19use DeviceDetector\Parser\Client\AbstractClientParser;
20use DeviceDetector\Parser\Client\Browser;
21use DeviceDetector\Parser\Client\FeedReader;
22use DeviceDetector\Parser\Client\Library;
23use DeviceDetector\Parser\Client\MediaPlayer;
24use DeviceDetector\Parser\Client\MobileApp;
25use DeviceDetector\Parser\Client\PIM;
26use DeviceDetector\Parser\Device\AbstractDeviceParser;
27use DeviceDetector\Parser\Device\Camera;
28use DeviceDetector\Parser\Device\CarBrowser;
29use DeviceDetector\Parser\Device\Console;
30use DeviceDetector\Parser\Device\HbbTv;
31use DeviceDetector\Parser\Device\Mobile;
32use DeviceDetector\Parser\Device\Notebook;
33use DeviceDetector\Parser\Device\PortableMediaPlayer;
34use DeviceDetector\Parser\Device\ShellTv;
35use DeviceDetector\Parser\OperatingSystem;
36use DeviceDetector\Parser\VendorFragment;
37use DeviceDetector\Yaml\ParserInterface as YamlParser;
38use DeviceDetector\Yaml\Spyc;
39
40/**
41 * Class DeviceDetector
42 *
43 * Magic Device Type Methods:
44 * @method bool isSmartphone()
45 * @method bool isFeaturePhone()
46 * @method bool isTablet()
47 * @method bool isPhablet()
48 * @method bool isConsole()
49 * @method bool isPortableMediaPlayer()
50 * @method bool isCarBrowser()
51 * @method bool isTV()
52 * @method bool isSmartDisplay()
53 * @method bool isSmartSpeaker()
54 * @method bool isCamera()
55 * @method bool isWearable()
56 * @method bool isPeripheral()
57 *
58 * Magic Client Type Methods:
59 * @method bool isBrowser()
60 * @method bool isFeedReader()
61 * @method bool isMobileApp()
62 * @method bool isPIM()
63 * @method bool isLibrary()
64 * @method bool isMediaPlayer()
65 */
66class DeviceDetector
67{
68    /**
69     * Current version number of DeviceDetector
70     */
71    public const VERSION = '6.4.6';
72
73    /**
74     * Constant used as value for unknown browser / os
75     */
76    public const UNKNOWN = 'UNK';
77
78    /**
79     * Holds all registered client types
80     * @var array
81     */
82    protected $clientTypes = [];
83
84    /**
85     * Holds the useragent that should be parsed
86     * @var string
87     */
88    protected $userAgent = '';
89
90    /**
91     * Holds the client hints that should be parsed
92     * @var ?ClientHints
93     */
94    protected $clientHints = null;
95
96    /**
97     * Holds the operating system data after parsing the UA
98     * @var ?array
99     */
100    protected $os = null;
101
102    /**
103     * Holds the client data after parsing the UA
104     * @var ?array
105     */
106    protected $client = null;
107
108    /**
109     * Holds the device type after parsing the UA
110     * @var ?int
111     */
112    protected $device = null;
113
114    /**
115     * Holds the device brand data after parsing the UA
116     * @var string
117     */
118    protected $brand = '';
119
120    /**
121     * Holds the device model data after parsing the UA
122     * @var string
123     */
124    protected $model = '';
125
126    /**
127     * Holds bot information if parsing the UA results in a bot
128     * (All other information attributes will stay empty in that case)
129     *
130     * If $discardBotInformation is set to true, this property will be set to
131     * true if parsed UA is identified as bot, additional information will be not available
132     *
133     * If $skipBotDetection is set to true, bot detection will not be performed and isBot will
134     * always be false
135     *
136     * @var array|bool|null
137     */
138    protected $bot = null;
139
140    /**
141     * @var bool
142     */
143    protected $discardBotInformation = false;
144
145    /**
146     * @var bool
147     */
148    protected $skipBotDetection = false;
149
150    /**
151     * Holds the cache class used for caching the parsed yml-Files
152     * @var CacheInterface|null
153     */
154    protected $cache = null;
155
156    /**
157     * Holds the parser class used for parsing yml-Files
158     * @var YamlParser|null
159     */
160    protected $yamlParser = null;
161
162    /**
163     * @var array<AbstractClientParser>
164     */
165    protected $clientParsers = [];
166
167    /**
168     * @var array<AbstractDeviceParser>
169     */
170    protected $deviceParsers = [];
171
172    /**
173     * @var array<AbstractBotParser>
174     */
175    public $botParsers = [];
176
177    /**
178     * @var bool
179     */
180    private $parsed = false;
181
182    /**
183     * Constructor
184     *
185     * @param string      $userAgent   UA to parse
186     * @param ClientHints $clientHints Browser client hints to parse
187     */
188    public function __construct(string $userAgent = '', ?ClientHints $clientHints = null)
189    {
190        if ('' !== $userAgent) {
191            $this->setUserAgent($userAgent);
192        }
193
194        if ($clientHints instanceof ClientHints) {
195            $this->setClientHints($clientHints);
196        }
197
198        $this->addClientParser(new FeedReader());
199        $this->addClientParser(new MobileApp());
200        $this->addClientParser(new MediaPlayer());
201        $this->addClientParser(new PIM());
202        $this->addClientParser(new Browser());
203        $this->addClientParser(new Library());
204
205        $this->addDeviceParser(new HbbTv());
206        $this->addDeviceParser(new ShellTv());
207        $this->addDeviceParser(new Notebook());
208        $this->addDeviceParser(new Console());
209        $this->addDeviceParser(new CarBrowser());
210        $this->addDeviceParser(new Camera());
211        $this->addDeviceParser(new PortableMediaPlayer());
212        $this->addDeviceParser(new Mobile());
213
214        $this->addBotParser(new Bot());
215    }
216
217    /**
218     * @param string $methodName
219     * @param array  $arguments
220     *
221     * @return bool
222     */
223    public function __call(string $methodName, array $arguments): bool
224    {
225        foreach (AbstractDeviceParser::getAvailableDeviceTypes() as $deviceName => $deviceType) {
226            if (\strtolower($methodName) === 'is' . \strtolower(\str_replace(' ', '', $deviceName))) {
227                return $this->getDevice() === $deviceType;
228            }
229        }
230
231        foreach ($this->clientTypes as $client) {
232            if (\strtolower($methodName) === 'is' . \strtolower(\str_replace(' ', '', $client))) {
233                return $this->getClient('type') === $client;
234            }
235        }
236
237        throw new \BadMethodCallException("Method {$methodName} not found");
238    }
239
240    /**
241     * Sets the useragent to be parsed
242     *
243     * @param string $userAgent
244     */
245    public function setUserAgent(string $userAgent): void
246    {
247        if ($this->userAgent !== $userAgent) {
248            $this->reset();
249        }
250
251        $this->userAgent = $userAgent;
252    }
253
254    /**
255     * Sets the browser client hints to be parsed
256     *
257     * @param ?ClientHints $clientHints
258     */
259    public function setClientHints(?ClientHints $clientHints = null): void
260    {
261        if ($this->clientHints !== $clientHints) {
262            $this->reset();
263        }
264
265        $this->clientHints = $clientHints;
266    }
267
268    /**
269     * @param AbstractClientParser $parser
270     *
271     * @throws \Exception
272     */
273    public function addClientParser(AbstractClientParser $parser): void
274    {
275        $this->clientParsers[] = $parser;
276        $this->clientTypes[]   = $parser->getName();
277    }
278
279    /**
280     * @return array<AbstractClientParser>
281     */
282    public function getClientParsers(): array
283    {
284        return $this->clientParsers;
285    }
286
287    /**
288     * @param AbstractDeviceParser $parser
289     *
290     * @throws \Exception
291     */
292    public function addDeviceParser(AbstractDeviceParser $parser): void
293    {
294        $this->deviceParsers[] = $parser;
295    }
296
297    /**
298     * @return array<AbstractDeviceParser>
299     */
300    public function getDeviceParsers(): array
301    {
302        return $this->deviceParsers;
303    }
304
305    /**
306     * @param AbstractBotParser $parser
307     */
308    public function addBotParser(AbstractBotParser $parser): void
309    {
310        $this->botParsers[] = $parser;
311    }
312
313    /**
314     * @return array<AbstractBotParser>
315     */
316    public function getBotParsers(): array
317    {
318        return $this->botParsers;
319    }
320
321    /**
322     * Sets whether to discard additional bot information
323     * If information is discarded it's only possible check whether UA was detected as bot or not.
324     * (Discarding information speeds up the detection a bit)
325     *
326     * @param bool $discard
327     */
328    public function discardBotInformation(bool $discard = true): void
329    {
330        $this->discardBotInformation = $discard;
331    }
332
333    /**
334     * Sets whether to skip bot detection.
335     * It is needed if we want bots to be processed as a simple clients. So we can detect if it is mobile client,
336     * or desktop, or enything else. By default all this information is not retrieved for the bots.
337     *
338     * @param bool $skip
339     */
340    public function skipBotDetection(bool $skip = true): void
341    {
342        $this->skipBotDetection = $skip;
343    }
344
345    /**
346     * Returns if the parsed UA was identified as a Bot
347     *
348     * @return bool
349     *
350     * @see bots.yml for a list of detected bots
351     *
352     */
353    public function isBot(): bool
354    {
355        return !empty($this->bot);
356    }
357
358    /**
359     * Returns if the parsed UA was identified as a touch enabled device
360     *
361     * Note: That only applies to windows 8 tablets
362     *
363     * @return bool
364     */
365    public function isTouchEnabled(): bool
366    {
367        $regex = 'Touch';
368
369        return !!$this->matchUserAgent($regex);
370    }
371
372    /**
373     * Returns if the parsed UA is detected as a mobile device
374     *
375     * @return bool
376     */
377    public function isMobile(): bool
378    {
379        // Client hints indicate a mobile device
380        if ($this->clientHints instanceof ClientHints && $this->clientHints->isMobile()) {
381            return true;
382        }
383
384        // Mobile device types
385        if (\in_array($this->device, [
386            AbstractDeviceParser::DEVICE_TYPE_FEATURE_PHONE,
387            AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE,
388            AbstractDeviceParser::DEVICE_TYPE_TABLET,
389            AbstractDeviceParser::DEVICE_TYPE_PHABLET,
390            AbstractDeviceParser::DEVICE_TYPE_CAMERA,
391            AbstractDeviceParser::DEVICE_TYPE_PORTABLE_MEDIA_PAYER,
392        ])
393        ) {
394            return true;
395        }
396
397        // non mobile device types
398        if (\in_array($this->device, [
399            AbstractDeviceParser::DEVICE_TYPE_TV,
400            AbstractDeviceParser::DEVICE_TYPE_SMART_DISPLAY,
401            AbstractDeviceParser::DEVICE_TYPE_CONSOLE,
402        ])
403        ) {
404            return false;
405        }
406
407        // Check for browsers available for mobile devices only
408        if ($this->usesMobileBrowser()) {
409            return true;
410        }
411
412        $osName = $this->getOs('name');
413
414        if (empty($osName) || self::UNKNOWN === $osName) {
415            return false;
416        }
417
418        return !$this->isBot() && !$this->isDesktop();
419    }
420
421    /**
422     * Returns if the parsed UA was identified as desktop device
423     * Desktop devices are all devices with an unknown type that are running a desktop os
424     *
425     * @return bool
426     *
427     * @see OperatingSystem::$desktopOsArray
428     *
429     */
430    public function isDesktop(): bool
431    {
432        $osName = $this->getOsAttribute('name');
433
434        if (empty($osName) || self::UNKNOWN === $osName) {
435            return false;
436        }
437
438        // Check for browsers available for mobile devices only
439        if ($this->usesMobileBrowser()) {
440            return false;
441        }
442
443        return OperatingSystem::isDesktopOs($osName);
444    }
445
446    /**
447     * Returns the operating system data extracted from the parsed UA
448     *
449     * If $attr is given only that property will be returned
450     *
451     * @param string $attr property to return(optional)
452     *
453     * @return array|string|null
454     */
455    public function getOs(string $attr = '')
456    {
457        if ('' === $attr) {
458            return $this->os;
459        }
460
461        return $this->getOsAttribute($attr);
462    }
463
464    /**
465     * Returns the client data extracted from the parsed UA
466     *
467     * If $attr is given only that property will be returned
468     *
469     * @param string $attr property to return(optional)
470     *
471     * @return array|string|null
472     */
473    public function getClient(string $attr = '')
474    {
475        if ('' === $attr) {
476            return $this->client;
477        }
478
479        return $this->getClientAttribute($attr);
480    }
481
482    /**
483     * Returns the device type extracted from the parsed UA
484     *
485     * @return int|null
486     *
487     * @see AbstractDeviceParser::$deviceTypes for available device types
488     *
489     */
490    public function getDevice(): ?int
491    {
492        return $this->device;
493    }
494
495    /**
496     * Returns the device type extracted from the parsed UA
497     *
498     * @return string
499     *
500     * @see AbstractDeviceParser::$deviceTypes for available device types
501     *
502     */
503    public function getDeviceName(): string
504    {
505        if (null !== $this->getDevice()) {
506            return AbstractDeviceParser::getDeviceName($this->getDevice());
507        }
508
509        return '';
510    }
511
512    /**
513     * Returns the device brand extracted from the parsed UA
514     *
515     * @return string
516     *
517     * @see self::$deviceBrand for available device brands
518     *
519     * @deprecated since 4.0 - short codes might be removed in next major release
520     */
521    public function getBrand(): string
522    {
523        return AbstractDeviceParser::getShortCode($this->brand);
524    }
525
526    /**
527     * Returns the full device brand name extracted from the parsed UA
528     *
529     * @return string
530     *
531     * @see self::$deviceBrand for available device brands
532     *
533     */
534    public function getBrandName(): string
535    {
536        return $this->brand;
537    }
538
539    /**
540     * Returns the device model extracted from the parsed UA
541     *
542     * @return string
543     */
544    public function getModel(): string
545    {
546        return $this->model;
547    }
548
549    /**
550     * Returns the user agent that is set to be parsed
551     *
552     * @return string
553     */
554    public function getUserAgent(): string
555    {
556        return $this->userAgent;
557    }
558
559    /**
560     * Returns the client hints that is set to be parsed
561     *
562     * @return ?ClientHints
563     */
564    public function getClientHints(): ?ClientHints
565    {
566        return $this->clientHints;
567    }
568
569    /**
570     * Returns the bot extracted from the parsed UA
571     *
572     * @return array|bool|null
573     */
574    public function getBot()
575    {
576        return $this->bot;
577    }
578
579    /**
580     * Returns true, if userAgent was already parsed with parse()
581     *
582     * @return bool
583     */
584    public function isParsed(): bool
585    {
586        return $this->parsed;
587    }
588
589    /**
590     * Triggers the parsing of the current user agent
591     */
592    public function parse(): void
593    {
594        if ($this->isParsed()) {
595            return;
596        }
597
598        $this->parsed = true;
599
600        // skip parsing for empty useragents or those not containing any letter (if no client hints were provided)
601        if ((empty($this->userAgent) || !\preg_match('/([a-z])/i', $this->userAgent))
602            && empty($this->clientHints)
603        ) {
604            return;
605        }
606
607        $this->parseBot();
608
609        if ($this->isBot()) {
610            return;
611        }
612
613        $this->parseOs();
614
615        /**
616         * Parse Clients
617         * Clients might be browsers, Feed Readers, Mobile Apps, Media Players or
618         * any other application accessing with an parseable UA
619         */
620        $this->parseClient();
621
622        $this->parseDevice();
623    }
624
625    /**
626     * Parses a useragent and returns the detected data
627     *
628     * ATTENTION: Use that method only for testing or very small applications
629     * To get fast results from DeviceDetector you need to make your own implementation,
630     * that should use one of the caching mechanisms. See README.md for more information.
631     *
632     * @param string       $ua          UserAgent to parse
633     * @param ?ClientHints $clientHints Client Hints to parse
634     *
635     * @return array
636     *
637     * @deprecated
638     *
639     * @internal
640     *
641     */
642    public static function getInfoFromUserAgent(string $ua, ?ClientHints $clientHints = null): array
643    {
644        static $deviceDetector;
645
646        if (!($deviceDetector instanceof DeviceDetector)) {
647            $deviceDetector = new DeviceDetector();
648        }
649
650        $deviceDetector->setUserAgent($ua);
651        $deviceDetector->setClientHints($clientHints);
652
653        $deviceDetector->parse();
654
655        if ($deviceDetector->isBot()) {
656            return [
657                'user_agent' => $deviceDetector->getUserAgent(),
658                'bot'        => $deviceDetector->getBot(),
659            ];
660        }
661
662        /** @var array $client */
663        $client        = $deviceDetector->getClient();
664        $browserFamily = 'Unknown';
665
666        if ($deviceDetector->isBrowser()
667            && true === \is_array($client)
668            && true === \array_key_exists('family', $client)
669            && null !== $client['family']
670        ) {
671            $browserFamily = $client['family'];
672        }
673
674        unset($client['short_name'], $client['family']);
675
676        /** @var array $os */
677        $os       = $deviceDetector->getOs();
678        $osFamily = $os['family'] ?? 'Unknown';
679
680        unset($os['short_name'], $os['family']);
681
682        return [
683            'user_agent'     => $deviceDetector->getUserAgent(),
684            'os'             => $os,
685            'client'         => $client,
686            'device'         => [
687                'type'  => $deviceDetector->getDeviceName(),
688                'brand' => $deviceDetector->getBrandName(),
689                'model' => $deviceDetector->getModel(),
690            ],
691            'os_family'      => $osFamily,
692            'browser_family' => $browserFamily,
693        ];
694    }
695
696    /**
697     * Sets the Cache class
698     *
699     * @param CacheInterface $cache
700     */
701    public function setCache(CacheInterface $cache): void
702    {
703        $this->cache = $cache;
704    }
705
706    /**
707     * Returns Cache object
708     *
709     * @return CacheInterface
710     */
711    public function getCache(): CacheInterface
712    {
713        if (!empty($this->cache)) {
714            return $this->cache;
715        }
716
717        return new StaticCache();
718    }
719
720    /**
721     * Sets the Yaml Parser class
722     *
723     * @param YamlParser $yamlParser
724     */
725    public function setYamlParser(YamlParser $yamlParser): void
726    {
727        $this->yamlParser = $yamlParser;
728    }
729
730    /**
731     * Returns Yaml Parser object
732     *
733     * @return YamlParser
734     */
735    public function getYamlParser(): YamlParser
736    {
737        if (!empty($this->yamlParser)) {
738            return $this->yamlParser;
739        }
740
741        return new Spyc();
742    }
743
744    /**
745     * @param string $attr
746     *
747     * @return string
748     */
749    protected function getClientAttribute(string $attr): string
750    {
751        if (!isset($this->client[$attr])) {
752            return self::UNKNOWN;
753        }
754
755        return $this->client[$attr];
756    }
757
758    /**
759     * @param string $attr
760     *
761     * @return string
762     */
763    protected function getOsAttribute(string $attr): string
764    {
765        if (!isset($this->os[$attr])) {
766            return self::UNKNOWN;
767        }
768
769        return $this->os[$attr];
770    }
771
772    /**
773     * Returns if the parsed UA contains the 'Android; Tablet;' fragment
774     *
775     * @return bool
776     */
777    protected function hasAndroidTableFragment(): bool
778    {
779        $regex = 'Android( [.0-9]+)?; Tablet;|Tablet(?! PC)|.*\-tablet$';
780
781        return !!$this->matchUserAgent($regex);
782    }
783
784    /**
785     * Returns if the parsed UA contains the 'Android; Mobile;' fragment
786     *
787     * @return bool
788     */
789    protected function hasAndroidMobileFragment(): bool
790    {
791        $regex = 'Android( [.0-9]+)?; Mobile;|.*\-mobile$';
792
793        return !!$this->matchUserAgent($regex);
794    }
795
796    /**
797     * Returns if the parsed UA contains the 'Android; Mobile VR;' fragment
798     *
799     * @return bool
800     */
801    protected function hasAndroidVRFragment(): bool
802    {
803        $regex = 'Android( [.0-9]+)?; Mobile VR;| VR ';
804
805        return !!$this->matchUserAgent($regex);
806    }
807
808    /**
809     * Returns if the parsed UA contains the 'Desktop;', 'Desktop x32;', 'Desktop x64;' or 'Desktop WOW64;' fragment
810     *
811     * @return bool
812     */
813    protected function hasDesktopFragment(): bool
814    {
815        $regex = 'Desktop(?: (x(?:32|64)|WOW64))?;';
816
817        return !!$this->matchUserAgent($regex);
818    }
819
820    /**
821     * Returns if the parsed UA contains usage of a mobile only browser
822     *
823     * @return bool
824     */
825    protected function usesMobileBrowser(): bool
826    {
827        return 'browser' === $this->getClient('type')
828            && Browser::isMobileOnlyBrowser($this->getClientAttribute('name'));
829    }
830
831    /**
832     * Parses the UA for bot information using the Bot parser
833     */
834    protected function parseBot(): void
835    {
836        if ($this->skipBotDetection) {
837            $this->bot = false;
838
839            return;
840        }
841
842        $parsers = $this->getBotParsers();
843
844        foreach ($parsers as $parser) {
845            $parser->setYamlParser($this->getYamlParser());
846            $parser->setCache($this->getCache());
847            $parser->setUserAgent($this->getUserAgent());
848            $parser->setClientHints($this->getClientHints());
849
850            if ($this->discardBotInformation) {
851                $parser->discardDetails();
852            }
853
854            $bot = $parser->parse();
855
856            if (!empty($bot)) {
857                $this->bot = $bot;
858
859                break;
860            }
861        }
862    }
863
864    /**
865     * Tries to detect the client (e.g. browser, mobile app, ...)
866     */
867    protected function parseClient(): void
868    {
869        $parsers = $this->getClientParsers();
870
871        foreach ($parsers as $parser) {
872            $parser->setYamlParser($this->getYamlParser());
873            $parser->setCache($this->getCache());
874            $parser->setUserAgent($this->getUserAgent());
875            $parser->setClientHints($this->getClientHints());
876            $client = $parser->parse();
877
878            if (!empty($client)) {
879                $this->client = $client;
880
881                break;
882            }
883        }
884    }
885
886    /**
887     * Tries to detect the device type, model and brand
888     */
889    protected function parseDevice(): void
890    {
891        $parsers = $this->getDeviceParsers();
892
893        foreach ($parsers as $parser) {
894            $parser->setYamlParser($this->getYamlParser());
895            $parser->setCache($this->getCache());
896            $parser->setUserAgent($this->getUserAgent());
897            $parser->setClientHints($this->getClientHints());
898
899            if ($parser->parse()) {
900                $this->device = $parser->getDeviceType();
901                $this->model  = $parser->getModel();
902                $this->brand  = $parser->getBrand();
903
904                break;
905            }
906        }
907
908        /**
909         * If no model could be parsed from useragent, we use the one from client hints if available
910         */
911        if ($this->clientHints instanceof ClientHints && empty($this->model)) {
912            $this->model = $this->clientHints->getModel();
913        }
914
915        /**
916         * If no brand has been assigned try to match by known vendor fragments
917         */
918        if (empty($this->brand)) {
919            $vendorParser = new VendorFragment($this->getUserAgent());
920            $vendorParser->setYamlParser($this->getYamlParser());
921            $vendorParser->setCache($this->getCache());
922            $this->brand = $vendorParser->parse()['brand'] ?? '';
923        }
924
925        $osName       = $this->getOsAttribute('name');
926        $osFamily     = $this->getOsAttribute('family');
927        $osVersion    = $this->getOsAttribute('version');
928        $clientName   = $this->getClientAttribute('name');
929        $appleOsNames = ['iPadOS', 'tvOS', 'watchOS', 'iOS', 'Mac'];
930
931        /**
932         * if it's fake UA then it's best not to identify it as Apple running Android OS or GNU/Linux
933         */
934        if ('Apple' === $this->brand && !\in_array($osName, $appleOsNames)) {
935            $this->device = null;
936            $this->brand  = '';
937            $this->model  = '';
938        }
939
940        /**
941         * Assume all devices running iOS / Mac OS are from Apple
942         */
943        if (empty($this->brand) && \in_array($osName, $appleOsNames)) {
944            $this->brand = 'Apple';
945        }
946
947        /**
948         * All devices containing VR fragment are assumed to be a wearable
949         */
950        if (null === $this->device && $this->hasAndroidVRFragment()) {
951            $this->device = AbstractDeviceParser::DEVICE_TYPE_WEARABLE;
952        }
953
954        /**
955         * Chrome on Android passes the device type based on the keyword 'Mobile'
956         * If it is present the device should be a smartphone, otherwise it's a tablet
957         * See https://developer.chrome.com/multidevice/user-agent#chrome_for_android_user_agent
958         * Note: We do not check for browser (family) here, as there might be mobile apps using Chrome, that won't have
959         *       a detected browser, but can still be detected. So we check the useragent for Chrome instead.
960         */
961        if (null === $this->device && 'Android' === $osFamily
962            && $this->matchUserAgent('Chrome/[.0-9]*')
963        ) {
964            if ($this->matchUserAgent('(?:Mobile|eliboM)')) {
965                $this->device = AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE;
966            } else {
967                $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
968            }
969        }
970
971        /**
972         * Some UA contain the fragment 'Pad/APad', so we assume those devices as tablets
973         */
974        if (AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE === $this->device && $this->matchUserAgent('Pad/APad')) {
975            $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
976        }
977
978        /**
979         * Some UA contain the fragment 'Android; Tablet;' or 'Opera Tablet', so we assume those devices as tablets
980         */
981        if (null === $this->device && ($this->hasAndroidTableFragment()
982            || $this->matchUserAgent('Opera Tablet'))
983        ) {
984            $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
985        }
986
987        /**
988         * Some user agents simply contain the fragment 'Android; Mobile;', so we assume those devices as smartphones
989         */
990        if (null === $this->device && $this->hasAndroidMobileFragment()) {
991            $this->device = AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE;
992        }
993
994        /**
995         * Android up to 3.0 was designed for smartphones only. But as 3.0, which was tablet only, was published
996         * too late, there were a bunch of tablets running with 2.x
997         * With 4.0 the two trees were merged and it is for smartphones and tablets
998         *
999         * So were are expecting that all devices running Android < 2 are smartphones
1000         * Devices running Android 3.X are tablets. Device type of Android 2.X and 4.X+ are unknown
1001         */
1002        if (null === $this->device && 'Android' === $osName && '' !== $osVersion) {
1003            if (-1 === \version_compare($osVersion, '2.0')) {
1004                $this->device = AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE;
1005            } elseif (\version_compare($osVersion, '3.0') >= 0
1006                && -1 === \version_compare($osVersion, '4.0')
1007            ) {
1008                $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
1009            }
1010        }
1011
1012        /**
1013         * All detected feature phones running android are more likely a smartphone
1014         */
1015        if (AbstractDeviceParser::DEVICE_TYPE_FEATURE_PHONE === $this->device && 'Android' === $osFamily) {
1016            $this->device = AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE;
1017        }
1018
1019        /**
1020         * All unknown devices under running Java ME are more likely features phones
1021         */
1022        if ('Java ME' === $osName && null === $this->device) {
1023            $this->device = AbstractDeviceParser::DEVICE_TYPE_FEATURE_PHONE;
1024        }
1025
1026        /**
1027         * All devices running KaiOS are more likely features phones
1028         */
1029        if ('KaiOS' === $osName) {
1030            $this->device = AbstractDeviceParser::DEVICE_TYPE_FEATURE_PHONE;
1031        }
1032
1033        /**
1034         * According to http://msdn.microsoft.com/en-us/library/ie/hh920767(v=vs.85).aspx
1035         * Internet Explorer 10 introduces the "Touch" UA string token. If this token is present at the end of the
1036         * UA string, the computer has touch capability, and is running Windows 8 (or later).
1037         * This UA string will be transmitted on a touch-enabled system running Windows 8 (RT)
1038         *
1039         * As most touch enabled devices are tablets and only a smaller part are desktops/notebooks we assume that
1040         * all Windows 8 touch devices are tablets.
1041         */
1042
1043        if (null === $this->device && ('Windows RT' === $osName || ('Windows' === $osName
1044            && \version_compare($osVersion, '8') >= 0)) && $this->isTouchEnabled()
1045        ) {
1046            $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
1047        }
1048
1049        /**
1050         * All devices running Puffin Secure Browser that contain letter 'D' are assumed to be desktops
1051         */
1052        if (null === $this->device && $this->matchUserAgent('Puffin/(?:\d+[.\d]+)[LMW]D')) {
1053            $this->device = AbstractDeviceParser::DEVICE_TYPE_DESKTOP;
1054        }
1055
1056        /**
1057         * All devices running Puffin Web Browser that contain letter 'P' are assumed to be smartphones
1058         */
1059        if (null === $this->device && $this->matchUserAgent('Puffin/(?:\d+[.\d]+)[AIFLW]P')) {
1060            $this->device = AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE;
1061        }
1062
1063        /**
1064         * All devices running Puffin Web Browser that contain letter 'T' are assumed to be tablets
1065         */
1066        if (null === $this->device && $this->matchUserAgent('Puffin/(?:\d+[.\d]+)[AILW]T')) {
1067            $this->device = AbstractDeviceParser::DEVICE_TYPE_TABLET;
1068        }
1069
1070        /**
1071         * All devices running Opera TV Store are assumed to be a tv
1072         */
1073        if ($this->matchUserAgent('Opera TV Store| OMI/')) {
1074            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1075        }
1076
1077        /**
1078         * All devices running Coolita OS are assumed to be a tv
1079         */
1080        if ('Coolita OS' === $osName) {
1081            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1082            $this->brand  = 'coocaa';
1083        }
1084
1085        /**
1086         * All devices that contain Andr0id in string are assumed to be a tv
1087         */
1088        $hasDeviceTvType = false === \in_array($this->device, [
1089            AbstractDeviceParser::DEVICE_TYPE_TV,
1090            AbstractDeviceParser::DEVICE_TYPE_PERIPHERAL,
1091        ]) && $this->matchUserAgent('Andr0id|(?:Android(?: UHD)?|Google) TV|\(lite\) TV|BRAVIA|Firebolt| TV$');
1092
1093        if ($hasDeviceTvType) {
1094            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1095        }
1096
1097        /**
1098         * All devices running Tizen TV or SmartTV are assumed to be a tv
1099         */
1100        if (null === $this->device && $this->matchUserAgent('SmartTV|Tizen.+ TV .+$')) {
1101            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1102        }
1103
1104        /**
1105         * Devices running those clients are assumed to be a TV
1106         */
1107        if (\in_array($clientName, [
1108            'Kylo', 'Espial TV Browser', 'LUJO TV Browser', 'LogicUI TV Browser', 'Open TV Browser', 'Seraphic Sraf',
1109            'Opera Devices', 'Crow Browser', 'Vewd Browser', 'TiviMate', 'Quick Search TV', 'QJY TV Browser', 'TV Bro',
1110        ])
1111        ) {
1112            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1113        }
1114
1115        /**
1116         * All devices containing TV fragment are assumed to be a tv
1117         */
1118        if (null === $this->device && $this->matchUserAgent('\(TV;')) {
1119            $this->device = AbstractDeviceParser::DEVICE_TYPE_TV;
1120        }
1121
1122        /**
1123         * Set device type desktop if string ua contains desktop
1124         */
1125        $hasDesktop = AbstractDeviceParser::DEVICE_TYPE_DESKTOP !== $this->device
1126            && false !== \strpos($this->userAgent, 'Desktop')
1127            && $this->hasDesktopFragment();
1128
1129        if ($hasDesktop) {
1130            $this->device = AbstractDeviceParser::DEVICE_TYPE_DESKTOP;
1131        }
1132
1133        // set device type to desktop for all devices running a desktop os that were not detected as another device type
1134        if (null !== $this->device || !$this->isDesktop()) {
1135            return;
1136        }
1137
1138        $this->device = AbstractDeviceParser::DEVICE_TYPE_DESKTOP;
1139    }
1140
1141    /**
1142     * Tries to detect the operating system
1143     */
1144    protected function parseOs(): void
1145    {
1146        $osParser = new OperatingSystem();
1147        $osParser->setUserAgent($this->getUserAgent());
1148        $osParser->setClientHints($this->getClientHints());
1149        $osParser->setYamlParser($this->getYamlParser());
1150        $osParser->setCache($this->getCache());
1151        $this->os = $osParser->parse();
1152    }
1153
1154    /**
1155     * @param string $regex
1156     *
1157     * @return array|null
1158     */
1159    protected function matchUserAgent(string $regex): ?array
1160    {
1161        $regex = '/(?:^|[^A-Z_-])(?:' . \str_replace('/', '\/', $regex) . ')/i';
1162
1163        if (\preg_match($regex, $this->userAgent, $matches)) {
1164            return $matches;
1165        }
1166
1167        return null;
1168    }
1169
1170    /**
1171     * Resets all detected data
1172     */
1173    protected function reset(): void
1174    {
1175        $this->bot    = null;
1176        $this->client = null;
1177        $this->device = null;
1178        $this->os     = null;
1179        $this->brand  = '';
1180        $this->model  = '';
1181        $this->parsed = false;
1182    }
1183}
1184