Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
2d0f852
test(profiles): store inventory and scope guard
A-EDev Oct 27, 2026
aedf291
feat(profiles): profile registry read at process start
A-EDev Oct 27, 2026
44b8016
refactor(data): every preferences store states its scope
A-EDev Oct 27, 2026
4f8c152
refactor(neuro): engine stores take their file from the profile
A-EDev Oct 27, 2026
38d77a1
feat(settings): device-wide settings store
A-EDev Oct 27, 2026
c9317e8
refactor(db): profile and device database handles
A-EDev Oct 27, 2026
b461b9b
refactor(identity): profile-named identity and cache prefs
A-EDev Oct 27, 2026
0125a23
feat(backup): profile folders in Auto Backup rules
A-EDev Oct 27, 2026
2de97ba
perf(startup): regenerate baseline profile
A-EDev Oct 27, 2026
73b96c3
feat(profiles): create, rename and delete in the registry
A-EDev Oct 27, 2026
9d0ff46
feat(profiles): switch by restarting the process
A-EDev Oct 27, 2026
fecd3a5
fix(work): background work stays with the profile that queued it
A-EDev Oct 27, 2026
db60f4b
fix(notifications): old notifications never open another profile
A-EDev Oct 27, 2026
14beffe
fix(widgets): widgets redraw for the active profile
A-EDev Oct 27, 2026
3682a26
fix(media): media sessions and Android Auto follow a switch
A-EDev Oct 27, 2026
09a3c21
fix(identity): WebView data and request cache per profile
A-EDev Oct 27, 2026
782cfd1
feat(profiles): profile slices for copying and restoring
A-EDev Oct 27, 2026
2f2fe0d
feat(sync): sync only while Default is active
A-EDev Oct 27, 2026
1ff9197
fix(intents): links arriving during a switch open in the new profile
A-EDev Oct 27, 2026
2c1dce3
fix(discord): one Discord link per profile
A-EDev Oct 27, 2026
2a5a88c
fix(downloads): playlist collections remember their profile
A-EDev Oct 27, 2026
4b5f921
fix(profiles): leave stale device keys out of settings exports
A-EDev Oct 27, 2026
6068eff
refactor(main): move the root content out of MainActivity
A-EDev Oct 27, 2026
75d2411
refactor(ui): shared color swatch
A-EDev Oct 27, 2026
f60a5bd
feat(profiles): profile avatar and colors
A-EDev Oct 27, 2026
09915a5
feat(profiles): Profiles settings and profile editor
A-EDev Oct 27, 2026
ed334b9
feat(profiles): switch from the Home avatar
A-EDev Oct 27, 2026
41cc896
fix(profiles): fold the other profiles under the one in use and space…
A-EDev Oct 27, 2026
5e637d3
feat(profiles): switching screen
A-EDev Oct 28, 2026
01967b7
feat(tv): profile in the rail and Who's watching
A-EDev Oct 28, 2026
5d4146d
feat(onboarding): imports for a new profile
A-EDev Oct 28, 2026
cee6d25
feat(backup): master backup holds every profile
A-EDev Oct 28, 2026
224bc28
feat(backup): restore every profile from a master backup
A-EDev Oct 28, 2026
4e75b76
feat(backup): one auto backup for all profiles
A-EDev Oct 28, 2026
29d4b75
feat(profiles): show Profiles
A-EDev Oct 28, 2026
aef1663
fix(backup): word the waiting profiles message for one or several
A-EDev Oct 28, 2026
c996ff4
Merge branch 'feat/profiles-master-restore' into feat/profiles-auto-b…
A-EDev Oct 28, 2026
374b92d
Merge branch 'feat/profiles-auto-backup' into feat/profiles-show
A-EDev Oct 28, 2026
d316187
test(profiles): leak matrix
A-EDev Oct 28, 2026
dac582a
test(profiles): upgrade and downgrade
A-EDev Oct 28, 2026
8db1d01
fix(benchmark): opt in to the experimental trace section metric
A-EDev Oct 27, 2026
8addc62
build(profile): regenerate the baseline profile
A-EDev Oct 28, 2026
852de12
docs(benchmark): name the profile task that exists
A-EDev Oct 28, 2026
7aacdc5
Merge pull request #1518 from Flow-Tube/build/profiles-baseline-profile
A-EDev Oct 28, 2026
439ca67
Merge pull request #1516 from Flow-Tube/test/profiles-upgrade-downgrade
A-EDev Oct 28, 2026
c1c3f55
Merge pull request #1515 from Flow-Tube/test/profiles-leak-matrix
A-EDev Oct 28, 2026
740d7dd
Merge pull request #1512 from Flow-Tube/feat/profiles-show
A-EDev Oct 28, 2026
225cd3c
Merge pull request #1511 from Flow-Tube/feat/profiles-auto-backup
A-EDev Oct 28, 2026
040aef0
Merge pull request #1510 from Flow-Tube/feat/profiles-master-restore
A-EDev Oct 28, 2026
50fd710
Merge pull request #1509 from Flow-Tube/feat/profiles-master-backup
A-EDev Oct 28, 2026
1b2db22
Merge pull request #1508 from Flow-Tube/feat/profiles-onboarding-imports
A-EDev Oct 28, 2026
3a6369e
Merge pull request #1506 from Flow-Tube/feat/profiles-tv
A-EDev Oct 28, 2026
99665f2
Merge pull request #1504 from Flow-Tube/feat/profiles-switching-screen
A-EDev Oct 28, 2026
73bebe8
Merge pull request #1500 from Flow-Tube/feat/profiles-home-switch
A-EDev Oct 28, 2026
e18dbce
Merge pull request #1499 from Flow-Tube/feat/profiles-settings
A-EDev Oct 28, 2026
f7c07b4
Merge pull request #1498 from Flow-Tube/feat/profiles-avatar
A-EDev Oct 28, 2026
0074ecb
Merge pull request #1497 from Flow-Tube/refactor/shared-color-swatch
A-EDev Oct 28, 2026
38f0b04
Merge pull request #1496 from Flow-Tube/refactor/main-flow-root
A-EDev Oct 28, 2026
7b08cf6
Merge pull request #1495 from Flow-Tube/fix/profiles-download-collect…
A-EDev Oct 28, 2026
d7e8de4
Merge pull request #1494 from Flow-Tube/fix/profiles-discord
A-EDev Oct 28, 2026
430907d
Merge pull request #1493 from Flow-Tube/fix/profiles-intents
A-EDev Oct 28, 2026
ddbb03b
Merge pull request #1492 from Flow-Tube/feat/profiles-sync-default
A-EDev Oct 28, 2026
0b3994f
Merge pull request #1490 from Flow-Tube/feat/profiles-slices
A-EDev Oct 28, 2026
3e172b5
Merge pull request #1488 from Flow-Tube/fix/profiles-webview
A-EDev Oct 28, 2026
243f7bf
Merge pull request #1486 from Flow-Tube/fix/profiles-media
A-EDev Oct 28, 2026
657abbd
Merge pull request #1483 from Flow-Tube/fix/profiles-widgets
A-EDev Oct 28, 2026
d3e453c
Merge pull request #1482 from Flow-Tube/fix/profiles-notifications
A-EDev Oct 28, 2026
a0131a8
Merge pull request #1481 from Flow-Tube/fix/profiles-work
A-EDev Oct 28, 2026
eb9765f
Merge pull request #1480 from Flow-Tube/feat/profiles-switch
A-EDev Oct 28, 2026
f07af35
Merge pull request #1479 from Flow-Tube/feat/profiles-repository
A-EDev Oct 28, 2026
f1f6ec0
Merge pull request #1476 from Flow-Tube/feat/profiles-backup-rules
A-EDev Oct 28, 2026
4b97283
Merge pull request #1475 from Flow-Tube/refactor/profiles-identity-prefs
A-EDev Oct 28, 2026
db14d23
Merge pull request #1474 from Flow-Tube/refactor/profiles-database-ha…
A-EDev Oct 28, 2026
a4ea13b
Merge pull request #1473 from Flow-Tube/feat/profiles-device-settings
A-EDev Oct 28, 2026
46c72ab
Merge pull request #1472 from Flow-Tube/refactor/profiles-engine-stores
A-EDev Oct 28, 2026
5269a34
Merge pull request #1471 from Flow-Tube/refactor/profiles-store-scope
A-EDev Oct 28, 2026
a887c85
Merge pull request #1470 from Flow-Tube/feat/profiles-registry
A-EDev Oct 28, 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
2 changes: 2 additions & 0 deletions .github/nightly-notes/2026-10-27-feat-profiles-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Fixed
- Backups now keep hidden music shelves and library sections, per-channel speeds, playlist sort orders and channels left out of Shorts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Fixed
- Tapping a premiere reminder notification opens the video
2 changes: 2 additions & 0 deletions .github/nightly-notes/2026-10-27-fix-profiles-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Fixed
- The break reminder stops once you turn it off
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Fixed
- Automatic master backups now include your recap and favourite artists
- Automatic backups start again after the app is restored onto a phone
2 changes: 2 additions & 0 deletions .github/nightly-notes/2026-10-28-feat-profiles-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## New
- Profiles: up to five people can each have their own feed, history, subscriptions, settings and recommendations in one app, switched from the avatar on Home or, on TV, from the rail and a Who's watching? screen (#372, #602, #883, #1039)
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.github.aedev.flow.data.local

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.github.aedev.flow.data.local.entity.DownloadEntity
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

