v0.1.0-T (vc=7): bug fixes + Opus audit pass #2 + home redesign

User-visible:
- BUG: 'clicking a second item went back to the first video' — VideoDetailViewModel
  guard was short-circuiting on Activity-scoped ViewModel reuse. Now tracks
  loadedUrl and only skips when the requested URL matches.
- BUG: PiP / window mode now auto-enters on Home gesture (Android 12+ via
  setAutoEnterEnabled). Manual PiP button reports failure cause via Toast.
- HOME REDESIGN: replaced 3-tab bottom nav with hamburger ModalNavigationDrawer.
  Default view = sub feed. Drawer items = Subscriptions / History / Search /
  Settings. Top-left hamburger like normal Android apps.

Audit pass #2 (Opus max-effort) — CRIT + HIGH fixes shipped:
- CRIT-1: PlaybackService now calls startForeground() inside onStartCommand
  with a media-playback notification + channel. Pre-fix could throw
  ForegroundServiceDidNotStartInTimeException on Android 12+ and crash-kill.
- CRIT-2: AndroidManifest service exported=false. Previously any installed
  app could craft an Intent and drive playback from attacker URLs.
- HIGH-1: 🎧 background handoff stops the activity player before starting
  the service so we don't dual-host two ExoPlayers + MediaSessions.
- HIGH-2: onStartCommand returns START_NOT_STICKY and tears down on null
  intent; no more crash-restart-crash loop after OS kills.
- HIGH-3: stop service on STATE_ENDED / STATE_IDLE via Player.Listener.
  onTaskRemoved checks playbackState properly so we don't hold WAKE_LOCK
  forever after a video ends in background.
- HIGH-4: Downloader validates scheme=https + googlevideo/youtube host
  before handing the URL to DownloadManager.
- HIGH-5: filename sanitization extended to ASCII control chars, DEL,
  Unicode bidi-override block, leading-dot, trailing whitespace.
- HIGH-6: SubscriptionFeedViewModel cancels prior in-flight refresh,
  caps parallelism at 8 via Semaphore, applies 15s per-channel timeout.
- HIGH-7: sub feed error banner now shows above cached items when refresh
  fails (previously hidden, looked indistinguishable from success).
- HIGH-8: PlayerViewModel falls back to lowest-available stream when no
  stream is under the max-resolution ceiling (was: silent black screen).
