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:
Sulkta 2026-05-23 19:40:08 -07:00
parent c30c171c91
commit 0252f6c63f
9 changed files with 215 additions and 29 deletions

View file

@ -11,15 +11,33 @@
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity <activity
android:name=".StrawActivity" android:name=".StrawActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden"> android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </application>
</manifest> </manifest>

View file

@ -5,6 +5,7 @@
package com.sulkta.straw package com.sulkta.straw
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback 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.player.PlayerScreen
import com.sulkta.straw.feature.search.SearchScreen 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() { class StrawActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val startUrl = pickYouTubeUrl(intent)
setContent { setContent {
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = scheme) { MaterialTheme(colorScheme = scheme) {
Surface(modifier = Modifier.fillMaxSize()) { 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) { DisposableEffect(nav) {
val cb = object : OnBackPressedCallback(true) { 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.util.stripHtml
@Composable @Composable
fun VideoDetailScreen( fun VideoDetailScreen(
@ -123,7 +124,7 @@ fun VideoDetailScreen(
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = d.description.take(2000), text = stripHtml(d.description).take(2000),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }

View file

@ -8,11 +8,14 @@
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -23,6 +26,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@ -109,6 +114,7 @@ fun PlayerScreen(
val posSec = exoPlayer.currentPosition / 1000.0 val posSec = exoPlayer.currentPosition / 1000.0
val segment = pickActiveSegment(segments, posSec) val segment = pickActiveSegment(segments, posSec)
if (segment != null) { if (segment != null) {
Log.i("StrawSb", "skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)")
exoPlayer.seekTo((segment.endSec * 1000).toLong()) exoPlayer.seekTo((segment.endSec * 1000).toLong())
Toast.makeText( Toast.makeText(
context, context,
@ -137,15 +143,34 @@ fun PlayerScreen(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
else -> AndroidView( else -> {
factory = { ctx -> AndroidView(
PlayerView(ctx).apply { factory = { ctx ->
player = exoPlayer PlayerView(ctx).apply {
useController = true 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 package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -23,18 +24,29 @@ data class RydVotes(
) )
object RydClient { object RydClient {
private const val TAG = "StrawRyd"
private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val json = Json { ignoreUnknownKeys = true; isLenient = true }
/** Blocking — call from Dispatchers.IO. */ /** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? { fun fetch(videoId: String): RydVotes? {
val url = "https://returnyoutubedislikeapi.com/votes?videoId=$videoId"
Log.d(TAG, "fetch start: $videoId$url")
val req = Request.Builder() val req = Request.Builder()
.url("https://returnyoutubedislike.com/votes?videoId=$videoId") .url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT) .header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json")
.build() .build()
return NewPipeDownloader.client().newCall(req).execute().use { r -> return runCatching {
if (!r.isSuccessful) return@use null NewPipeDownloader.client().newCall(req).execute().use { r ->
val body = r.body?.string() ?: return@use null val code = r.code
runCatching { json.decodeFromString<RydVotes>(body) }.getOrNull() 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 package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -32,13 +33,9 @@ data class SbSegment(
} }
object SponsorBlockClient { object SponsorBlockClient {
private const val TAG = "StrawSb"
private val json = Json { ignoreUnknownKeys = true; isLenient = true } 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( fun fetch(
videoId: String, videoId: String,
categories: List<String> = listOf("sponsor"), categories: List<String> = listOf("sponsor"),
@ -46,18 +43,28 @@ object SponsorBlockClient {
val prefix = sha256Hex(videoId).substring(0, 4) val prefix = sha256Hex(videoId).substring(0, 4)
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" + val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
"categories=" + buildJsonArray(categories) "categories=" + buildJsonArray(categories)
Log.d(TAG, "fetch: videoId=$videoId prefix=$prefix url=$urlStr")
val req = Request.Builder() val req = Request.Builder()
.url(urlStr) .url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT) .header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json")
.build() .build()
return NewPipeDownloader.client().newCall(req).execute().use { r -> return runCatching {
if (!r.isSuccessful) return@use emptyList() NewPipeDownloader.client().newCall(req).execute().use { r ->
val body = r.body?.string() ?: return@use emptyList() val code = r.code
val all = runCatching { val bodyStr = r.body?.string() ?: ""
json.decodeFromString<List<SbVideoSegments>>(body) Log.d(TAG, "response: code=$code body_len=${bodyStr.length}")
}.getOrDefault(emptyList()) if (!r.isSuccessful) return@use emptyList()
all.firstOrNull { it.videoID == videoId }?.segments.orEmpty() 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 = private fun buildJsonArray(items: List<String>): String =

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* 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
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>