Server setup improvements (#67)

* Improve AddServer fragment

* Improve login fragment

With other general improvements

* Resize the addserver and login fragments when the soft keyboard appears

* Upgrade androidx.core to 1.7.0 and add lifecycle deps

* New UI state system for AddServerFragment

This uses StateFlow for the state and SharedFlow for navigation

* Remove public flows and use collector functions

* Update Login ViewModel and Fragment

* Speed up server discovery

* Better login error message
This commit is contained in:
Jarne Demeulemeester 2021-11-14 18:20:19 +01:00 committed by GitHub
parent 44d0a34539
commit 98cb038c24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 516 additions and 294 deletions

View file

@ -33,7 +33,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FindroidSplashScreen" android:theme="@style/Theme.FindroidSplashScreen"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View file

@ -12,7 +12,7 @@ interface ServerDatabaseDao {
fun update(server: Server) fun update(server: Server)
@Query("select * from servers where id = :id") @Query("select * from servers where id = :id")
fun get(id: String): Server fun get(id: String): Server?
@Query("delete from servers") @Query("delete from servers")
fun clear() fun clear()

View file

@ -26,7 +26,7 @@ object ApiModule {
val serverId = sharedPreferences.getString("selectedServer", null) val serverId = sharedPreferences.getString("selectedServer", null)
if (serverId != null) { if (serverId != null) {
val server = serverDatabase.get(serverId) val server = serverDatabase.get(serverId) ?: return jellyfinApi
jellyfinApi.apply { jellyfinApi.apply {
api.baseUrl = server.address api.baseUrl = server.address
api.accessToken = server.accessToken api.accessToken = server.accessToken

View file

@ -4,13 +4,19 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class AddServerFragment : Fragment() { class AddServerFragment : Fragment() {
@ -27,33 +33,62 @@ class AddServerFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel binding.viewModel = viewModel
binding.buttonConnect.setOnClickListener { binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
val serverAddress = binding.editTextServerAddress.text.toString() return@setOnEditorActionListener when (actionId) {
if (serverAddress.isNotBlank()) { EditorInfo.IME_ACTION_GO -> {
viewModel.checkServer(serverAddress, resources) connectToServer()
binding.progressCircular.visibility = View.VISIBLE true
binding.editTextServerAddressLayout.error = "" }
} else { else -> false
binding.editTextServerAddressLayout.error = resources.getString(R.string.add_server_error_empty_address)
} }
} }
viewModel.navigateToLogin.observe(viewLifecycleOwner, { binding.buttonConnect.setOnClickListener {
if (it) { connectToServer()
navigateToLoginFragment() }
}
binding.progressCircular.visibility = View.GONE
})
viewModel.error.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
binding.editTextServerAddressLayout.error = it viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}) viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToLogin(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to login: $it")
if (it) {
navigateToLoginFragment()
}
}
}
}
return binding.root return binding.root
} }
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.editTextServerAddressLayout.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.editTextServerAddressLayout.error = null
}
private fun connectToServer() {
val serverAddress = binding.editTextServerAddress.text.toString()
viewModel.checkServer(serverAddress)
}
private fun navigateToLoginFragment() { private fun navigateToLoginFragment() {
findNavController().navigate(AddServerFragmentDirections.actionAddServerFragment3ToLoginFragment2()) findNavController().navigate(AddServerFragmentDirections.actionAddServerFragment3ToLoginFragment2())
viewModel.onNavigateToLoginDone()
} }
} }

View file

