summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/YouTubeLinks.kt
blob: 00624a1fbfde36f88251539bae037758c59cce5f (plain)
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 }
}