diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 396395fae..f2346ea29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,10 +75,13 @@ jobs: echo "Release keystore secret is not available. Continuing with an unsigned CI build." fi + # Keep the two debug flavors in separate Gradle invocations. Compiling + # both in one task graph lets Kotlin compile both variants concurrently + # and can exhaust the hosted runner's memory even without --parallel. - name: Run unit tests - run: > - ./gradlew :app:testGithubDebugUnitTest :app:testFossDebugUnitTest - --parallel --build-cache --stacktrace + run: | + ./gradlew :app:testGithubDebugUnitTest --build-cache --stacktrace + ./gradlew :app:testFossDebugUnitTest --build-cache --stacktrace # Instrumentation tests are not executed in CI, but they must still # compile: breakage here otherwise lands on main unnoticed. @@ -330,4 +333,4 @@ jobs: draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file 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..008451bdf --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionImporter.kt @@ -0,0 +1,125 @@ +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 = runCatching { avatarFetcher(subscription.channelId) }.getOrDefault("") + 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..8ce05cc87 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionParser.kt @@ -0,0 +1,120 @@ +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 { + if (!xml.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(xml))) + }.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..9a70651d6 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,19 @@ 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 +51,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 +98,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 +250,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 +276,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 +329,37 @@ 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 +372,22 @@ 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) } + val message = + safeImportErrorMessage( + rawMessage = error?.message, + fallback = context.getString(R.string.unknown_error), + ) + _state.value = State.Error(label, message) } } 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