Same same but different: Unicode Variation Selector-16

The other day, I did an analysis of Facebook's WebView, which you are kindly invited to read. They have a code path in which they check whether a given page is using AMPHTML, where \u26A1 is the Unicode code point escape of the ⚡ High Voltage emoji.

var nvtiming__fb_html_amp =
nvtiming__fb_html.hasAttribute("amp") ||
nvtiming__fb_html.hasAttribute("\u26A1");
console.log("FBNavAmpDetect:" + nvtiming__fb_html_amp);

An undetected fake AMP page

I was curious to see if they did something special when they detect a page is using AMP (spoiler alert: they do not), so I quickly hacked together a fake AMP page that seemingly fulfilled their simple test.

<html ⚡️>
<body>Fake AMP</body>
</html>

I am a big emoji fan, so instead of the <html amp> variant, I went for the <html ⚡> variant and entered the via the macOS emoji picker. To my surprise, Facebook logged "FBNavAmpDetect: false". Huh 🤷‍♂️?

⚡️ High Voltage sign is a valid attribute name

My first reaction was: <html ⚡️> does not quite look like what the founders of HTML had in mind, so maybe hasAttribute() is specified to return false when an attribute name is invalid. But what even is a valid attribute name? I consulted the HTML spec where it says (emphasis mine):

Attribute names must consist of one or more characters other than controls, U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=), and noncharacters. In the HTML syntax, attribute names, even those for foreign elements, may be written with any mix of ASCII lower and ASCII upper alphas.

I was on company chat with Jake Archibald at that moment, so I confirmed my reading of the spec that is not a valid attribute name. Turns out, it is a valid name, but the spec is formulated in an ambiguous way, so Jake filed "HTML syntax" attribute names. And my lead to a rational explanation was gone.

Perfect Heisenbug?

Luckily a valid AMP boilerplate example was just a quick Web search away, so I copy-pasted the code and Facebook, as expected, reported "FBNavAmpDetect: true". I reduced the AMP boilerplate example until it looked like my fake AMP page, but still Facebook detected the modified boilerplate as AMP, but did not detect mine as AMP. Essentially my experiment looked like the below code sample. Perfect Heisenbug?

JavaScript console showing the code sample from this post

The Unicode Variation Selector-16

Jake eventually traced it down to the Unicode Variation Selector-16:

An invisible code point which specifies that the preceding character should be displayed with emoji presentation. Only required if the preceding character defaults to text presentation.

You may have seen this in effect with the Unicode snowman that appears in a textual ☃︎ as well as in an emoji representation ☃️ (depending on the device you read this on, they may both look the same). As far as I can tell, Chrome DevTools prefers to always render the textual variant, as you can see in the screenshot above. But with the help of the length() and the charCodeAt() functions, the difference gets visible.

document.querySelector('html').hasAttribute('⚡');
// false
document.querySelector('html').hasAttribute('⚡️');
// true
'⚡️'.length;
// 2
'⚡'.length;
// 1
'⚡'.charCodeAt(0) + ' ' + '⚡'.charCodeAt(1);
// "9889 NaN"
'⚡️'.charCodeAt(0) + ' ' + '⚡️'.charCodeAt(1);
// "9889 65039"

The AMP Validator and ⚡️

The macOS emoji picker creates the variant ⚡️, which includes the Variation Selector-16, but AMP requires the variant without, which I have also confirmed in the validator code. You can see in the screenshot below how the AMP Validator rejects one of the two High Voltage symbols.

AMP Validator rejecting the emoji variant with Variation Selector-16

Making this actionable

I have filed crbug.com/1033453 against the Chrome DevTools asking for rendering the characters differently, depending on whether the Variation Selector-16 is present or not. Further, I have opened a feature request on the AMP Project repo demanding that AMP should respect ⚡️ apart from ⚡. Same same, but different.

Thomas Steiner
This post appeared first on https://blog.tomayac.com/2019/12/12/same-same-but-different-unicode-variation-selector-16/.

Inspecting Facebook’s WebView