/**
* Every profile opens its own file of the same AppDatabase class, so no profile ever needs a schema change or a
* migration of its own. The test runner's Application skips FlowApplication, so the active profile is read lazily
* from the registry, as in a restore.
*/
@RunWith(AndroidJUnit4::class)
class ProfileDatabaseTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val root = File(context.filesDir, "profile_library/profile-database-test")
private val path = File(root, "databases/${AppDatabase.DEFAULT_NAME}").apply { parentFile?.mkdirs() }.absolutePath

@After
fun tearDown() {
root.deleteRecursively()
root.parentFile?.delete()
}

@Test
fun withDefaultRunningBothHandlesAreOneDatabase() {
assertSame(AppDatabase.device(context), AppDatabase.profile(context))
}

@Test
fun anotherProfilesFileOpensAtTheCurrentVersionWithEmptyTables() {
val database = AppDatabase.open(context, path)
try {
val sqlite = database.openHelper.readableDatabase
assertEquals(33, sqlite.version)
listOf("watch_history", "playlists", "videos", "notes", "downloads", "sync_log").forEach { table ->
sqlite.query("SELECT COUNT(*) FROM $table").use { cursor ->
cursor.moveToFirst()
assertEquals(table, 0, cursor.getInt(0))
}
}
assertTrue(File(path).exists())
} finally {
database.close()
}
}

@Test
fun anotherProfilesFileKeepsItsRowsWhenOpenedAgain() =
runTest {
AppDatabase.open(context, path).run {
downloadDao().insertDownload(DownloadEntity(videoId = "v1", title = "Kept", uploader = "Uploader"))
close()
}

val reopened = AppDatabase.open(context, path)
try {
assertEquals(
"Kept",
reopened
.downloadDao()
.getDownloadWithItems("v1")
?.download
?.title,
)
} finally {
reopened.close()
}
}
}
11 changes: 11 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- DUMP is held by adb's shell and the system only, so nothing installed on the device can send to it. -->
<receiver
android:name="io.github.aedev.flow.data.profile.ProfileProbeReceiver"
android:exported="true"
android:permission="android.permission.DUMP" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package io.github.aedev.flow.data.profile

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import io.github.aedev.flow.data.local.ChannelSubscription
import io.github.aedev.flow.data.local.LikedVideoInfo
import io.github.aedev.flow.data.local.LikedVideosRepository
import io.github.aedev.flow.data.local.PlayerPreferences
import io.github.aedev.flow.data.local.PlaylistRepository
import io.github.aedev.flow.data.local.SearchHistoryRepository
import io.github.aedev.flow.data.local.SubscriptionRepository
import io.github.aedev.flow.data.local.ViewHistory
import io.github.aedev.flow.data.model.Video
import io.github.aedev.flow.data.notes.NoteKind
import io.github.aedev.flow.data.notes.NotesRepository
import io.github.aedev.flow.data.recommendation.FlowNeuroEngine
import io.github.aedev.flow.data.recommendation.InteractionType
import io.github.aedev.flow.notification.UpcomingReminder
import io.github.aedev.flow.notification.UpcomingVideoReminderWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit

/**
* Debug builds only: the hook `scripts/profiles/leak_matrix.sh` drives over adb. It writes a marker into every
* profile-bound store of the running profile, creates profiles and switches between them; the script then looks for
* each marker in files that belong to any other profile.
*
* `--es op mark --es tag zzleakA` · `--es op create --es name A [--ez copySettings true]` · `--es op switch --es id <id>`
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface ProfileProbeEntryPoint {
fun profiles(): ProfileRepository

fun switcher(): ProfileSwitcher

fun searches(): SearchHistoryRepository

fun subscriptions(): SubscriptionRepository

fun playlists(): PlaylistRepository

fun history(): ViewHistory

fun likes(): LikedVideosRepository

fun notes(): NotesRepository

fun preferences(): PlayerPreferences

fun engine(): FlowNeuroEngine
}

class ProfileProbeReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
val app = EntryPointAccessors.fromApplication(context, ProfileProbeEntryPoint::class.java)
val pending = goAsync()
scope.launch {
val result = runCatching { app.handle(context, intent) }.getOrElse { "error ${it.javaClass.simpleName}: ${it.message}" }
Log.i(TAG, "${intent.getStringExtra("op")} in ${app.profiles().running().id}: $result")
pending.finish()
}
}

private suspend fun ProfileProbeEntryPoint.handle(
context: Context,
intent: Intent,
): String =
when (intent.getStringExtra("op")) {
"mark" -> {
mark(context, requireNotNull(intent.getStringExtra("tag")))
}

"create" -> {
val copy = if (intent.getBooleanExtra("copySettings", false)) setOf(SlicePart.SETTINGS) else emptySet()
when (val created = profiles().create(requireNotNull(intent.getStringExtra("name")), copy = copy)) {
is CreateResult.Created -> "created ${created.profile.id}"
else -> created.toString()
}
}

"switch" -> {
val id = requireNotNull(intent.getStringExtra("id"))
withContext(Dispatchers.Main) { switcher().switchTo(id) }.toString()
}

else -> {
"unknown op"
}
}

/** [tag] in every profile-bound store; the subtitle language gets `zzset…` so a settings-only copy can be told apart. */
private suspend fun ProfileProbeEntryPoint.mark(
context: Context,
tag: String,
): String {
val videoId = "${tag}vid"
val title = "$tag video"
val channel = "$tag channel"
searches().saveSearchQuery(tag)
subscriptions().subscribe(ChannelSubscription(channelId = "UC$tag", channelName = channel, channelThumbnail = ""))
playlists().createPlaylist(UUID.randomUUID().toString(), "$tag playlist", "", isPrivate = true)
history().savePlaybackPosition(videoId, position = 30_000, duration = 60_000, title = title, channelName = channel)
likes().likeVideo(LikedVideoInfo(videoId = videoId, title = title, thumbnail = "", channelName = channel))
notes().save(NoteKind.Video, videoId, "$tag note")
engine().onVideoInteraction(
Video(videoId, title, channel, "UC$tag", "", duration = 600, viewCount = 1_000, uploadDate = ""),
InteractionType.WATCHED,
percentWatched = 0.9f,
)
preferences().setPreferredSubtitleLanguage(tag.replace("zzleak", "zzset"))
val releaseMs = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7)
preferences().setUpcomingVideoReminder(videoId, true)
preferences().saveUpcomingReminder(UpcomingReminder(videoId, releaseMs, title, channel, null))
UpcomingVideoReminderWorker.scheduleReminder(context, videoId, releaseMs, title, channel, null)
return "marked $tag"
}

private companion object {
const val TAG = "ProfileProbe"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,39 @@ import kotlinx.coroutines.flow.StateFlow
import okhttp3.OkHttpClient

object DiscordPresenceRuntime {
private val unavailableState = DiscordSettingsState(
isAvailable = false,
isEnabled = false,
canEnable = false,
connectionState = DiscordConnectionState.UNAVAILABLE,
summary = DiscordSettingsSummary.UNAVAILABLE,
accountName = null,
errorMessage = null,
)
private val unavailableState =
DiscordSettingsState(
isAvailable = false,
isEnabled = false,
canEnable = false,
connectionState = DiscordConnectionState.UNAVAILABLE,
summary = DiscordSettingsSummary.UNAVAILABLE,
accountName = null,
errorMessage = null,
)
private val mutableSettingsState = MutableStateFlow(unavailableState)
val settingsState: StateFlow<DiscordSettingsState> = mutableSettingsState

fun initialize(context: Context, okHttpClient: OkHttpClient) = Unit
fun initialize(
context: Context,
okHttpClient: OkHttpClient,
) = Unit

fun attachActivity(activity: Activity?) = Unit

fun detachActivity(activity: Activity) = Unit

fun setAppForeground(foreground: Boolean) = Unit

suspend fun setEnabled(enabled: Boolean) = Unit

suspend fun connectAccount(): DiscordLinkResult = DiscordLinkResult.Failure("")

suspend fun retry(): DiscordLinkResult = DiscordLinkResult.Failure("")

suspend fun unlink(): Boolean = false

suspend fun endForSwitch() = Unit

fun shutdown() = Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,29 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.aedev.flow.data.local.safePreferencesDataStore
import io.github.aedev.flow.data.profile.StoreScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

private val Context.discordDataStore: DataStore<Preferences> by safePreferencesDataStore(
name = "discord_presence",
scope = StoreScope.PROFILE_PRIVATE,
)

class DiscordPreferences(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.applicationContext.discordDataStore)

val enabled: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[ENABLED] ?: false
}
val enabled: Flow<Boolean> =
dataStore.data.map { preferences ->
preferences[ENABLED] ?: false
}

val linkedAccountLabel: Flow<String?> = dataStore.data.map { preferences ->
preferences[LINKED_ACCOUNT_LABEL]?.takeIf(String::isNotBlank)
}
val linkedAccountLabel: Flow<String?> =
dataStore.data.map { preferences ->
preferences[LINKED_ACCOUNT_LABEL]?.takeIf(String::isNotBlank)
}

suspend fun setEnabled(enabled: Boolean) {
dataStore.edit { preferences -> preferences[ENABLED] = enabled }
Expand Down
Loading
Loading