Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite connection screen in Jetpack Compose #800

Merged
merged 1 commit into from
Sep 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,15 @@ android {
@Suppress("UnstableApiUsage")
buildFeatures {
viewBinding = true
compose = true
}
kotlinOptions {
@Suppress("SuspiciousCollectionReassignment")
freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn")
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
Expand All @@ -108,7 +112,7 @@ dependencies {
implementation(libs.bundles.coroutines)

// Core
implementation(libs.koin)
implementation(libs.bundles.koin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)
Expand All @@ -124,6 +128,9 @@ dependencies {
implementation(libs.androidx.webkit)
implementation(libs.modernandroidpreferences)

// Jetpack Compose
implementation(libs.bundles.compose)

// Network
val sdkVersion = findProperty("sdk.version")?.toString()
implementation(libs.jellyfin.sdk) {
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ class MainViewModel(

init {
viewModelScope.launch {
apiClientController.migrateFromPreferences()
refreshServer()
}
}

suspend fun refreshServer() {
val server = apiClientController.loadSavedServer()
_serverState.value = server?.let { ServerState.Available(it) } ?: ServerState.Unset
suspend fun switchServer(hostname: String) {
apiClientController.setupServer(hostname)
refreshServer()
}

private suspend fun refreshServer() {
val serverEntity = apiClientController.loadSavedServer()
_serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset
}
}

Expand Down
11 changes: 1 addition & 10 deletions app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.*

class ApiClientController(
private val appPreferences: AppPreferences,
Expand All @@ -22,16 +21,8 @@ class ApiClientController(
get() = jellyfin.options.deviceInfo!!

/**
* Migrate from preferences if necessary
* Store server with [hostname] in the database.
*/
@Suppress("DEPRECATION")
suspend fun migrateFromPreferences() {
appPreferences.instanceUrl?.let { url ->
setupServer(url)
appPreferences.instanceUrl = null
}
}

suspend fun setupServer(hostname: String) {
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.setup.ConnectFragment
import org.jellyfin.mobile.setup.ConnectionHelper
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.isLowRamDevice
Expand Down Expand Up @@ -58,10 +58,12 @@ val applicationModule = module {
viewModel { MainViewModel(get(), get()) }

// Fragments
fragment { ConnectFragment() }
fragment { WebViewFragment() }
fragment { PlayerFragment() }

// Connection helper
single { ConnectionHelper(get(), get()) }

// Media player helpers
single { MediaSourceResolver(get()) }
single { DeviceProfileBuilder() }
Expand Down
218 changes: 18 additions & 200 deletions app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt
Original file line number Diff line number Diff line change
@@ -1,228 +1,46 @@
package org.jellyfin.mobile.setup

import android.app.AlertDialog
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.core.content.getSystemService
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.databinding.FragmentConnectBinding
import org.jellyfin.mobile.databinding.FragmentComposeBinding
import org.jellyfin.mobile.ui.screens.connect.ConnectScreen
import org.jellyfin.mobile.ui.utils.AppTheme
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.discovery.LocalServerDiscovery
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import timber.log.Timber

class ConnectFragment : Fragment() {
private val mainViewModel: MainViewModel by sharedViewModel()
private val jellyfin: Jellyfin by inject()
private val apiClientController: ApiClientController by inject()
private var _viewBinding: FragmentComposeBinding? = null
private val viewBinding get() = _viewBinding!!
private val composeView: ComposeView get() = viewBinding.composeView

// UI
private var _connectServerBinding: FragmentConnectBinding? = null
private val connectServerBinding get() = _connectServerBinding!!
private val serverSetupLayout: View get() = connectServerBinding.root
private val hostInput: EditText get() = connectServerBinding.hostInput
private val connectionErrorText: TextView get() = connectServerBinding.connectionErrorText
private val connectButton: Button get() = connectServerBinding.connectButton
private val chooseServerButton: Button get() = connectServerBinding.chooseServerButton
private val connectionProgress: View get() = connectServerBinding.connectionProgress

private val serverList = ArrayList<ServerDiscoveryInfo>(LocalServerDiscovery.DISCOVERY_MAX_SERVERS)

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_connectServerBinding = FragmentConnectBinding.inflate(inflater, container, false)
return serverSetupLayout.apply { applyWindowInsetsAsMargins() }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_viewBinding = FragmentComposeBinding.inflate(inflater, container, false)
return composeView.apply { applyWindowInsetsAsMargins() }
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// Apply window insets
ViewCompat.requestApplyInsets(serverSetupLayout)

hostInput.setText(mainViewModel.serverState.value.server?.hostname)
hostInput.setSelection(hostInput.length())
hostInput.setOnEditorActionListener { _, action, event ->
when {
action == EditorInfo.IME_ACTION_DONE || event.keyCode == KeyEvent.KEYCODE_ENTER -> {
connect()
true
}
else -> false
}
}
connectButton.setOnClickListener {
connect()
}
chooseServerButton.setOnClickListener {
chooseServer()
}

if (arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true)
showConnectionError()

// Show keyboard
serverSetupLayout.doOnNextLayout {
@Suppress("MagicNumber")
hostInput.postDelayed(25) {
hostInput.requestFocus()

requireContext().getSystemService<InputMethodManager>()?.showSoftInput(hostInput, InputMethodManager.SHOW_IMPLICIT)
}
}
ViewCompat.requestApplyInsets(composeView)

discoverServers()
}

override fun onDestroyView() {
super.onDestroyView()
_connectServerBinding = null
}
val encounteredConnectionError = arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true

private fun connect(enteredUrl: String = hostInput.text.toString()) {
hostInput.isEnabled = false
connectButton.isVisible = false
connectionProgress.isVisible = true
chooseServerButton.isVisible = false
clearConnectionError()
lifecycleScope.launch {
val httpUrl = checkServerUrlAndConnection(enteredUrl)
if (httpUrl != null) {
serverList.clear()
apiClientController.setupServer(httpUrl)
mainViewModel.refreshServer()
composeView.setContent {
AppTheme {
ConnectScreen(
mainViewModel = mainViewModel,
showExternalConnectionError = encounteredConnectionError,
)
}
hostInput.isEnabled = true
connectButton.isVisible = true
connectionProgress.isVisible = false
chooseServerButton.isVisible = serverList.isNotEmpty()
}
}

private fun discoverServers() {
lifecycleScope.launch {
jellyfin.discovery
.discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
.flowOn(Dispatchers.IO)
.collect { serverInfo ->
serverList.add(serverInfo)
// Only show server chooser when not connecting already
if (connectButton.isVisible) chooseServerButton.isVisible = true
}
}
}

private fun chooseServer() {
AlertDialog.Builder(activity).apply {
setTitle(R.string.available_servers_title)
setItems(serverList.map { "${it.name}\n${it.address}" }.toTypedArray()) { _, index ->
connect(serverList[index].address)
}
}.show()
}

private fun showConnectionError(error: String? = null) {
connectionErrorText.apply {
text = error ?: getText(R.string.connection_error_cannot_connect)
isVisible = true
}
}

private fun clearConnectionError() {
connectionErrorText.apply {
text = null
isVisible = false
}
}

private suspend fun checkServerUrlAndConnection(enteredUrl: String): String? {
Timber.i("checkServerUrlAndConnection $enteredUrl")

val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl)
Timber.i("Address candidates are $candidates")

// Find servers and classify them into groups.
// BAD servers are collected in case we need an error message,
// GOOD are kept if there's no GREAT one.
val badServers = mutableListOf<RecommendedServerInfo>()
val goodServers = mutableListOf<RecommendedServerInfo>()
val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodServers += recommendedServer
false
}
RecommendedServerInfoScore.OK,
RecommendedServerInfoScore.BAD,
-> {
badServers += recommendedServer
false
}
}
}

val server = greatServer ?: goodServers.firstOrNull()
if (server != null) {
val systemInfo = requireNotNull(server.systemInfo.getOrNull())
Timber.i("Found valid server at ${server.address} with rating ${server.score} and version ${systemInfo.version}")
return server.address
}

// No valid server found, log and show error message
val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" }
Timber.i("No valid servers found, invalid candidates were: $loggedServers")

val error = if (badServers.isNotEmpty()) {
val count = badServers.size
val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo.getOrNull() == null }

StringBuilder(resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply {
if (unreachableServers.isNotEmpty()) {
append("\n\n")
append(getString(R.string.connection_error_unable_to_reach_sever))
append(":\n")
append(unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" })
}
if (incompatibleServers.isNotEmpty()) {
append("\n\n")
append(getString(R.string.connection_error_unsupported_version_or_product))
append(":\n")
append(incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" })
}
}.toString()
} else null

showConnectionError(error)
return null
}
}
Loading