diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2346ea29..396395fae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,13 +75,10 @@ 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 --build-cache --stacktrace - ./gradlew :app:testFossDebugUnitTest --build-cache --stacktrace + run: > + ./gradlew :app:testGithubDebugUnitTest :app:testFossDebugUnitTest + --parallel --build-cache --stacktrace # Instrumentation tests are not executed in CI, but they must still # compile: breakage here otherwise lands on main unnoticed. @@ -333,4 +330,4 @@ jobs: draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 6b9be2de2..60145f326 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,6 +30,8 @@ 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 @@ -2279,5 +2281,18 @@ class BackupRepository( } } - private fun fetchChannelAvatar(channelId: String): String = fetchYouTubeChannelAvatar(channelId) + // 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) { + "" + } } 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 deleted file mode 100644 index 008451bdf..000000000 --- a/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionImporter.kt +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index 8ce05cc87..000000000 --- a/app/src/main/java/io/github/aedev/flow/data/local/OpmlSubscriptionParser.kt +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 3281df0b3..000000000 --- a/app/src/main/java/io/github/aedev/flow/data/local/YouTubeChannelAvatarResolver.kt +++ /dev/null @@ -1,32 +0,0 @@ -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 9a70651d6..c9392c108 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,15 +2,12 @@ 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 @@ -19,19 +16,6 @@ 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. * @@ -51,12 +35,7 @@ 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) } @@ -98,45 +77,6 @@ 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) @@ -250,12 +190,13 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, 0, summary) } } else { - setLocalizedError(label, result.exceptionOrNull()) + val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) + _state.value = State.Error(label, msg) } } catch (e: CancellationException) { throw e } catch (e: Exception) { - setLocalizedError(label, e) + _state.value = State.Error(label, e.message ?: context.getString(R.string.unknown_error)) } finally { NotificationHelper.cancelImportNotification(context) } @@ -276,12 +217,13 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, 0, successMessage) } } else { - setLocalizedError(label, result.exceptionOrNull()) + val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) + _state.value = State.Error(label, msg) } } catch (e: CancellationException) { throw e } catch (e: Exception) { - setLocalizedError(label, e) + _state.value = State.Error(label, e.message ?: context.getString(R.string.unknown_error)) } finally { NotificationHelper.cancelImportNotification(context) } @@ -329,37 +271,6 @@ 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, @@ -372,22 +283,8 @@ class ImportViewModel NotificationHelper.showImportComplete(context, label, count) } } else { - setLocalizedError(label, result.exceptionOrNull()) - } - } - - private fun setLocalizedError( - label: String, - error: Throwable?, - ) { - if (error != null) { - Log.e(TAG, "Import failed: $label", error) + val msg = result.exceptionOrNull()?.message ?: context.getString(R.string.unknown_error) + _state.value = State.Error(label, msg) } - 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 6a08a52e9..bddee2c51 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,7 +5,6 @@ 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 @@ -27,9 +26,6 @@ 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 @@ -67,17 +63,6 @@ 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