Straw phase F: visible polish — RYD, HTML, intent filter, network sec

Four polish items, all visible:
1. Fixed RYD URL: was returnyoutubedislike.com (the website, 404s on /votes)
   → returnyoutubedislikeapi.com (the actual API).
2. Bundled Sectigo "Public Server Authentication CA DV R36" intermediate
   as an additional trust anchor (network_security_config.xml) for the
   .com web host — the API uses Google Trust Services already so this
   is defensive, in case we ever fetch the landing page.
3. Added intent-filter for YouTube URLs (VIEW + SEND) on StrawActivity.
   Single-task launchMode; pickYouTubeUrl() routes the initial Intent
   to Screen.VideoDetail(url) instead of Home.
4. stripHtml() utility removes <br>, </p>, and other tags from
   NewPipeExtractor's description. Chapter timestamps now render
   readably; raw HTML gone.

Also added Log.d to SponsorBlockClient and RydClient for visible
verification (StrawSb / StrawRyd tags). PlayerScreen now shows a
"SB: N segments" overlay chip in the top-left so the user can see
that SponsorBlock is armed.

Verified on Android 14 emulator with "Me at the zoo" intent:
- RYD: 👍 18.9M / 👎 424.2K renders
- Intent: direct URL → VideoDetail (no Home pass-through)
- HTML: chapter timestamps render clean
This commit is contained in:
Kayos 2026-05-23 19:40:08 -07:00
parent 496ed30bda
commit f3b78b4530
9 changed files with 215 additions and 29 deletions

View file

@ -11,15 +11,33 @@
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".StrawActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Open YouTube URLs with Straw. -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="youtube.com" />
<data android:host="youtu.be" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -5,6 +5,7 @@
package com.sulkta.straw
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
@ -22,15 +23,23 @@ import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.search.SearchScreen
private val YT_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be")
private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)")
class StrawActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val startUrl = pickYouTubeUrl(intent)
setContent {
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = scheme) {
Surface(modifier = Modifier.fillMaxSize()) {
val nav = rememberNavigator()
val initial: Screen =
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
val nav = rememberNavigator(initial)
DisposableEffect(nav) {
val cb = object : OnBackPressedCallback(true) {
@ -70,4 +79,26 @@ class StrawActivity : ComponentActivity() {
}
}
}
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null
when (intent.action) {
Intent.ACTION_VIEW -> {
val data = intent.data?.toString() ?: return null
if (looksLikeYouTube(data)) return data
}
Intent.ACTION_SEND -> {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
val match = YT_URL_RE.find(shared) ?: return null
return match.value
}
}
return null
}
private fun looksLikeYouTube(url: String): Boolean {
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
return host.lowercase() in YT_HOSTS
}
}

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.util.stripHtml
@Composable
fun VideoDetailScreen(
@ -123,7 +124,7 @@ fun VideoDetailScreen(
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = d.description.take(2000),
text = stripHtml(d.description).take(2000),
style = MaterialTheme.typography.bodySmall,
)
}

View file