Both Facebook's Android app as well as Facebook's iOS app use a so-called in-app browser, sometimes also referred to as IAB. The core argument for using an in-app browser (instead of the user's default browser) is to keep users in the app and to enable closer app integration patterns (like "quote from a page in a new Facebook post"), while making others harder or even impossible (like "share this link to Twitter"). Technically, IABs are implemented as WebViews on Android, respectively as WKWebViews on iOS. To simplify things, from hereon, I will refer to both simply as WebViews.

In-App Browsers are less capable than real browsers

Turns out, WebViews are rather limited compared to real browsers like Firefox, Edge, Chrome, and to some extent also Safari. In the past, I have done some research on their limitations when it comes to features that are important in the context of Progressive Web Apps. The linked paper has all the details, but you can simply see for yourself by opening the 🕵️ PWA Feature Detector app that I have developed for this research in your regular browser, and then in a WebView like Facebook's in-app browser (you can share the link visible to just yourself on Facebook and then click through, or try to open my post in the app).

In-App Browsers can modify webpages

On top of limited features, WebViews can also be used for effectively conducting intended man-in-the-middle attacks, since the IAB developer can arbitrarily inject JavaScript code and also intercept network traffic. Most of the time, this feature is used for good. For example, an airline company might reuse the 💺 airplane seat selector logic on both their native app as well as on their Web app. In their native app, they would remove things like the header and the footer, which they then would show on the Web (this is likely the origin of the CSS can kill you meme).

If you build an IAB, don't use a WebView

For these reasons, people like Alex Russell—whom you should definitely follow—have been advocating against WebView-based IABs. Instead, you should wherever possible use Chrome Custom Tabs on Android, or the iOS counterpart SFSafariViewController. Alex writes:

Note that responsible native apps have a way of creating an "in app browser" that doesn't subvert user choice or break the web:

https://developer.chrome.com/multidevice/android/customtabs

Any browser can implement the protocol & default browser will be used. FB can enable this with their next update.

If you have to use a WebView-based IAB, mark it debuggable

Alex has been telling people for a long time that they should mark their WebView-based IABs debuggable. The actual code for that is a one-liner:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}

Looking into Facebook's IAB

The other day, I learned with great joy that Facebook finally have marked their IAB debuggable 🎉. Patrick Meenan—whom you should likewise follow and whom you might know from the amazing WebPageTest project—writes in a Twitter thread:

You can now remote-debug sites in the Facebook in-app browser on Android. It is enabled automatically so once your device is in dev mode with USB debugging and a browser open just visit chrome://inspect to attach to the WebView.

The browser (on iOS and Android) is just a WebView so it should behave mostly like Chrome and Safari but it adds some identifiers to the UA string which sometimes causes pages that UA sniff to break.

Finally, if your analytics aren't breaking out the in-app browsers for you, I highly recommend you see if it is possible to enable. You might be shocked at how much of your traffic comes from an in-app browser (odds are it is the 3rd most common browser behind Chrome and Safari).

I have thus followed up on the invitation and had a closer look at their IAB by inspecting example.org and also a simple test page facebook-debug.glitch.me that contains the debugger statement in its head. I have linked a debug trace 📄 that you can open for yourself in the Performance tab of the Chrome DevTools.

User-Agent String

As pre-announced by Patrick, Facebook's IAB changes the user-agent string. The default WebView user-agent string looks something like Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36 Facebook's IAB browser currently sends this:

navigator.userAgent
// "Mozilla/5.0 (Linux; Android 10; Pixel 3a Build/QQ2A.191125.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/78.0.3904.108 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/250.0.0.14.241;]"

Compared to the default user-agent string, the identifying bit is the suffix [FB_IAB/FB4A;FBAV/250.0.0.14.241;].

Added window properties

Facebook's IAB adds two new properties to window, with the values 0 and 1.

window.TEMPORARY
// 0
window.PERSISTENT
// 1

Added window object

Facebook further adds a new window object FbQuoteShareJSInterface.

document.onselectionchange = function() {
window.FbQuoteShareJSInterface.onSelectionChange(
window.getSelection().toString(),
window.location.href
);
};

This code is used for the "share quote" feature that allows users to mark text on a page to share.

Facebook's in-app browser and its "share quote" feature

Performance monitoring and feature detection

Facebook runs some performance monitoring via the Performance interface. This is split up in two scripts, each of which they seem to run three times. They also check if a given page is using AMP by checking for the presence of the amp or ⚡️ attribute on <html>.