@ -4,50 +4,93 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentLoginBinding import dev.jdtech.jellyfin.databinding.FragmentLoginBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class LoginFragment : Fragment() { class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding
private val viewModel: LoginViewModel by viewModels() private val viewModel: LoginViewModel by viewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = FragmentLoginBinding.inflate(inflater) binding = FragmentLoginBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel binding.viewModel = viewModel
binding.buttonLogin.setOnClickListener { binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
val username = binding.editTextUsername.text.toString() return@setOnEditorActionListener when (actionId) {
val password = binding.editTextPassword.text.toString() EditorInfo.IME_ACTION_GO -> {
login()
binding.progressCircular.visibility = View.VISIBLE true
viewModel.login(username, password) }
else -> false
}
} }
viewModel.error.observe(viewLifecycleOwner, { binding.buttonLogin.setOnClickListener {
binding.progressCircular.visibility = View.GONE login()
binding.editTextUsernameLayout.error = it }
})
viewModel.navigateToMain.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
if (it) { repeatOnLifecycle(Lifecycle.State.STARTED) {
navigateToMainActivity() viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when(uiState) {
is LoginViewModel.UiState.Normal -> bindUiStateNormal()
is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
is LoginViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to MainActivity: $it")
if (it) {
navigateToMainActivity()
}
}
} }
}) }
return binding.root return binding.root
} }
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.editTextUsernameLayout.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.editTextUsernameLayout.error = null
}
private fun login() {
val username = binding.editTextUsername.text.toString()
val password = binding.editTextPassword.text.toString()
binding.progressCircular.visibility = View.VISIBLE
viewModel.login(username, password)
}
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome()) findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome())
viewModel.doneNavigatingToMain()
} }
} }

View file

@ -21,6 +21,8 @@ class MediaFragment : Fragment() {
private lateinit var binding: FragmentMediaBinding private lateinit var binding: FragmentMediaBinding
private val viewModel: MediaViewModel by viewModels() private val viewModel: MediaViewModel by viewModels()
private var originalSoftInputMode: Int? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -33,7 +35,7 @@ class MediaFragment : Fragment() {
val searchView = search.actionView as SearchView val searchView = search.actionView as SearchView
searchView.queryHint = getString(R.string.search_hint) searchView.queryHint = getString(R.string.search_hint)
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(p0: String?): Boolean { override fun onQueryTextSubmit(p0: String?): Boolean {
if (p0 != null) { if (p0 != null) {
navigateToSearchResultFragment(p0) navigateToSearchResultFragment(p0)
@ -81,12 +83,28 @@ class MediaFragment : Fragment() {
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
} }
return binding.root return binding.root
} }
override fun onStart() {
super.onStart()
requireActivity().window.let {
originalSoftInputMode = it.attributes?.softInputMode
it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
}
}
override fun onStop() {
super.onStop()
originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) }
}
private fun navigateToLibraryFragment(library: BaseItemDto) { private fun navigateToLibraryFragment(library: BaseItemDto) {
findNavController().navigate( findNavController().navigate(
MediaFragmentDirections.actionNavigationMediaToLibraryFragment( MediaFragmentDirections.actionNavigationMediaToLibraryFragment(

View file

@ -4,16 +4,21 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.TvAddServerFragmentBinding import dev.jdtech.jellyfin.databinding.TvAddServerFragmentBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
internal class TvAddServerFragment: Fragment() { internal class TvAddServerFragment : Fragment() {
private lateinit var binding: TvAddServerFragmentBinding private lateinit var binding: TvAddServerFragmentBinding
private val viewModel: AddServerViewModel by viewModels() private val viewModel: AddServerViewModel by viewModels()
@ -29,30 +34,46 @@ internal class TvAddServerFragment: Fragment() {
binding.buttonConnect.setOnClickListener { binding.buttonConnect.setOnClickListener {
val serverAddress = binding.serverAddress.text.toString() val serverAddress = binding.serverAddress.text.toString()
if (serverAddress.isNotBlank()) { viewModel.checkServer(serverAddress)
viewModel.checkServer(serverAddress, resources)
binding.progressCircular.visibility = View.VISIBLE
} else {
binding.serverAddress.error = resources.getString(R.string.add_server_empty_error)
}
} }
viewModel.navigateToLogin.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
if (it) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
navigateToLoginFragment() viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToLogin(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to login: $it")
if (it) {
navigateToLoginFragment()
}
}
} }
binding.progressCircular.visibility = View.GONE }
})
viewModel.error.observe(viewLifecycleOwner, {
binding.serverAddress.error = it
})
return binding.root return binding.root
} }
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.serverAddress.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.serverAddress.error = null
}
private fun navigateToLoginFragment() { private fun navigateToLoginFragment() {
findNavController().navigate(TvAddServerFragmentDirections.actionAddServerFragmentToLoginFragment()) findNavController().navigate(TvAddServerFragmentDirections.actionAddServerFragmentToLoginFragment())
viewModel.onNavigateToLoginDone()
} }
} }

