diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76ab6c4d8..bce77e605 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,6 +62,7 @@ jobs: if [ -n "$RELEASE_KEYSTORE_BASE64" ]; then echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > release.keystore echo "signed=true" >> "$GITHUB_OUTPUT" + echo "ci_debug_signing=false" >> "$GITHUB_OUTPUT" echo "Release keystore decoded." elif [ "${GITHUB_REF#refs/tags/v}" != "$GITHUB_REF" ]; then # A tag build publishes to GitHub Releases and IzzyOnDroid. Without @@ -72,7 +73,8 @@ jobs: exit 1 else echo "signed=false" >> "$GITHUB_OUTPUT" - echo "Release keystore secret is not available. Continuing with an unsigned CI build." + echo "ci_debug_signing=true" >> "$GITHUB_OUTPUT" + echo "Release keystore secret is not available. Using debug signing for installable CI artifacts." fi - name: Run unit tests @@ -103,6 +105,7 @@ jobs: if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: > ./gradlew :app:assembleGithubRelease :app:assembleGithubNightly :app:assembleFossRelease + -PciSignReleaseWithDebug=${{ steps.keystore.outputs.ci_debug_signing }} --max-workers=1 --build-cache --stacktrace env: STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} @@ -119,6 +122,34 @@ jobs: KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + # Every non-tag artifact must be installable. PR builds use the debug + # certificate and a .ci application id; official tag builds use the release key. + - name: Verify CI APKs are signed + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + set -euo pipefail + apksigner=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner -type f | sort -V | tail -1) + if [ -z "$apksigner" ]; then + echo "::error::apksigner was not found in the runner Android SDK." + exit 1 + fi + + for apk in \ + app/build/outputs/apk/github/release/app-github-universal-release.apk \ + app/build/outputs/apk/github/release/app-github-arm64-v8a-release.apk \ + app/build/outputs/apk/github/release/app-github-armeabi-v7a-release.apk \ + app/build/outputs/apk/foss/release/app-foss-universal-release.apk \ + app/build/outputs/apk/foss/release/app-foss-arm64-v8a-release.apk \ + app/build/outputs/apk/foss/release/app-foss-armeabi-v7a-release.apk + do + if [ ! -f "$apk" ]; then + echo "::error::Missing expected CI APK $apk" + exit 1 + fi + "$apksigner" verify "$apk" + echo "OK $(basename "$apk") is signed." + done + # Guards the IzzyOnDroid/F-Droid update path: a wrong or missing key here # is unrecoverable for installed users, so fail the build rather than # publish. Skipped when no keystore was available, e.g. on fork PRs. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce27758f3..fb98690eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,8 @@ android { } } + val ciSignReleaseWithDebug = project.findProperty("ciSignReleaseWithDebug") == "true" + buildTypes { debug { applicationIdSuffix = ".debug" @@ -123,19 +125,27 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - // Use release signing if configured, otherwise fallback to debug - val releaseKeystore = - try { - signingConfigs.getByName("release").storeFile - } catch (e: Exception) { - null - } - if (releaseKeystore?.exists() == true) { - signingConfig = signingConfigs.getByName("release") - println("Using RELEASE signing config with keystore: ${releaseKeystore.absolutePath}") + if (ciSignReleaseWithDebug) { + // PR artifacts must be installable, but must not masquerade as official releases. + applicationIdSuffix = ".ci" + versionNameSuffix = "-ci" + signingConfig = signingConfigs.getByName("debug") + println("Using DEBUG signing config for installable CI APKs.") } else { - signingConfig = null // Let Gradle build an unsigned APK for IzzyOnDroid/F-Droid - println("WARNING: Release keystore not found. Building UNSIGNED release APK.") + // Official release builds require the configured release keystore. + val releaseKeystore = + try { + signingConfigs.getByName("release").storeFile + } catch (e: Exception) { + null + } + if (releaseKeystore?.exists() == true) { + signingConfig = signingConfigs.getByName("release") + println("Using RELEASE signing config with keystore: ${releaseKeystore.absolutePath}") + } else { + signingConfig = null // Tag builds are rejected by CI before this point. + println("WARNING: Release keystore not found. Building UNSIGNED release APK.") + } } } } diff --git a/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvChannelNavigationTest.kt b/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvChannelNavigationTest.kt new file mode 100644 index 000000000..c98b0e178 --- /dev/null +++ b/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvChannelNavigationTest.kt @@ -0,0 +1,65 @@ +package io.github.aedev.flow.ui.tv + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.aedev.flow.ui.tv.components.TvButton +import io.github.aedev.flow.ui.tv.navigation.TvRoutes +import io.github.aedev.flow.ui.tv.navigation.tvChannelDestination +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TvChannelNavigationTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun remoteOpensChannelWithoutDecodingItsUrlAgain() { + val channelRef = "https://www.youtube.com/@caf%C3%A9?source=a+b&percent=%25" + compose.setContent { + MaterialTheme { + val navController = rememberNavController() + NavHost(navController, startDestination = "player") { + composable("player") { + val focus = remember { FocusRequester() } + val inputModeManager = LocalInputModeManager.current + TvButton( + text = "Open channel", + onClick = { navController.navigate(TvRoutes.channel(channelRef)) }, + modifier = Modifier.focusRequester(focus), + ) + LaunchedEffect(Unit) { + inputModeManager.requestInputMode(InputMode.Keyboard) + focus.requestFocus() + } + } + tvChannelDestination { Text(it) } + } + } + } + + compose.onNodeWithText("Open channel").assertIsFocused().performKeyInput { + pressKey(Key.DirectionCenter) + } + compose.onNodeWithText(channelRef).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvImportPickerTest.kt b/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvImportPickerTest.kt new file mode 100644 index 000000000..9e3a530e5 --- /dev/null +++ b/app/src/androidTest/java/io/github/aedev/flow/ui/tv/TvImportPickerTest.kt @@ -0,0 +1,26 @@ +package io.github.aedev.flow.ui.tv + +import android.content.ActivityNotFoundException +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.aedev.flow.ui.tv.screens.launchTvImportPicker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TvImportPickerTest { + @Test + fun reportsMissingDocumentPickerWithoutCrashing() { + assertFalse(launchTvImportPicker { throw ActivityNotFoundException("No document picker") }) + } + + @Test + fun launchesAvailablePickerOnce() { + var launches = 0 + + assertTrue(launchTvImportPicker { launches++ }) + assertEquals(1, launches) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/local/BackupRepository.kt b/app/src/main/java/io/github/aedev/flow/data/local/BackupRepository.kt index 60145f326..6b9be2de2 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/BackupRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/BackupRepository.kt @@ -30,8 +30,6 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.channel.ChannelInfo import java.io.BufferedReader import java.io.ByteArrayOutputStream import java.io.InputStreamReader @@ -2281,18 +2279,5 @@ class BackupRepository( } } - // Helper to fetch channel avatar using NewPipe - private fun fetchChannelAvatar(channelId: String): String = - try { - val url = - if (channelId.startsWith("UC") && channelId.length > 20) { - "https://www.youtube.com/channel/$channelId" - } else { - "https://www.youtube.com/@$channelId" - } - val info = ChannelInfo.getInfo(ServiceList.YouTube, url) - info.avatars.maxByOrNull { it.height }?.url ?: "" - } catch (e: Exception) { - "" - } + private fun fetchChannelAvatar(channelId: String): String = fetchYouTubeChannelAvatar(channelId) } diff --git a/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionImporter.kt b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionImporter.kt new file mode 100644 index 000000000..090d91f25 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionImporter.kt @@ -0,0 +1,132 @@ +package io.github.aedev.flow.data.local + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.aedev.flow.data.recommendation.FlowNeuroEngine +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +internal sealed class OpmlImportException : Exception() { + data object UnreadableFile : OpmlImportException() + + data object NoSubscriptions : OpmlImportException() +} + +class OpmlSubscriptionImporter + @Inject + constructor( + @ApplicationContext context: Context, + private val subscriptionRepository: SubscriptionRepository, + ) { + private val appContext = context.applicationContext + + suspend fun import( + uri: Uri, + onProgress: ((current: Int, total: Int) -> Unit)? = null, + ): Result = + withContext(Dispatchers.IO) { + try { + val xml = + appContext.contentResolver + .openInputStream(uri) + ?.bufferedReader(Charsets.UTF_8) + ?.use { it.readText() } + ?: return@withContext Result.failure(OpmlImportException.UnreadableFile) + + val entries = OpmlSubscriptionParser.parse(xml) + if (entries.isEmpty()) { + return@withContext Result.failure(OpmlImportException.NoSubscriptions) + } + + val existingIds = subscriptionRepository.getAllSubscriptionIds() + val missingSubscriptions = + buildMissingOpmlSubscriptions( + entries = entries, + existingIds = existingIds, + subscribedAt = System.currentTimeMillis(), + ) + val subscriptions = + enrichOpmlSubscriptionAvatars( + subscriptions = missingSubscriptions, + avatarFetcher = ::fetchYouTubeChannelAvatar, + onProgress = onProgress, + ) + subscriptionRepository.subscribeAll(subscriptions) + + val channelNames = subscriptions.map(ChannelSubscription::channelName).filter(String::isNotBlank) + if (channelNames.isNotEmpty()) { + runCatching { + FlowNeuroEngine.bootstrapFromSubscriptions(appContext, channelNames) + } + } + + Result.success(subscriptions.size) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + } + +internal fun buildMissingOpmlSubscriptions( + entries: List, + existingIds: Set, + subscribedAt: Long, +): List = + entries + .filterNot { it.channelId in existingIds } + .mapIndexed { index, entry -> + ChannelSubscription( + channelId = entry.channelId, + channelName = entry.channelName, + channelThumbnail = "", + subscribedAt = subscribedAt - index, + ) + } + +internal suspend fun enrichOpmlSubscriptionAvatars( + subscriptions: List, + avatarFetcher: suspend (String) -> String, + onProgress: ((current: Int, total: Int) -> Unit)? = null, +): List { + if (subscriptions.isEmpty()) { + onProgress?.invoke(0, 0) + return emptyList() + } + + val semaphore = Semaphore(5) + val completed = AtomicInteger(0) + onProgress?.invoke(0, subscriptions.size) + + return supervisorScope { + subscriptions + .map { subscription -> + async(Dispatchers.IO) { + val enriched = + semaphore.withPermit { + val avatar = + try { + avatarFetcher(subscription.channelId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "" + } + if (avatar.isBlank()) subscription else subscription.copy(channelThumbnail = avatar) + } + onProgress?.invoke(completed.incrementAndGet(), subscriptions.size) + enriched + } + }.awaitAll() + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionParser.kt b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionParser.kt new file mode 100644 index 000000000..d962866a5 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionParser.kt @@ -0,0 +1,121 @@ +package io.github.aedev.flow.data.local + +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.xml.sax.InputSource +import java.io.StringReader +import java.net.URI +import java.net.URLDecoder +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilderFactory + +internal data class OpmlSubscriptionEntry( + val channelId: String, + val channelName: String, +) + +/** XML-backed OPML reader for YouTube subscription exports. */ +internal object OpmlSubscriptionParser { + private val youtubeChannelIdRegex = Regex("""UC[0-9A-Za-z_-]{22}""") + + fun parse(xml: String): List { + val xmlWithoutBom = xml.removePrefix("\uFEFF") + if (!xmlWithoutBom.trimStart().startsWith("<")) return emptyList() + + val document = + runCatching { + val factory = DocumentBuilderFactory.newInstance() + runCatching { factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } + runCatching { factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } + runCatching { factory.setFeature("http://xml.org/sax/features/external-general-entities", false) } + runCatching { factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) } + runCatching { factory.isXIncludeAware = false } + runCatching { factory.isExpandEntityReferences = false } + + factory + .newDocumentBuilder() + .apply { + setEntityResolver { _, _ -> InputSource(StringReader("")) } + }.parse(InputSource(StringReader(xmlWithoutBom))) + }.getOrNull() ?: return emptyList() + + val seen = LinkedHashSet() + val entries = mutableListOf() + + fun visit(node: Node) { + if (node is Element && node.tagName.equals("outline", ignoreCase = true)) { + val attributes = node.attributes.toAttributeMap() + val channelId = extractChannelId(attributes) + if (channelId != null && seen.add(channelId)) { + val channelName = + sequenceOf("title", "text") + .mapNotNull(attributes::get) + .map(String::trim) + .firstOrNull(String::isNotEmpty) + ?: channelId + entries += OpmlSubscriptionEntry(channelId = channelId, channelName = channelName) + } + } + + val children = node.childNodes + for (index in 0 until children.length) { + visit(children.item(index)) + } + } + + document.documentElement?.let(::visit) + return entries + } + + private fun org.w3c.dom.NamedNodeMap.toAttributeMap(): Map = + buildMap { + for (index in 0 until length) { + val attribute = item(index) + put(attribute.nodeName.lowercase(), attribute.nodeValue.orEmpty()) + } + } + + private fun extractChannelId(attributes: Map): String? { + sequenceOf("channelid", "channel_id") + .mapNotNull(attributes::get) + .map(String::trim) + .firstOrNull(youtubeChannelIdRegex::matches) + ?.let { return it } + + return sequenceOf("xmlurl", "htmlurl", "url", "href") + .mapNotNull(attributes::get) + .mapNotNull(::extractChannelIdFromYouTubeUrl) + .firstOrNull() + } + + private fun extractChannelIdFromYouTubeUrl(rawUrl: String): String? { + val uri = runCatching { URI(rawUrl.trim()) }.getOrNull() ?: return null + if (uri.scheme?.lowercase() !in setOf("http", "https")) return null + + val host = uri.host?.lowercase() ?: return null + if (host != "youtube.com" && !host.endsWith(".youtube.com")) return null + + val path = uri.path.orEmpty() + if (path.equals("/feeds/videos.xml", ignoreCase = true)) { + return uri.rawQuery + .orEmpty() + .split("&") + .asSequence() + .mapNotNull { part -> + val key = part.substringBefore("=", missingDelimiterValue = part) + if (!key.equals("channel_id", ignoreCase = true)) return@mapNotNull null + runCatching { + URLDecoder.decode(part.substringAfter("=", ""), Charsets.UTF_8.name()) + }.getOrNull() + }.map(String::trim) + .firstOrNull(youtubeChannelIdRegex::matches) + } + + val segments = path.split('/').filter(String::isNotBlank) + if (segments.size >= 2 && segments[0].equals("channel", ignoreCase = true)) { + return segments[1].trim().takeIf(youtubeChannelIdRegex::matches) + } + + return null + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/local/YouTubeChannelAvatarResolver.kt b/app/src/main/java/io/github/aedev/flow/data/local/YouTubeChannelAvatarResolver.kt new file mode 100644 index 000000000..3281df0b3 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/YouTubeChannelAvatarResolver.kt @@ -0,0 +1,32 @@ +package io.github.aedev.flow.data.local + +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.channel.ChannelInfo + +internal fun buildYouTubeChannelUrl(channelId: String): String? { + val channelRef = channelId.trim() + if (channelRef.isEmpty()) return null + + return when { + channelRef.startsWith("UC") && channelRef.length > 20 -> { + "https://www.youtube.com/channel/$channelRef" + } + + channelRef.startsWith("@") -> { + "https://www.youtube.com/$channelRef" + } + + else -> { + "https://www.youtube.com/@$channelRef" + } + } +} + +internal fun fetchYouTubeChannelAvatar(channelId: String): String = + try { + val url = buildYouTubeChannelUrl(channelId) ?: return "" + val info = ChannelInfo.getInfo(ServiceList.YouTube, url) + info.avatars.maxByOrNull { it.height }?.url ?: "" + } catch (e: Exception) { + "" + } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/ImportViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/ImportViewModel.kt index c9392c108..f545677b1 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/ImportViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/ImportViewModel.kt @@ -2,12 +2,15 @@ package io.github.aedev.flow.ui.screens.settings import android.content.Context import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import io.github.aedev.flow.R import io.github.aedev.flow.data.local.BackupRepository +import io.github.aedev.flow.data.local.OpmlImportException +import io.github.aedev.flow.data.local.OpmlSubscriptionImporter import io.github.aedev.flow.notification.NotificationHelper import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +19,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private val KNOWN_IMPORT_ERROR_CODES = + setOf("no_entries", "no_videos", "no_content", "invalid_format") + +internal fun safeImportErrorMessage( + rawMessage: String?, + fallback: String, +): String = rawMessage?.takeIf(KNOWN_IMPORT_ERROR_CODES::contains) ?: fallback + /** * Activity-scoped ViewModel for all data-import operations. * @@ -35,7 +46,12 @@ class ImportViewModel @Inject constructor( @ApplicationContext private val context: Context, + private val opmlImporter: OpmlSubscriptionImporter, ) : ViewModel() { + companion object { + private const val TAG = "ImportViewModel" + } + init { NotificationHelper.cancelImportNotification(context) } @@ -77,6 +93,45 @@ class ImportViewModel // ── Public import launchers ─────────────────────────────────────────────── + fun importFlowBackup(uri: Uri) { + if (isRunning) return + val label = context.getString(R.string.import_flow_backup_item_title) + val successMessage = context.getString(R.string.import_flow_backup_success) + viewModelScope.launch { + startProgress(label, 0, 0) + try { + val result = backupRepo.importData(uri) + if (result.isSuccess) { + _state.value = State.Success(label, message = successMessage) + if (NotificationHelper.hasNotificationPermission(context)) { + NotificationHelper.showImportComplete(context, label, 0, successMessage) + } + } else { + setLocalizedError(label, result.exceptionOrNull()) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + setLocalizedError(label, e) + } finally { + NotificationHelper.cancelImportNotification(context) + } + } + } + + fun importOpmlSubscriptions(uri: Uri) { + if (isRunning) return + val label = context.getString(R.string.import_subscriptions_xml_title) + viewModelScope.launch { + startProgress(label, 0, 0) + val result = + opmlImporter.import(uri) { current, total -> + updateProgress(label, current, total) + } + handleOpmlResult(label, result) + } + } + fun importNewPipe(uri: Uri) { if (isRunning) return val label = context.getString(R.string.import_label_newpipe_subscriptions) @@ -190,13 +245,12 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, 0, summary) } } else { - val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) - _state.value = State.Error(label, msg) + setLocalizedError(label, result.exceptionOrNull()) } } catch (e: CancellationException) { throw e } catch (e: Exception) { - _state.value = State.Error(label, e.message ?: context.getString(R.string.unknown_error)) + setLocalizedError(label, e) } finally { NotificationHelper.cancelImportNotification(context) } @@ -217,13 +271,12 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, 0, successMessage) } } else { - val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) - _state.value = State.Error(label, msg) + setLocalizedError(label, result.exceptionOrNull()) } } catch (e: CancellationException) { throw e } catch (e: Exception) { - _state.value = State.Error(label, e.message ?: context.getString(R.string.unknown_error)) + setLocalizedError(label, e) } finally { NotificationHelper.cancelImportNotification(context) } @@ -271,6 +324,35 @@ class ImportViewModel } } + private fun handleOpmlResult( + label: String, + result: Result, + ) { + if (result.isSuccess) { + handleResult(label, result) + return + } + + NotificationHelper.cancelImportNotification(context) + val error = result.exceptionOrNull() + if (error != null) Log.e(TAG, "OPML import failed: $label", error) + val message = + when (error) { + OpmlImportException.UnreadableFile -> { + context.getString(R.string.import_subscriptions_xml_read_error) + } + + OpmlImportException.NoSubscriptions -> { + context.getString(R.string.import_subscriptions_xml_no_entries) + } + + else -> { + context.getString(R.string.unknown_error) + } + } + _state.value = State.Error(label, message) + } + private fun handleResult( label: String, result: Result, @@ -283,8 +365,19 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, count) } } else { - val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) - _state.value = State.Error(label, msg) + setLocalizedError(label, result.exceptionOrNull()) } } + + private fun setLocalizedError( + label: String, + error: Throwable?, + ) { + if (error != null) Log.e(TAG, "Import failed: $label", error) + _state.value = + State.Error( + label, + safeImportErrorMessage(error?.message, context.getString(R.string.unknown_error)), + ) + } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt index bddee2c51..6a08a52e9 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,6 +27,9 @@ import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel import io.github.aedev.flow.ui.screens.search.SearchViewModel import io.github.aedev.flow.ui.screens.subscriptions.SubscriptionsViewModel import io.github.aedev.flow.ui.tv.music.TvMusicNowPlayingScreen +import io.github.aedev.flow.ui.tv.navigation.TvRoutes +import io.github.aedev.flow.ui.tv.player.LocalTvPlayerChannelAction +import io.github.aedev.flow.ui.tv.player.TvPlayerChannelAction import io.github.aedev.flow.ui.tv.screens.TvPlayerScreen import io.github.aedev.flow.ui.tv.theme.TvTheme @@ -63,6 +67,17 @@ fun FlowTvApp( playerViewModel.playVideo(video) } + fun closeVideo() { + playerViewModel.clearVideo() + GlobalPlayerState.setCurrentVideo(null) + } + + fun openVideoChannel(channelRef: String) { + if (channelRef.isBlank()) return + closeVideo() + navController.navigate(TvRoutes.channel(channelRef)) + } + fun playPlaylist( videos: List