- HIGH-9: network_security_config explicit cleartextTrafficPermitted='false'
  on the RYD domain-config block (doesn't inherit from base-config).
- MED-1: PlaybackService.onDestroy nulls field before releasing session to
  close a race with onGetSession during teardown.
- MED-6: Downloader catches enqueue exceptions, returns -1L, caller toasts
  'download refused (bad URL)' instead of crashing.

Deferred (audit said 'can wait'): MED-2..5, MED-7..11, HIGH-10 UX consistency.
This commit is contained in:
Kayos 2026-05-24 07:49:35 -07:00
parent 081f238355
commit 9550b207ab
11 changed files with 510 additions and 267 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 6
const val STRAW_VERSION_NAME = "0.1.0-S"
const val STRAW_VERSION_CODE = 7
const val STRAW_VERSION_NAME = "0.1.0-T"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -47,10 +47,14 @@
</intent-filter>
</activity>
<!-- Phase M-2: MediaSessionService for background audio + notification + lock-screen controls. -->
<!-- Phase M-2 / S: MediaSessionService for background audio + notification + lock-screen
controls. Marked NOT exported (audit CRIT-2): any installed app can otherwise
craft an Intent with the MediaSessionService action and drive playback from
attacker-controlled URLs. The intent-filter stays so the Media3 session router
can find the service within our own process. -->
<service
android:name=".feature.player.PlaybackService"
android:exported="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />

View file

@ -2,10 +2,8 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase P: bottom navigation host. Three tabs:
* - Home: search + recent-watches summary
* - Library: full recent-watches list
* - Subs: subscription feed (Q wires the actual feed; P just lists channels)
* Home shell: hamburger drawer top-left, sub-feed as the default landing
* view. Drawer items take you to History, Search, Settings.
*/
package com.sulkta.straw
@ -27,22 +25,29 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -50,9 +55,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.BuildConfig
@ -63,13 +65,11 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch
private enum class HomeTab(val label: String) {
Home("Home"),
Library("Library"),
Subs("Subs"),
}
private enum class HomeView { Subs, History }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StrawHome(
onOpenSearch: () -> Unit,
@ -78,29 +78,92 @@ fun StrawHome(
onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(),
) {
var tab by remember { mutableStateOf(HomeTab.Home) }
var view by remember { mutableStateOf(HomeView.Subs) }
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
Scaffold(
bottomBar = {
NavigationBar {
HomeTab.entries.forEach { t ->
NavigationBarItem(
selected = t == tab,
onClick = { tab = t },
icon = {
// Material-icons-core only ships a small set;
// use unicode for the rest.
when (t) {
HomeTab.Home -> Icon(Icons.Filled.Home, contentDescription = t.label)
HomeTab.Library -> Text("📺")
HomeTab.Subs -> Text("👤")
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "straw",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 24.dp),
)
Text(
text = "v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp),
)
Spacer(modifier = Modifier.height(20.dp))
NavigationDrawerItem(
label = { Text("Subscriptions") },
icon = { Text("👤") },
selected = view == HomeView.Subs,
onClick = {
view = HomeView.Subs
scope.launch { drawerState.close() }
},
label = { Text(t.label) },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("History") },
icon = { Text("📺") },
selected = view == HomeView.History,
onClick = {
view = HomeView.History
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem(
label = { Text("Search") },
icon = { Text("🔍") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenSearch()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("Settings") },
icon = { Text("") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenSettings()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = when (view) {
HomeView.Subs -> "Subscriptions"
HomeView.History -> "History"
},
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
@ -108,72 +171,9 @@ fun StrawHome(
.padding(padding)
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
HeaderRow(onOpenSettings = onOpenSettings)
Spacer(modifier = Modifier.height(12.dp))
when (tab) {
HomeTab.Home -> HomePane(onOpenSearch, onOpenVideo)
HomeTab.Library -> LibraryPane(onOpenVideo)
HomeTab.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
}
}
}
}
@Composable
private fun HeaderRow(onOpenSettings: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "straw",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
}
@Composable
private fun HomePane(
onOpenSearch: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val watches by History.get().watches.collectAsState()
Column {
Button(
onClick = onOpenSearch,
modifier = Modifier.fillMaxWidth(),
) { Text("Search YouTube") }
Spacer(modifier = Modifier.height(20.dp))
if (watches.isEmpty()) {
Text(
text = "Recently watched videos appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = "Recently watched",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(watches.take(10)) { w ->
RecentRow(w) { onOpenVideo(w.url, w.title) }
HorizontalDivider()
when (view) {
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
HomeView.History -> HistoryPane(onOpenVideo)
}
}
}
@ -181,18 +181,12 @@ private fun HomePane(
}
@Composable
private fun LibraryPane(onOpenVideo: (url: String, title: String) -> Unit) {
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
val watches by History.get().watches.collectAsState()
Column {
Text(
text = "Library",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${watches.size} watched videos",
text = "${watches.size} watched video${if (watches.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -200,7 +194,7 @@ private fun LibraryPane(onOpenVideo: (url: String, title: String) -> Unit) {
if (watches.isEmpty()) {
Text(
"No watch history yet. Play a video and it'll show up here.",
"Nothing watched yet. Play a video and it'll show up here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -226,49 +220,55 @@ private fun SubsPane(
LaunchedEffect(subs) { feedVm.refreshIfStale() }
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Subscriptions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${subs.size} channel${if (subs.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (subs.isNotEmpty()) {
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (subs.isEmpty()) {
Text(
"No subscriptions yet. Open a channel and tap Subscribe.",
"No subscriptions yet. Tap Search in the menu, find a channel, and Subscribe.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${subs.size} channel${if (subs.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Channel chip row.
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(subs) { ch -> SubChip(ch, onOpenChannel) }
}
Spacer(modifier = Modifier.height(16.dp))
// Aggregated feed below.
// Show a slim error banner above cached items even if we have data —
// audit HIGH-7: previously a 401/429 looked identical to a successful
// refresh because the error chip was hidden whenever items != empty.
if (feed.error != null && feed.items.isNotEmpty()) {
Text(
text = "refresh failed: ${feed.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
}
when {
feed.loading && feed.items.isEmpty() -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Pulling subscription feed...", style = MaterialTheme.typography.bodySmall)
Text(
"Pulling latest from your subs...",
style = MaterialTheme.typography.bodySmall,
)
}
}
feed.error != null && feed.items.isEmpty() -> {
@ -279,12 +279,6 @@ private fun SubsPane(
)
}
else -> {
Text(
"Latest from your subs",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(feed.items) { item ->
FeedRow(item) { onOpenVideo(item.url, item.title) }

View file

@ -213,8 +213,9 @@ fun VideoDetailScreen(
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (audio != null) {
Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
Toast.makeText(context, "audio queued", Toast.LENGTH_SHORT).show()
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
@ -230,8 +231,9 @@ fun VideoDetailScreen(
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (video != null) {
Downloader.enqueue(context, video, d.title, DownloadKind.Video)
Toast.makeText(context, "video queued", Toast.LENGTH_SHORT).show()
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}

View file

@ -48,13 +48,13 @@ class VideoDetailViewModel : ViewModel() {
private val _ui = MutableStateFlow(VideoDetailUiState())
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
private var loadedUrl: String? = null
fun load(streamUrl: String) {
// AUD-HIGH: previous guard was a dead-code if-block. The
// LaunchedEffect(streamUrl) caller only fires once per key + a new
// ViewModel is constructed for each nav entry, so the guard isn't
// strictly needed — but a real one is cheap insurance against
// future callers.
if (_ui.value.detail != null) return
// viewModel() is Activity-scoped, so the same VM is reused across
// navigations. Compare the requested URL with what we last loaded.
if (loadedUrl == streamUrl && _ui.value.detail != null) return
loadedUrl = streamUrl
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {

View file

@ -6,6 +6,14 @@
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
* on older Android. The user can pull files out via a file manager
* (under Android/data/com.sulkta.straw.debug/files/...).
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-4: scheme + host validation on the URL before handing it to
* DownloadManager extractor output is not trusted root-of-truth.
* HIGH-5: harder filename sanitization control chars, bidi overrides,
* leading dots, trailing whitespace.
* MED-6: catch IllegalArgumentException from enqueue so a malformed URI
* doesn't crash the click handler.
*/
package com.sulkta.straw.feature.download
@ -15,6 +23,7 @@ import android.content.Context
import android.net.Uri
import android.os.Environment
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
enum class DownloadKind(val subdir: String, val ext: String) {
Audio("audio", ".m4a"),
@ -22,21 +31,28 @@ enum class DownloadKind(val subdir: String, val ext: String) {
}
object Downloader {
// HIGH-5 — ASCII control chars + DEL + Unicode bidi-override block.
private val UNSAFE_CHARS = Regex("[\\x00-\\x1F\\x7F\\u202A-\\u202E]")
private val PATH_SEP_CHARS = Regex("[\\\\/:*?\"<>|]")
fun enqueue(
context: Context,
url: String,
title: String,
kind: DownloadKind,
): Long {
if (!isAllowedDownloadUrl(url)) {
strawLogW("StrawDl") { "refused non-YT URL: ${url.take(80)}" }
return -1L
}
val ctx = context.applicationContext
val safeTitle = title
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.take(120)
.ifBlank { "straw-${System.currentTimeMillis()}" }
val safeTitle = sanitizeFilename(title)
val filename = "$safeTitle${kind.ext}"
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(url))
val req = runCatching {
DownloadManager.Request(Uri.parse(url))
.setTitle(title)
.setDescription("Straw — ${kind.name.lowercase()}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
@ -47,9 +63,33 @@ object Downloader {
Environment.DIRECTORY_MOVIES + "/" + kind.subdir,
filename,
)
}.getOrElse {
strawLogW("StrawDl") { "Request.build failed: ${it.message}" }
return -1L
}
val id = dm.enqueue(req)
strawLogD("StrawDl") { "enqueued $kind id=$id title=$title file=$filename" }
return id
return runCatching { dm.enqueue(req) }
.onSuccess { id -> strawLogD("StrawDl") { "enqueued $kind id=$id file=$filename" } }
.onFailure { strawLogW("StrawDl") { "enqueue failed: ${it.message}" } }
.getOrDefault(-1L)
}
private fun sanitizeFilename(raw: String): String {
val cleaned = raw
.replace(UNSAFE_CHARS, "_")
.replace(PATH_SEP_CHARS, "_")
.trim()
.trimStart('.')
.take(120)
return cleaned.ifBlank { "straw-${System.currentTimeMillis()}" }
}
private fun isAllowedDownloadUrl(url: String): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true)) return false
val host = uri.host?.lowercase() ?: return false
return host.endsWith(".googlevideo.com") ||
host.endsWith(".youtube.com") ||
host == "youtube.com"
}
}

View file

@ -4,7 +4,14 @@
*
* Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* fetches in parallel, merges by upload timestamp, caps at 200 items.
* fetches in parallel, merges by view count desc, caps at 200 items.
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
* concurrency with a Semaphore, time-bound each per-channel fetch so
* one hung channel can't stall the whole feed.
* MED-7: use `update { }` for atomic UI-state writes (matches the
* convention applied to the data stores in audit pass #1).
*/
package com.sulkta.straw.feature.feed
@ -16,13 +23,19 @@ import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
@ -44,6 +57,15 @@ class SubscriptionFeedViewModel : ViewModel() {
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
private val cacheTtlMs = 10L * 60 * 1000
/** Per-channel fetch timeout — slowest channel can't stall the whole batch. */
private val perChannelTimeoutMs = 15_000L
/** Cap parallel network fetches even with 100+ subs. */
private val parallelism = 8
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null
fun refreshIfStale() {
val now = System.currentTimeMillis()
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
@ -53,22 +75,28 @@ class SubscriptionFeedViewModel : ViewModel() {
fun refresh() {
val channels = Subscriptions.get().subs.value
if (channels.isEmpty()) {
_ui.value = SubscriptionFeedUiState(loading = false, items = emptyList())
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
return
}
_ui.value = _ui.value.copy(loading = true, error = null)
viewModelScope.launch {
inFlight?.cancel()
_ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val perChannelMax = 5
val gate = Semaphore(parallelism)
coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = ChannelInfo.getInfo(service, ch.url)
val tab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull() ?: return@async emptyList<StreamItem>()
} ?: info.tabs.firstOrNull()
?: return@runCatching emptyList<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
@ -87,29 +115,37 @@ class SubscriptionFeedViewModel : ViewModel() {
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
}
}
}
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp from extractor's StreamInfoItem
// in all cases — keep the per-channel insertion order (newest first
// within each channel) and interleave by simple round-robin position.
// Sort by view count desc as a soft proxy for recency-popularity.
// in all cases — sort by view count desc as a soft proxy for
// recency-popularity within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
}
_ui.value = SubscriptionFeedUiState(
_ui.update {
SubscriptionFeedUiState(
loading = false,
items = items,
lastFetchedAt = System.currentTimeMillis(),
)
}
} catch (t: Throwable) {
_ui.value = SubscriptionFeedUiState(
_ui.update {
it.copy(
loading = false,
items = _ui.value.items,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}
}

View file

@ -8,6 +8,17 @@
* this service with the audio URL. Audio continues even if the activity
* is killed (swipe out of recents).
*
* Audit fixes (2026-05-24 pass #2):
* CRIT-1: call startForeground() immediately on first onStartCommand so
* Android 12+ doesn't kill the process with
* ForegroundServiceDidNotStartInTimeException after the 5s window.
* HIGH-2: return START_NOT_STICKY when there is no playable URL the
* OS will not relaunch us with a null intent and crash-loop.
* HIGH-3: stop the service when playback ends (Player.Listener) so the
* WAKE_LOCK / foreground notification doesn't linger.
* MED-1: null the field before releasing the session to close a tiny
* onGetSession race during teardown.
*
* Limitations:
* - Single URL only. The activity-side merged-DASH path doesn't carry
* over (we just use the best audioStream). Acceptable trade-off for
@ -19,11 +30,19 @@
package com.sulkta.straw.feature.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
@ -37,9 +56,12 @@ import com.sulkta.straw.extractor.NewPipeDownloader
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private var foregroundStarted = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
@ -57,6 +79,15 @@ class PlaybackService : MediaSessionService() {
)
.build()
// HIGH-3: end-of-playback should release the foreground slot.
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
stopSelf()
}
}
})
val sessionActivityIntent = PendingIntent.getActivity(
this,
0,
@ -78,11 +109,21 @@ class PlaybackService : MediaSessionService() {
flags: Int,
startId: Int,
): Int {
val url = intent?.getStringExtra(EXTRA_URL)
// CRIT-1: must startForeground within ~5s of startForegroundService,
// before anything that can throw or block.
startForegroundCompat()
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
val title = intent?.getStringExtra(EXTRA_TITLE)
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
if (url != null) {
val player = mediaSession?.player ?: return super.onStartCommand(intent, flags, startId)
val player = mediaSession?.player
if (url == null || player == null) {
// HIGH-2: nothing to play (likely a re-launch with null intent
// after a kill). Tear down so we don't sit holding the FG slot.
stopSelf()
return START_NOT_STICKY
}
val item = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(
@ -95,31 +136,97 @@ class PlaybackService : MediaSessionService() {
player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
}
return super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
// If audio is still playing when user swipes Straw out of recents,
// KEEP playing. Only stop the service when nothing is queued.
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
// HIGH-3: keep service alive ONLY while playback is genuinely in
// progress. After STATE_ENDED, playWhenReady stays true but state
// is ENDED — old check missed that and held WAKE_LOCK forever.
val p = mediaSession?.player
val keep = p != null &&
p.playWhenReady &&
p.mediaItemCount > 0 &&
p.playbackState != Player.STATE_IDLE &&
p.playbackState != Player.STATE_ENDED
if (!keep) stopSelf()
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
// MED-1: null the field first so a late onGetSession from the
// controller-binding teardown gets null instead of a released session.
val s = mediaSession
mediaSession = null
}
s?.player?.release()
s?.release()
super.onDestroy()
}
private fun startForegroundCompat() {
if (foregroundStarted) return
val tap = PendingIntent.getActivity(
this,
0,
Intent(this, StrawActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val notification: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("Straw")
.setContentText("Background audio")
.setContentIntent(tap)
.setOngoing(true)
.setCategory(Notification.CATEGORY_TRANSPORT)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIF_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(NOTIF_ID, notification)
}
foregroundStarted = true
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(NOTIF_CHANNEL_ID) != null) return
val ch = NotificationChannel(
NOTIF_CHANNEL_ID,
"Background audio",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Straw audio playback while the app is in background"
setShowBadge(false)
}
nm.createNotificationChannel(ch)
}
/**
* HIGH-4 mirror on the service side: the URL in EXTRA_URL came from
* NewPipeExtractor's audioStream.content. Re-validate host + scheme
* before handing it to ExoPlayer's HTTP source. Only YT googlevideo
* hosts allowed; HTTPS only.
*/
private fun isAllowedAudioUrl(url: String): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true)) return false
val host = uri.host?.lowercase() ?: return false
return host.endsWith(".googlevideo.com") ||
host.endsWith(".youtube.com") ||
host == "youtube.com"
}
companion object {
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
private const val NOTIF_CHANNEL_ID = "straw.playback"
private const val NOTIF_ID = 4242
}
}

View file

@ -121,6 +121,31 @@ fun PlayerScreen(
}
}
// PiP setup: on Android 12+ tell the OS this activity can auto-enter
// PiP, so when the user presses Home or swipes away the video shrinks
// into a floating window instead of pausing/exiting. Aspect ratio is
// set eagerly so the system can sample it before the first transition.
val activity = context as? Activity
DisposableEffect(activity) {
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setAutoEnterEnabled(true)
.build()
runCatching { activity.setPictureInPictureParams(params) }
}
onDispose {
// Disable auto-enter when leaving the player so the rest of the
// app doesn't accidentally PiP on background.
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val off = PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
runCatching { activity.setPictureInPictureParams(off) }
}
}
}
// AUD-MED: pause playback when app goes to background. Without this,
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
// from the notification shade. EXCEPTION: don't pause when entering
@ -289,17 +314,43 @@ fun PlayerScreen(
}
context.startActivity(Intent.createChooser(send, "Share video"))
}
// PiP
// PiP — manual entry (auto-enter on home gesture is wired
// up via the DisposableEffect above on Android 12+).
OverlayButton(label = "") {
val activity = (context as? Activity) ?: return@OverlayButton
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val act = (context as? Activity)
if (act == null) {
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
return@OverlayButton
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OverlayButton
}
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
runCatching { activity.enterPictureInPictureMode(params) }
val result = runCatching { act.enterPictureInPictureMode(params) }
result.onSuccess { ok ->
if (!ok) {
Toast.makeText(
context,
"PiP refused — check Settings > Apps > Straw > PiP",
Toast.LENGTH_LONG,
).show()
}
}
// Background audio (phase S) — independent foreground-service playback
result.onFailure { t ->
Toast.makeText(
context,
"PiP failed: ${t.message ?: t.javaClass.simpleName}",
Toast.LENGTH_LONG,
).show()
}
}
// Background audio (phase S) — independent foreground-service playback.
// Audit HIGH-1: handing off, not dual-hosting. Stop activity's player
// first so the OS sees a single MediaSession (cleaner lockscreen +
// audio focus) and we don't leak two active ExoPlayers.
OverlayButton(label = "🎧") {
val r = resolved ?: return@OverlayButton
val audio = r.audioUrl ?: r.combinedUrl
@ -307,7 +358,8 @@ fun PlayerScreen(
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
return@OverlayButton
}
exoPlayer.pause()
runCatching { exoPlayer.stop() }
runCatching { exoPlayer.clearMediaItems() }
val intent = Intent(context, PlaybackService::class.java).apply {
component = ComponentName(context, PlaybackService::class.java)
putExtra(PlaybackService.EXTRA_URL, audio)

View file

@ -64,14 +64,20 @@ class PlayerViewModel : ViewModel() {
fun heightOf(q: String?): Int =
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
val combined = info.videoStreams
?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val videoOnly = info.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
// Audit HIGH-8: when no stream is under the resolution ceiling
// (e.g. user picked 144p but the video only has 360p+), fall
// back to the lowest-resolution available instead of returning
// null and showing a black-screen player.
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
if (streams.isNullOrEmpty()) return null
val withContent = streams.filter { it.content?.isNotBlank() == true }
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
val pool = filtered.ifEmpty { withContent }
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
}
val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }

View file

@ -15,7 +15,9 @@
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config>
<!-- domain-config does NOT inherit cleartextTrafficPermitted from base-config; set it
explicitly here so RYD stays HTTPS-only even if the default ever flips. -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">returnyoutubedislike.com</domain>
<domain includeSubdomains="true">returnyoutubedislikeapi.com</domain>
<trust-anchors>