From f3b78b453048e9f921f3f1550fde4a3ff0f19fcb Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 19:40:08 -0700 Subject: [PATCH] =?UTF-8?q?Straw=20phase=20F:=20visible=20polish=20?= =?UTF-8?q?=E2=80=94=20RYD,=20HTML,=20intent=20filter,=20network=20sec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
,

, 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 --- strawApp/src/main/AndroidManifest.xml | 18 ++++++++ .../kotlin/com/sulkta/straw/StrawActivity.kt | 33 ++++++++++++++- .../straw/feature/detail/VideoDetailScreen.kt | 3 +- .../straw/feature/player/PlayerScreen.kt | 41 +++++++++++++++---- .../kotlin/com/sulkta/straw/net/RydClient.kt | 24 ++++++++--- .../sulkta/straw/net/SponsorBlockClient.kt | 33 +++++++++------ .../kotlin/com/sulkta/straw/util/HtmlText.kt | 30 ++++++++++++++ strawApp/src/main/res/raw/sectigo_dv_r36.crt | 36 ++++++++++++++++ .../main/res/xml/network_security_config.xml | 26 ++++++++++++ 9 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt create mode 100644 strawApp/src/main/res/raw/sectigo_dv_r36.crt create mode 100644 strawApp/src/main/res/xml/network_security_config.xml diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 603cf8b03..7c9b0c3a4 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -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"> + + + + + + + + + + + + + + + + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index bb0471331..376b3f0f4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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 + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 62e15073e..bda85ce24 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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, ) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index d2ee7ff5b..cfdd49988 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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(), - ) + } + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 1cfa6a7e9..7c30dacd4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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(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(bodyStr) } + .onFailure { Log.w(TAG, "json decode failed: ${it.message}") } + .getOrNull() + } + }.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") } + .getOrNull() } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index 70d664ef0..c8b524905 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -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 = 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>(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>(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 = diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt new file mode 100644 index 000000000..f228b23d2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt @@ -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("", RegexOption.IGNORE_CASE) +private val pCloseRe = Regex("

", RegexOption.IGNORE_CASE) + +private val entityMap = mapOf( + "&" to "&", "<" to "<", ">" to ">", """ to "\"", + "'" to "'", " " to " ", "'" to "'", """ 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") +} diff --git a/strawApp/src/main/res/raw/sectigo_dv_r36.crt b/strawApp/src/main/res/raw/sectigo_dv_r36.crt new file mode 100644 index 000000000..af4a9d661 --- /dev/null +++ b/strawApp/src/main/res/raw/sectigo_dv_r36.crt @@ -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----- diff --git a/strawApp/src/main/res/xml/network_security_config.xml b/strawApp/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..4ba64bd35 --- /dev/null +++ b/strawApp/src/main/res/xml/network_security_config.xml @@ -0,0 +1,26 @@ + + + + + + + + + + returnyoutubedislike.com + returnyoutubedislikeapi.com + + + + + +