From 15a481b7c0d9b20b7510278ebddc583f69d2104f Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 00:05:10 +0000 Subject: [PATCH] profile: Fetch the user profile on every app start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login flow stored displayName/avatar inside one swallow-all try that serverConfig() also had to survive first, and /users/me carries both in its nested account object on current PeerTube versions (verified live against 8.3.1) — so stored sessions kept showing the raw username and no avatar. The profile now refreshes after login and whenever an existing session starts, and the relative avatar fallback no longer points into /api/v1. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 68 ++++++++++--------- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 9 +++ .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 21 +++++- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 8922c39..496ca1f 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -69,45 +69,47 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // Login accepts emails; resolve the real account name for /accounts/{handle}. - // The request MUST carry the fresh token (UX report BUG-1: unauthenticated - // /users/me returns 401 and the fallback breaks Channels for email logins). - try { - val authed = plainOkHttp().newBuilder() - .addInterceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .header("Authorization", "Bearer ${token.accessToken}") - .build() - ) - } - .build() - val meApi = Retrofit.Builder() - .baseUrl(apiUrlOf(base) + "/") - .client(authed) - .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) - .build() - .create(PeerTubeApi::class.java) - store.instanceName = try { - meApi.serverConfig().instance.name - } catch (_: Exception) { null } - val me = meApi.me() - store.accountName = me.username - store.userDisplayName = me.displayName ?: me.username - me.avatars.maxByOrNull { it.width }?.let { avatar -> - store.userAvatarUrl = avatar.fileUrl - ?.takeIf { url -> url.startsWith("http") } - ?: apiUrlOf(base) + avatar.path - } - } catch (_: Exception) { - store.accountName = username.substringBefore('@') // best effort fallback - } + // Login accepts email addresses too; the login input is not a valid + // handle for /accounts/{handle}, so default it, refreshMyProfile() + // corrects it right after (and on every app start). + store.accountName = username.substringBefore('@') + store.instanceName = runCatching { network.api.serverConfig().instance.name }.getOrNull() + // /users/me fills displayName + avatar; failure is not fatal because + // the profile is refreshed again on the next app start. + refreshMyProfile() } suspend fun me(): Result = runCatching { network.api.me() } suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } + /** Display name + avatar URL of the logged-in user, from /users/me. */ + data class UserProfile(val displayName: String, val avatarUrl: String?) + + /** + * Fetches and persists the profile of the logged-in user. Called after login + * AND on every app start with a stored session: it used to run only at login + * inside a swallow-all try, so sessions from earlier app versions showed the + * raw username and no avatar. Returns null on failure. + */ + suspend fun refreshMyProfile(): UserProfile? { + // /users/me carries displayName + avatars inside `account` (verified + // live v8.3.1); the flat fields exist on some older versions only. + val me = runCatching { network.api.me() }.getOrNull() ?: return null + val name = me.account?.displayName ?: me.displayName ?: me.username + val avatars = me.account?.avatars?.takeIf { it.isNotEmpty() } ?: me.avatars + store.accountName = me.username + val displayName = name + store.userDisplayName = displayName + val avatarUrl = avatars.maxByOrNull { it.width }?.let { avatar -> + avatar.fileUrl?.takeIf { it.startsWith("http") } + // lazy-static avatars live at the instance root, NOT under /api/v1 + ?: store.baseUrl?.let { base -> normalizeBaseUrl(base) + avatar.path } + } + store.userAvatarUrl = avatarUrl + return UserProfile(displayName, avatarUrl) + } + /** Display name of a (possibly foreign) instance, via that instance's /config. */ suspend fun instanceName(rawHost: String): Result = runCatching { val base = "https://" + rawHost diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 91d3777..4507861 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -165,6 +165,15 @@ data class ChannelStats( @Serializable data class UserDto( val username: String, + // /users/me does NOT return these at top level on newer PeerTube versions — + // the account object carries displayName + avatars (verified live v8.3.1). val displayName: String? = null, val avatars: List = emptyList(), + val account: UserAccountDto? = null, +) + +@Serializable +data class UserAccountDto( + @SerialName("displayName") val displayName: String? = null, + val avatars: List = emptyList(), ) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index e6e542f..8bc14bd 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _sessionState + /** Display name + avatar of the logged-in user (for the drawer header). */ + private val _userProfile = MutableStateFlow(null) + val userProfile: StateFlow = _userProfile + /** * Bumped on every logout and successful login: all list ViewModels and saved * UI state are keyed by it, so switching accounts never shows the previous @@ -30,8 +34,19 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit val loggingIn = MutableStateFlow(false) init { - _sessionState.value = - if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup + if (repository.hasSession()) { + _sessionState.value = SessionState.Ready + // Re-fetch the profile on every app start: stored sessions from older + // app versions may lack displayName/avatar (or the login-time fetch + // failed back then). Failure keeps the stored/fallback values. + refreshProfile() + } else { + _sessionState.value = SessionState.NeedsSetup + } + } + + private fun refreshProfile() { + viewModelScope.launch { _userProfile.value = repository.refreshMyProfile() } } fun login(baseUrl: String, username: String, password: String) { @@ -43,6 +58,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit if (result.isSuccess) { _sessionState.value = SessionState.Ready sessionEpoch.value += 1 + refreshProfile() } else { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" @@ -67,6 +83,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() + _userProfile.value = null _sessionState.value = SessionState.NeedsSetup sessionEpoch.value += 1 }