View file

@ -4,23 +4,30 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.TvLoginFragmentBinding import dev.jdtech.jellyfin.databinding.TvLoginFragmentBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class TvLoginFragment : Fragment() { class TvLoginFragment : Fragment() {
private lateinit var binding: TvLoginFragmentBinding
private val viewModel: LoginViewModel by viewModels() private val viewModel: LoginViewModel by viewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val binding = TvLoginFragmentBinding.inflate(inflater) binding = TvLoginFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel binding.viewModel = viewModel
@ -32,22 +39,43 @@ class TvLoginFragment : Fragment() {
viewModel.login(username, password) viewModel.login(username, password)
} }
viewModel.error.observe(viewLifecycleOwner, { viewLifecycleOwner.lifecycleScope.launch {
binding.progressCircular.visibility = View.GONE repeatOnLifecycle(Lifecycle.State.STARTED) {
binding.username.error = it viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
}) Timber.d("$uiState")
when(uiState) {
viewModel.navigateToMain.observe(viewLifecycleOwner, { is LoginViewModel.UiState.Normal -> bindUiStateNormal()
if (it) { is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
navigateToMainActivity() is LoginViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to MainActivity: $it")
if (it) {
navigateToMainActivity()
}
}
} }
}) }
return binding.root return binding.root
} }
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.username.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.username.error = null
}
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
findNavController().navigate(TvLoginFragmentDirections.actionLoginFragmentToNavigationHome()) findNavController().navigate(TvLoginFragmentDirections.actionLoginFragmentToNavigationHome())
viewModel.doneNavigatingToMain()
} }
} }

View file

