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