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:
parent
798c5d1c92
commit
9c4b3f8318
11 changed files with 510 additions and 267 deletions
|
|
@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
|
||||||
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
const val STRAW_VERSION_CODE = 6
|
const val STRAW_VERSION_CODE = 7
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-S"
|
const val STRAW_VERSION_NAME = "0.1.0-T"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,14 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".feature.player.PlaybackService"
|
android:name=".feature.player.PlaybackService"
|
||||||
android:exported="true"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
* SPDX-FileCopyrightText: 2026 Sulkta
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* Phase P: bottom navigation host. Three tabs:
|
* Home shell: hamburger drawer top-left, sub-feed as the default landing
|
||||||
* - Home: search + recent-watches summary
|
* view. Drawer items take you to History, Search, Settings.
|
||||||
* - Library: full recent-watches list
|
|
||||||
* - Subs: subscription feed (Q wires the actual feed; P just lists channels)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.sulkta.straw
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.BuildConfig
|
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.feed.SubscriptionFeedViewModel
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
import com.sulkta.straw.util.formatViews
|
import com.sulkta.straw.util.formatViews
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class HomeTab(val label: String) {
|
private enum class HomeView { Subs, History }
|
||||||
Home("Home"),
|
|
||||||
Library("Library"),
|
|
||||||
Subs("Subs"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StrawHome(
|
fun StrawHome(
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
|
@ -78,29 +78,92 @@ fun StrawHome(
|
||||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||||
feedVm: SubscriptionFeedViewModel = viewModel(),
|
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(
|
ModalNavigationDrawer(
|
||||||
bottomBar = {
|
drawerState = drawerState,
|
||||||
NavigationBar {
|
drawerContent = {
|
||||||
HomeTab.entries.forEach { t ->
|
ModalDrawerSheet {
|
||||||
NavigationBarItem(
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
selected = t == tab,
|
Text(
|
||||||
onClick = { tab = t },
|
text = "straw",
|
||||||
icon = {
|
style = MaterialTheme.typography.displaySmall,
|
||||||
// Material-icons-core only ships a small set;
|
color = MaterialTheme.colorScheme.primary,
|
||||||
// use unicode for the rest.
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
when (t) {
|
)
|
||||||
HomeTab.Home -> Icon(Icons.Filled.Home, contentDescription = t.label)
|
Text(
|
||||||
HomeTab.Library -> Text("📺")
|
text = "v${BuildConfig.VERSION_NAME}",
|
||||||
HomeTab.Subs -> Text("👤")
|
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 ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -108,72 +171,9 @@ fun StrawHome(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
HeaderRow(onOpenSettings = onOpenSettings)
|
when (view) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
|
||||||
when (tab) {
|
HomeView.History -> HistoryPane(onOpenVideo)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,18 +181,12 @@ private fun HomePane(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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()
|
val watches by History.get().watches.collectAsState()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Library",
|
text = "${watches.size} watched video${if (watches.size == 1) "" else "s"}",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${watches.size} watched videos",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -200,7 +194,7 @@ private fun LibraryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
||||||
|
|
||||||
if (watches.isEmpty()) {
|
if (watches.isEmpty()) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -226,49 +220,55 @@ private fun SubsPane(
|
||||||
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
||||||
|
|
||||||
Column {
|
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()) {
|
if (subs.isEmpty()) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
return@Column
|
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.
|
// Channel chip row.
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
items(subs) { ch -> SubChip(ch, onOpenChannel) }
|
items(subs) { ch -> SubChip(ch, onOpenChannel) }
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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 {
|
when {
|
||||||
feed.loading && feed.items.isEmpty() -> {
|
feed.loading && feed.items.isEmpty() -> {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||||
Spacer(modifier = Modifier.width(8.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() -> {
|
feed.error != null && feed.items.isEmpty() -> {
|
||||||
|
|
@ -279,12 +279,6 @@ private fun SubsPane(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Text(
|
|
||||||
"Latest from your subs",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(feed.items) { item ->
|
items(feed.items) { item ->
|
||||||
FeedRow(item) { onOpenVideo(item.url, item.title) }
|
FeedRow(item) { onOpenVideo(item.url, item.title) }
|
||||||
|
|
|
||||||
|
|
@ -213,8 +213,9 @@ fun VideoDetailScreen(
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
?.maxByOrNull { it.bitrate ?: 0 }
|
||||||
?.content
|
?.content
|
||||||
if (audio != null) {
|
if (audio != null) {
|
||||||
Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
|
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
|
||||||
Toast.makeText(context, "audio queued", Toast.LENGTH_SHORT).show()
|
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
@ -230,8 +231,9 @@ fun VideoDetailScreen(
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
?.maxByOrNull { it.bitrate ?: 0 }
|
||||||
?.content
|
?.content
|
||||||
if (video != null) {
|
if (video != null) {
|
||||||
Downloader.enqueue(context, video, d.title, DownloadKind.Video)
|
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
|
||||||
Toast.makeText(context, "video queued", Toast.LENGTH_SHORT).show()
|
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,13 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
private val _ui = MutableStateFlow(VideoDetailUiState())
|
private val _ui = MutableStateFlow(VideoDetailUiState())
|
||||||
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
|
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
|
||||||
|
|
||||||
|
private var loadedUrl: String? = null
|
||||||
|
|
||||||
fun load(streamUrl: String) {
|
fun load(streamUrl: String) {
|
||||||
// AUD-HIGH: previous guard was a dead-code if-block. The
|
// viewModel() is Activity-scoped, so the same VM is reused across
|
||||||
// LaunchedEffect(streamUrl) caller only fires once per key + a new
|
// navigations. Compare the requested URL with what we last loaded.
|
||||||
// ViewModel is constructed for each nav entry, so the guard isn't
|
if (loadedUrl == streamUrl && _ui.value.detail != null) return
|
||||||
// strictly needed — but a real one is cheap insurance against
|
loadedUrl = streamUrl
|
||||||
// future callers.
|
|
||||||
if (_ui.value.detail != null) return
|
|
||||||
_ui.value = VideoDetailUiState(loading = true)
|
_ui.value = VideoDetailUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@
|
||||||
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
|
* 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
|
* on older Android. The user can pull files out via a file manager
|
||||||
* (under Android/data/com.sulkta.straw.debug/files/...).
|
* (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
|
package com.sulkta.straw.feature.download
|
||||||
|
|
@ -15,6 +23,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import com.sulkta.straw.util.strawLogD
|
import com.sulkta.straw.util.strawLogD
|
||||||
|
import com.sulkta.straw.util.strawLogW
|
||||||
|
|
||||||
enum class DownloadKind(val subdir: String, val ext: String) {
|
enum class DownloadKind(val subdir: String, val ext: String) {
|
||||||
Audio("audio", ".m4a"),
|
Audio("audio", ".m4a"),
|
||||||
|
|
@ -22,21 +31,28 @@ enum class DownloadKind(val subdir: String, val ext: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
object Downloader {
|
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(
|
fun enqueue(
|
||||||
context: Context,
|
context: Context,
|
||||||
url: String,
|
url: String,
|
||||||
title: String,
|
title: String,
|
||||||
kind: DownloadKind,
|
kind: DownloadKind,
|
||||||
): Long {
|
): Long {
|
||||||
|
if (!isAllowedDownloadUrl(url)) {
|
||||||
|
strawLogW("StrawDl") { "refused non-YT URL: ${url.take(80)}" }
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
val ctx = context.applicationContext
|
val ctx = context.applicationContext
|
||||||
val safeTitle = title
|
val safeTitle = sanitizeFilename(title)
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
|
||||||
.take(120)
|
|
||||||
.ifBlank { "straw-${System.currentTimeMillis()}" }
|
|
||||||
val filename = "$safeTitle${kind.ext}"
|
val filename = "$safeTitle${kind.ext}"
|
||||||
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
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)
|
.setTitle(title)
|
||||||
.setDescription("Straw — ${kind.name.lowercase()}")
|
.setDescription("Straw — ${kind.name.lowercase()}")
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
|
@ -47,9 +63,33 @@ object Downloader {
|
||||||
Environment.DIRECTORY_MOVIES + "/" + kind.subdir,
|
Environment.DIRECTORY_MOVIES + "/" + kind.subdir,
|
||||||
filename,
|
filename,
|
||||||
)
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
strawLogW("StrawDl") { "Request.build failed: ${it.message}" }
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
val id = dm.enqueue(req)
|
return runCatching { dm.enqueue(req) }
|
||||||
strawLogD("StrawDl") { "enqueued $kind id=$id title=$title file=$filename" }
|
.onSuccess { id -> strawLogD("StrawDl") { "enqueued $kind id=$id file=$filename" } }
|
||||||
return id
|
.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,14 @@
|
||||||
*
|
*
|
||||||
* Phase Q: aggregate latest videos across all subscribed channels into a
|
* Phase Q: aggregate latest videos across all subscribed channels into a
|
||||||
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
|
* 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
|
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.bestThumbnail
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import org.schabi.newpipe.extractor.ServiceList
|
import org.schabi.newpipe.extractor.ServiceList
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
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. */
|
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
|
||||||
private val cacheTtlMs = 10L * 60 * 1000
|
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() {
|
fun refreshIfStale() {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
|
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
|
||||||
|
|
@ -53,22 +75,28 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val channels = Subscriptions.get().subs.value
|
val channels = Subscriptions.get().subs.value
|
||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
_ui.value = SubscriptionFeedUiState(loading = false, items = emptyList())
|
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ui.value = _ui.value.copy(loading = true, error = null)
|
inFlight?.cancel()
|
||||||
viewModelScope.launch {
|
_ui.update { it.copy(loading = true, error = null) }
|
||||||
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val items = withContext(Dispatchers.IO) {
|
val items = withContext(Dispatchers.IO) {
|
||||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||||
val perChannelMax = 5
|
val perChannelMax = 5
|
||||||
|
val gate = Semaphore(parallelism)
|
||||||
|
coroutineScope {
|
||||||
val deferreds = channels.map { ch ->
|
val deferreds = channels.map { ch ->
|
||||||
async {
|
async {
|
||||||
|
gate.withPermit {
|
||||||
|
withTimeoutOrNull(perChannelTimeoutMs) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val info = ChannelInfo.getInfo(service, ch.url)
|
val info = ChannelInfo.getInfo(service, ch.url)
|
||||||
val tab = info.tabs.firstOrNull {
|
val tab = info.tabs.firstOrNull {
|
||||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
||||||
} ?: info.tabs.firstOrNull() ?: return@async emptyList<StreamItem>()
|
} ?: info.tabs.firstOrNull()
|
||||||
|
?: return@runCatching emptyList<StreamItem>()
|
||||||
ChannelTabInfo.getInfo(service, tab)
|
ChannelTabInfo.getInfo(service, tab)
|
||||||
.relatedItems
|
.relatedItems
|
||||||
.filterIsInstance<StreamInfoItem>()
|
.filterIsInstance<StreamInfoItem>()
|
||||||
|
|
@ -87,29 +115,37 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
|
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
} ?: run {
|
||||||
|
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deferreds.awaitAll()
|
deferreds.awaitAll()
|
||||||
|
}
|
||||||
.flatten()
|
.flatten()
|
||||||
// No reliable upload-timestamp from extractor's StreamInfoItem
|
// No reliable upload-timestamp from extractor's StreamInfoItem
|
||||||
// in all cases — keep the per-channel insertion order (newest first
|
// in all cases — sort by view count desc as a soft proxy for
|
||||||
// within each channel) and interleave by simple round-robin position.
|
// recency-popularity within the recent window.
|
||||||
// Sort by view count desc as a soft proxy for recency-popularity.
|
|
||||||
.sortedByDescending { it.viewCount }
|
.sortedByDescending { it.viewCount }
|
||||||
.take(200)
|
.take(200)
|
||||||
}
|
}
|
||||||
_ui.value = SubscriptionFeedUiState(
|
_ui.update {
|
||||||
|
SubscriptionFeedUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
items = items,
|
items = items,
|
||||||
lastFetchedAt = System.currentTimeMillis(),
|
lastFetchedAt = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = SubscriptionFeedUiState(
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
loading = false,
|
loading = false,
|
||||||
items = _ui.value.items,
|
|
||||||
error = t.message ?: t.javaClass.simpleName,
|
error = t.message ?: t.javaClass.simpleName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@
|
||||||
* this service with the audio URL. Audio continues even if the activity
|
* this service with the audio URL. Audio continues even if the activity
|
||||||
* is killed (swipe out of recents).
|
* 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:
|
* Limitations:
|
||||||
* - Single URL only. The activity-side merged-DASH path doesn't carry
|
* - Single URL only. The activity-side merged-DASH path doesn't carry
|
||||||
* over (we just use the best audioStream). Acceptable trade-off for
|
* over (we just use the best audioStream). Acceptable trade-off for
|
||||||
|
|
@ -19,11 +30,19 @@
|
||||||
|
|
||||||
package com.sulkta.straw.feature.player
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
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.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DefaultHttpDataSource
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
@ -37,9 +56,12 @@ import com.sulkta.straw.extractor.NewPipeDownloader
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
|
private var foregroundStarted = false
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
ensureChannel()
|
||||||
|
|
||||||
val httpFactory = DefaultHttpDataSource.Factory()
|
val httpFactory = DefaultHttpDataSource.Factory()
|
||||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
|
|
@ -57,6 +79,15 @@ class PlaybackService : MediaSessionService() {
|
||||||
)
|
)
|
||||||
.build()
|
.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(
|
val sessionActivityIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
|
|
@ -78,11 +109,21 @@ class PlaybackService : MediaSessionService() {
|
||||||
flags: Int,
|
flags: Int,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): 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 title = intent?.getStringExtra(EXTRA_TITLE)
|
||||||
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
||||||
if (url != null) {
|
val player = mediaSession?.player
|
||||||
val player = mediaSession?.player ?: return super.onStartCommand(intent, flags, startId)
|
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()
|
val item = MediaItem.Builder()
|
||||||
.setUri(url)
|
.setUri(url)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
|
|
@ -95,31 +136,97 @@ class PlaybackService : MediaSessionService() {
|
||||||
player.setMediaItem(item)
|
player.setMediaItem(item)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
}
|
return START_NOT_STICKY
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
// If audio is still playing when user swipes Straw out of recents,
|
// HIGH-3: keep service alive ONLY while playback is genuinely in
|
||||||
// KEEP playing. Only stop the service when nothing is queued.
|
// progress. After STATE_ENDED, playWhenReady stays true but state
|
||||||
val player = mediaSession?.player
|
// is ENDED — old check missed that and held WAKE_LOCK forever.
|
||||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
val p = mediaSession?.player
|
||||||
stopSelf()
|
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() {
|
override fun onDestroy() {
|
||||||
mediaSession?.run {
|
// MED-1: null the field first so a late onGetSession from the
|
||||||
player.release()
|
// controller-binding teardown gets null instead of a released session.
|
||||||
release()
|
val s = mediaSession
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
}
|
s?.player?.release()
|
||||||
|
s?.release()
|
||||||
super.onDestroy()
|
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 {
|
companion object {
|
||||||
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
||||||
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
||||||
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
|
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
|
||||||
|
|
||||||
|
private const val NOTIF_CHANNEL_ID = "straw.playback"
|
||||||
|
private const val NOTIF_ID = 4242
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
// AUD-MED: pause playback when app goes to background. Without this,
|
||||||
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
|
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
|
||||||
// from the notification shade. EXCEPTION: don't pause when entering
|
// from the notification shade. EXCEPTION: don't pause when entering
|
||||||
|
|
@ -289,17 +314,43 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
context.startActivity(Intent.createChooser(send, "Share video"))
|
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 = "⊟") {
|
OverlayButton(label = "⊟") {
|
||||||
val activity = (context as? Activity) ?: return@OverlayButton
|
val act = (context as? Activity)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
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()
|
val params = PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(Rational(16, 9))
|
.setAspectRatio(Rational(16, 9))
|
||||||
.build()
|
.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 = "🎧") {
|
OverlayButton(label = "🎧") {
|
||||||
val r = resolved ?: return@OverlayButton
|
val r = resolved ?: return@OverlayButton
|
||||||
val audio = r.audioUrl ?: r.combinedUrl
|
val audio = r.audioUrl ?: r.combinedUrl
|
||||||
|
|
@ -307,7 +358,8 @@ fun PlayerScreen(
|
||||||
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
||||||
return@OverlayButton
|
return@OverlayButton
|
||||||
}
|
}
|
||||||
exoPlayer.pause()
|
runCatching { exoPlayer.stop() }
|
||||||
|
runCatching { exoPlayer.clearMediaItems() }
|
||||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||||
component = ComponentName(context, PlaybackService::class.java)
|
component = ComponentName(context, PlaybackService::class.java)
|
||||||
putExtra(PlaybackService.EXTRA_URL, audio)
|
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||||
|
|
|
||||||
|
|
@ -64,14 +64,20 @@ class PlayerViewModel : ViewModel() {
|
||||||
fun heightOf(q: String?): Int =
|
fun heightOf(q: String?): Int =
|
||||||
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
|
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
|
||||||
|
|
||||||
val combined = info.videoStreams
|
// Audit HIGH-8: when no stream is under the resolution ceiling
|
||||||
?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
|
// (e.g. user picked 144p but the video only has 360p+), fall
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
// back to the lowest-resolution available instead of returning
|
||||||
?.content
|
// null and showing a black-screen player.
|
||||||
val videoOnly = info.videoOnlyStreams
|
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
|
||||||
?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
|
if (streams.isNullOrEmpty()) return null
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
val withContent = streams.filter { it.content?.isNotBlank() == true }
|
||||||
?.content
|
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
|
val audioOnly = info.audioStreams
|
||||||
?.filter { it.content?.isNotBlank() == true }
|
?.filter { it.content?.isNotBlank() == true }
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
?.maxByOrNull { it.bitrate ?: 0 }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</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">returnyoutubedislike.com</domain>
|
||||||
<domain includeSubdomains="true">returnyoutubedislikeapi.com</domain>
|
<domain includeSubdomains="true">returnyoutubedislikeapi.com</domain>
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue