Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 32 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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.
Expand Down
34 changes: 22 additions & 12 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ android {
}
}

val ciSignReleaseWithDebug = project.findProperty("ciSignReleaseWithDebug") == "true"

buildTypes {
debug {
applicationIdSuffix = ".debug"
Expand Down Expand Up @@ -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.")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
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,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<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 =
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()
}
}
Loading
Loading