@ -2,8 +2,7 @@ package dev.jdtech.jellyfin.viewmodels
import android.content.res.Resources import android.content.res.Resources
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.LiveData import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -13,9 +12,7 @@ import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.discovery.RecommendedServerInfo import org.jellyfin.sdk.discovery.RecommendedServerInfo
@ -32,100 +29,144 @@ constructor(
private val jellyfinApi: JellyfinApi, private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao private val database: ServerDatabaseDao
) : ViewModel() { ) : ViewModel() {
private val resources: Resources = application.resources
private val _navigateToLogin = MutableLiveData<Boolean>() private val uiState = MutableStateFlow<UiState>(UiState.Normal)
val navigateToLogin: LiveData<Boolean> = _navigateToLogin
private val _error = MutableLiveData<String>() private val navigateToLogin = MutableSharedFlow<Boolean>()
val error: LiveData<String> = _error
sealed class UiState {
object Normal : UiState()
object Loading : UiState()
data class Error(val message: String) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun onNavigateToLogin(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
scope.launch { navigateToLogin.collect { collector(it) } }
}
/** /**
* Run multiple check on the server before continuing: * Run multiple check on the server before continuing:
* *
* - Connect to server and check if it is a Jellyfin server * - Connect to server and check if it is a Jellyfin server
* - Check if server is not already in Database * - Check if server is not already in Database
*
* @param inputValue Can be an ip address or hostname
*/ */
fun checkServer(inputValue: String, resources: Resources) { fun checkServer(inputValue: String) {
_error.value = null
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
// Check if input value is not empty
if (inputValue.isBlank()) {
throw Exception(resources.getString(R.string.add_server_error_empty_address))
}
val candidates = jellyfinApi.jellyfin.discovery.getAddressCandidates(inputValue) val candidates = jellyfinApi.jellyfin.discovery.getAddressCandidates(inputValue)
val recommended = jellyfinApi.jellyfin.discovery.getRecommendedServers( val recommended = jellyfinApi.jellyfin.discovery.getRecommendedServers(
candidates, candidates,
RecommendedServerInfoScore.OK RecommendedServerInfoScore.OK
) )
// Check if any servers have been found val greatServers = mutableListOf<RecommendedServerInfo>()
if (recommended.toList().isNullOrEmpty()) { val goodServers = mutableListOf<RecommendedServerInfo>()
throw Exception(resources.getString(R.string.add_server_error_not_found)) val okServers = mutableListOf<RecommendedServerInfo>()
}
// Create separate flow of great, good and ok servers. recommended
val greatServers = .onCompletion {
recommended.filter { it.score == RecommendedServerInfoScore.GREAT } if (greatServers.isNotEmpty()) {
val goodServers = recommended.filter { it.score == RecommendedServerInfoScore.GOOD } connectToServer(greatServers.first())
val okServers = recommended.filter { it.score == RecommendedServerInfoScore.OK } } else if (goodServers.isNotEmpty()) {
val issuesString = createIssuesString(goodServers.first())
// Only allow connecting to great and good servers. Show toast of issues if good server Toast.makeText(
val recommendedServer = if (greatServers.toList().isNotEmpty()) { application,
greatServers.first() issuesString,
} else if (goodServers.toList().isNotEmpty()) { Toast.LENGTH_LONG
val issuesString = createIssuesString(goodServers.first(), resources) ).show()
Toast.makeText( connectToServer(goodServers.first())
application, } else if (okServers.isNotEmpty()) {
issuesString, val okServer = okServers.first()
Toast.LENGTH_LONG val issuesString = createIssuesString(okServer)
).show() throw Exception(issuesString)
goodServers.first() } else {
} else { throw Exception(resources.getString(R.string.add_server_error_not_found))
val okServer = okServers.first() }
val issuesString = createIssuesString(okServer, resources) }
throw Exception(issuesString) .collect { recommendedServerInfo ->
} when (recommendedServerInfo.score) {
RecommendedServerInfoScore.GREAT -> greatServers.add(recommendedServerInfo)
jellyfinApi.apply { RecommendedServerInfoScore.GOOD -> goodServers.add(recommendedServerInfo)
api.baseUrl = recommendedServer.address RecommendedServerInfoScore.OK -> okServers.add(recommendedServerInfo)
api.accessToken = null RecommendedServerInfoScore.BAD -> Unit
} }
}
Timber.d("Remote server: ${recommendedServer.systemInfo.getOrNull()?.id}")
if (serverAlreadyInDatabase(recommendedServer.systemInfo.getOrNull()?.id)) {
_error.value = resources.getString(R.string.add_server_error_already_added)
_navigateToLogin.value = false
} else {
_error.value = null
_navigateToLogin.value = true
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) uiState.emit(
_error.value = e.message UiState.Error(
_navigateToLogin.value = false e.message ?: resources.getString(R.string.unknown_error)
)
)
} }
} }
} }
private suspend fun connectToServer(recommendedServerInfo: RecommendedServerInfo) {
val serverId = recommendedServerInfo.systemInfo.getOrNull()?.id
?: throw Exception(resources.getString(R.string.add_server_error_no_id))
Timber.d("Connecting to server: $serverId")
if (serverAlreadyInDatabase(serverId)) {
throw Exception(resources.getString(R.string.add_server_error_already_added))
}
jellyfinApi.apply {
api.baseUrl = recommendedServerInfo.address
api.accessToken = null
}
uiState.emit(UiState.Normal)
navigateToLogin.emit(true)
}
/** /**
* Create a presentable string of issues with a server * Create a presentable string of issues with a server
* *
* @param server The server with issues * @param server The server with issues
* @return A presentable string of issues separated with \n * @return A presentable string of issues separated with \n
*/ */
private fun createIssuesString(server: RecommendedServerInfo, resources: Resources): String { private fun createIssuesString(server: RecommendedServerInfo): String {
return server.issues.joinToString("\n") { return server.issues.joinToString("\n") {
when (it) { when (it) {
is RecommendedServerIssue.OutdatedServerVersion -> { is RecommendedServerIssue.OutdatedServerVersion -> {
String.format(resources.getString(R.string.add_server_error_outdated), it.version) String.format(
resources.getString(R.string.add_server_error_outdated),
it.version
)
} }
is RecommendedServerIssue.InvalidProductName -> { is RecommendedServerIssue.InvalidProductName -> {
String.format(resources.getString(R.string.add_server_error_not_jellyfin), it.productName) String.format(
resources.getString(R.string.add_server_error_not_jellyfin),
it.productName
)
} }
is RecommendedServerIssue.UnsupportedServerVersion -> { is RecommendedServerIssue.UnsupportedServerVersion -> {
String.format(resources.getString(R.string.add_server_error_version), it.version) String.format(
resources.getString(R.string.add_server_error_version),
it.version
)
} }
is RecommendedServerIssue.SlowResponse -> { is RecommendedServerIssue.SlowResponse -> {
String.format(resources.getString(R.string.add_server_error_slow), it.responseTime) String.format(
resources.getString(R.string.add_server_error_slow),
it.responseTime
)
} }
else -> { else -> {
resources.getString(R.string.unknown_error) resources.getString(R.string.unknown_error)
@ -140,22 +181,12 @@ constructor(
* @param id Server ID * @param id Server ID
* @return True if server is already in database * @return True if server is already in database
*/ */
private suspend fun serverAlreadyInDatabase(id: String?): Boolean { private suspend fun serverAlreadyInDatabase(id: String): Boolean {
val servers: List<Server> val server: Server?
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
servers = database.getAllServersSync() server = database.get(id)
}
for (server in servers) {
Timber.d("Database server: ${server.id}")
if (server.id == id) {
Timber.w("Server already in the database")
return true
}
} }
if (server != null) return true
return false return false
} }
fun onNavigateToLoginDone() {
_navigateToLogin.value = false
}
} }

View file

@ -1,12 +1,18 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.BaseApplication
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.AuthenticateUserByName import org.jellyfin.sdk.model.api.AuthenticateUserByName
@ -18,17 +24,30 @@ import javax.inject.Inject
class LoginViewModel class LoginViewModel
@Inject @Inject
constructor( constructor(
application: BaseApplication,
private val sharedPreferences: SharedPreferences, private val sharedPreferences: SharedPreferences,
private val jellyfinApi: JellyfinApi, private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao private val database: ServerDatabaseDao
) : ViewModel() { ) : ViewModel() {
private val resources: Resources = application.resources
private val _error = MutableLiveData<String>() private val uiState = MutableStateFlow<UiState>(UiState.Normal)
val error: LiveData<String> = _error
private val navigateToMain = MutableSharedFlow<Boolean>()
private val _navigateToMain = MutableLiveData<Boolean>() sealed class UiState {
val navigateToMain: LiveData<Boolean> = _navigateToMain object Normal : UiState()
object Loading : UiState()
data class Error(val message: String) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
scope.launch { navigateToMain.collect { collector(it) } }
}
/** /**
* Send a authentication request to the Jellyfin server * Send a authentication request to the Jellyfin server
@ -38,6 +57,8 @@ constructor(
*/ */
fun login(username: String, password: String) { fun login(username: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
uiState.emit(UiState.Loading)
try { try {
val authenticationResult by jellyfinApi.userApi.authenticateUserByName( val authenticationResult by jellyfinApi.userApi.authenticateUserByName(
data = AuthenticateUserByName( data = AuthenticateUserByName(
@ -45,8 +66,9 @@ constructor(
pw = password pw = password
) )
) )
_error.value = null
val serverInfo by jellyfinApi.systemApi.getPublicSystemInfo() val serverInfo by jellyfinApi.systemApi.getPublicSystemInfo()
val server = Server( val server = Server(
serverInfo.id!!, serverInfo.id!!,
serverInfo.serverName!!, serverInfo.serverName!!,
@ -55,18 +77,27 @@ constructor(
authenticationResult.user?.name!!, authenticationResult.user?.name!!,
authenticationResult.accessToken!! authenticationResult.accessToken!!
) )
insert(server) insert(server)
val spEdit = sharedPreferences.edit() val spEdit = sharedPreferences.edit()
spEdit.putString("selectedServer", server.id) spEdit.putString("selectedServer", server.id)
spEdit.apply() spEdit.apply()
jellyfinApi.apply { jellyfinApi.apply {
api.accessToken = authenticationResult.accessToken api.accessToken = authenticationResult.accessToken
userId = authenticationResult.user?.id userId = authenticationResult.user?.id
} }
_navigateToMain.value = true
uiState.emit(UiState.Normal)
navigateToMain.emit(true)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
_error.value = e.toString() val message =
if (e.cause?.message?.contains("401") == true) resources.getString(R.string.login_error_wrong_username_password) else resources.getString(
R.string.unknown_error
)
uiState.emit(UiState.Error(message))
} }
} }
} }
@ -81,8 +112,4 @@ constructor(
database.insert(server) database.insert(server)
} }
} }
fun doneNavigatingToMain() {
_navigateToMain.value = false
}
} }

View file

@ -11,87 +11,95 @@
type="dev.jdtech.jellyfin.viewmodels.AddServerViewModel" /> type="dev.jdtech.jellyfin.viewmodels.AddServerViewModel" />
</data> </data>
<androidx.constraintlayout.widget.ConstraintLayout <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".fragments.AddServerFragment"> tools:context=".fragments.AddServerFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView <ImageView
android:id="@+id/image_banner" android:id="@+id/image_banner"
android:layout_width="268dp" android:layout_width="268dp"
android:layout_height="75dp" android:layout_height="75dp"
android:layout_marginTop="64dp" android:layout_marginTop="64dp"
android:contentDescription="@string/jellyfin_banner" android:contentDescription="@string/jellyfin_banner"
android:src="@drawable/ic_banner" android:src="@drawable/ic_banner"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
<LinearLayout android:id="@+id/linearLayout"
android:id="@+id/linearLayout" android:layout_width="@dimen/setup_container_width"
android:layout_width="@dimen/setup_container_width"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36">
<TextView
android:id="@+id/text_add_server"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="32dp" android:layout_marginStart="24dp"
android:text="@string/add_server" android:layout_marginEnd="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5" android:orientation="vertical"
android:textColor="?android:textColorPrimary" /> app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36">
<com.google.android.material.textfield.TextInputLayout <TextView
android:id="@+id/edit_text_server_address_layout" android:id="@+id/text_add_server"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="wrap_content"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginBottom="32dp"
android:layout_marginBottom="8dp" android:text="@string/add_server"
android:hint="@string/edit_text_server_address_hint" android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:errorEnabled="true" android:textColor="?android:textColorPrimary" />
app:startIconDrawable="@drawable/ic_server">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/edit_text_server_address" android:id="@+id/edit_text_server_address_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textUri" android:layout_marginBottom="8dp"
android:singleLine="true" /> android:hint="@string/edit_text_server_address_hint"
app:errorEnabled="true"
app:startIconDrawable="@drawable/ic_server">
</com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_text_server_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textUri"
android:singleLine="true" />
<RelativeLayout </com.google.android.material.textfield.TextInputLayout>
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button <RelativeLayout
android:id="@+id/button_connect"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:drawableStart="@drawable/ic_launcher_foreground"
android:text="@string/button_connect" />
<ProgressBar <Button
android:id="@+id/progress_circular" android:id="@+id/button_connect"
android:layout_width="48dp" android:layout_width="match_parent"
android:layout_height="48dp" android:layout_height="wrap_content"
android:elevation="8dp" android:drawableStart="@drawable/ic_launcher_foreground"
android:indeterminateTint="@color/white" android:text="@string/button_connect" />
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout> <ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout> </layout>

View file

@ -11,102 +11,111 @@
type="dev.jdtech.jellyfin.viewmodels.LoginViewModel" /> type="dev.jdtech.jellyfin.viewmodels.LoginViewModel" />
</data> </data>
<androidx.constraintlayout.widget.ConstraintLayout <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".fragments.LoginFragment"> tools:context=".fragments.LoginFragment">
<ImageView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/image_banner" android:layout_width="match_parent"
android:layout_width="268dp" android:layout_height="wrap_content">
android:layout_height="75dp"
android:layout_marginTop="64dp"
android:contentDescription="@string/jellyfin_banner"
android:src="@drawable/ic_banner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout <ImageView
android:id="@+id/linearLayout" android:id="@+id/image_banner"
android:layout_width="@dimen/setup_container_width" android:layout_width="268dp"
android:layout_height="wrap_content" android:layout_height="75dp"
android:layout_marginStart="24dp" android:layout_marginTop="64dp"
android:layout_marginEnd="24dp" android:contentDescription="@string/jellyfin_banner"
android:orientation="vertical" android:src="@drawable/ic_banner"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36">
<TextView <LinearLayout
android:id="@+id/text_login" android:id="@+id/linearLayout"
android:layout_width="wrap_content" android:layout_width="@dimen/setup_container_width"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="32dp" android:layout_marginStart="24dp"
android:text="@string/login" android:layout_marginEnd="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5" android:orientation="vertical"
android:textColor="?android:textColorPrimary" /> app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/image_banner"
app:layout_constraintVertical_bias="0.36">
<com.google.android.material.textfield.TextInputLayout <TextView
android:id="@+id/edit_text_username_layout" android:id="@+id/text_login"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="wrap_content"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginBottom="32dp"
android:layout_marginBottom="12dp" android:text="@string/login"
android:hint="@string/edit_text_username_hint" android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:startIconDrawable="@drawable/ic_user"> android:textColor="?android:textColorPrimary" />
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/edit_text_username" android:id="@+id/edit_text_username_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:autofillHints="username" android:layout_marginBottom="12dp"
android:inputType="text" /> android:hint="@string/edit_text_username_hint"
app:startIconDrawable="@drawable/ic_user">
</com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_text_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:inputType="text" />
<com.google.android.material.textfield.TextInputLayout </com.google.android.material.textfield.TextInputLayout>
android:id="@+id/edit_text_password_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="@string/edit_text_password_hint"
app:passwordToggleEnabled="true"
app:startIconDrawable="@drawable/ic_lock">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/edit_text_password" android:id="@+id/edit_text_password_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:autofillHints="password" android:layout_marginBottom="24dp"
android:inputType="textPassword" /> android:hint="@string/edit_text_password_hint"
app:passwordToggleEnabled="true"
app:startIconDrawable="@drawable/ic_lock">
</com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_text_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="password"
android:imeOptions="actionGo"
android:inputType="textPassword" />
<RelativeLayout </com.google.android.material.textfield.TextInputLayout>
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button <RelativeLayout
android:id="@+id/button_login"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:text="@string/button_login" />
<ProgressBar <Button
android:id="@+id/progress_circular" android:id="@+id/button_login"
android:layout_width="48dp" android:layout_width="match_parent"
android:layout_height="48dp" android:layout_height="wrap_content"
android:elevation="8dp" android:text="@string/button_login" />
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> <ProgressBar
android:id="@+id/progress_circular"
android:layout_width="48dp"
android:layout_height="48dp"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:padding="8dp"
android:visibility="invisible" />
</RelativeLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</layout> </layout>

View file

@ -10,7 +10,9 @@
<string name="add_server_error_already_added">Server already added</string> <string name="add_server_error_already_added">Server already added</string>
<string name="add_server_error_empty_address">Empty server address</string> <string name="add_server_error_empty_address">Empty server address</string>
<string name="add_server_error_not_found">Server not found</string> <string name="add_server_error_not_found">Server not found</string>
<string name="add_server_error_no_id">Server has no id, something seems to be wrong with the server</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="login_error_wrong_username_password">Wrong username or password</string>
<string name="select_server">Select server</string> <string name="select_server">Select server</string>
<string name="edit_text_server_address_hint">Server address</string> <string name="edit_text_server_address_hint">Server address</string>
<string name="edit_text_username_hint">Username</string> <string name="edit_text_username_hint">Username</string>