Multi-user support (#199)

* Add multiple users per server

* Remove unnecessary longClickListener

* Check if user is selected on startup

* Still create JellyfinApi even if no user is selected

* Already fill in the username when needing to re-login
This commit is contained in:
Jarne Demeulemeester 2022-11-19 21:18:50 +01:00 committed by GitHub
parent 1806b19646
commit aeabb620ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 571 additions and 68 deletions

View file

@ -16,6 +16,7 @@ import com.google.android.material.navigation.NavigationBarView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.ActivityMainBinding import dev.jdtech.jellyfin.databinding.ActivityMainBinding
import dev.jdtech.jellyfin.utils.AppPreferences
import dev.jdtech.jellyfin.utils.loadDownloadLocation import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel import dev.jdtech.jellyfin.viewmodels.MainViewModel
import javax.inject.Inject import javax.inject.Inject
@ -31,6 +32,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var database: ServerDatabaseDao lateinit var database: ServerDatabaseDao
@Inject
lateinit var appPreferences: AppPreferences
@OptIn(NavigationUiSaveStateControl::class) @OptIn(NavigationUiSaveStateControl::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -49,6 +53,7 @@ class MainActivity : AppCompatActivity() {
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
graph.setStartDestination(R.id.homeFragmentTv) graph.setStartDestination(R.id.homeFragmentTv)
checkServersEmpty(graph) checkServersEmpty(graph)
checkUser(graph)
if (!viewModel.startDestinationTvChanged) { if (!viewModel.startDestinationTvChanged) {
viewModel.startDestinationTvChanged = true viewModel.startDestinationTvChanged = true
navController.setGraph(graph, intent.extras) navController.setGraph(graph, intent.extras)
@ -57,6 +62,9 @@ class MainActivity : AppCompatActivity() {
checkServersEmpty(graph) { checkServersEmpty(graph) {
navController.setGraph(graph, intent.extras) navController.setGraph(graph, intent.extras)
} }
checkUser(graph) {
navController.setGraph(graph, intent.extras)
}
} }
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_TELEVISION) { if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_TELEVISION) {
@ -108,4 +116,18 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
private fun checkUser(graph: NavGraph, onNoUser: () -> Unit = {}) {
if (!viewModel.startDestinationChanged) {
appPreferences.currentServer?.let {
val currentUser = database.getServerCurrentUser(it)
if (currentUser == null) {
graph.setStartDestination(R.id.loginFragment)
viewModel.startDestinationChanged = true
onNoUser()
}
}
}
}
} }

View file

@ -5,13 +5,14 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.UserItemBinding import dev.jdtech.jellyfin.databinding.UserListItemBinding
import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.models.User
class UserListAdapter( class UserListAdapter(
private val clickListener: (user: User) -> Unit private val clickListener: (user: User) -> Unit,
private val longClickListener: (user: User) -> Boolean
) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) { ) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) {
class UserViewHolder(private var binding: UserItemBinding) : class UserViewHolder(private var binding: UserListItemBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) { fun bind(user: User) {
binding.user = user binding.user = user
@ -34,7 +35,7 @@ class UserListAdapter(
viewType: Int viewType: Int
): UserViewHolder { ): UserViewHolder {
return UserViewHolder( return UserViewHolder(
UserItemBinding.inflate( UserListItemBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
parent, parent,
false false
@ -45,6 +46,7 @@ class UserListAdapter(
override fun onBindViewHolder(holder: UserViewHolder, position: Int) { override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val user = getItem(position) val user = getItem(position)
holder.itemView.setOnClickListener { clickListener(user) } holder.itemView.setOnClickListener { clickListener(user) }
holder.itemView.setOnLongClickListener { longClickListener(user) }
holder.bind(user) holder.bind(user)
} }
} }

View file

@ -0,0 +1,50 @@
package dev.jdtech.jellyfin.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.UserItemBinding
import dev.jdtech.jellyfin.models.User
class UserLoginListAdapter(
private val clickListener: (user: User) -> Unit
) : ListAdapter<User, UserLoginListAdapter.UserLoginViewHolder>(DiffCallback) {
class UserLoginViewHolder(private var binding: UserItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.user = user
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): UserLoginViewHolder {
return UserLoginViewHolder(
UserItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: UserLoginViewHolder, position: Int) {
val user = getItem(position)
holder.itemView.setOnClickListener { clickListener(user) }
holder.bind(user)
}
}

View file

@ -11,6 +11,7 @@ import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.ServerAddress import dev.jdtech.jellyfin.models.ServerAddress
import dev.jdtech.jellyfin.models.ServerWithAddresses import dev.jdtech.jellyfin.models.ServerWithAddresses
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
import dev.jdtech.jellyfin.models.ServerWithUsers
import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.models.User
import java.util.UUID import java.util.UUID
@ -40,7 +41,7 @@ interface ServerDatabaseDao {
@Transaction @Transaction
@Query("select * from servers where id = :id") @Query("select * from servers where id = :id")
fun getServerWithUsers(id: String): ServerWithAddresses fun getServerWithUsers(id: String): ServerWithUsers
@Transaction @Transaction
@Query("select * from servers where id = :id") @Query("select * from servers where id = :id")
@ -60,4 +61,13 @@ interface ServerDatabaseDao {
@Query("delete from servers where id = :id") @Query("delete from servers where id = :id")
fun delete(id: String) fun delete(id: String)
@Query("delete from users where id = :id")
fun deleteUser(id: UUID)
@Query("update servers set currentUserId = :userId where id = :serverId")
fun updateServerCurrentUser(serverId: String, userId: UUID)
@Query("select * from users where id = (select currentUserId from servers where serverId = :serverId)")
fun getServerCurrentUser(serverId: String): User?
} }

View file

@ -1,7 +1,6 @@
package dev.jdtech.jellyfin.di package dev.jdtech.jellyfin.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -19,7 +18,6 @@ object ApiModule {
@Provides @Provides
fun provideJellyfinApi( fun provideJellyfinApi(
@ApplicationContext application: Context, @ApplicationContext application: Context,
sharedPreferences: SharedPreferences,
appPreferences: AppPreferences, appPreferences: AppPreferences,
serverDatabase: ServerDatabaseDao serverDatabase: ServerDatabaseDao
): JellyfinApi { ): JellyfinApi {
@ -30,16 +28,16 @@ object ApiModule {
socketTimeout = appPreferences.socketTimeout socketTimeout = appPreferences.socketTimeout
) )
val serverId = sharedPreferences.getString("selectedServer", null) val serverId = appPreferences.currentServer
if (serverId != null) { if (serverId != null) {
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
val server = serverWithAddressesAndUsers.server val server = serverWithAddressesAndUsers.server
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId } ?: return jellyfinApi val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
jellyfinApi.apply { jellyfinApi.apply {
api.baseUrl = serverAddress.address api.baseUrl = serverAddress.address
api.accessToken = user.accessToken api.accessToken = user?.accessToken
userId = user.id userId = user?.id
} }
} }

View file

@ -0,0 +1,26 @@
package dev.jdtech.jellyfin.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.models.User
import dev.jdtech.jellyfin.viewmodels.UsersViewModel
import java.lang.IllegalStateException
class DeleteUserDialogFragment(private val viewModel: UsersViewModel, val user: User) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val builder = MaterialAlertDialogBuilder(it)
builder.setTitle(getString(R.string.remove_user))
.setMessage(getString(R.string.remove_user_dialog_text, user.name))
.setPositiveButton(getString(R.string.remove)) { _, _ ->
viewModel.deleteUser(user)
}
.setNegativeButton(getString(R.string.cancel)) { _, _ ->
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View file

@ -16,12 +16,16 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.adapters.UserListAdapter import dev.jdtech.jellyfin.adapters.UserLoginListAdapter
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.FragmentLoginBinding import dev.jdtech.jellyfin.databinding.FragmentLoginBinding
import dev.jdtech.jellyfin.utils.AppPreferences
import dev.jdtech.jellyfin.viewmodels.LoginViewModel import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class LoginFragment : Fragment() { class LoginFragment : Fragment() {
@ -29,6 +33,13 @@ class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding private lateinit var binding: FragmentLoginBinding
private lateinit var uiModeManager: UiModeManager private lateinit var uiModeManager: UiModeManager
private val viewModel: LoginViewModel by viewModels() private val viewModel: LoginViewModel by viewModels()
private val args: LoginFragmentArgs by navArgs()
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var dataBase: ServerDatabaseDao
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -39,6 +50,14 @@ class LoginFragment : Fragment() {
uiModeManager = uiModeManager =
requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager
if (args.reLogin) {
appPreferences.currentServer?.let { currentServerId ->
dataBase.getServerCurrentUser(currentServerId)?.let { user ->
(binding.editTextUsername as AppCompatEditText).setText(user.name)
}
}
}
(binding.editTextPassword as AppCompatEditText).setOnEditorActionListener { _, actionId, _ -> (binding.editTextPassword as AppCompatEditText).setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) { return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> { EditorInfo.IME_ACTION_GO -> {
@ -53,7 +72,7 @@ class LoginFragment : Fragment() {
login() login()
} }
binding.usersRecyclerView.adapter = UserListAdapter { user -> binding.usersRecyclerView.adapter = UserLoginListAdapter { user ->
(binding.editTextUsername as AppCompatEditText).setText(user.name) (binding.editTextUsername as AppCompatEditText).setText(user.name)
(binding.editTextPassword as AppCompatEditText).requestFocus() (binding.editTextPassword as AppCompatEditText).requestFocus()
} }
@ -149,7 +168,7 @@ class LoginFragment : Fragment() {
binding.usersRecyclerView.isVisible = false binding.usersRecyclerView.isVisible = false
} else { } else {
binding.usersRecyclerView.isVisible = true binding.usersRecyclerView.isVisible = true
(binding.usersRecyclerView.adapter as UserListAdapter).submitList(users) (binding.usersRecyclerView.adapter as UserLoginListAdapter).submitList(users)
} }
} }

View file

@ -6,9 +6,16 @@ import android.os.Bundle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.utils.AppPreferences
import javax.inject.Inject
@AndroidEntryPoint
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.fragment_settings, rootKey) setPreferencesFromResource(R.xml.fragment_settings, rootKey)
@ -17,6 +24,12 @@ class SettingsFragment : PreferenceFragmentCompat() {
true true
} }
findPreference<Preference>("switchUser")?.setOnPreferenceClickListener {
val serverId = appPreferences.currentServer!!
findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToUsersFragment(serverId))
true
}
findPreference<Preference>("privacyPolicy")?.setOnPreferenceClickListener { findPreference<Preference>("privacyPolicy")?.setOnPreferenceClickListener {
val intent = Intent( val intent = Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,

View file

@ -0,0 +1,104 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.AppNavigationDirections
import dev.jdtech.jellyfin.adapters.UserListAdapter
import dev.jdtech.jellyfin.databinding.FragmentUsersBinding
import dev.jdtech.jellyfin.dialogs.DeleteUserDialogFragment
import dev.jdtech.jellyfin.viewmodels.UsersViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class UsersFragment : Fragment() {
private lateinit var binding: FragmentUsersBinding
private val viewModel: UsersViewModel by viewModels()
private val args: UsersFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentUsersBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.usersRecyclerView.adapter =
UserListAdapter(
{ user ->
viewModel.loginAsUser(user)
},
{ user ->
DeleteUserDialogFragment(viewModel, user).show(
parentFragmentManager,
"deleteUser"
)
true
}
)
binding.buttonAddUser.setOnClickListener {
navigateToLoginFragment()
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToMain.collect {
if (it) {
navigateToMainActivity()
}
}
}
}
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
Timber.d("$uiState")
when (uiState) {
is UsersViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is UsersViewModel.UiState.Loading -> Unit
is UsersViewModel.UiState.Error -> Unit
}
}
}
}
viewModel.loadUsers(args.serverId)
}
fun bindUiStateNormal(uiState: UsersViewModel.UiState.Normal) {
(binding.usersRecyclerView.adapter as UserListAdapter).submitList(uiState.users)
}
private fun navigateToLoginFragment() {
findNavController().navigate(
AppNavigationDirections.actionGlobalLoginFragment()
)
}
private fun navigateToMainActivity() {
findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragment())
}
}

View file

@ -10,5 +10,5 @@ data class Server(
val id: String, val id: String,
val name: String, val name: String,
val currentServerAddressId: UUID?, val currentServerAddressId: UUID?,
val currentUserId: UUID?, var currentUserId: UUID?,
) )

View file

@ -12,6 +12,15 @@ class AppPreferences
constructor( constructor(
private val sharedPreferences: SharedPreferences private val sharedPreferences: SharedPreferences
) { ) {
// Server
var currentServer: String?
get() = sharedPreferences.getString(Constants.PREF_CURRENT_SERVER, null)
set(value) {
sharedPreferences.edit {
putString(Constants.PREF_CURRENT_SERVER, value)
}
}
// Appearance // Appearance
val theme = sharedPreferences.getString(Constants.PREF_THEME, null) val theme = sharedPreferences.getString(Constants.PREF_THEME, null)
val dynamicColors = sharedPreferences.getBoolean(Constants.PREF_DYNAMIC_COLORS, true) val dynamicColors = sharedPreferences.getBoolean(Constants.PREF_DYNAMIC_COLORS, true)

View file

@ -8,6 +8,7 @@ object Constants {
const val ZOOM_SCALE_THRESHOLD = 0.01f const val ZOOM_SCALE_THRESHOLD = 0.01f
// pref // pref
const val PREF_CURRENT_SERVER = "pref_current_server"
const val PREF_PLAYER_GESTURES = "pref_player_gestures" const val PREF_PLAYER_GESTURES = "pref_player_gestures"
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb" const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom" const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"

View file

@ -28,7 +28,7 @@ fun Fragment.checkIfLoginRequired(error: String?) {
if (error != null) { if (error != null) {
if (error.contains("401")) { if (error.contains("401")) {
Timber.d("Login required!") Timber.d("Login required!")
findNavController().navigate(AppNavigationDirections.actionGlobalLoginFragment()) findNavController().navigate(AppNavigationDirections.actionGlobalLoginFragment(reLogin = true))
} }
} }
} }

View file

@ -11,6 +11,9 @@ import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.DiscoveredServer import dev.jdtech.jellyfin.models.DiscoveredServer
import dev.jdtech.jellyfin.models.Server import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.ServerAddress
import dev.jdtech.jellyfin.utils.AppPreferences
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -32,6 +35,7 @@ class AddServerViewModel
@Inject @Inject
constructor( constructor(
private val application: BaseApplication, private val application: BaseApplication,
private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi, private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao private val database: ServerDatabaseDao
) : ViewModel() { ) : ViewModel() {
@ -152,15 +156,33 @@ constructor(
} }
private suspend fun connectToServer(recommendedServerInfo: RecommendedServerInfo) { private suspend fun connectToServer(recommendedServerInfo: RecommendedServerInfo) {
val serverId = recommendedServerInfo.systemInfo.getOrNull()?.id val serverInfo = recommendedServerInfo.systemInfo.getOrNull()
?: throw Exception(resources.getString(R.string.add_server_error_no_id)) ?: throw Exception(resources.getString(R.string.add_server_error_no_id))
Timber.d("Connecting to server: $serverId") Timber.d("Connecting to server: ${serverInfo.serverName}")
if (serverAlreadyInDatabase(serverId)) { if (serverAlreadyInDatabase(serverInfo.id!!)) {
throw Exception(resources.getString(R.string.add_server_error_already_added)) throw Exception(resources.getString(R.string.add_server_error_already_added))
} }
val serverAddress = ServerAddress(
id = UUID.randomUUID(),
serverId = serverInfo.id!!,
address = recommendedServerInfo.address
)
val server = Server(
id = serverInfo.id!!,
name = serverInfo.serverName!!,
currentServerAddressId = serverAddress.id,
currentUserId = null,
)
insertServer(server)
insertServerAddress(serverAddress)
appPreferences.currentServer = server.id
jellyfinApi.apply { jellyfinApi.apply {
api.baseUrl = recommendedServerInfo.address api.baseUrl = recommendedServerInfo.address
api.accessToken = null api.accessToken = null
@ -221,7 +243,28 @@ constructor(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
server = database.get(id) server = database.get(id)
} }
if (server != null) return true return (server != null)
return false }
/**
* Add server to the database
*
* @param server The server
*/
private suspend fun insertServer(server: Server) {
withContext(Dispatchers.IO) {
database.insertServer(server)
}
}
/**
* Add server address to the database
*
* @param address The address
*/
private suspend fun insertServerAddress(address: ServerAddress) {
withContext(Dispatchers.IO) {
database.insertServerAddress(address)
}
} }
} }

View file

@ -1,6 +1,5 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -9,10 +8,8 @@ import dev.jdtech.jellyfin.BaseApplication
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.ServerAddress
import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.models.User
import java.util.UUID import dev.jdtech.jellyfin.utils.AppPreferences
import javax.inject.Inject import javax.inject.Inject
import kotlin.Exception import kotlin.Exception
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -29,7 +26,7 @@ class LoginViewModel
@Inject @Inject
constructor( constructor(
application: BaseApplication, application: BaseApplication,
private val sharedPreferences: SharedPreferences, private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi, private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao private val database: ServerDatabaseDao
) : ViewModel() { ) : ViewModel() {
@ -61,9 +58,19 @@ constructor(
viewModelScope.launch { viewModelScope.launch {
_usersState.emit(UsersState.Loading) _usersState.emit(UsersState.Loading)
try { try {
val publicUsers by jellyfinApi.userApi.getPublicUsers() // Local users
val users = val localUsers = appPreferences.currentServer?.let {
publicUsers.map { User(id = it.id, name = it.name.orEmpty(), serverId = it.serverId!!) } database.getServerWithUsers(it).users
} ?: emptyList()
// Public users
val publicUsersResponse by jellyfinApi.userApi.getPublicUsers()
val publicUsers =
publicUsersResponse.map { User(id = it.id, name = it.name.orEmpty(), serverId = it.serverId!!) }
// Combine both local and public users
val users = (localUsers + publicUsers).distinctBy { it.id }
_usersState.emit(UsersState.Users(users)) _usersState.emit(UsersState.Users(users))
} catch (e: Exception) { } catch (e: Exception) {
_usersState.emit(UsersState.Users(emptyList())) _usersState.emit(UsersState.Users(emptyList()))
@ -98,26 +105,7 @@ constructor(
accessToken = authenticationResult.accessToken!! accessToken = authenticationResult.accessToken!!
) )
val serverAddress = ServerAddress( insertUser(appPreferences.currentServer!!, user)
id = UUID.randomUUID(),
serverId = serverInfo.id!!,
address = jellyfinApi.api.baseUrl!!
)
val server = Server(
id = serverInfo.id!!,
name = serverInfo.serverName!!,
currentServerAddressId = serverAddress.id,
currentUserId = user.id,
)
insertServer(server)
insertServerAddress(serverAddress)
insertUser(user)
val spEdit = sharedPreferences.edit()
spEdit.putString("selectedServer", server.id)
spEdit.apply()
jellyfinApi.apply { jellyfinApi.apply {
api.accessToken = authenticationResult.accessToken api.accessToken = authenticationResult.accessToken
@ -136,26 +124,10 @@ constructor(
} }
} }
/** private suspend fun insertUser(serverId: String, user: User) {
* Add server to the database
*
* @param server The server
*/
private suspend fun insertServer(server: Server) {
withContext(Dispatchers.IO) {
database.insertServer(server)
}
}
private suspend fun insertServerAddress(address: ServerAddress) {
withContext(Dispatchers.IO) {
database.insertServerAddress(address)
}
}
private suspend fun insertUser(user: User) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.insertUser(user) database.insertUser(user)
database.updateServerCurrentUser(serverId, user.id)
} }
} }
} }

View file

@ -0,0 +1,83 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.User
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
@HiltViewModel
class UsersViewModel
@Inject
constructor(
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
sealed class UiState {
data class Normal(val users: List<User>) : UiState()
object Loading : UiState()
data class Error(val error: Exception) : UiState()
}
private val _navigateToMain = MutableSharedFlow<Boolean>()
val navigateToMain = _navigateToMain.asSharedFlow()
private var currentServerId: String = ""
fun loadUsers(serverId: String) {
currentServerId = serverId
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
val serverWithUser = database.getServerWithUsers(serverId)
_uiState.emit(UiState.Normal(serverWithUser.users))
} catch (e: Exception) {
_uiState.emit(UiState.Error(e))
}
}
}
/**
* Delete user from database
*
* @param user The user
*/
fun deleteUser(user: User) {
viewModelScope.launch(Dispatchers.IO) {
val currentUser = database.getServerCurrentUser(currentServerId)
if (user == currentUser) {
Timber.e("You cannot delete the current user")
return@launch
}
database.deleteUser(user.id)
loadUsers(currentServerId)
}
}
fun loginAsUser(user: User) {
viewModelScope.launch {
val server = database.get(currentServerId) ?: return@launch
server.currentUserId = user.id
database.update(server)
jellyfinApi.apply {
api.accessToken = user.accessToken
userId = user.id
}
_navigateToMain.emit(true)
}
}
}

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,5L12,19"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M5,12L19,12"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -2,7 +2,8 @@
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path <path
android:pathData="M20,21v-2a4,4 0,0 0,-4 -4H8a4,4 0,0 0,-4 4v2" android:pathData="M20,21v-2a4,4 0,0 0,-4 -4H8a4,4 0,0 0,-4 4v2"
android:strokeLineJoin="round" android:strokeLineJoin="round"

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.UsersViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/users_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4"
tools:listitem="@layout/user_list_item" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/button_add_user"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="@string/add_user"
app:icon="@drawable/ic_plus" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="user"
type="dev.jdtech.jellyfin.models.User" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
android:foreground="@drawable/ripple_background">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/user_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="w,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image"
app:userImage="@{user}" />
<TextView
android:id="@+id/user_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{user.name}"
android:layout_marginStart="12dp"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/user_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="username" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -96,6 +96,9 @@
<action <action
android:id="@+id/action_navigation_settings_to_serverSelectFragment" android:id="@+id/action_navigation_settings_to_serverSelectFragment"
app:destination="@id/serverSelectFragment" /> app:destination="@id/serverSelectFragment" />
<action
android:id="@+id/action_navigation_settings_to_usersFragment"
app:destination="@id/usersFragment" />
<action <action
android:id="@+id/action_settingsFragment_to_about_libraries" android:id="@+id/action_settingsFragment_to_about_libraries"
app:destination="@id/about_libraries" /> app:destination="@id/about_libraries" />
@ -346,6 +349,10 @@
app:destination="@id/homeFragmentTv" app:destination="@id/homeFragmentTv"
app:popUpTo="@id/homeFragmentTv" app:popUpTo="@id/homeFragmentTv"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
<argument
android:name="reLogin"
app:argType="boolean"
android:defaultValue="false" />
</fragment> </fragment>
<fragment <fragment
@ -388,4 +395,27 @@
android:id="@+id/action_global_loginFragment" android:id="@+id/action_global_loginFragment"
app:destination="@id/loginFragment" /> app:destination="@id/loginFragment" />
<fragment
android:id="@+id/usersFragment"
android:name="dev.jdtech.jellyfin.fragments.UsersFragment"
android:label="@string/users"
tools:layout="@layout/fragment_users">
<action
android:id="@+id/action_usersFragment_to_loginFragment"
app:destination="@id/loginFragment" />
<action
android:id="@+id/action_usersFragment_to_homeFragment"
app:destination="@id/homeFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_usersFragment_to_homeFragmentTv"
app:destination="@id/homeFragmentTv"
app:popUpTo="@id/homeFragmentTv"
app:popUpToInclusive="true" />
<argument
android:name="serverId"
app:argType="string" />
</fragment>
</navigation> </navigation>

View file

@ -21,6 +21,8 @@
<string name="button_login">Login</string> <string name="button_login">Login</string>
<string name="remove_server">Remove server</string> <string name="remove_server">Remove server</string>
<string name="remove_server_dialog_text">Are you sure you want to remove the server %1$s</string> <string name="remove_server_dialog_text">Are you sure you want to remove the server %1$s</string>
<string name="remove_user">Remove user</string>
<string name="remove_user_dialog_text">Are you sure you want to remove the user %1$s</string>
<string name="remove">Remove</string> <string name="remove">Remove</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="title_home">Home</string> <string name="title_home">Home</string>
@ -142,4 +144,6 @@
<string name="settings_request_timeout">Request timeout (ms)</string> <string name="settings_request_timeout">Request timeout (ms)</string>
<string name="settings_connect_timeout">Connect timeout (ms)</string> <string name="settings_connect_timeout">Connect timeout (ms)</string>
<string name="settings_socket_timeout">Socket timeout (ms)</string> <string name="settings_socket_timeout">Socket timeout (ms)</string>
<string name="users">Users</string>
<string name="add_user">Add user</string>
</resources> </resources>

View file

@ -11,6 +11,11 @@
app:key="switchServer" app:key="switchServer"
app:title="@string/manage_servers" /> app:title="@string/manage_servers" />
<Preference
app:icon="@drawable/ic_user"
app:key="switchUser"
app:title="@string/users" />
<Preference <Preference
app:fragment="dev.jdtech.jellyfin.fragments.SettingsAppearanceFragment" app:fragment="dev.jdtech.jellyfin.fragments.SettingsAppearanceFragment"
app:icon="@drawable/ic_palette" app:icon="@drawable/ic_palette"