Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
57708b3
fix(tv): restore imports, UI mode selection, and channel navigation
PastaHimself Sep 3, 2026
9ac33d1
style(tv): apply ktlint wrapping
PastaHimself Sep 3, 2026
820eeeb
style(tv): format navigation host
PastaHimself Sep 3, 2026
1f7087d
style(tv): format routes
PastaHimself Sep 3, 2026
06d5084
style(tv): format player overlay
PastaHimself Sep 3, 2026
e32b25a
style(tv): format import screen
PastaHimself Sep 3, 2026
ac7178c
style(tv): format settings routing
PastaHimself Sep 3, 2026
5801343
style(tv): match ktlint modifier wrapping
PastaHimself Sep 3, 2026
1504837
style(tv): preserve multiline UI mode launch
PastaHimself Sep 3, 2026
5bbe66a
style(tv): satisfy ktlint when-entry bracing
PastaHimself Sep 3, 2026
d8bfb70
style(tv): apply ktlint multiline expression rules
PastaHimself Sep 3, 2026
0626a00
style(tv): wrap multiline when expressions
PastaHimself Sep 3, 2026
0c75905
style(tv): align settings category filename with ktlint
PastaHimself Sep 3, 2026
0e89dea
style(tv): sort settings category imports
PastaHimself Sep 3, 2026
a8f0e78
feat(import): parse subscription OPML exports
PastaHimself Sep 3, 2026
b600d6f
feat(import): import subscription OPML files
PastaHimself Sep 3, 2026
9f21350
feat(tv): add OPML import strings
PastaHimself Sep 3, 2026
67c7f8a
feat(import): add activity-scoped OPML import state
PastaHimself Sep 3, 2026
a518a3c
feat(tv): expose XML subscription import
PastaHimself Sep 3, 2026
ece7c23
test(import): cover OPML subscription parsing
PastaHimself Sep 3, 2026
203bd82
test(tv): guard feedback regressions
PastaHimself Sep 3, 2026
9e8e77b
fix(import): share import operation state for XML
PastaHimself Sep 3, 2026
82339b4
fix(tv): reuse shared import state for XML
PastaHimself Sep 3, 2026
c3ec386
refactor(import): remove duplicate XML import state
PastaHimself Sep 3, 2026
80da3df
ci: serialize debug flavor unit tests
PastaHimself Sep 3, 2026
afb5556
ci: isolate debug flavor unit test compiles
PastaHimself Sep 3, 2026
fc1fc1c
fix(tv): preserve subscriptions during OPML import
PastaHimself Sep 4, 2026
a8bf3d6
fix(tv): localize OPML import errors
PastaHimself Sep 4, 2026
b54963b
fix(tv): inject OPML importer and map errors
PastaHimself Sep 4, 2026
56ff423
fix(ci): preserve release tag version prefix
PastaHimself Sep 4, 2026
aa3e954
test(tv): guard OPML metadata preservation
PastaHimself Sep 4, 2026
564e70f
style(tv): apply OPML result formatting
PastaHimself Sep 4, 2026
855f836
fix(import): parse OPML with strict YouTube URL validation
PastaHimself Sep 4, 2026
07fa1d2
fix(import): enrich OPML subscriptions with channel avatars
PastaHimself Sep 4, 2026
6ddb1d8
fix(import): localize generic file import failures
PastaHimself Sep 4, 2026
f2c2ae5
fix(import): keep technical errors out of user-facing state
PastaHimself Sep 4, 2026
32ec044
test(import): cover strict OPML parsing and avatar enrichment
PastaHimself Sep 4, 2026
8c6fb2e
fix(import): preserve known import error states
PastaHimself Sep 4, 2026
367cb36
fix(import): reuse default import resources
PastaHimself Sep 4, 2026
6237180
fix(tv): reuse default import strings
PastaHimself Sep 4, 2026
9bff983
chore(resources): remove duplicate TV import strings
PastaHimself Sep 4, 2026
bbafaca
refactor(import): centralize safe error mapping
PastaHimself Sep 4, 2026
01a6c78
fix(tv): localize preserved import error codes
PastaHimself Sep 4, 2026
3773098
test(import): preserve known error codes safely
PastaHimself Sep 4, 2026
6740e87
fix(import): skip malformed OPML feed URLs
PastaHimself Sep 4, 2026
51068c1
test(import): keep valid OPML entries after malformed URL
PastaHimself Sep 4, 2026
171a231
refactor(import): share YouTube channel avatar resolver
PastaHimself Sep 4, 2026
53e14c6
refactor(import): reuse shared avatar resolver for OPML
PastaHimself Sep 4, 2026
5b3d960
fix(import): restore precise OPML error messages
PastaHimself Sep 4, 2026
04a730d
fix(tv): label OPML import precisely
PastaHimself Sep 4, 2026
f18f413
test(import): cover shared channel avatar URL resolver
PastaHimself Sep 4, 2026
a4064c6
fix(import): restore OPML strings in default resources
PastaHimself Sep 4, 2026
53be8c5
refactor(import): share YouTube avatar resolver
PastaHimself Sep 4, 2026
02c5446
fix(import): keep OPML strings scoped and precise
PastaHimself Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -330,4 +333,4 @@ jobs:
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Int> =
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<OpmlSubscriptionEntry>,
existingIds: Set<String>,
subscribedAt: Long,
): List<ChannelSubscription> =
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<ChannelSubscription>,
avatarFetcher: suspend (String) -> String,
onProgress: ((current: Int, total: Int) -> Unit)? = null,
): List<ChannelSubscription> {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<OpmlSubscriptionEntry> {
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<String>()
val entries = mutableListOf<OpmlSubscriptionEntry>()

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<String, String> =
buildMap {
for (index in 0 until length) {
val attribute = item(index)
put(attribute.nodeName.lowercase(), attribute.nodeValue.orEmpty())
}
}

private fun extractChannelId(attributes: Map<String, String>): 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
}
}
Original file line number Diff line number Diff line change
@@ -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) {
""
}
Loading
Loading