@ -8,11 +8,14 @@
package com.sulkta.straw.feature.player
import android.util.Log
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -23,6 +26,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@ -109,6 +114,7 @@ fun PlayerScreen(
val posSec = exoPlayer.currentPosition / 1000.0
val segment = pickActiveSegment(segments, posSec)
if (segment != null) {
Log.i("StrawSb", "skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)")
exoPlayer.seekTo((segment.endSec * 1000).toLong())
Toast.makeText(
context,
@ -137,15 +143,34 @@ fun PlayerScreen(
modifier = Modifier.padding(16.dp),
)
else -> AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
else -> {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
// SponsorBlock segment count badge — small overlay top-left.
resolved?.let { r ->
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = "SB: ${r.segments.size} segment${if (r.segments.size == 1) "" else "s"}",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}

View file

@ -8,6 +8,7 @@
package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -23,18 +24,29 @@ data class RydVotes(
)
object RydClient {
private const val TAG = "StrawRyd"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
val url = "https://returnyoutubedislikeapi.com/votes?videoId=$videoId"
Log.d(TAG, "fetch start: $videoId$url")
val req = Request.Builder()
.url("https://returnyoutubedislike.com/votes?videoId=$videoId")
.url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json")
.build()
return NewPipeDownloader.client().newCall(req).execute().use { r ->
if (!r.isSuccessful) return@use null
val body = r.body?.string() ?: return@use null
runCatching { json.decodeFromString<RydVotes>(body) }.getOrNull()
}
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code
val bodyStr = r.body?.string() ?: ""
Log.d(TAG, "response: code=$code, body[0..120]=${bodyStr.take(120)}")
if (!r.isSuccessful) return@use null
runCatching { json.decodeFromString<RydVotes>(bodyStr) }
.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
.getOrNull()
}
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull()
}
}

View file

@ -8,6 +8,7 @@
package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -32,13 +33,9 @@ data class SbSegment(
}
object SponsorBlockClient {
private const val TAG = "StrawSb"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
/**
* Fetch SponsorBlock segments for [videoId] limited to [categories].
* Returns segments matching the exact video ID (the API returns matches
* for the prefix, so we filter client-side).
*/
fun fetch(
videoId: String,
categories: List<String> = listOf("sponsor"),
@ -46,18 +43,28 @@ object SponsorBlockClient {
val prefix = sha256Hex(videoId).substring(0, 4)
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
"categories=" + buildJsonArray(categories)
Log.d(TAG, "fetch: videoId=$videoId prefix=$prefix url=$urlStr")
val req = Request.Builder()
.url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json")
.build()
return NewPipeDownloader.client().newCall(req).execute().use { r ->
if (!r.isSuccessful) return@use emptyList()
val body = r.body?.string() ?: return@use emptyList()
val all = runCatching {
json.decodeFromString<List<SbVideoSegments>>(body)
}.getOrDefault(emptyList())
all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
}
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code
val bodyStr = r.body?.string() ?: ""
Log.d(TAG, "response: code=$code body_len=${bodyStr.length}")
if (!r.isSuccessful) return@use emptyList()
val all = runCatching {
json.decodeFromString<List<SbVideoSegments>>(bodyStr)
}.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
.getOrDefault(emptyList())
val mine = all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
Log.d(TAG, "armed ${mine.size} segments for $videoId (response had ${all.size} matching-prefix videos)")
mine
}
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault(emptyList())
}
private fun buildJsonArray(items: List<String>): String =

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Strip HTML tags from NewPipeExtractor's description.content for plain-text
* rendering. Day-3 polish replaces this with a real Markwon/Compose annotated
* renderer; for now we just want readable text.
*/
package com.sulkta.straw.util
private val tagRe = Regex("</?[^>]+>")
private val entityRe = Regex("&[a-zA-Z#0-9]+;")
private val brRe = Regex("<br\\s*/?>", RegexOption.IGNORE_CASE)
private val pCloseRe = Regex("</p>", RegexOption.IGNORE_CASE)
private val entityMap = mapOf(
"&amp;" to "&", "&lt;" to "<", "&gt;" to ">", "&quot;" to "\"",
"&apos;" to "'", "&nbsp;" to " ", "&#39;" to "'", "&#34;" to "\"",
)
fun stripHtml(input: String): String {
if (input.isBlank()) return ""
var s = input
s = brRe.replace(s, "\n")
s = pCloseRe.replace(s, "\n\n")
s = tagRe.replace(s, "")
s = entityRe.replace(s) { entityMap[it.value] ?: it.value }
return s.trim().replace(Regex("\n{3,}"), "\n\n")
}

View file

@ -0,0 +1,36 @@
-----BEGIN CERTIFICATE-----
MIIGTDCCBDSgAwIBAgIQOXpmzCdWNi4NqofKbqvjsTANBgkqhkiG9w0BAQwFADBf
MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY
MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp
YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRFYgUjM2MIIBojANBgkqhkiG9w0B
AQEFAAOCAY8AMIIBigKCAYEAljZf2HIz7+SPUPQCQObZYcrxLTHYdf1ZtMRe7Yeq
RPSwygz16qJ9cAWtWNTcuICc++p8Dct7zNGxCpqmEtqifO7NvuB5dEVexXn9RFFH
12Hm+NtPRQgXIFjx6MSJcNWuVO3XGE57L1mHlcQYj+g4hny90aFh2SCZCDEVkAja
EMMfYPKuCjHuuF+bzHFb/9gV8P9+ekcHENF2nR1efGWSKwnfG5RawlkaQDpRtZTm
M64TIsv/r7cyFO4nSjs1jLdXYdz5q3a4L0NoabZfbdxVb+CUEHfB0bpulZQtH1Rv
38e/lIdP7OTTIlZh6OYL6NhxP8So0/sht/4J9mqIGxRFc0/pC8suja+wcIUna0HB
pXKfXTKpzgis+zmXDL06ASJf5E4A2/m+Hp6b84sfPAwQ766rI65mh50S0Di9E3Pn
2WcaJc+PILsBmYpgtmgWTR9eV9otfKRUBfzHUHcVgarub/XluEpRlTtZudU5xbFN
xx/DgMrXLUAPaI60fZ6wA+PTAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk
lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUaMASFhgOr872h6YyV6NGUV3LBycw
DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgEw
VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv
UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH
AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp
Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF
BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA
YtOC9Fy+TqECFw40IospI92kLGgoSZGPOSQXMBqmsGWZUQ7rux7cj1du6d9rD6C8
ze1B2eQjkrGkIL/OF1s7vSmgYVafsRoZd/IHUrkoQvX8FZwUsmPu7amgBfaY3g+d
q1x0jNGKb6I6Bzdl6LgMD9qxp+3i7GQOnd9J8LFSietY6Z4jUBzVoOoz8iAU84OF
h2HhAuiPw1ai0VnY38RTI+8kepGWVfGxfBWzwH9uIjeooIeaosVFvE8cmYUB4TSH
5dUyD0jHct2+8ceKEtIoFU/FfHq/mDaVnvcDCZXtIgitdMFQdMZaVehmObyhRdDD
4NQCs0gaI9AAgFj4L9QtkARzhQLNyRf87Kln+YU0lgCGr9HLg3rGO8q+Y4ppLsOd
unQZ6ZxPNGIfOApbPVf5hCe58EZwiWdHIMn9lPP6+F404y8NNugbQixBber+x536
WrZhFZLjEkhp7fFXf9r32rNPfb74X/U90Bdy4lzp3+X1ukh1BuMxA/EEhDoTOS3l
7ABvc7BYSQubQ2490OcdkIzUh3ZwDrakMVrbaTxUM2p24N6dB+ns2zptWCva6jzW
r8IWKIMxzxLPv5Kt3ePKcUdvkBU/smqujSczTzzSjIoR5QqQA6lN1ZRSnuHIWCvh
JEltkYnTAH41QJ6SAWO66GrrUESwN/cgZzL4JLEqz1Y=
-----END CERTIFICATE-----

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2026 Sulkta-Coop
SPDX-License-Identifier: GPL-3.0-or-later
Returnyoutubedislike.com serves a leaf cert without including the
Sectigo "Public Server Authentication CA DV R36" intermediate.
Android's system trust store has the USERTrust root but not the
intermediate, so chain reconstruction fails. We bundle the intermediate
as an additional trust anchor scoped to that domain.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config>
<domain includeSubdomains="true">returnyoutubedislike.com</domain>
<domain includeSubdomains="true">returnyoutubedislikeapi.com</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/sectigo_dv_r36" />
</trust-anchors>
</domain-config>
</network-security-config>