Skip to content

Commit

Permalink
Rewrite connection screen in Jetpack Compose
Browse files Browse the repository at this point in the history
  • Loading branch information
Maxr1998 committed Sep 21, 2022
1 parent 1f6e475 commit 30251b7
Show file tree
Hide file tree
Showing 18 changed files with 629 additions and 338 deletions.
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

0 comments on commit 30251b7

Please sign in to comment.