Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/nightly-notes/2026-11-14-feat-ai-channel-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## New
- Hide or label videos from channels on the AiSList list of AI-generated content, in Settings > Integrations. Off by default (#865)
- Use your own server for the AI channel list, and report AI channels to AiSList from a video's menu (#865)
6 changes: 6 additions & 0 deletions app/src/main/java/io/github/aedev/flow/FlowApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import coil3.PlatformContext
import coil3.SingletonImageLoader
import dagger.hilt.android.HiltAndroidApp
import io.github.aedev.flow.applock.AppLock
import io.github.aedev.flow.data.contentfilter.aislist.AiListRepository
import io.github.aedev.flow.data.local.PlayerPreferences
import io.github.aedev.flow.data.local.SubscriptionRepository
import io.github.aedev.flow.data.profile.ActiveProfile
Expand All @@ -18,6 +19,7 @@ import io.github.aedev.flow.data.profile.ProfileFiles
import io.github.aedev.flow.data.profile.ProfileSlices
import io.github.aedev.flow.data.profile.ProfileWebView
import io.github.aedev.flow.data.profile.ProfileWorkReconciler
import io.github.aedev.flow.data.recommendation.FlowNeuroEngine
import io.github.aedev.flow.data.repository.NewPipeDownloader
import io.github.aedev.flow.data.repository.YouTubeRepository
import io.github.aedev.flow.discord.DiscordPresenceRuntime
Expand Down Expand Up @@ -115,6 +117,9 @@ class FlowApplication :
@Inject
lateinit var linkedSync: dagger.Lazy<LinkedSync>

@Inject
lateinit var aiList: AiListRepository

override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader

companion object {
Expand Down Expand Up @@ -199,6 +204,7 @@ class FlowApplication :
if (linkedProfiles.get().catchingUp()) linkedSync.get().syncNow()
}
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
FlowNeuroEngine.getInstance(this@FlowApplication).aiListMatcher = aiList::currentMatcher
launch { profileWorkReconciler.reconcile() }
launch { AutoBackupWorker.keepScheduled(this@FlowApplication) }
launch { postSwitchWork.runIfSwitched() }
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import io.github.aedev.flow.applock.AppLock
import io.github.aedev.flow.applock.hideFromRecentsWhenAsked
import io.github.aedev.flow.data.contentfilter.aislist.AiListRepository
import io.github.aedev.flow.data.local.AppFontPreferences
import io.github.aedev.flow.data.local.LocalDataManager
import io.github.aedev.flow.data.playlist.PlaylistTransfer
Expand Down Expand Up @@ -71,6 +72,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var deArrow: DeArrowRepository

@Inject
lateinit var aiList: AiListRepository

@Inject
lateinit var lifecyclePlaybackPreferences: LifecyclePlaybackPreferences

Expand Down Expand Up @@ -214,6 +218,7 @@ class MainActivity : ComponentActivity() {
splashController = splashController,
originalTitles = originalTitles,
deArrow = deArrow,
aiList = aiList,
appIconController = appIconController,
appLock = appLock,
onUiRoot = { cachedAppUiRoot = it },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package io.github.aedev.flow.data.contentfilter.aislist

import io.github.aedev.flow.BuildConfig
import io.github.aedev.flow.data.local.PlayerPreferences
import io.github.aedev.flow.data.model.Video
import io.github.aedev.flow.innertube.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.CacheControl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/** What a report would be about, worked out before the viewer confirms it. */
sealed interface AiReportCheck {
data class Ready(
val handle: String,
) : AiReportCheck

data class AlreadyListed(
val verdict: AiVerdict,
) : AiReportCheck

data object NoName : AiReportCheck

data object NoHandle : AiReportCheck
}

enum class AiReportResult {
SENT,
BUSY,
RATE_LIMITED,
REJECTED,
FAILED,
;

internal companion object {
/** AiSList's answers as its own clients read them; it publishes no API documentation. */
fun fromStatus(code: Int): AiReportResult =
when (code) {
in 200..299 -> SENT
409 -> BUSY
429 -> RATE_LIMITED
400 -> REJECTED
else -> FAILED
}
}
}

/**
* Reports a channel to AiSList, the list's own maintainers, with a video as evidence. Opt-in: it
* sends the channel handle, the video link and the viewer's chosen submitter name, which AiSList
* shows on its public leaderboard. Their reviewers decide; nothing changes on the device.
*/
@Singleton
class AiChannelReporter
@Inject
constructor(
private val preferences: PlayerPreferences,
private val aiList: AiListRepository,
private val client: OkHttpClient,
) {
val enabled: Flow<Boolean> = preferences.aiListReportEnabled

suspend fun check(video: Video): AiReportCheck {
if (preferences.aiListReportName.first().isBlank()) return AiReportCheck.NoName
val handle = handleOf(video) ?: return AiReportCheck.NoHandle
val listed = aiList.listing(video.channelId, handle)
return if (listed == AiVerdict.NONE) AiReportCheck.Ready(handle) else AiReportCheck.AlreadyListed(listed)
}

suspend fun submit(
handle: String,
videoId: String,
): AiReportResult {
val name = preferences.aiListReportName.first()
if (name.isBlank()) return AiReportResult.REJECTED
val body =
buildJsonObject {
put("channel_handle", handle)
put("video_url", "https://youtu.be/$videoId")
put("username", name)
}.toString()
return withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(SUBMIT_URL)
.header("User-Agent", "Flow/${BuildConfig.VERSION_NAME}")
.cacheControl(CacheControl.Builder().noStore().build())
.post(body.toRequestBody(JSON))
.build()
try {
client.newCall(request).execute().use { AiReportResult.fromStatus(it.code) }
} catch (_: IOException) {
AiReportResult.FAILED
}
}
}

/** The card's handle when the feed sent one, otherwise the channel page's. */
private suspend fun handleOf(video: Video): String? {
video.channelHandle?.let(AiHandle::forReport)?.let { return it }
if (!video.channelId.startsWith("UC")) return video.channelId.takeIf { it.startsWith('@') }?.let(AiHandle::forReport)
val header = YouTube.channelLanding(video.channelId).getOrNull()?.header ?: return null
return header.handle?.let(AiHandle::forReport)
}

private companion object {
const val SUBMIT_URL = "https://api.aisloplist.com"
val JSON = "application/json".toMediaType()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package io.github.aedev.flow.data.contentfilter.aislist

import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
import java.text.Normalizer
import java.util.Locale

/**
* Channel handle normalization, step for step the same as the list pipeline and Flow Desktop
* (Flow-Tube/AIlist README, "Handle normalization"), or nothing matches. The shared vectors in
* `normalize-vectors.json` pin it.
*/
internal object AiHandle {
private const val MAX_LENGTH = 64

/** The handle without `@`, lowercased, or null when the input is not a usable handle. */
fun normalize(input: String): String? {
val decoded = percentDecode(extract(input.trimJs()))
val handle = Normalizer.normalize(decoded, Normalizer.Form.NFC).lowercase(Locale.ROOT)
if (handle.isEmpty()) return null
if (handle.codePointCount(0, handle.length) > MAX_LENGTH) return null
if (handle.any { it.isJsWhitespace() || it in FORBIDDEN }) return null
return handle
}

/**
* The handle as a report names it, `@Name` in its own case, or null when it is not one AiSList
* takes: 3 to 30 letters, digits, marks, `_`, `-`, `.` or `·` after the `@`, as YouTube allows.
*/
fun forReport(input: String): String? {
val handle = Normalizer.normalize(percentDecode(extract(input.trimJs())), Normalizer.Form.NFC)
val length = handle.codePointCount(0, handle.length)
if (length !in MIN_REPORT_LENGTH..MAX_REPORT_LENGTH) return null
val allowed =
handle.codePoints().allMatch { point ->
Character.isLetterOrDigit(point) ||
Character.getType(point).toByte() in MARK_TYPES ||
point in REPORT_PUNCTUATION
}
return if (allowed) "@$handle" else null
}

private const val MIN_REPORT_LENGTH = 3
private const val MAX_REPORT_LENGTH = 30
private val MARK_TYPES = setOf(Character.NON_SPACING_MARK, Character.COMBINING_SPACING_MARK, Character.ENCLOSING_MARK)
private val REPORT_PUNCTUATION = setOf('_'.code, '-'.code, '.'.code, '·'.code)

private const val FORBIDDEN = "/%?#@"

private fun extract(text: String): String {
val rest =
when {
text.startsWith('@') -> text.substring(1)
else -> text.indexOf("/@").takeIf { it >= 0 }?.let { text.substring(it + 2) } ?: return text
}
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }
return if (end < 0) rest else rest.substring(0, end)
}

// java.net.URLDecoder turns '+' into a space; decodeURIComponent, which the pipeline uses, does not.
private fun percentDecode(text: String): String {
if ('%' !in text) return text
val out = StringBuilder(text.length)
val pending = ByteArrayOutputStream()
var i = 0
while (i < text.length) {
val c = text[i]
if (c == '%') {
val byte = text.hexByteAt(i + 1) ?: return text
pending.write(byte)
i += 3
continue
}
if (pending.size() > 0) {
out.append(pending.decodeStrict() ?: return text)
pending.reset()
}
out.append(c)
i++
}
if (pending.size() > 0) out.append(pending.decodeStrict() ?: return text)
return out.toString()
}

private fun String.hexByteAt(index: Int): Int? {
if (index + 1 >= length) return null
val high = Character.digit(this[index], 16)
val low = Character.digit(this[index + 1], 16)
return if (high < 0 || low < 0) null else high * 16 + low
}

private fun ByteArrayOutputStream.decodeStrict(): String? =
try {
Charsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(toByteArray()))
.toString()
} catch (_: CharacterCodingException) {
null
}

private fun String.trimJs(): String = trim { it.isJsWhitespace() }

// JavaScript's \s, which String.trim() and the pipeline's rejection rule use. Char.isWhitespace()
// disagrees on U+00A0, U+2007, U+202F and U+001C to U+001F.
private fun Char.isJsWhitespace(): Boolean =
this in '\u0009'..'\u000D' ||
this == ' ' ||
this == ' ' ||
this == ' ' ||
this in ' '..' ' ||
this == '
' ||
this == '
' ||
this == ' ' ||
this == ' ' ||
this == ' ' ||
this == ''
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.github.aedev.flow.data.contentfilter.aislist

import io.github.aedev.flow.sync2.crypto.verify
import java.util.Base64

/**
* The public keys the list may be signed with, by `key_id`. A new key ships here in an app release
* before the pipeline signs with it; it is never fetched from the host it protects.
*/
internal object AiListKeys {
val trusted: Map<String, ByteArray> =
mapOf("2026-11" to Base64.getDecoder().decode("xak9y4JaDG+UjKfl7cDwiaJs+7FjrOM+urMW1YfSuc0="))
}

/** Verifies a manifest envelope and only then hands back the payload bytes to parse. */
internal object AiListEnvelope {
private const val SIGNATURE_SIZE = 64

sealed interface Opened {
class Verified(
val payload: ByteArray,
) : Opened

data class Refused(
val reason: String,
) : Opened
}

fun open(
envelopeBytes: ByteArray,
keyFor: (keyId: String) -> ByteArray?,
): Opened {
val envelope =
runCatching { aiListJson.decodeFromString(AiListEnvelopeJson.serializer(), envelopeBytes.decodeToString()) }
.getOrNull() ?: return Opened.Refused("manifest is not an envelope")
val keyId = envelope.keyId ?: return Opened.Refused("no key_id")
val key = keyFor(keyId) ?: return Opened.Refused("unknown key_id $keyId")
val payload = envelope.payload?.decodeCanonicalBase64() ?: return Opened.Refused("payload is not standard base64")
val signature = envelope.sig?.decodeCanonicalBase64() ?: return Opened.Refused("sig is not standard base64")
if (signature.size != SIGNATURE_SIZE) return Opened.Refused("sig is not $SIGNATURE_SIZE bytes")
if (!verify(key, payload, signature)) return Opened.Refused("signature does not verify")
return Opened.Verified(payload)
}

// The basic decoder also takes unpadded input; the publisher only ever writes padded base64.
private fun String.decodeCanonicalBase64(): ByteArray? {
val bytes = runCatching { Base64.getDecoder().decode(this) }.getOrNull() ?: return null
return bytes.takeIf { Base64.getEncoder().encodeToString(it) == this }
}
}
Loading
Loading