void (function() {
try {
if (window.location.href === "about:blank") {
return;
}
if (
!window.performance ||
!window.performance.timing ||
!document ||
!document.body ||
document.body.scrollHeight <= 0 ||
!document.body.children ||
document.body.children.length < 1
) {
return;
}
var nvtiming__fb_t = window.performance.timing;
if (nvtiming__fb_t.responseEnd > 0) {
console.log("FBNavResponseEnd:" + nvtiming__fb_t.responseEnd);
}
if (nvtiming__fb_t.domContentLoadedEventStart > 0) {
console.log(
"FBNavDomContentLoaded:" + nvtiming__fb_t.domContentLoadedEventStart
);
}
if (nvtiming__fb_t.loadEventEnd > 0) {
console.log("FBNavLoadEventEnd:" + nvtiming__fb_t.loadEventEnd);
}
var nvtiming__fb_html = document.getElementsByTagName("html")[0];
if (nvtiming__fb_html) {
var nvtiming__fb_html_amp =
nvtiming__fb_html.hasAttribute("amp") ||
nvtiming__fb_html.hasAttribute("\u26A1");
console.log("FBNavAmpDetect:" + nvtiming__fb_html_amp);
}
} catch (err) {
console.log("fb_navigation_timing_error:" + err.message);
}
})();
// FBNavResponseEnd:1575904580720
// FBNavDomContentLoaded:1575904586057
// FBNavAmpDetect:false
document.addEventListener("DOMContentLoaded", event => {
console.info(
"FBNavDomContentLoaded:" +
window.performance.timing.domContentLoadedEventStart
);
});
// FBNavDomContentLoaded:1575904586057

Feature Policy tests

They run some Feature Policy tests via a function named getFeaturePolicyAllowListOnPage(). You can see the documentation for the tested directives on the Mozilla Developer Network.

(function() {
function getFeaturePolicyAllowListOnPage(features) {
const map = {};
const featurePolicy = document.policy || document.featurePolicy;
for (const feature of features) {
map[feature] = {
allowed: featurePolicy.allowsFeature(feature),
allowList: featurePolicy.getAllowlistForFeature(feature)
};
}
return map;
}
const allPolicies = [
"geolocation",
"midi",
"ch-ect",
"execution-while-not-rendered",
"layout-animations",
"vertical-scroll",
"forms",
"oversized-images",
"document-access",
"magnetometer",
"picture-in-picture",
"modals",
"unoptimized-lossless-images-strict",
"accelerometer",
"vr",
"document-domain",
"serial",
"encrypted-media",
"font-display-late-swap",
"unsized-media",
"ch-downlink",
"ch-ua-arch",
"presentation",
"xr-spatial-tracking",
"lazyload",
"idle-detection",
"popups",
"scripts",
"unoptimized-lossless-images",
"sync-xhr",
"ch-width",
"ch-ua-model",
"top-navigation",
"ch-lang",
"camera",
"ch-viewport-width",
"loading-frame-default-eager",
"payment",
"pointer-lock",
"focus-without-user-activation",
"downloads-without-user-activation",
"ch-rtt",
"fullscreen",
"autoplay",
"execution-while-out-of-viewport",
"ch-dpr",
"hid",
"usb",
"wake-lock",
"ch-ua-platform",
"ambient-light-sensor",
"gyroscope",
"document-write",
"unoptimized-lossy-images",
"sync-script",
"ch-device-memory",
"orientation-lock",
"ch-ua",
"microphone"
];
return getFeaturePolicyAllowListOnPage(allPolicies);
})();

Not all directives are currently supported by the WebView, so a number of warnings are logged. The recognized ones (i.e., the output of the getFeaturePolicyAllowListOnPage() function above) result in an object as follows.

