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 }