Merge pull request #730 from nielsvanvelzen/auth-ui
Screen based login UI and tons of fixes
This commit is contained in:
@@ -99,6 +99,7 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$androidxLifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
|
||||
implementation("androidx.window:window:1.0.0-alpha02")
|
||||
implementation("androidx.viewpager:viewpager:1.0.0")
|
||||
|
||||
// Dependency Injection
|
||||
val koinVersion = "2.2.0"
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.*
|
||||
class AuthenticationRepository(
|
||||
private val application: JellyfinApplication,
|
||||
private val jellyfin: Jellyfin,
|
||||
private val apiClient: ApiClient,
|
||||
private val api: ApiClient,
|
||||
private val device: IDevice,
|
||||
private val accountManagerHelper: AccountManagerHelper,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
@@ -40,7 +40,8 @@ class AuthenticationRepository(
|
||||
serverId = authInfo?.server ?: server, name = userInfo.name,
|
||||
accessToken = authInfo?.accessToken,
|
||||
requirePassword = userInfo.requirePassword,
|
||||
imageTag = userInfo.imageTag
|
||||
imageTag = userInfo.imageTag,
|
||||
lastUsed = userInfo.lastUsed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +50,7 @@ class AuthenticationRepository(
|
||||
val current = authenticationStore.getServer(id)
|
||||
|
||||
if (current != null)
|
||||
authenticationStore.putServer(id, current.copy(name = name, address = address))
|
||||
authenticationStore.putServer(id, current.copy(name = name, address = address, lastUsed = Date().time))
|
||||
else
|
||||
authenticationStore.putServer(id, AuthenticationStoreServer(name, address))
|
||||
}
|
||||
@@ -61,9 +62,19 @@ class AuthenticationRepository(
|
||||
* @return Whether the user information can be retrieved.
|
||||
*/
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
apiClient.setDevice(AuthenticationDevice(device, user.name))
|
||||
apiClient.SetAuthenticationInfo(user.accessToken, user.id.toString())
|
||||
apiClient.EnableAutomaticNetworking(ServerInfo().apply {
|
||||
// Update last use in store
|
||||
authenticationStore.getServer(server.id)?.let { storedServer ->
|
||||
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
|
||||
}
|
||||
|
||||
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
|
||||
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
|
||||
}
|
||||
|
||||
// Set user in apiclient
|
||||
api.setDevice(AuthenticationDevice(device, user.name))
|
||||
api.SetAuthenticationInfo(user.accessToken, user.id.toString())
|
||||
api.EnableAutomaticNetworking(ServerInfo().apply {
|
||||
id = server.id.toString()
|
||||
name = server.name
|
||||
address = server.address
|
||||
@@ -74,7 +85,7 @@ class AuthenticationRepository(
|
||||
// Suppressed because the old apiclient is unreliable
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val userDto = callApi<UserDto?> { callback -> apiClient.GetUserAsync(user.id.toString(), callback) }
|
||||
val userDto = callApi<UserDto?> { callback -> api.GetUserAsync(user.id.toString(), callback) }
|
||||
if (userDto != null) {
|
||||
application.currentUser = userDto
|
||||
return true
|
||||
@@ -152,7 +163,15 @@ class AuthenticationRepository(
|
||||
authenticationStore.putUser(server.id, userId, updatedUser)
|
||||
accountManagerHelper.putAccount(AccountManagerAccount(userId, server.id, updatedUser.name, result.accessToken))
|
||||
|
||||
val user = PrivateUser(userId, server.id, updatedUser.name, result.accessToken, result.user.hasPassword, result.user.primaryImageTag)
|
||||
val user = PrivateUser(
|
||||
id = userId,
|
||||
serverId = server.id,
|
||||
name = updatedUser.name,
|
||||
accessToken = result.accessToken,
|
||||
requirePassword = result.user.hasPassword,
|
||||
imageTag = result.user.primaryImageTag,
|
||||
lastUsed = Date().time,
|
||||
)
|
||||
val authenticated = setActiveSession(user, server)
|
||||
if (authenticated) emit(AuthenticatedState)
|
||||
else emit(RequireSignInState)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.jellyfin.androidtv.auth.model.*
|
||||
import org.jellyfin.androidtv.util.apiclient.callApi
|
||||
@@ -12,15 +11,18 @@ import org.jellyfin.androidtv.util.toUUID
|
||||
import org.jellyfin.apiclient.Jellyfin
|
||||
import org.jellyfin.apiclient.discovery.DiscoveryServerInfo
|
||||
import org.jellyfin.apiclient.interaction.device.IDevice
|
||||
import org.jellyfin.apiclient.model.dto.UserDto
|
||||
import org.jellyfin.apiclient.model.system.PublicSystemInfo
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
interface ServerRepository {
|
||||
fun getServers(discovery: Boolean = true, stored: Boolean = true): Flow<Set<Server>>
|
||||
fun getServersWithUsers(discovery: Boolean = true, stored: Boolean = true): Flow<Map<Server, Set<User>>>
|
||||
suspend fun getStoredServers(): List<Server>
|
||||
fun getDiscoveryServers(): Flow<Server>
|
||||
suspend fun migrateLegacyCredentials()
|
||||
|
||||
fun removeServer(serverId: UUID)
|
||||
suspend fun gerServerUsers(server: Server): Set<User>
|
||||
fun removeServer(serverId: UUID): Unit
|
||||
fun addServer(address: String): Flow<ServerAdditionState>
|
||||
}
|
||||
|
||||
@@ -31,95 +33,36 @@ class ServerRepositoryImpl(
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val legacyAccountMigration: LegacyAccountMigration
|
||||
) : ServerRepository {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun getDiscoveryServers(): Flow<Server> = flow {
|
||||
emitAll(jellyfin.discovery.discover().map(DiscoveryServerInfo::toServer))
|
||||
override suspend fun getStoredServers() = authenticationRepository.getServers()
|
||||
|
||||
override fun getDiscoveryServers() = flow {
|
||||
val servers = jellyfin.discovery.discover().map(DiscoveryServerInfo::toServer)
|
||||
emitAll(servers)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun getStoredServers(): Flow<Server> = flow {
|
||||
authenticationRepository.getServers().forEach { server -> emit(server) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
private suspend fun getServerPublicUsers(server: Server): List<PublicUser> = jellyfin
|
||||
.createApi(server.address, device = device)
|
||||
.getPublicUsers()
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
.map(UserDto::toPublicUser)
|
||||
|
||||
private fun getPublicUsersForServer(server: Server): Flow<PublicUser> = flow {
|
||||
jellyfin.createApi(server.address, device = device).getPublicUsers()?.forEach { userDto ->
|
||||
emit(userDto.toPublicUser())
|
||||
}
|
||||
}
|
||||
private fun getServerStoredUsers(server: Server): List<PrivateUser> = authenticationRepository
|
||||
.getUsers(server.id)
|
||||
.orEmpty()
|
||||
|
||||
private fun getStoredUsersForServer(server: Server): Flow<PrivateUser> = flow {
|
||||
authenticationRepository.getUsers(server.id)?.forEach { user -> emit(user) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getServers(discovery: Boolean, stored: Boolean): Flow<Set<Server>> = flow {
|
||||
// Migrate old servers and users to new store before reading them from the new store
|
||||
legacyAccountMigration.migrate()
|
||||
|
||||
// Start by emitting an empty collection
|
||||
val servers = mutableSetOf<Server>()
|
||||
val flows = mutableListOf<Flow<Server>>()
|
||||
emit(servers)
|
||||
|
||||
// Add discovered servers
|
||||
if (discovery) flows += getDiscoveryServers().onEach { server ->
|
||||
// Only add if not already found in storage
|
||||
if (servers.none { it.id == server.id }) {
|
||||
servers.add(server)
|
||||
emit(servers)
|
||||
}
|
||||
}
|
||||
|
||||
// Add stored servers
|
||||
if (stored) flows += getStoredServers().onEach { server ->
|
||||
// Remove existing server with id
|
||||
// only happens for servers added via discovery
|
||||
servers.removeAll { it.id == server.id }
|
||||
servers += server
|
||||
emit(servers)
|
||||
}
|
||||
|
||||
// Wait for all flows to complete
|
||||
flows.forEach { it.collect() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun getUsers(server: Server): Set<User> {
|
||||
override suspend fun gerServerUsers(server: Server): Set<User> {
|
||||
val users = mutableSetOf<User>()
|
||||
val flows = mutableListOf<Flow<User>>()
|
||||
|
||||
flows += getPublicUsersForServer(server).onEach { user ->
|
||||
// Only add if not already found in storage
|
||||
if (users.none { it.id == user.id }) {
|
||||
users.add(user)
|
||||
}
|
||||
users.addAll(getServerStoredUsers(server))
|
||||
getServerPublicUsers(server).forEach { user ->
|
||||
if (users.none { it.id == user.id }) users.add(user)
|
||||
}
|
||||
|
||||
flows += getStoredUsersForServer(server).onEach { user ->
|
||||
// Remove existing server with id
|
||||
// only happens for servers added via discovery
|
||||
users.removeAll { it.id == user.id }
|
||||
users += user
|
||||
}
|
||||
|
||||
// Wait for all flows to complete
|
||||
flows.forEach { it.collect() }
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getServersWithUsers(discovery: Boolean, stored: Boolean): Flow<Map<Server, Set<User>>> = flow {
|
||||
val userFlows = mutableMapOf<UUID, Set<User>>()
|
||||
|
||||
getServers(discovery, stored).collect { servers ->
|
||||
emit(servers.map { server ->
|
||||
if (server.id !in userFlows)
|
||||
userFlows[server.id] = getUsers(server)
|
||||
|
||||
server to userFlows[server.id]!!
|
||||
}.toMap())
|
||||
}
|
||||
}
|
||||
override suspend fun migrateLegacyCredentials() = legacyAccountMigration.migrate()
|
||||
|
||||
override fun removeServer(serverId: UUID) {
|
||||
authenticationStore.removeServer(serverId)
|
||||
|
||||
@@ -33,7 +33,8 @@ data class PrivateUser(
|
||||
override val name: String,
|
||||
override val accessToken: String?,
|
||||
override val requirePassword: Boolean,
|
||||
override val imageTag: String?
|
||||
override val imageTag: String?,
|
||||
val lastUsed: Long,
|
||||
) : User()
|
||||
|
||||
/**
|
||||
@@ -45,5 +46,5 @@ data class PublicUser(
|
||||
override val name: String,
|
||||
override val accessToken: String?,
|
||||
override val requirePassword: Boolean,
|
||||
override val imageTag: String?
|
||||
override val imageTag: String?,
|
||||
) : User()
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.jellyfin.androidtv.util.toUUID
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import java.util.*
|
||||
|
||||
class AddServerFragment(
|
||||
class AddServerAlertFragment(
|
||||
private val onServerAdded: (serverId: UUID) -> Unit = {},
|
||||
private val onCancelCallback: () -> Unit = {},
|
||||
private val onClose: () -> Unit = {}
|
||||
@@ -30,6 +30,10 @@ class AddServerFragment(
|
||||
onCancelCallback = onCancelCallback,
|
||||
onClose = onClose
|
||||
) {
|
||||
companion object {
|
||||
const val ARG_SERVER_ADDRESS = "server_address"
|
||||
}
|
||||
|
||||
private val loginViewModel: LoginViewModel by sharedViewModel()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
@@ -78,6 +82,12 @@ class AddServerFragment(
|
||||
}
|
||||
}
|
||||
|
||||
arguments?.getString(ARG_SERVER_ADDRESS)?.let { serverAddress ->
|
||||
address.setText(serverAddress)
|
||||
address.isEnabled = false
|
||||
confirm.callOnClick()
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.jellyfin.androidtv.ui.startup
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.databinding.FragmentAddServerScreenBinding
|
||||
import org.jellyfin.androidtv.databinding.ItemDiscoveryServerBinding
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class AddServerScreenFragment : Fragment() {
|
||||
private lateinit var binding: FragmentAddServerScreenBinding
|
||||
private val loginViewModel: LoginViewModel by sharedViewModel()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
binding = FragmentAddServerScreenBinding.inflate(inflater, container, false)
|
||||
|
||||
// Discovery
|
||||
binding.discoveryServers.setHasFixedSize(true)
|
||||
val discoveryServerAdapter = DiscoveryServerAdapter { server ->
|
||||
requireActivity()
|
||||
.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(
|
||||
R.id.content_view,
|
||||
AddServerAlertFragment(onClose = {
|
||||
requireActivity().supportFragmentManager.popBackStack()
|
||||
}).apply {
|
||||
arguments = bundleOf(
|
||||
AddServerAlertFragment.ARG_SERVER_ADDRESS to server.address
|
||||
)
|
||||
}
|
||||
)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
binding.discoveryServers.adapter = discoveryServerAdapter
|
||||
|
||||
lifecycleScope.launchWhenCreated {
|
||||
binding.discoveryProgressIndicator.visibility = View.VISIBLE
|
||||
binding.discoveryServers.isFocusable = false
|
||||
|
||||
loginViewModel.discoveredServers.collect { server ->
|
||||
discoveryServerAdapter.addServer(server)
|
||||
|
||||
binding.discoveryServers.isFocusable = true
|
||||
}
|
||||
|
||||
binding.discoveryProgressIndicator.visibility = View.GONE
|
||||
binding.discoveryNoneFound.visibility = if (discoveryServerAdapter.servers.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
// Manual
|
||||
binding.enterServerAddress.setOnClickListener { (requireActivity() as StartupActivity).addServer() }
|
||||
|
||||
// App info
|
||||
binding.appVersion.text = "jellyfin-androidtv ${BuildConfig.VERSION_NAME} ${BuildConfig.BUILD_TYPE}"
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
class DiscoveryServerAdapter(
|
||||
var serverClickListener: (server: Server) -> Unit = {}
|
||||
) : RecyclerView.Adapter<DiscoveryServerAdapter.ViewHolder>() {
|
||||
private var _servers = mutableListOf<Server>()
|
||||
val servers: List<Server> = _servers
|
||||
|
||||
fun addServer(server: Server) {
|
||||
_servers.add(server)
|
||||
notifyItemInserted(_servers.size - 1)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
|
||||
ItemDiscoveryServerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) = with(holder.binding) {
|
||||
val server = servers[position]
|
||||
|
||||
// Set data
|
||||
serverName.text = server.name
|
||||
serverAddress.text = server.address
|
||||
|
||||
// FIXME Show server version. It is not exposed with the current apiclient in the DiscoveryServerInfo class
|
||||
serverVersion.visibility = View.GONE
|
||||
|
||||
// Set actions
|
||||
root.setOnClickListener {
|
||||
serverClickListener.invoke(server)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = servers.size
|
||||
|
||||
inner class ViewHolder(
|
||||
val binding: ItemDiscoveryServerBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
val root = binding.root
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,49 @@
|
||||
package org.jellyfin.androidtv.ui.startup
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.AuthenticationRepository
|
||||
import org.jellyfin.androidtv.auth.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.model.ConnectedState
|
||||
import org.jellyfin.androidtv.auth.model.LoginState
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.model.ServerAdditionState
|
||||
import org.jellyfin.androidtv.auth.model.User
|
||||
import java.util.*
|
||||
|
||||
class LoginViewModel(
|
||||
private val serverRepository: ServerRepository,
|
||||
private val authenticationRepository: AuthenticationRepository,
|
||||
) : ViewModel() {
|
||||
// All available servers and users
|
||||
private val _servers = serverRepository.getServersWithUsers(
|
||||
discovery = true,
|
||||
stored = true
|
||||
).asLiveData()
|
||||
val servers: LiveData<Map<Server, Set<User>>> get() = _servers
|
||||
val discoveredServers: Flow<Server>
|
||||
get() = serverRepository.getDiscoveryServers()
|
||||
|
||||
fun addServer(address: String): LiveData<ServerAdditionState> = serverRepository.addServer(address).asLiveData()
|
||||
private val _storedServers = MutableLiveData<List<Server>>()
|
||||
val storedServers: LiveData<List<Server>>
|
||||
get() = _storedServers
|
||||
|
||||
init {
|
||||
// Initial values
|
||||
viewModelScope.launch {
|
||||
_storedServers.postValue(serverRepository.getStoredServers())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getServer(id: UUID) = serverRepository.getStoredServers()
|
||||
.find { it.id == id }
|
||||
|
||||
suspend fun getUsers(server: Server) = serverRepository.gerServerUsers(server)
|
||||
|
||||
fun addServer(address: String) = liveData {
|
||||
serverRepository.addServer(address).onEach {
|
||||
// Reload stored servers when new server is added
|
||||
if (it is ConnectedState) _storedServers.postValue(serverRepository.getStoredServers())
|
||||
|
||||
emit(it)
|
||||
}.collect()
|
||||
}
|
||||
|
||||
fun authenticate(user: User, server: Server): LiveData<LoginState> = authenticationRepository.authenticateUser(user, server).asLiveData()
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.jellyfin.androidtv.ui.startup
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.databinding.FragmentServerListBinding
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class OverviewFragment : Fragment() {
|
||||
private val loginViewModel: LoginViewModel by sharedViewModel()
|
||||
private lateinit var binding: FragmentServerListBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
binding = FragmentServerListBinding.inflate(inflater, container, false)
|
||||
|
||||
// Create adapter for screens
|
||||
val serverAdapter = ServerAdapter(requireContext(), childFragmentManager)
|
||||
binding.serverView.adapter = serverAdapter
|
||||
|
||||
// Show server list after loading so the "add server" fragment doesn't pop up
|
||||
binding.serverView.visibility = View.GONE
|
||||
loginViewModel.storedServers.observe(viewLifecycleOwner) { servers ->
|
||||
binding.serverView.visibility = View.VISIBLE
|
||||
serverAdapter.servers = servers
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private class ServerAdapter(
|
||||
private val context: Context,
|
||||
fragmentManager: FragmentManager,
|
||||
) : FragmentStatePagerAdapter(
|
||||
fragmentManager,
|
||||
BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT,
|
||||
) {
|
||||
private val comparator = compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name }
|
||||
private var _servers = emptyList<Server>()
|
||||
var servers
|
||||
set(value) {
|
||||
_servers = value.sortedWith(comparator)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
get() = _servers
|
||||
|
||||
override fun getCount() = servers.size + 1
|
||||
|
||||
override fun getItem(position: Int) = when {
|
||||
// Last page is always used to add servers
|
||||
position == servers.size -> AddServerScreenFragment()
|
||||
else -> ServerFragment().apply {
|
||||
val server = servers[position]
|
||||
|
||||
arguments = bundleOf(
|
||||
ServerFragment.ARG_SERVER_ID to server.id.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPageTitle(position: Int) = when {
|
||||
// Last page is always used to add servers
|
||||
position == servers.size -> context.getString(R.string.connect_title)
|
||||
else -> servers.getOrNull(position)?.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
package org.jellyfin.androidtv.ui.startup
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.leanback.app.RowsSupportFragment
|
||||
import androidx.leanback.widget.HeaderItem
|
||||
import androidx.leanback.widget.ListRow
|
||||
import androidx.leanback.widget.OnItemViewClickedListener
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
import org.jellyfin.androidtv.auth.model.RequireSignInState
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
|
||||
import org.jellyfin.androidtv.auth.model.User
|
||||
import org.jellyfin.androidtv.auth.model.*
|
||||
import org.jellyfin.androidtv.ui.GridButton
|
||||
import org.jellyfin.androidtv.ui.presentation.CustomListRowPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
class ListServerFragment : RowsSupportFragment() {
|
||||
private companion object {
|
||||
private const val ADD_USER = 1
|
||||
private const val SELECT_USER = 2
|
||||
class ServerFragment : RowsSupportFragment() {
|
||||
companion object {
|
||||
const val ARG_SERVER_ID = "server_id"
|
||||
}
|
||||
|
||||
private val loginViewModel: LoginViewModel by sharedViewModel()
|
||||
private val rowAdapter = MutableObjectAdapter<ListRow>(CustomListRowPresenter())
|
||||
private val userComparator = compareByDescending<User> { if (it is PrivateUser) it.lastUsed else 0L }.thenBy { it.name }
|
||||
|
||||
private val itemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
|
||||
if (item is UserGridButton) {
|
||||
@@ -43,7 +37,7 @@ class ListServerFragment : RowsSupportFragment() {
|
||||
}
|
||||
RequireSignInState -> {
|
||||
// Open login fragment
|
||||
navigate(UserLoginFragment(
|
||||
navigate(UserLoginAlertFragment(
|
||||
server = item.server,
|
||||
user = item.user,
|
||||
))
|
||||
@@ -59,7 +53,7 @@ class ListServerFragment : RowsSupportFragment() {
|
||||
}
|
||||
} else if (item is AddUserGridButton) {
|
||||
// Open login fragment
|
||||
navigate(UserLoginFragment(
|
||||
navigate(UserLoginAlertFragment(
|
||||
server = item.server
|
||||
))
|
||||
}
|
||||
@@ -70,62 +64,65 @@ class ListServerFragment : RowsSupportFragment() {
|
||||
|
||||
adapter = rowAdapter
|
||||
onItemViewClickedListener = itemViewClickedListener
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
val serverId = UUID.fromString(arguments?.getString(ARG_SERVER_ID))
|
||||
lifecycleScope.launch {
|
||||
val server = loginViewModel.getServer(serverId) ?: return@launch
|
||||
val users = loginViewModel.getUsers(server).sortedWith(userComparator)
|
||||
|
||||
loginViewModel.servers.observe(viewLifecycleOwner) { servers ->
|
||||
buildRows(servers)
|
||||
// Fragment may be unloaded at this point, verify by checking for context
|
||||
if (context != null) buildRow(server, users)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return super.onCreateView(inflater, container, savedInstanceState)?.apply {
|
||||
updatePadding(top = 20)
|
||||
private fun buildRow(server: Server, users: List<User>) {
|
||||
Timber.d("Creating server row %s", server.name)
|
||||
|
||||
val userListAdapter = MutableObjectAdapter<GridButton>(GridButtonPresenter())
|
||||
|
||||
users.forEachIndexed { index, user ->
|
||||
userListAdapter.add(UserGridButton(
|
||||
server = server,
|
||||
user = user,
|
||||
id = index + 1,
|
||||
text = user.name,
|
||||
imageId = R.drawable.tile_port_person,
|
||||
imageUrl = loginViewModel.getUserImage(server, user),
|
||||
))
|
||||
}
|
||||
|
||||
userListAdapter.add(AddUserGridButton(
|
||||
server = server,
|
||||
id = 0,
|
||||
text = requireContext().getString(R.string.lbl_manual_login),
|
||||
imageId = R.drawable.tile_edit,
|
||||
))
|
||||
|
||||
val row = ListRow(
|
||||
HeaderItem(server.name.ifBlank { server.address }),
|
||||
userListAdapter,
|
||||
)
|
||||
|
||||
rowAdapter.add(row)
|
||||
}
|
||||
|
||||
private fun buildRows(servers: Map<Server, Set<User>>) {
|
||||
servers.forEach { (server, users) ->
|
||||
// Convert the UUID of the server to a long to get a unique id
|
||||
// to make sure a server can't be added multiple times
|
||||
val uniqueRowId = server.id.mostSignificantBits and Long.MAX_VALUE
|
||||
val exists = rowAdapter.any { it.id == uniqueRowId }
|
||||
// Already added, don't add it again
|
||||
if (exists) return@forEach
|
||||
|
||||
Timber.d("Creating server row %s", server.name)
|
||||
|
||||
val userListAdapter = MutableObjectAdapter<GridButton>(GridButtonPresenter())
|
||||
|
||||
users.forEach { user ->
|
||||
userListAdapter.add(UserGridButton(server, user, SELECT_USER, user.name, R.drawable.tile_port_person, loginViewModel.getUserImage(server, user)))
|
||||
}
|
||||
|
||||
userListAdapter.add(AddUserGridButton(server, ADD_USER, requireContext().getString(R.string.lbl_manual_login), R.drawable.tile_edit))
|
||||
|
||||
val row = ListRow(
|
||||
uniqueRowId,
|
||||
HeaderItem(if (server.name.isNotBlank()) server.name else server.address),
|
||||
userListAdapter
|
||||
)
|
||||
|
||||
rowAdapter.add(row)
|
||||
}
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Ensure the server rows get focus
|
||||
// FIXME Ideally not done in current fragment as this changes the focus when the screen changes
|
||||
requireView().requestFocus()
|
||||
}
|
||||
|
||||
private fun navigate(fragment: Fragment) {
|
||||
parentFragmentManager.beginTransaction()
|
||||
requireActivity()
|
||||
.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.content_view, fragment)
|
||||
.addToBackStack(this::class.simpleName)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private class AddUserGridButton(val server: Server, id: Int, text: String, @DrawableRes imageId: Int) : GridButton(id, text, imageId)
|
||||
|
||||
private class UserGridButton(val server: Server, val user: User, id: Int, text: String, @DrawableRes imageId: Int, imageUrl: String?) : GridButton(id, text, imageId, imageUrl)
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import android.os.Bundle
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.TvApp
|
||||
import org.jellyfin.androidtv.auth.ServerRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
@@ -31,6 +34,7 @@ class StartupActivity : FragmentActivity() {
|
||||
|
||||
private var application: TvApp? = null
|
||||
private val apiClient: ApiClient by inject()
|
||||
private val serverRepository: ServerRepository by inject()
|
||||
private var isLoaded = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -43,7 +47,12 @@ class StartupActivity : FragmentActivity() {
|
||||
}
|
||||
application = applicationContext as TvApp
|
||||
|
||||
//Ensure basic permissions
|
||||
// Migrate old credentials
|
||||
lifecycleScope.launch {
|
||||
serverRepository.migrateLegacyCredentials()
|
||||
}
|
||||
|
||||
// Ensure basic permissions
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED
|
||||
|| ContextCompat.checkSelfPermission(this, Manifest.permission.INTERNET) != PackageManager.PERMISSION_GRANTED)) {
|
||||
Timber.i("Requesting network permissions")
|
||||
@@ -116,8 +125,7 @@ class StartupActivity : FragmentActivity() {
|
||||
fun addServer() {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.addToBackStack(null)
|
||||
.replace(R.id.content_view, AddServerFragment(
|
||||
onServerAdded = { id -> },
|
||||
.replace(R.id.content_view, AddServerAlertFragment(
|
||||
onClose = { supportFragmentManager.popBackStack() }
|
||||
))
|
||||
.commit()
|
||||
@@ -126,7 +134,7 @@ class StartupActivity : FragmentActivity() {
|
||||
private fun showServerList() {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.content_view, StartupToolbarFragment())
|
||||
.add(R.id.content_view, ListServerFragment())
|
||||
.add(R.id.content_view, OverviewFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.jellyfin.androidtv.ui.shared.AlertFragment
|
||||
import org.jellyfin.androidtv.ui.shared.KeyboardFocusChangeListener
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class UserLoginFragment(
|
||||
class UserLoginAlertFragment(
|
||||
private val server: Server,
|
||||
private val user: User? = null,
|
||||
private val onClose: () -> Unit = {}
|
||||
@@ -1,30 +0,0 @@
|
||||
package org.jellyfin.androidtv.util
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
/**
|
||||
* Collect all items emitted from flow of type T to a List<T> and return it as a LiveData instance.
|
||||
* The LiveData is updated for each item emitted from the flow.
|
||||
*
|
||||
* To remove duplicate entries use the [distinctUntilChanged] or [distinctUntilChangedBy] functions.
|
||||
*/
|
||||
fun <T> Flow<T>.asLiveDataCollection(
|
||||
context: CoroutineContext = EmptyCoroutineContext
|
||||
): LiveData<List<T>> {
|
||||
val list = mutableListOf<T>()
|
||||
val liveData = MediatorLiveData<List<T>>()
|
||||
|
||||
liveData.addSource(asLiveData(context)) {
|
||||
list.add(it)
|
||||
liveData.value = list
|
||||
}
|
||||
|
||||
return liveData
|
||||
}
|
||||
124
app/src/main/res/layout/fragment_add_server_screen.xml
Normal file
124
app/src/main/res/layout/fragment_add_server_screen.xml
Normal file
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/activity_horizontal_margin"
|
||||
android:paddingEnd="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/discovery_title"
|
||||
style="@style/Widget.Jellyfin.Row.Header"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start"
|
||||
android:text="@string/discovered_servers_title"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/discovery_progress_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/discovery_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/discovery_title" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/discovery_servers"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/discovery_title"
|
||||
tools:itemCount="3"
|
||||
tools:listitem="@layout/item_discovery_server" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/discovery_none_found"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/discovered_servers_empty"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="@id/discovery_servers"
|
||||
app:layout_constraintStart_toStartOf="@id/discovery_servers"
|
||||
app:layout_constraintTop_toTopOf="@id/discovery_servers" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/connect_manually_title"
|
||||
style="@style/Widget.Jellyfin.Row.Header"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start"
|
||||
android:text="@string/connect_manually_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/enter_server_address"
|
||||
style="@style/Button.Default"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/connect_manually_by_address"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/connect_manually_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/help_title"
|
||||
style="@style/Widget.Jellyfin.Row.Header"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:gravity="start"
|
||||
android:text="@string/login_help_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/enter_server_address" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/help_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login_help_description"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/help_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/app_version"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textColor="@color/lb_grey"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/help_description"
|
||||
tools:text="App version" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
||||
15
app/src/main/res/layout/fragment_server_list.xml
Normal file
15
app/src/main/res/layout/fragment_server_list.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.viewpager.widget.ViewPager xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/server_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.viewpager.widget.PagerTitleStrip
|
||||
android:id="@+id/dddd"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:paddingStart="@dimen/activity_horizontal_margin"
|
||||
android:paddingEnd="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin" />
|
||||
</androidx.viewpager.widget.ViewPager>
|
||||
51
app/src/main/res/layout/item_discovery_server.xml
Normal file
51
app/src/main/res/layout/item_discovery_server.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/Button.Default"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:src="@drawable/ic_cloud"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="#73FFFFFF" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_name"
|
||||
style="@style/TextAppearance.AppCompat.Body2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constraintStart_toEndOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="ServerName" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_address"
|
||||
style="@style/TextAppearance.AppCompat.Caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="@id/server_name"
|
||||
app:layout_constraintTop_toBottomOf="@id/server_name"
|
||||
tools:text="http://192.168.1.1:8096" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_version"
|
||||
style="@style/TextAppearance.AppCompat.Caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/server_name"
|
||||
tools:text="Jellyfin 10.7.0" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -454,4 +454,11 @@
|
||||
<string name="pref_developer_link">Developer options</string>
|
||||
<string name="pref_developer_link_description">Advanced and experimental features</string>
|
||||
<string name="lbl_hidden">Hidden</string>
|
||||
<string name="discovered_servers_title">Discovered servers</string>
|
||||
<string name="discovered_servers_empty">No servers discoverd on local network.</string>
|
||||
<string name="connect_manually_title">Connect manually</string>
|
||||
<string name="connect_manually_by_address">Enter server address</string>
|
||||
<string name="login_help_title">Need help?</string>
|
||||
<string name="login_help_description">Jellyfin requires a server to connect with. Visit our documentation at docs.jellyfin.org to get started with Jellyfin.</string>
|
||||
<string name="connect_title">Connect</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user