{
"geolocation": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"midi": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"ch-ect": {
"allowed": false,
"allowList": []
},
"execution-while-not-rendered": {
"allowed": false,
"allowList": []
},
"layout-animations": {
"allowed": false,
"allowList": []
},
"vertical-scroll": {
"allowed": false,
"allowList": []
},
"forms": {
"allowed": false,
"allowList": []
},
"oversized-images": {
"allowed": false,
"allowList": []
},
"document-access": {
"allowed": false,
"allowList": []
},
"magnetometer": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"picture-in-picture": {
"allowed": true,
"allowList": [
"*"
]
},
"modals": {
"allowed": false,
"allowList": []
},
"unoptimized-lossless-images-strict": {
"allowed": false,
"allowList": []
},
"accelerometer": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"vr": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"document-domain": {
"allowed": true,
"allowList": [
"*"
]
},
"serial": {
"allowed": false,
"allowList": []
},
"encrypted-media": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"font-display-late-swap": {
"allowed": false,
"allowList": []
},
"unsized-media": {
"allowed": false,
"allowList": []
},
"ch-downlink": {
"allowed": false,
"allowList": []
},
"ch-ua-arch": {
"allowed": false,
"allowList": []
},
"presentation": {
"allowed": false,
"allowList": []
},
"xr-spatial-tracking": {
"allowed": false,
"allowList": []
},
"lazyload": {
"allowed": false,
"allowList": []
},
"idle-detection": {
"allowed": false,
"allowList": []
},
"popups": {
"allowed": false,
"allowList": []
},
"scripts": {
"allowed": false,
"allowList": []
},
"unoptimized-lossless-images": {
"allowed": false,
"allowList": []
},
"sync-xhr": {
"allowed": true,
"allowList": [
"*"
]
},
"ch-width": {
"allowed": false,
"allowList": []
},
"ch-ua-model": {
"allowed": false,
"allowList": []
},
"top-navigation": {
"allowed": false,
"allowList": []
},
"ch-lang": {
"allowed": false,
"allowList": []
},
"camera": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"ch-viewport-width": {
"allowed": false,
"allowList": []
},
"loading-frame-default-eager": {
"allowed": false,
"allowList": []
},
"payment": {
"allowed": false,
"allowList": []
},
"pointer-lock": {
"allowed": false,
"allowList": []
},
"focus-without-user-activation": {
"allowed": false,
"allowList": []
},
"downloads-without-user-activation": {
"allowed": false,
"allowList": []
},
"ch-rtt": {
"allowed": false,
"allowList": []
},
"fullscreen": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"autoplay": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"execution-while-out-of-viewport": {
"allowed": false,
"allowList": []
},
"ch-dpr": {
"allowed": false,
"allowList": []
},
"hid": {
"allowed": false,
"allowList": []
},
"usb": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"wake-lock": {
"allowed": false,
"allowList": []
},
"ch-ua-platform": {
"allowed": false,
"allowList": []
},
"ambient-light-sensor": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"gyroscope": {
"allowed": true,
"allowList": [
"https://example.com"
]
},
"document-write": {
"allowed": false,
"allowList": []
},
"unoptimized-lossy-images": {
"allowed": false,
"allowList": []
},
"sync-script": {
"allowed": false,
"allowList": []
},
"ch-device-memory": {
"allowed": false,
"allowList": []
},
"orientation-lock": {
"allowed": false,
"allowList": []
},
"ch-ua": {
"allowed": false,
"allowList": []
},
"microphone": {
"allowed": true,
"allowList": [
"https://example.com"
]
}
}

HTTP headers

I checked the response and request headers, but nothing special stood out. The only remarkable thing given that they look at Feature Policy is the absence of the Feature-Policy header.

Request header

:authority: facebook-debug.glitch.me
:method: GET
:path: /
:scheme: https
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
accept-encoding: gzip, deflate
accept-language: en-US,en;q=0.9,de-DE;q=0.8,de;q=0.7,ca-ES;q=0.6,ca;q=0.5
cache-control: no-cache
pragma: no-cache
referer: http://m.facebook.com/
sec-fetch-mode: navigate
sec-fetch-site: none
sec-fetch-user: ?1
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Linux; Android 10; Pixel 3a Build/QQ2A.191125.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/78.0.3904.108 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/250.0.0.14.241;]
x-requested-with: com.facebook.katana

Response header

accept-ranges: bytes
cache-control: max-age=0
content-length: 527
content-type: text/html; charset=utf-8
date: Mon, 09 Dec 2019 15:23:40 GMT
etag: W/"20f-16eeb283880"
last-modified: Mon, 09 Dec 2019 14:55:12 GMT
status: 200
vary: Origin

