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 dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
import dev.jdtech.jellyfin.utils.AppPreferences
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
import javax.inject.Inject
@ -31,6 +32,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var database: ServerDatabaseDao
@Inject
lateinit var appPreferences: AppPreferences
@OptIn(NavigationUiSaveStateControl::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -49,6 +53,7 @@ class MainActivity : AppCompatActivity() {
if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
graph.setStartDestination(R.id.homeFragmentTv)
checkServersEmpty(graph)
checkUser(graph)
if (!viewModel.startDestinationTvChanged) {
viewModel.startDestinationTvChanged = true
navController.setGraph(graph, intent.extras)
@ -57,6 +62,9 @@ class MainActivity : AppCompatActivity() {
checkServersEmpty(graph) {
navController.setGraph(graph, intent.extras)
}
checkUser(graph) {
navController.setGraph(graph, intent.extras)
}
}
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.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.UserItemBinding
import dev.jdtech.jellyfin.databinding.UserListItemBinding
import dev.jdtech.jellyfin.models.User
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) {
class UserViewHolder(private var binding: UserItemBinding) :
class UserViewHolder(private var binding: UserListItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.user = user
@ -34,7 +35,7 @@ class UserListAdapter(
viewType: Int
): UserViewHolder {
return UserViewHolder(
UserItemBinding.inflate(
UserListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
@ -45,6 +46,7 @@ class UserListAdapter(
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val user = getItem(position)
holder.itemView.setOnClickListener { clickListener(user) }
holder.itemView.setOnLongClickListener { longClickListener(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.ServerWithAddresses
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
import dev.jdtech.jellyfin.models.ServerWithUsers
import dev.jdtech.jellyfin.models.User
import java.util.UUID
@ -40,7 +41,7 @@ interface ServerDatabaseDao {
@Transaction
@Query("select * from servers where id = :id")
fun getServerWithUsers(id: String): ServerWithAddresses
fun getServerWithUsers(id: String): ServerWithUsers
@Transaction
@Query("select * from servers where id = :id")
@ -60,4 +61,13 @@ interface ServerDatabaseDao {
@Query("delete from servers where id = :id")
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
import android.content.Context
import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -19,7 +18,6 @@ object ApiModule {
@Provides
fun provideJellyfinApi(
@ApplicationContext application: Context,
sharedPreferences: SharedPreferences,
appPreferences: AppPreferences,
serverDatabase: ServerDatabaseDao
): JellyfinApi {
@ -30,16 +28,16 @@ object ApiModule {
socketTimeout = appPreferences.socketTimeout
)
val serverId = sharedPreferences.getString("selectedServer", null)
val serverId = appPreferences.currentServer
if (serverId != null) {
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
val server = serverWithAddressesAndUsers.server
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 {
api.baseUrl = serverAddress.address
api.accessToken = user.accessToken
userId = user.id
api.accessToken = user?.accessToken
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.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
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.utils.AppPreferences
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class LoginFragment : Fragment() {
@ -29,6 +33,13 @@ class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding
private lateinit var uiModeManager: UiModeManager
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(
inflater: LayoutInflater,
@ -39,6 +50,14 @@ class LoginFragment : Fragment() {
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, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
@ -53,7 +72,7 @@ class LoginFragment : Fragment() {
login()
}
binding.usersRecyclerView.adapter = UserListAdapter { user ->
binding.usersRecyclerView.adapter = UserLoginListAdapter { user ->
(binding.editTextUsername as AppCompatEditText).setText(user.name)
(binding.editTextPassword as AppCompatEditText).requestFocus()
}
@ -149,7 +168,7 @@ class LoginFragment : Fragment() {
binding.usersRecyclerView.isVisible = false
} else {
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.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.utils.AppPreferences
import javax.inject.Inject
@AndroidEntryPoint
class SettingsFragment : PreferenceFragmentCompat() {
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.fragment_settings, rootKey)
@ -17,6 +24,12 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
findPreference<Preference>("switchUser")?.setOnPreferenceClickListener {
val serverId = appPreferences.currentServer!!
findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToUsersFragment(serverId))
true
}
findPreference<Preference>("privacyPolicy")?.setOnPreferenceClickListener {
val intent = Intent(
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 name: String,
val currentServerAddressId: UUID?,
val currentUserId: UUID?,
var currentUserId: UUID?,
)

View file

@ -12,6 +12,15 @@ class AppPreferences
constructor(
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
val theme = sharedPreferences.getString(Constants.PREF_THEME, null)
val dynamicColors = sharedPreferences.getBoolean(Constants.PREF_DYNAMIC_COLORS, true)

View file

@ -8,6 +8,7 @@ object Constants {
const val ZOOM_SCALE_THRESHOLD = 0.01f
// pref
const val PREF_CURRENT_SERVER = "pref_current_server"
const val PREF_PLAYER_GESTURES = "pref_player_gestures"
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
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.contains("401")) {
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.models.DiscoveredServer
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@ -32,6 +35,7 @@ class AddServerViewModel
@Inject
constructor(
private val application: BaseApplication,
private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao
) : ViewModel() {
@ -152,15 +156,33 @@ constructor(
}
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))
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))
}
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 {
api.baseUrl = recommendedServerInfo.address
api.accessToken = null
@ -221,7 +243,28 @@ constructor(
withContext(Dispatchers.IO) {
server = database.get(id)
}
if (server != null) return true
return false
return (server != null)
}
/**
* 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
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -9,10 +8,8 @@ import dev.jdtech.jellyfin.BaseApplication
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi
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 java.util.UUID
import dev.jdtech.jellyfin.utils.AppPreferences
import javax.inject.Inject
import kotlin.Exception
import kotlinx.coroutines.Dispatchers
@ -29,7 +26,7 @@ class LoginViewModel
@Inject
constructor(
application: BaseApplication,
private val sharedPreferences: SharedPreferences,
private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao
) : ViewModel() {
@ -61,9 +58,19 @@ constructor(
viewModelScope.launch {
_usersState.emit(UsersState.Loading)
try {
val publicUsers by jellyfinApi.userApi.getPublicUsers()
val users =
publicUsers.map { User(id = it.id, name = it.name.orEmpty(), serverId = it.serverId!!) }
// Local users
val localUsers = appPreferences.currentServer?.let {
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))
} catch (e: Exception) {
_usersState.emit(UsersState.Users(emptyList()))
@ -98,26 +105,7 @@ constructor(
accessToken = authenticationResult.accessToken!!
)
val serverAddress = ServerAddress(
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()
insertUser(appPreferences.currentServer!!, user)
jellyfinApi.apply {
api.accessToken = authenticationResult.accessToken
@ -136,26 +124,10 @@ constructor(
}
}
/**
* 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) {
private suspend fun insertUser(serverId: String, user: User) {
withContext(Dispatchers.IO) {
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:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M20,21v-2a4,4 0,0 0,-4 -4H8a4,4 0,0 0,-4 4v2"
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
android:id="@+id/action_navigation_settings_to_serverSelectFragment"
app:destination="@id/serverSelectFragment" />
<action
android:id="@+id/action_navigation_settings_to_usersFragment"
app:destination="@id/usersFragment" />
<action
android:id="@+id/action_settingsFragment_to_about_libraries"
app:destination="@id/about_libraries" />
@ -346,6 +349,10 @@
app:destination="@id/homeFragmentTv"
app:popUpTo="@id/homeFragmentTv"
app:popUpToInclusive="true" />
<argument
android:name="reLogin"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment
@ -388,4 +395,27 @@
android:id="@+id/action_global_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>

View file

@ -21,6 +21,8 @@
<string name="button_login">Login</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_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="cancel">Cancel</string>
<string name="title_home">Home</string>
@ -142,4 +144,6 @@
<string name="settings_request_timeout">Request timeout (ms)</string>
<string name="settings_connect_timeout">Connect 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>

View file

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