1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
package org.terst.feedreader.ui
/**
* Recognising a YouTube placeholder link, and loading an embed that actually plays.
*
* Split out of ReaderWebView for the usual reason: this is string work with no WebView, no
* gestures and no Compose in it, so it can be exercised without a device -- which matters here
* because the bug it fixes is invisible until a video is tapped.
*
* **Why the embed needs a referrer.** The player dialog called
* `loadUrl("https://www.youtube-nocookie.com/embed/<id>")` and YouTube answered with "Video
* player configuration error -- Error 153", reported as "YouTube play is misconfigured". The
* embed refuses to play for a page it cannot attribute to a site. Measured on an Android user
* agent:
*
* | how the embed is loaded | result |
* |--------------------------------------------|-----------|
* | plain, no referrer | Error 153 |
* | with `&origin=` in the query | Error 153 |
* | inside an `<iframe>` on an ordinary page | plays |
* | with a `Referer` header | plays |
*
* The last is what this uses. The iframe works too and was tried first, but it meant hosting
* the embed in a `loadDataWithBaseURL` document -- a synthetic origin, with the video in a
* cross-origin subframe of it. Both are WebView constructs with a history of rendering video
* oddly, and neither is needed: one request header gets the same result with the embed as the
* WebView's own ordinary top-level document.
*/
/** Video id characters, per YouTube: 11 of these and nothing else. */
private val VIDEO_ID_CHARS = Regex("[^A-Za-z0-9_-]")
/** Hosts whose links are YouTube's. Matched whole, so `notyoutube.com` is not one of them. */
private val YOUTUBE_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be")
/** Thumbnail hosts. YouTube serves these from i.ytimg.com and numbered variants of it. */
private val THUMBNAIL_HOST = Regex("^i[0-9]*\\.ytimg\\.com$")
/** `/vi/<id>/...` or `/vi_webp/<id>/...` -- the id is the first segment after the vi folder. */
private val THUMBNAIL_PATH = Regex("^/vi(?:_webp)?/([A-Za-z0-9_-]{11})/")
/** `?v=<id>` anywhere in the query. */
private val WATCH_QUERY = Regex("[?&]v=([A-Za-z0-9_-]{11})(?:&|$)")
/** `/<id>` as the whole path, which is what youtu.be links are. */
private val SHORT_PATH = Regex("^/([A-Za-z0-9_-]{11})/?$")
/**
* The site the embed is told it is playing on.
*
* A constant, and a deliberately uninformative one: YouTube learns which app asked and nothing
* else. The article's own URL would satisfy the embed just as well and would tell YouTube what
* is being read, which is the thing choosing the nocookie domain already avoids. `.invalid` is
* reserved by RFC 2606, so this can never collide with somebody's real site.
*/
const val YOUTUBE_EMBED_REFERRER = "https://reader.feedreader.invalid/"
/**
* The video a long-press reported, as a watch URL, or null when the long-press was not on one.
*
* **What a long-press actually reports.** Not the link's href. The reader's video placeholder is
* `<a href="https://www.youtube.com/watch?v=ID"><img src="https://i.ytimg.com/vi/ID/hq720.jpg"></a>`,
* and for an image inside a link WebView's hit test gives SRC_IMAGE_ANCHOR_TYPE with the
* *image's* URL in `getExtra()`, not the anchor's. Confirmed on the device: long-pressing the
* thumbnail opened the ordinary link menu showing the i.ytimg.com JPEG. So the thumbnail URL is
* the shape that has to be recognised, and it carries the video id in its path.
*
* Watch and youtu.be links are recognised too, for wherever one does arrive -- a plain text link
* to a video in an article, which is not wrapped in an image.
*
* Anything on YouTube that is not one specific video -- a channel, a playlist -- returns null and
* gets the ordinary actions menu, since "open this directly" has no single video to mean.
*/
fun youTubeTargetForLongPress(url: String): String? {
val host = hostOf(url) ?: return null
val path = pathOf(url)
val id = when {
THUMBNAIL_HOST.matches(host) -> THUMBNAIL_PATH.find(path)?.groupValues?.get(1)
host !in YOUTUBE_HOSTS -> null
host.endsWith("youtu.be") -> SHORT_PATH.find(path)?.groupValues?.get(1)
else -> WATCH_QUERY.find(url)?.groupValues?.get(1)
}
return id?.let { youTubeWatchUrl(it) }
}
/** Where a video lives, in the form a phone hands to the YouTube app. */
fun youTubeWatchUrl(videoId: String): String = "https://www.youtube.com/watch?v=${scrub(videoId)}"
/**
* Where the player lives.
*
* `youtube-nocookie.com` rather than `youtube.com`: YouTube's own privacy-enhanced embed domain,
* so a casual view doesn't pull in the viewer's signed-in account and history. Must be loaded
* with [YOUTUBE_EMBED_REFERRER] as the `Referer` header or it refuses to play.
*/
fun youTubeEmbedUrl(videoId: String): String =
"https://www.youtube-nocookie.com/embed/${scrub(videoId)}?autoplay=1&playsinline=1&rel=0"
/**
* The id with anything outside YouTube's own alphabet removed.
*
* The ids reaching here are already matched against `[a-zA-Z0-9_-]{11}` upstream, so this
* changes nothing today. It exists because the value is spliced straight into an HTML
* attribute, and that should be safe on its own terms rather than because of a check in a
* different file that a later edit might move.
*/
private fun scrub(videoId: String): String = VIDEO_ID_CHARS.replace(videoId, "")
/** The path of [url], or "/" when it has none. */
private fun pathOf(url: String): String {
val afterScheme = url.substringAfter("://", missingDelimiterValue = "")
val afterAuthority = afterScheme.substringAfter('/', missingDelimiterValue = "")
val path = "/" + afterAuthority.substringBefore('?').substringBefore('#')
return path
}
/** The host of [url], lowercased, or null when it is not a URL with one. */
private fun hostOf(url: String): String? {
val afterScheme = url.substringAfter("://", missingDelimiterValue = "")
if (afterScheme.isEmpty()) return null
val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#')
val host = authority.substringAfterLast('@').substringBefore(':')
return host.lowercase().ifEmpty { null }
}
|