Conclusion

All in all, these are all the things Facebook did that I could observe on the pages that I have tested. I didn't notice any click listeners or scroll listeners (that could be used for engagement tracking of Facebook users with the pages they browse on) or any other kind of "phoning home" functionality, but they could of course have implemented this natively via the WebView's View.OnScrollChangeListener or View.OnClickListener, as they did for long clicks for the FbQuoteShareJSInterface.

That being said, if after reading this you prefer your links to open in your default browser, it's well hidden, but definitely possible: Settings > Media and Contacts > Links open externally.

Facebook Settings option: "Links open externally"

It goes without saying, but just in case: all code snippets in this post are owned by and copyright of Facebook.

Did you run a similar analysis with similar (or maybe different) findings? Let me know on Twitter or Mastodon by posting your thoughts with a link to this post. It will then show up as a Webmention at the bottom. On supporting platforms, you can simply use the "Share Article" button below.

Thomas Steiner
This post appeared first on https://blog.tomayac.com/2019/12/09/inspecting-facebooks-webview/.

Animated SVG favicons

When it comes to animating SVGs, there're three options: using CSS, JS, or SMIL. Each comes with its own pros and cons, whose discussion is beyond the scope of this article, but Sara Soueidan has a great article on the topic. In this post, I add a repeating shrink animation to a circle with all three methods, and then try to use these SVGs as favicons.

Animating SVG with CSS

Here's an example of animating an SVG with CSS based on the animation and the transform properties. I scale the circle from the center and repeat the animation forever:

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<style>
svg {
max-width: 100px;
}

circle {
display: block;
animation: 2s linear infinite both circle-animation;
transform-origin: 50% 50%;
}

@keyframes circle-animation {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
</style>
<circle fill="red" cx="50" cy="50" r="45"/>
</svg>

Animating SVG with JS

The SVG <script> tag allows to add scripts to an SVG document. It has some subtle differences to the regular HTML <script>, for example, it uses the href instead of the src attribute, but above all it's important to know that any functions defined within any <script> tag have a global scope across the entire current document. Below, you can see an SVG script used to reduce the radius of the circle until it's equal to zero, then reset it to the initial value, and finally repeat this forever.

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle fill="blue" cx="50" cy="50" r="45" />
<script type="text/javascript"><![CDATA[
const circle = document.querySelector('circle');
let r = 45;
const animate = () => {
circle.setAttribute('r', r--);
if (r === 0) {
r = 45;
}
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
]]>
</script>
</svg>

Animating SVG with SMIL

The last example uses SMIL, where, via the <animate> tag inside of the <circle> tag, I declaratively describe that I want to animate the circle's r attribute (that determines the radius) and repeat it indefinitely.

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle fill="green" cx="50" cy="50" r="45">
<animate attributeName="r" from="45" to="0" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>

Using Animated SVGs as Images

Before using animated SVGs as favicons, I want to briefly discuss how you can use each of the three examples on a website. Again there're three options: referenced via the src attribute of an <img> tag, in an <iframe>, or inlined in the main document. Again, SVG scripts have access to the global scope, so they should definitely be used with care. Some user agents, for example, Google Chrome, don't run scripts for SVGs in <img>. The Glitch embedded below shows all variants in action. My recommendation would be to stick with CSS animations whenever you can, since it's the most compatible and future-proof variant.

Using Animated SVGs as Favicons

Since crbug.com/294179 is fixed, Chrome finally supports SVG favicons, alongside many other browsers. I have recently successfully experimented with prefers-color-scheme in SVG favicons, so I wanted to see if animated SVGs work, too. Long story short, it seems only Firefox supports them at the time of writing, and only favicons that are animated with either CSS or JS. You can see this working in Firefox in the screencast embedded below. If you open my Glitch demo in a standalone window, you can test this yourself with the radio buttons at the top.

Should you use this in practice? Probably not, since it can be really distracting. It might be useful as a progressive enhancement to show activity during a short period of time, for example, while a web application is busy with processing data. Before considering to use this, I would definitely recommend taking the user's prefers-reduced-motion preferences into account.

Thomas Steiner
This post appeared first on https://blog.tomayac.com/2019/12/01/animated-svg-favicons/.