Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.02% covered (warning)
53.02%
114 / 215
29.73% covered (danger)
29.73%
11 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
MobileContext
53.02% covered (warning)
53.02%
114 / 215
29.73% covered (danger)
29.73%
11 / 37
834.01
0.00% covered (danger)
0.00%
0 / 1
 singleton
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 resetInstanceForTesting
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isMobileDevice
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 setForceMobileView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAmcUser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 shouldDisplayMobileView
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 shouldDisplayMobileViewInternal
25.00% covered (danger)
25.00%
5 / 20
0.00% covered (danger)
0.00%
0 / 1
43.17
 getMobileAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getUseFormat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 unsetStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getUseFormatCookie
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCookieDomain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStopMobileRedirectCookieDomain
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setUseFormatCookie
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 unsetUseFormatCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUseFormatCookieExpiry
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getUseFormatCookieDuration
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getMobileUrlCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasMobileDomain
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 getMobileUrl
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
7.39
 usingMobileDomain
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDesktopUrl
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 updateDesktopUrlHost
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 updateDesktopUrlQuery
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 toggleView
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 doToggling
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
6.03
 checkToggleView
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isLocalUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addAnalyticsLogItem
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAnalyticsLogItems
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getXAnalyticsHeader
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 addAnalyticsLogItemFromXAnalytics
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 logMobileMode
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 shouldShowWikibaseDescriptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3use MediaWiki\Config\Config;
4use MediaWiki\Context\ContextSource;
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\MediaWikiServices;
8use MediaWiki\Utils\UrlUtils;
9use MobileFrontend\Amc\UserMode;
10use MobileFrontend\Devices\DeviceDetectorService;
11use MobileFrontend\WMFBaseDomainExtractor;
12
13/**
14 * Provide various request-dependant methods to use in mobile context
15 */
16class MobileContext extends ContextSource {
17    public const STOP_MOBILE_REDIRECT_COOKIE_NAME = 'stopMobileRedirect';
18    public const USEFORMAT_COOKIE_NAME = 'mf_useformat';
19    // Keep in sync with https://wikitech.wikimedia.org/wiki/X-Analytics.
20    private const ANALYTICS_HEADER_KEY = 'mf-m';
21    private const ANALYTICS_HEADER_DELIMITER = ',';
22    private const ANALYTICS_HEADER_VALUE_AMC = 'amc';
23
24    /**
25     * Save explicitly requested format
26     */
27    protected ?string $useFormat = null;
28
29    /**
30     * Key/value pairs of things to add to X-Analytics response header for analytics
31     * @var array[]
32     */
33    protected array $analyticsLogItems = [];
34
35    /**
36     * The memoized result of `MobileContext#isMobileDevice`.
37     *
38     * This defaults to `null`, meaning that `MobileContext#isMobileDevice` has
39     * yet to be called.
40     *
41     * @see MobileContext#isMobileDevice
42     */
43    private ?bool $isMobileDevice = null;
44
45    /**
46     * Saves requested Mobile action
47     */
48    protected ?string $mobileAction = null;
49
50    /**
51     * Save whether the mobile view is explicitly requested
52     */
53    private bool $forceMobileView = false;
54
55    /**
56     * Save whether we should display the mobile view
57     */
58    private ?bool $mobileView = null;
59
60    /**
61     * Have we already checked for desktop/mobile view toggling?
62     */
63    private bool $toggleViewChecked = false;
64
65    private static ?self $instance = null;
66
67    /**
68     * @var string|null What to switch the view to
69     */
70    private ?string $viewChange = null;
71
72    /**
73     * @var string|null Domain to use for the stopMobileRedirect cookie
74     */
75    public static ?string $mfStopRedirectCookieHost = null;
76
77    /**
78     * In-process cache for checking whether the current wiki has a mobile URL that's
79     * different from the desktop one.
80     */
81    private ?bool $hasMobileUrl = null;
82
83    /**
84     * Returns the actual MobileContext Instance or create a new if no exists
85     * @deprecated use MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
86     * @return self
87     */
88    public static function singleton() {
89        if ( !self::$instance ) {
90            self::$instance = new self(
91                RequestContext::getMain(),
92                MediaWikiServices::getInstance()->getService( 'MobileFrontend.Config' )
93            );
94        }
95        return self::$instance;
96    }
97
98    /**
99     * Resets the singleton instance.
100     */
101    public static function resetInstanceForTesting() {
102        self::$instance = null;
103    }
104
105    protected function __construct(
106        IContextSource $context,
107        private readonly Config $config,
108    ) {
109        $this->setContext( $context );
110    }
111
112    /**
113     * Detects whether the UA is sending the request from a device and, if so,
114     * whether to display the mobile view to that device.
115     *
116     * The mobile view will always be displayed to mobile devices. However, it
117     * will only be displayed to tablet devices if `$wgMFShowMobileViewToTablets`
118     * is truthy.
119     *
120     * @fixme This should be renamed to something more appropriate, e.g.
121     * `shouldDisplayMobileViewToDevice`.
122     *
123     * @see MobileContext::shouldDisplayMobileView
124     *
125     * @return bool
126     */
127    public function isMobileDevice() {
128        if ( $this->isMobileDevice !== null ) {
129            return $this->isMobileDevice;
130        }
131
132        $this->isMobileDevice = false;
133
134        $properties = DeviceDetectorService::factory( $this->config )
135            ->detectDeviceProperties( $this->getRequest(), $_SERVER );
136
137        if ( $properties ) {
138            $showMobileViewToTablets = $this->config->get( 'MFShowMobileViewToTablets' );
139
140            $this->isMobileDevice =
141                $properties->isMobileDevice()
142                || ( $properties->isTabletDevice() && $showMobileViewToTablets );
143        }
144
145        return $this->isMobileDevice;
146    }
147
148    /**
149     * Save whether mobile view should always be enforced
150     * @param bool $value should mobile view be enforced?
151     */
152    public function setForceMobileView( $value ) {
153        $this->forceMobileView = $value;
154    }
155
156    /**
157     * Whether the current user has advanced mobile contributions enabled.
158     * @return bool
159     */
160    private static function isAmcUser() {
161        $services = MediaWikiServices::getInstance();
162        /** @var UserMode $userMode */
163        $userMode = $services->getService( 'MobileFrontend.AMC.UserMode' );
164        return $userMode->isEnabled();
165    }
166
167    /**
168     * Determine whether we should display the mobile view
169     *
170     * Step through the hierarchy of what should or should not trigger
171     * the mobile view.
172     *
173     * Primacy is given to the page action - we will never show the mobile view
174     * for page edits or page history. 'useformat' request param is then
175     * honored, followed by cookie settings, then actual device detection,
176     * finally falling back on false.
177     * @return bool
178     */
179    public function shouldDisplayMobileView() {
180        if ( $this->mobileView === null ) {
181            // Compute viewChange, to toggle between mobile/desktop view
182            $this->checkToggleView();
183            $this->mobileView = $this->shouldDisplayMobileViewInternal();
184        }
185        return $this->mobileView;
186    }
187
188    /**
189     * Value for shouldDisplayMobileView()
190     * @return bool
191     */
192    private function shouldDisplayMobileViewInternal() {
193        // May be overridden programmatically
194        if ( $this->forceMobileView ) {
195            return true;
196        }
197
198        // always display the desktop or mobile view if it's explicitly requested
199        $useFormat = $this->getUseFormat();
200        if ( $useFormat === 'desktop' ) {
201            return false;
202        } elseif ( $useFormat === 'mobile' ) {
203            return true;
204        }
205
206        if ( $this->getRequest()->getRawVal( 'mobileformat' ) !== null ) {
207            return true;
208        }
209
210        /**
211         * If a user is accessing the site from a mobile domain, then we should
212         * always display the mobile version of the site (otherwise, the cache
213         * may get polluted). See
214         * https://phabricator.wikimedia.org/T48473
215         */
216        if ( $this->usingMobileDomain() ) {
217            return true;
218        }
219
220        // check cookies for what to display
221        $useMobileFormat = $this->getUseFormatCookie();
222        if ( $useMobileFormat === 'true' ) {
223            return true;
224        }
225        $stopMobileRedirect = $this->getStopMobileRedirectCookie();
226        if ( $stopMobileRedirect === 'true' ) {
227            return false;
228        }
229
230        // do device detection
231        if ( $this->isMobileDevice() ) {
232            return true;
233        }
234
235        return false;
236    }
237
238    /**
239     * Get requested mobile action
240     * @return string
241     */
242    public function getMobileAction() {
243        if ( $this->mobileAction === null ) {
244            $this->mobileAction = $this->getRequest()->getRawVal( 'mobileaction' );
245        }
246
247        return $this->mobileAction;
248    }
249
250    /**
251     * Gets the value of the `useformat` query string parameter.
252     *
253     * @return string Typically "desktop" or "mobile"
254     */
255    private function getUseFormat() {
256        if ( $this->useFormat === null ) {
257            $this->useFormat = $this->getRequest()->getRawVal( 'useformat' );
258        }
259        return $this->useFormat;
260    }
261
262    /**
263     * Set Cookie to stop automatically redirect to mobile page
264     * @param int|null $expiry Expire time of cookie
265     */
266    public function setStopMobileRedirectCookie( $expiry = null ) {
267        $this->getRequest()->response()->setCookie(
268            self::STOP_MOBILE_REDIRECT_COOKIE_NAME,
269            'true',
270            $expiry ?? $this->getUseFormatCookieExpiry(),
271            [
272                'domain' => $this->getStopMobileRedirectCookieDomain(),
273                'prefix' => '',
274                'secure' => (bool)$this->config->get( 'MFStopMobileRedirectCookieSecureValue' ),
275            ]
276        );
277    }
278
279    /**
280     * Remove cookie and continue automatic redirect to mobile page
281     */
282    public function unsetStopMobileRedirectCookie() {
283        if ( $this->getStopMobileRedirectCookie() === null ) {
284            return;
285        }
286        $expire = $this->getUseFormatCookieExpiry( time(), -3600 );
287        $this->setStopMobileRedirectCookie( $expire );
288    }
289
290    /**
291     * Read cookie for stop automatic mobile redirect
292     * @return string
293     */
294    public function getStopMobileRedirectCookie() {
295        return $this->getRequest()
296            ->getCookie( self::STOP_MOBILE_REDIRECT_COOKIE_NAME, '' );
297    }
298
299    /**
300     * This cookie can determine whether a user should see the mobile
301     * version of a page.
302     *
303     * @return string|null
304     */
305    public function getUseFormatCookie() {
306        return $this->getRequest()->getCookie( self::USEFORMAT_COOKIE_NAME, '' );
307    }
308
309    /**
310     * Return the base level domain or IP address
311     *
312     * @return string|null
313     */
314    public function getCookieDomain() {
315        return ( new WMFBaseDomainExtractor() )->getCookieDomain( $this->config->get( 'Server' ) );
316    }
317
318    /**
319     * Determine the correct domain to use for the stopMobileRedirect cookie
320     *
321     * Will use $wgMFStopRedirectCookieHost if it's set, otherwise will use
322     * the result of getCookieDomain()
323     * @return string|null
324     */
325    public function getStopMobileRedirectCookieDomain() {
326        $mfStopRedirectCookieHost = $this->config->get( 'MFStopRedirectCookieHost' );
327
328        if ( !$mfStopRedirectCookieHost ) {
329            self::$mfStopRedirectCookieHost = $this->getCookieDomain();
330        } else {
331            self::$mfStopRedirectCookieHost = $mfStopRedirectCookieHost;
332        }
333
334        return self::$mfStopRedirectCookieHost;
335    }
336
337    /**
338     * Set the mf_useformat cookie
339     *
340     * This cookie can determine whether a user should see the mobile
341     * version of pages.
342     *
343     * @param string $cookieFormat should user see the mobile version of pages?
344     * @param int|null $expiry Expiration of cookie
345     */
346    public function setUseFormatCookie( $cookieFormat = 'true', $expiry = null ) {
347        $this->getRequest()->response()->setCookie(
348            self::USEFORMAT_COOKIE_NAME,
349            $cookieFormat,
350            $expiry ?? $this->getUseFormatCookieExpiry(),
351            [
352                'prefix' => '',
353                'httpOnly' => true,
354            ]
355        );
356        $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
357        $stats->updateCount( 'mobile.useformat_' . $cookieFormat . '_cookie_set', 1 );
358    }
359
360    /**
361     * Remove cookie based saved useformat value
362     */
363    public function unsetUseFormatCookie() {
364        if ( $this->getUseFormatCookie() === null ) {
365            return;
366        }
367
368        // set expiration date in the past
369        $expire = $this->getUseFormatCookieExpiry( time(), -3600 );
370        $this->setUseFormatCookie( '', $expire );
371    }
372
373    /**
374     * Get the expiration time for the mf_useformat cookie
375     *
376     * @param int|null $startTime The base time (in seconds since Epoch) from which to calculate
377     *  cookie expiration. If null, time() is used.
378     * @param int|null $cookieDuration The time (in seconds) the cookie should last
379     * @return int The time (in seconds since Epoch) that the cookie should expire
380     */
381    protected function getUseFormatCookieExpiry( $startTime = null, $cookieDuration = null ) {
382        // use $cookieDuration if it's valid
383        if ( intval( $cookieDuration ) === 0 ) {
384            $cookieDuration = $this->getUseFormatCookieDuration();
385        }
386
387        // use $startTime if it's valid
388        if ( intval( $startTime ) === 0 ) {
389            $startTime = time();
390        }
391
392        return $startTime + $cookieDuration;
393    }
394
395    /**
396     * Determine the duration the cookie should last.
397     *
398     * If $wgMobileFrontendFormatcookieExpiry has a non-0 value, use that
399     * for the duration. Otherwise, fall back to $wgCookieExpiration.
400     *
401     * @return int The number of seconds for which the cookie should last.
402     */
403    public function getUseFormatCookieDuration() {
404        $mobileFrontendFormatCookieExpiry =
405            $this->config->get( 'MobileFrontendFormatCookieExpiry' );
406
407        return ( abs( (int)$mobileFrontendFormatCookieExpiry ) > 0 )
408            ? $mobileFrontendFormatCookieExpiry
409            : $this->getConfig()->get( 'CookieExpiration' );
410    }
411
412    /**
413     * Returns the callback from $wgMobileUrlCallback, which changes
414     *   a desktop domain into a mobile domain.
415     * @return callable|null
416     * @phan-return callable(string):string|null
417     */
418    private function getMobileUrlCallback(): ?callable {
419        return $this->config->get( 'MobileUrlCallback' );
420    }
421
422    /**
423     * True if the current wiki has separate mobile and desktop domains (regardless
424     * of which domain is used by the current request).
425     */
426    public function hasMobileDomain(): bool {
427        if ( $this->hasMobileUrl !== null ) {
428            return $this->hasMobileUrl;
429        }
430
431        $mobileUrlCallback = $this->getMobileUrlCallback();
432        if ( $mobileUrlCallback ) {
433            $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
434            $server = $urlUtils->expand( $this->getConfig()->get( 'Server' ), PROTO_CANONICAL ) ?? '';
435            $host = $urlUtils->parse( $server )['host'] ?? '';
436            $mobileDomain = $mobileUrlCallback( $host );
437            $this->hasMobileUrl = $mobileDomain !== $host;
438        } else {
439            $this->hasMobileUrl = false;
440        }
441
442        return $this->hasMobileUrl;
443    }
444
445    /**
446     * Take a URL and return the equivalent mobile URL (ie. replace the domain with the
447     * mobile domain).
448     *
449     * Typically, this is a URL for the current wiki, but it can be anything as long as
450     * $wgMobileUrlCallback can convert its domain (so e.g. interwiki links can be
451     * converted). If the domain is already a mobile domain, or not recognized by
452     * $wgMobileUrlCallback, or the wiki does not use mobile domains, and so
453     * $wgMobileUrlCallback is not set, the URL will be returned unchanged (except
454     * $forceHttps will still be applied).
455     *
456     * @param string $url URL to convert
457     * @param bool $forceHttps Force HTTPS, even if the original URL used HTTP
458     * @return string|bool
459     */
460    public function getMobileUrl( $url, $forceHttps = false ) {
461        $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
462        $parsedUrl = $urlUtils->parse( $url );
463        // if parsing failed, maybe it's a local Url, try to expand and reparse it - task T107505
464        if ( !$parsedUrl ) {
465            $expandedUrl = $urlUtils->expand( $url, PROTO_CURRENT );
466            if ( $expandedUrl ) {
467                $parsedUrl = $urlUtils->parse( $expandedUrl );
468            }
469            if ( !$expandedUrl || !$parsedUrl ) {
470                return false;
471            }
472        }
473
474        $mobileUrlCallback = $this->getMobileUrlCallback();
475        if ( $mobileUrlCallback ) {
476            $parsedUrl['host'] = $mobileUrlCallback( $parsedUrl['host'] );
477        }
478        if ( $forceHttps ) {
479            $parsedUrl['scheme'] = 'https';
480            $parsedUrl['delimiter'] = '://';
481        }
482
483        return UrlUtils::assemble( $parsedUrl );
484    }
485
486    /**
487     * Checks whether the current request is using the mobile domain.
488     *
489     * This assumes that some infrastructure outside MediaWiki will set a
490     * header (specified by $wgMFMobileHeader) on requests which should use the
491     * mobile format. This means that the traffic routing layer can rewrite
492     * any m-dot hostnames to be canonical, allowing non-MobileFrontend-aware
493     * code in MediaWiki to work as-is.
494     *
495     * @return bool
496     */
497    public function usingMobileDomain() {
498        $mobileHeader = $this->config->get( 'MFMobileHeader' );
499        return ( $mobileHeader
500            && $this->getRequest()->getHeader( $mobileHeader ) !== false
501        );
502    }
503
504    /**
505     * Take a URL and return a copy that removes any mobile tokens.
506     *
507     * This only works with URLs of the current wiki.
508     *
509     * @param string $url representing a page on the mobile domain e.g. `https://en.m.wikipedia.org/`
510     * @return string (absolute url)
511     */
512    public function getDesktopUrl( $url ) {
513        $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
514        $parsedUrl = $urlUtils->parse( $url ) ?? [];
515        $this->updateDesktopUrlHost( $parsedUrl );
516        $this->updateDesktopUrlQuery( $parsedUrl );
517
518        return UrlUtils::assemble( $parsedUrl );
519    }
520
521    /**
522     * Update the host of a given URL to strip out any mobile tokens
523     * @param array &$parsedUrl Result of parseUrl() or UrlUtils::parse()
524     */
525    protected function updateDesktopUrlHost( array &$parsedUrl ) {
526        if ( !$this->hasMobileDomain() ) {
527            return;
528        }
529
530        $parsedWgServer = MediaWikiServices::getInstance()->getUrlUtils()
531            ->parse( $this->getConfig()->get( 'Server' ) );
532        $parsedUrl['host'] = $parsedWgServer['host'] ?? '';
533    }
534
535    /**
536     * Update the query portion of a given URL to remove any 'useformat' params
537     * @param array &$parsedUrl Result of parseUrl() or UrlUtils::parse()
538     */
539    protected function updateDesktopUrlQuery( array &$parsedUrl ) {
540        if ( isset( $parsedUrl['query'] ) && strpos( $parsedUrl['query'], 'useformat' ) !== false ) {
541            $query = wfCgiToArray( $parsedUrl['query'] );
542            unset( $query['useformat'] );
543            $parsedUrl['query'] = wfArrayToCgi( $query );
544        }
545    }
546
547    /**
548     * Toggles view to one specified by the user
549     *
550     * When a user requests a particular view (e.g. click 'Desktop view' on a mobile page),
551     * - apply the requested view to this particular request
552     * - set a cookie to keep them on that view for subsequent requests
553     * - redirect to strip off the 'mobileaction' parameter,
554     *   to avoid issues with CDN caching or URL sharing (T401595)
555     *
556     * @param string $view User requested particular view
557     */
558    private function toggleView( $view ) {
559        $this->viewChange = $view;
560        $this->useFormat = $view;
561    }
562
563    /**
564     * Set the toggling cookie and redirect, as requested by toggleView()
565     */
566    public function doToggling() {
567        // Compute viewChange now, if not already
568        $this->shouldDisplayMobileView();
569
570        if ( !$this->viewChange ) {
571            return;
572        }
573
574        $title = $this->getTitle();
575        if ( !$title ) {
576            return;
577        }
578
579        $query = $this->getRequest()->getQueryValues();
580        unset( $query['mobileaction'] );
581        unset( $query['useformat'] );
582        unset( $query['title'] );
583        $url = $title->getFullURL( $query, false, PROTO_CURRENT );
584
585        if ( $this->viewChange === 'mobile' ) {
586            // remove old desktop opt-in choice (if any)
587            $this->unsetStopMobileRedirectCookie();
588
589            // preserve mobile opt-in choice for future pageviews via same-domain cookie
590            $this->setUseFormatCookie();
591
592            // redirect to strip 'mobileaction' param (T401595)
593            $mobileUrl = $this->getMobileUrl( $url );
594            $this->getOutput()->redirect( $mobileUrl, 301 );
595        } elseif ( $this->viewChange === 'desktop' ) {
596            // remove old mobile opt-in choice (if any)
597            if ( $this->getUseFormatCookie() === 'true' ) {
598                $this->unsetUseFormatCookie();
599            }
600
601            // preserve desktop opt-in choice for future pageviews via same-domain cookie
602            $this->setStopMobileRedirectCookie();
603
604            // redirect to strip 'mobileaction' param (T401595)
605            $desktopUrl = $this->getDesktopUrl( $url );
606            $this->getOutput()->redirect( $desktopUrl, 301 );
607        }
608    }
609
610    /**
611     * Determine whether we need to toggle the view, and toggle it
612     */
613    public function checkToggleView() {
614        if ( !$this->toggleViewChecked ) {
615            $this->toggleViewChecked = true;
616            $mobileAction = $this->getMobileAction();
617            if ( $mobileAction === 'toggle_view_desktop' ) {
618                $this->toggleView( 'desktop' );
619            } elseif ( $mobileAction === 'toggle_view_mobile' ) {
620                $this->toggleView( 'mobile' );
621            }
622        }
623    }
624
625    /**
626     * Determine whether a given URL is local
627     *
628     * @param string $url URL to check against
629     * @return bool
630     */
631    public function isLocalUrl( $url ) {
632        $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
633        $parsedTargetHost = $urlUtils->parse( $url )['host'] ?? '';
634        $parsedServerHost = $urlUtils->parse( $this->config->get( 'Server' ) )['host'] ?? '';
635        return $parsedTargetHost === $parsedServerHost;
636    }
637
638    /**
639     * Add key/value pairs for analytics purposes to $this->analyticsLogItems. Pre-existing entries
640     * are appended to as sets delimited by commas.
641     * @param string $key for <key> in `X-Analytics: <key>=<value>`
642     * @param string $val for <value> in `X-Analytics: <key>=<value>`
643     */
644    public function addAnalyticsLogItem( $key, $val ) {
645        $key = trim( $key );
646        $val = trim( $val );
647        $items = $this->analyticsLogItems[$key] ?? [];
648        if ( !in_array( $val, $items ) ) {
649            $items[] = $val;
650            $this->analyticsLogItems[$key] = $items;
651        }
652    }
653
654    /**
655     * Read key/value pairs for analytics purposes from $this->analyticsLogItems
656     * @return array
657     */
658    public function getAnalyticsLogItems() {
659        return array_map(
660            static function ( $val ) {
661                return implode( self::ANALYTICS_HEADER_DELIMITER, $val );
662            },
663            $this->analyticsLogItems
664        );
665    }
666
667    /**
668     * Get HTTP header string for X-Analytics
669     *
670     * This is made up of key/value pairs and is used for analytics purposes.
671     *
672     * @return string|bool
673     */
674    public function getXAnalyticsHeader() {
675        $response = $this->getRequest()->response();
676        $currentHeader = method_exists( $response, 'getHeader' ) ?
677            (string)$response->getHeader( 'X-Analytics' ) : '';
678        parse_str( preg_replace( '/; */', '&', $currentHeader ), $logItems );
679        $logItems += $this->getAnalyticsLogItems();
680        if ( !count( $logItems ) ) {
681            return false;
682        }
683
684        $items = [];
685        foreach ( $logItems as $key => $val ) {
686            $items[] = urlencode( $key ) . "=" . urlencode( $val );
687        }
688        $headerValue = implode( ';', $items );
689
690        return "X-Analytics: $headerValue";
691    }
692
693    /**
694     * Take a key/val pair in string format and add it to $this->analyticsLogItems
695     *
696     * @param string $xanalytics_item In the format key=value
697     */
698    public function addAnalyticsLogItemFromXAnalytics( $xanalytics_item ) {
699        [ $key, $val ] = explode( '=', $xanalytics_item, 2 );
700        $this->addAnalyticsLogItem( urldecode( $key ), urldecode( $val ) );
701    }
702
703    /**
704     * Adds analytics log items depending on which modes are enabled for the user
705     *
706     * Invoked from MobileFrontendHooks::onRequestContextCreateSkin()
707     *
708     * Making changes to what this method logs? Make sure you update the
709     * documentation for the X-Analytics header: https://wikitech.wikimedia.org/wiki/X-Analytics
710     */
711    public function logMobileMode() {
712        if ( self::isAmcUser() ) {
713            $this->addAnalyticsLogItem( self::ANALYTICS_HEADER_KEY, self::ANALYTICS_HEADER_VALUE_AMC );
714        }
715    }
716
717    /**
718     * Gets whether Wikibase descriptions should be shown in search results,
719     * and watchlists; or as taglines on article pages.
720     * Doesn't take into account whether the wikidata descriptions
721     * feature has been enabled.
722     *
723     * @param string $feature which description to show?
724     * @param Config $config
725     * @return bool
726     * @throws DomainException If `feature` isn't one that shows Wikidata descriptions. See the
727     *  `wgMFDisplayWikibaseDescriptions` configuration variable for detail
728     */
729    public function shouldShowWikibaseDescriptions( $feature, Config $config ) {
730        $displayWikibaseDescriptions = $config->get( 'MFDisplayWikibaseDescriptions' );
731        if ( !isset( $displayWikibaseDescriptions[$feature] ) ) {
732            throw new DomainException(
733                "\"{$feature}\" isn't a feature that shows Wikidata descriptions."
734            );
735        }
736
737        return $displayWikibaseDescriptions[$feature];
738    }
739}