From 6572d7e85b78a9b9aeceaa9836e39b5bad74e7fe Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com> Date: Sat, 3 Dec 2022 20:53:14 +0100 Subject: [PATCH] Multiple server addresses (#208) * Add multiple addresses per server * Clean up * Change icon to globe * Fix AddServerAddressDialog crashing on tv * Fix navigation to main activity on tv * Hide nav bar in UsersFragment and ServerAddressesFragment * Add hint for server address --- .../java/dev/jdtech/jellyfin/MainActivity.kt | 2 +- .../jellyfin/adapters/ServerAddressAdapter.kt | 52 +++++++++ .../jellyfin/database/ServerDatabaseDao.kt | 6 + .../dialogs/AddServerAddressDialog.kt | 41 +++++++ .../dialogs/DeleteServerAddressDialog.kt | 29 +++++ .../fragments/ServerAddressesFragment.kt | 107 ++++++++++++++++++ .../jellyfin/fragments/SettingsFragment.kt | 6 + .../jellyfin/fragments/UsersFragment.kt | 16 ++- .../java/dev/jdtech/jellyfin/models/Server.kt | 2 +- .../viewmodels/ServerAddressesViewModel.kt | 89 +++++++++++++++ app/src/main/res/drawable/ic_globe.xml | 28 +++++ .../fragment_server_addresses.xml | 29 +++++ .../res/layout-television/fragment_users.xml | 54 ++++----- .../res/layout/fragment_server_addresses.xml | 29 +++++ app/src/main/res/layout/fragment_users.xml | 54 ++++----- .../res/layout/server_address_list_item.xml | 34 ++++++ .../main/res/navigation/app_navigation.xml | 23 ++++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/fragment_settings.xml | 5 + 19 files changed, 539 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/dev/jdtech/jellyfin/adapters/ServerAddressAdapter.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/dialogs/AddServerAddressDialog.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerAddressDialog.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/fragments/ServerAddressesFragment.kt create mode 100644 app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerAddressesViewModel.kt create mode 100644 app/src/main/res/drawable/ic_globe.xml create mode 100644 app/src/main/res/layout-television/fragment_server_addresses.xml create mode 100644 app/src/main/res/layout/fragment_server_addresses.xml create mode 100644 app/src/main/res/layout/server_address_list_item.xml diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt index b2e58f79..6ff2c5f6 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt @@ -90,7 +90,7 @@ class MainActivity : AppCompatActivity() { navController.addOnDestinationChangedListener { _, destination, _ -> binding.navView!!.visibility = when (destination.id) { - R.id.twoPaneSettingsFragment, R.id.serverSelectFragment, R.id.addServerFragment, R.id.loginFragment, R.id.about_libraries_dest -> View.GONE + R.id.twoPaneSettingsFragment, R.id.serverSelectFragment, R.id.addServerFragment, R.id.loginFragment, R.id.about_libraries_dest, R.id.usersFragment, R.id.serverAddressesFragment -> View.GONE else -> View.VISIBLE } if (destination.id == R.id.about_libraries_dest) binding.mainToolbar?.title = diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/ServerAddressAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/ServerAddressAdapter.kt new file mode 100644 index 00000000..763729a2 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/ServerAddressAdapter.kt @@ -0,0 +1,52 @@ +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.ServerAddressListItemBinding +import dev.jdtech.jellyfin.models.ServerAddress + +class ServerAddressAdapter( + private val clickListener: (address: ServerAddress) -> Unit, + private val longClickListener: (address: ServerAddress) -> Boolean +) : ListAdapter(DiffCallback) { + class ServerAddressViewHolder(private var binding: ServerAddressListItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(address: ServerAddress) { + binding.address = address + binding.executePendingBindings() + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ServerAddress, newItem: ServerAddress): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ServerAddress, newItem: ServerAddress): Boolean { + return oldItem == newItem + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ServerAddressViewHolder { + return ServerAddressViewHolder( + ServerAddressListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: ServerAddressViewHolder, position: Int) { + val address = getItem(position) + holder.itemView.setOnClickListener { clickListener(address) } + holder.itemView.setOnLongClickListener { longClickListener(address) } + holder.bind(address) + } +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt b/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt index b793bc93..4df8ee3c 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt @@ -65,9 +65,15 @@ interface ServerDatabaseDao { @Query("delete from users where id = :id") fun deleteUser(id: UUID) + @Query("delete from serverAddresses where id = :id") + fun deleteServerAddress(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 id = :serverId)") fun getServerCurrentUser(serverId: String): User? + + @Query("select * from serverAddresses where id = (select currentServerAddressId from servers where id = :serverId)") + fun getServerCurrentAddress(serverId: String): ServerAddress? } diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/AddServerAddressDialog.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/AddServerAddressDialog.kt new file mode 100644 index 00000000..f1d0ece5 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/AddServerAddressDialog.kt @@ -0,0 +1,41 @@ +package dev.jdtech.jellyfin.dialogs + +import android.app.Dialog +import android.app.UiModeManager +import android.content.res.Configuration +import android.os.Bundle +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.jdtech.jellyfin.R +import dev.jdtech.jellyfin.viewmodels.ServerAddressesViewModel +import java.lang.IllegalStateException + +class AddServerAddressDialog( + private val viewModel: ServerAddressesViewModel +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val uiModeManager = + requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager + val editText = EditText(this.context) + editText.hint = "http://:8096" + return activity?.let { activity -> + val builder = if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { + AlertDialog.Builder(activity) + } else { + MaterialAlertDialogBuilder(activity) + } + builder + .setTitle("Add server address") + .setView(editText) + .setPositiveButton(getString(R.string.add)) { _, _ -> + viewModel.addAddress(editText.text.toString()) + } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + } + builder.create() + } ?: throw IllegalStateException("Activity cannot be null") + } +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerAddressDialog.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerAddressDialog.kt new file mode 100644 index 00000000..a008d846 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerAddressDialog.kt @@ -0,0 +1,29 @@ +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.ServerAddress +import dev.jdtech.jellyfin.viewmodels.ServerAddressesViewModel +import java.lang.IllegalStateException + +class DeleteServerAddressDialog( + private val viewModel: ServerAddressesViewModel, + val address: ServerAddress +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return activity?.let { + val builder = MaterialAlertDialogBuilder(it) + builder.setTitle("Remove server address") + .setMessage("Are you sure you want to remove the server addres? ${address.address}") + .setPositiveButton(getString(R.string.remove)) { _, _ -> + viewModel.deleteAddress(address) + } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + } + builder.create() + } ?: throw IllegalStateException("Activity cannot be null") + } +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerAddressesFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerAddressesFragment.kt new file mode 100644 index 00000000..a79adc83 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerAddressesFragment.kt @@ -0,0 +1,107 @@ +package dev.jdtech.jellyfin.fragments + +import android.app.UiModeManager +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +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.adapters.ServerAddressAdapter +import dev.jdtech.jellyfin.databinding.FragmentServerAddressesBinding +import dev.jdtech.jellyfin.dialogs.AddServerAddressDialog +import dev.jdtech.jellyfin.dialogs.DeleteServerAddressDialog +import dev.jdtech.jellyfin.viewmodels.ServerAddressesViewModel +import kotlinx.coroutines.launch +import timber.log.Timber + +@AndroidEntryPoint +class ServerAddressesFragment : Fragment() { + + private lateinit var binding: FragmentServerAddressesBinding + private lateinit var uiModeManager: UiModeManager + private val viewModel: ServerAddressesViewModel by viewModels() + private val args: UsersFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentServerAddressesBinding.inflate(inflater) + uiModeManager = + requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager + + binding.addressesRecyclerView.adapter = + ServerAddressAdapter( + { address -> + viewModel.switchToAddress(address) + }, + { address -> + DeleteServerAddressDialog(viewModel, address).show( + parentFragmentManager, + "deleteServerAddress" + ) + true + } + ) + + binding.buttonAddAddress.setOnClickListener { + AddServerAddressDialog(viewModel).show( + parentFragmentManager, + "addServerAddress" + ) + } + + 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 ServerAddressesViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is ServerAddressesViewModel.UiState.Loading -> Unit + is ServerAddressesViewModel.UiState.Error -> Unit + } + } + } + } + + viewModel.loadAddresses(args.serverId) + } + + fun bindUiStateNormal(uiState: ServerAddressesViewModel.UiState.Normal) { + (binding.addressesRecyclerView.adapter as ServerAddressAdapter).submitList(uiState.addresses) + } + + private fun navigateToMainActivity() { + if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { + findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragmentTv()) + } else { + findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragment()) + } + } +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt index 1a04d124..1bf814b9 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt @@ -30,6 +30,12 @@ class SettingsFragment : PreferenceFragmentCompat() { true } + findPreference("switchAddress")?.setOnPreferenceClickListener { + val serverId = appPreferences.currentServer!! + findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToServerAddressesFragment(serverId)) + true + } + findPreference("privacyPolicy")?.setOnPreferenceClickListener { val intent = Intent( Intent.ACTION_VIEW, diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/UsersFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/UsersFragment.kt index 5ec967ed..18c74d33 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/UsersFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/UsersFragment.kt @@ -1,9 +1,12 @@ package dev.jdtech.jellyfin.fragments +import android.app.UiModeManager +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -24,6 +27,7 @@ import timber.log.Timber class UsersFragment : Fragment() { private lateinit var binding: FragmentUsersBinding + private lateinit var uiModeManager: UiModeManager private val viewModel: UsersViewModel by viewModels() private val args: UsersFragmentArgs by navArgs() @@ -33,10 +37,8 @@ class UsersFragment : Fragment() { savedInstanceState: Bundle? ): View { binding = FragmentUsersBinding.inflate(inflater) - - binding.lifecycleOwner = viewLifecycleOwner - - binding.viewModel = viewModel + uiModeManager = + requireContext().getSystemService(AppCompatActivity.UI_MODE_SERVICE) as UiModeManager binding.usersRecyclerView.adapter = UserListAdapter( @@ -99,6 +101,10 @@ class UsersFragment : Fragment() { } private fun navigateToMainActivity() { - findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragment()) + if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { + findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragmentTv()) + } else { + findNavController().navigate(UsersFragmentDirections.actionUsersFragmentToHomeFragment()) + } } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/Server.kt b/app/src/main/java/dev/jdtech/jellyfin/models/Server.kt index 6e53062a..0109d128 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/models/Server.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/models/Server.kt @@ -9,6 +9,6 @@ data class Server( @PrimaryKey val id: String, val name: String, - val currentServerAddressId: UUID?, + var currentServerAddressId: UUID?, var currentUserId: UUID?, ) diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerAddressesViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerAddressesViewModel.kt new file mode 100644 index 00000000..cefcea50 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/ServerAddressesViewModel.kt @@ -0,0 +1,89 @@ +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.ServerAddress +import java.util.UUID +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 ServerAddressesViewModel +@Inject +constructor( + private val jellyfinApi: JellyfinApi, + private val database: ServerDatabaseDao, +) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState = _uiState.asStateFlow() + + sealed class UiState { + data class Normal(val addresses: List) : UiState() + object Loading : UiState() + data class Error(val error: Exception) : UiState() + } + + private val _navigateToMain = MutableSharedFlow() + val navigateToMain = _navigateToMain.asSharedFlow() + + private var currentServerId: String = "" + + fun loadAddresses(serverId: String) { + currentServerId = serverId + viewModelScope.launch { + _uiState.emit(UiState.Loading) + try { + val serverWithUser = database.getServerWithAddresses(serverId) + _uiState.emit(UiState.Normal(serverWithUser.addresses)) + } catch (e: Exception) { + _uiState.emit(UiState.Error(e)) + } + } + } + + /** + * Delete server address from database + * + * @param address The server address + */ + fun deleteAddress(address: ServerAddress) { + viewModelScope.launch(Dispatchers.IO) { + val currentAddress = database.getServerCurrentAddress(currentServerId) + if (address == currentAddress) { + Timber.e("You cannot delete the current address") + return@launch + } + database.deleteServerAddress(address.id) + loadAddresses(currentServerId) + } + } + + fun switchToAddress(address: ServerAddress) { + viewModelScope.launch { + val server = database.get(currentServerId) ?: return@launch + server.currentServerAddressId = address.id + database.update(server) + + jellyfinApi.api.baseUrl = address.address + + _navigateToMain.emit(true) + } + } + + fun addAddress(address: String) { + viewModelScope.launch(Dispatchers.IO) { + val serverAddress = ServerAddress(UUID.randomUUID(), currentServerId, address) + database.insertServerAddress(serverAddress) + loadAddresses(currentServerId) + } + } +} diff --git a/app/src/main/res/drawable/ic_globe.xml b/app/src/main/res/drawable/ic_globe.xml new file mode 100644 index 00000000..33489f2f --- /dev/null +++ b/app/src/main/res/drawable/ic_globe.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/app/src/main/res/layout-television/fragment_server_addresses.xml b/app/src/main/res/layout-television/fragment_server_addresses.xml new file mode 100644 index 00000000..cbe11bb9 --- /dev/null +++ b/app/src/main/res/layout-television/fragment_server_addresses.xml @@ -0,0 +1,29 @@ + + + + + +