diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt index 1b34ce0ea..e4e60192c 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt @@ -44,6 +44,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults @@ -97,6 +99,7 @@ import com.m3u.smartphone.ui.common.AppNavHost import com.m3u.smartphone.ui.common.connect.RemoteControlSheet import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue import com.m3u.smartphone.ui.common.helper.LocalHelper +import com.m3u.smartphone.ui.common.helper.Action import com.m3u.smartphone.ui.common.helper.Metadata import com.m3u.smartphone.ui.material.components.Destination import com.m3u.smartphone.ui.material.components.SnackHost @@ -451,6 +454,59 @@ private fun AppImpl( } } +/** + * The overflow menu of the search bar, holding the actions the current screen + * published. + * + * Those actions used to be unreachable. They are only ever rendered by the + * TopAppBar, which shouldShowContextualTopBar keeps off every screen showing + * the search bar instead — the playlist screen among them. So its refresh and + * its sort existed, fully wired, and nothing on screen could invoke them: the + * only way to update a catalogue was to subscribe to it again, credentials and + * all. + * + * Taking the actions as a parameter rather than reading Metadata here keeps + * this a function of its arguments: what makes it recompose is visible in its + * signature. It also stays generic — any screen publishing actions gets them, + * with no change to this file. + */ +@Composable +private fun ScreenActionsMenu( + actions: List, + modifier: Modifier = Modifier, +) { + // No icon at all rather than one opening an empty menu. + if (actions.isEmpty()) return + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(string.ui_cd_more_actions), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.contentDescription.orEmpty()) }, + leadingIcon = { + Icon(imageVector = action.icon, contentDescription = null) + }, + enabled = action.enabled, + onClick = { + expanded = false + action.onClick() + }, + ) + } + } + } +} + private class AppContentArguments( val navController: NavHostController, val channels: Flow>, @@ -513,7 +569,11 @@ private fun AppContent( } }, trailingIcon = { - Icon(Icons.Default.MoreVert, contentDescription = null) + // Hidden while the search is expanded: these actions belong to + // the screen underneath, not to the search results. + if (searchBarState.currentValue != SearchBarValue.Expanded) { + ScreenActionsMenu(actions = Metadata.actions) + } }, ) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt index 70403fca0..ee069eace 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -79,6 +80,7 @@ fun ForyouRoute( val title = stringResource(string.ui_title_foryou) val addContentDescription = stringResource(string.ui_action_add) + val refreshContentDescription = stringResource(string.ui_action_refresh) val playlists by viewModel.playlists.collectAsStateWithLifecycle() val specs by viewModel.specs.collectAsStateWithLifecycle() @@ -97,6 +99,14 @@ fun ForyouRoute( icon = Icons.Rounded.Add, contentDescription = addContentDescription, onClick = navigateToSettingPlaylistManagement + ), + // Refreshing lives on the playlist screen, one playlist at a time — + // which means opening each of them in turn, from the very screen + // that already lists them all. + Action( + icon = Icons.Rounded.Refresh, + contentDescription = refreshContentDescription, + onClick = viewModel::onRefreshAllPlaylists ) ) onPauseOrDispose { diff --git a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt index 1d5aeeaee..86cdc925e 100644 --- a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt +++ b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt @@ -13,6 +13,7 @@ import com.m3u.core.foundation.wrapper.mapResource import com.m3u.core.foundation.wrapper.resource import com.m3u.data.database.model.Channel import com.m3u.data.database.model.Playlist +import com.m3u.data.database.model.refreshable import com.m3u.data.parser.xtream.XtreamEpisodeInfo import com.m3u.data.repository.channel.ChannelRepository import com.m3u.data.repository.playlist.PlaylistRepository @@ -114,6 +115,22 @@ class ForyouViewModel @Inject constructor( } } + /** + * Refreshes every playlist that can be refreshed. + * + * The playlist screen refreshes the one it shows, which means going into + * each of them in turn — and this screen is precisely where they are all + * in view. Sources that cannot be refreshed at all, such as a playlist + * imported from a local file, are skipped rather than reported as failing. + */ + fun onRefreshAllPlaylists() { + viewModelScope.launch { + playlistRepository.getAll() + .filter { playlist -> playlist.refreshable } + .forEach { playlist -> playlistRepository.refresh(playlist.url) } + } + } + val series = MutableStateFlow(null) val seriesReplay = MutableStateFlow(0) val episodes: StateFlow>> = series diff --git a/data/.gitignore b/data/.gitignore index c8b0aa09c..796b96d1c 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1 @@ /build -/src/test diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 103778ad6..df4c13b52 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(libs.jakewharton.disklrucache) + testImplementation(kotlin("test-junit")) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.core) diff --git a/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt index 1525e4e10..f7fc94b20 100644 --- a/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt @@ -11,6 +11,7 @@ import androidx.room.Upsert import com.m3u.data.database.model.AdjacentChannels import com.m3u.data.database.model.Channel import com.m3u.data.database.model.ChannelMetadataBase +import com.m3u.data.database.model.ChannelUserState import com.m3u.data.database.model.ExtensionChannelMetadataOverlay import kotlinx.coroutines.flow.Flow @@ -223,6 +224,24 @@ interface ChannelDao { @Query("SELECT url FROM streams WHERE relation_id IS NULL AND playlist_url IN (:playlistUrls) AND (favourite = 1 OR hidden = 1)") suspend fun getFavOrHiddenUrlsByPlaylistUrlNotContainsRelationId(vararg playlistUrls: String): List + /** + * Everything a viewer built up on the channels of a playlist, so a refresh + * can hand it back to the rows that replace them. + * + * Only rows that differ from a fresh import are returned. A catalogue runs + * to tens of thousands of channels, of which a handful were ever watched or + * favourited; carrying the rest would mean holding the whole thing in + * memory to restore nothing. + */ + @Query( + """ + SELECT relation_id, url, seen, favourite, hidden FROM streams + WHERE playlist_url = :playlistUrl + AND (seen != 0 OR favourite = 1 OR hidden = 1) + """ + ) + suspend fun getUserStateByPlaylistUrl(playlistUrl: String): List + @Query("SELECT * FROM streams WHERE seen != 0 ORDER BY seen DESC LIMIT 1") suspend fun getPlayedRecently(): Channel? diff --git a/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt b/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt new file mode 100644 index 000000000..aad676b91 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt @@ -0,0 +1,59 @@ +package com.m3u.data.database.model + +import androidx.room.ColumnInfo + +/** + * What a viewer built up on a channel, as opposed to what the provider says + * about it. + * + * Refreshing a catalogue deletes every channel and imports them anew, so rows + * come back with their id regenerated and these columns reset — the Continue + * watching row empties, favourites are lost. Carried across the import, none of + * that is. + */ +data class ChannelUserState( + /** Stable across imports when the provider gives one; M3U often does not. */ + @ColumnInfo(name = "relation_id") + val relationId: String?, + @ColumnInfo(name = "url") + val url: String, + @ColumnInfo(name = "seen") + val seen: Long, + @ColumnInfo(name = "favourite") + val favourite: Boolean, + @ColumnInfo(name = "hidden") + val hidden: Boolean, +) + +/** + * Looks a channel up by whichever identity survived the import. + * + * Xtream channels keep a relation id; an M3U playlist without tvg-id has none, + * and only its URL to go on. Falling back to the URL keeps both kinds covered + * without the callers having to know which is which. + */ +class PreservedUserStates(states: List) { + private val byRelationId: Map = states + .mapNotNull { state -> + state.relationId?.takeIf(String::isNotBlank)?.let { id -> id to state } + } + .toMap() + + private val byUrl: Map = states.associateBy(ChannelUserState::url) + + val isEmpty: Boolean get() = byRelationId.isEmpty() && byUrl.isEmpty() + + fun of(relationId: String?, url: String): ChannelUserState? = + relationId?.takeIf(String::isNotBlank)?.let(byRelationId::get) ?: byUrl[url] +} + +/** + * Hands a freshly imported channel back what its predecessor had earned. + * + * Returns the channel untouched when nothing was preserved for it, which is the + * case for all but a handful of a catalogue. + */ +fun Channel.restoring(states: PreservedUserStates?, relationId: String?): Channel { + val state = states?.of(relationId ?: this.relationId, url) ?: return this + return copy(seen = state.seen, favourite = state.favourite, hidden = state.hidden) +} diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt index 1f0fb319c..53b230dde 100644 --- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt +++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt @@ -21,6 +21,8 @@ import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.dao.ProgrammeDao import com.m3u.data.database.dao.ProviderDao import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.PreservedUserStates +import com.m3u.data.database.model.restoring import com.m3u.data.database.model.DataSource import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.PlaylistWithChannels @@ -253,6 +255,10 @@ internal class PlaylistRepositoryImpl @Inject constructor( // The previous row may have been saved as EPG or another source. source = DataSource.M3U, ) ?: Playlist(title, internalUrl, source = DataSource.M3U) + // Captured before the delete: a refresh re-imports every + // channel, and these columns would otherwise come back reset. + val preservedUserStates = + PreservedUserStates(channelDao.getUserStateByPlaylistUrl(internalUrl)) deleteChannelsForImport(internalUrl, playlistStrategy) playlistDao.insertOrReplace(playlist) staging.forEachBatch(BUFFER_M3U_CAPACITY) { staged -> @@ -269,7 +275,12 @@ internal class PlaylistRepositoryImpl @Inject constructor( } ) } - .map(StagedChannel::channel) + .map { record -> + record.channel.restoring( + preservedUserStates, + record.preservationRelationId, + ) + } .toList() if (channelsToInsert.isNotEmpty()) { channelDao.insertOrReplaceAll(*channelsToInsert.toTypedArray()) @@ -414,6 +425,14 @@ internal class PlaylistRepositoryImpl @Inject constructor( } else -> emptyMap() } + // Captured before the delete below wipes it. Refreshing a + // catalogue re-imports every channel from scratch, so + // without this the Continue watching row empties and + // favourites are lost every single time. + val userStateByPlaylistUrl = requiredPlaylistUrls + .associateWith { playlistUrl -> + PreservedUserStates(channelDao.getUserStateByPlaylistUrl(playlistUrl)) + } val requiredPlaylists = requiredPlaylistUrls.map { playlistUrl -> currentXtreamPlaylist(title, playlistUrl) } @@ -433,7 +452,12 @@ internal class PlaylistRepositoryImpl @Inject constructor( favOrHiddenRelationIdsByPlaylistUrl, ) } - .map(StagedChannel::channel) + .map { record -> + record.channel.restoring( + userStateByPlaylistUrl[record.channel.playlistUrl], + record.preservationRelationId, + ) + } .toList() if (channelsToInsert.isNotEmpty()) { channelDao.insertOrReplaceAll(*channelsToInsert.toTypedArray()) diff --git a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt index ea07d1865..616d5f95b 100644 --- a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt +++ b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt @@ -242,6 +242,18 @@ class SubscriptionWorker @AssistedInject constructor( } override suspend fun getForegroundInfo(): ForegroundInfo { + // WorkManager calls this before doWork, which is where the channel used + // to be created — so on the very first run of an install the channel did + // not exist yet and startForeground was handed a notification pointing + // at nothing: + // + // RemoteServiceException: Bad notification for startForeground: + // invalid channel for service notification + // + // That killed the process. It stayed unnoticed because the channel + // survives once created, and because nothing on screen could trigger a + // refresh in the first place. + createChannel() return ForegroundInfo(notificationId, createN10nBuilder().build()) } diff --git a/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt b/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt new file mode 100644 index 000000000..853ce4b9f --- /dev/null +++ b/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt @@ -0,0 +1,129 @@ +package com.m3u.data.database.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Refreshing a catalogue deletes every channel and imports it anew, which used + * to reset what the viewer had built up: the Continue watching row emptied and + * favourites disappeared on every update. These pin down the hand-over. + */ +class PreservedUserStatesTest { + @Test + fun `a watched channel keeps its history across an import`() { + val states = PreservedUserStates( + listOf(state(relationId = "12345", url = OLD_URL, seen = 1_700_000L)), + ) + + // The provider may hand back a different URL for the same title. + val imported = channel(relationId = "12345", url = NEW_URL) + val restored = imported.restoring(states, relationId = "12345") + + assertEquals(1_700_000L, restored.seen) + assertEquals(NEW_URL, restored.url) + } + + @Test + fun `favourite and hidden flags come back too`() { + val states = PreservedUserStates( + listOf(state(relationId = "1", url = OLD_URL, favourite = true, hidden = true)), + ) + + val restored = channel(relationId = "1", url = NEW_URL).restoring(states, "1") + + assertTrue(restored.favourite) + assertTrue(restored.hidden) + } + + @Test + fun `a playlist without relation ids falls back to the url`() { + // An M3U without tvg-id has no stable id; the URL is all there is. + val states = PreservedUserStates( + listOf(state(relationId = null, url = OLD_URL, seen = 42L)), + ) + + val restored = channel(relationId = null, url = OLD_URL).restoring(states, null) + + assertEquals(42L, restored.seen) + } + + @Test + fun `a channel nobody touched is returned untouched`() { + val states = PreservedUserStates( + listOf(state(relationId = "known", url = OLD_URL, seen = 1L)), + ) + + val imported = channel(relationId = "other", url = NEW_URL) + // Same instance, not a copy: nothing to hand over. + assertSame(imported, imported.restoring(states, "other")) + assertEquals(0L, imported.restoring(states, "other").seen) + } + + @Test + fun `a blank relation id does not match every blank one`() { + val states = PreservedUserStates( + listOf(state(relationId = "", url = OLD_URL, seen = 99L)), + ) + + // Falls through to the URL rather than matching anything blank. + assertEquals(0L, channel(relationId = "", url = NEW_URL).restoring(states, "").seen) + assertEquals(99L, channel(relationId = "", url = OLD_URL).restoring(states, "").seen) + } + + @Test + fun `nothing preserved leaves the import alone`() { + val empty = PreservedUserStates(emptyList()) + assertTrue(empty.isEmpty) + + val imported = channel(relationId = "1", url = NEW_URL) + assertSame(imported, imported.restoring(empty, "1")) + assertSame(imported, imported.restoring(null, "1")) + } + + @Test + fun `the relation id wins over the url when both are known`() { + val states = PreservedUserStates( + listOf( + state(relationId = "moved", url = OLD_URL, seen = 10L), + state(relationId = "other", url = NEW_URL, seen = 20L), + ), + ) + + // Same title, new URL: its own history follows it, not the one that + // happens to sit at that URL now. + val restored = channel(relationId = "moved", url = NEW_URL).restoring(states, "moved") + assertEquals(10L, restored.seen) + assertFalse(restored.favourite) + } + + private fun state( + relationId: String?, + url: String, + seen: Long = 0L, + favourite: Boolean = false, + hidden: Boolean = false, + ) = ChannelUserState( + relationId = relationId, + url = url, + seen = seen, + favourite = favourite, + hidden = hidden, + ) + + private fun channel(relationId: String?, url: String) = Channel( + url = url, + category = "Films", + title = "Le Prénom", + playlistUrl = PLAYLIST_URL, + relationId = relationId, + ) + + private companion object { + const val PLAYLIST_URL = "http://example.test/playlist" + const val OLD_URL = "http://example.test/movie/1.mkv" + const val NEW_URL = "http://example.test/movie/2.mkv" + } +} diff --git a/i18n/src/main/res/values/ui.xml b/i18n/src/main/res/values/ui.xml index 24210636f..ea1cceea1 100644 --- a/i18n/src/main/res/values/ui.xml +++ b/i18n/src/main/res/values/ui.xml @@ -116,4 +116,6 @@ %1$d days Never + + More actions