Updates to session restoration (#1905)
## Description This PR reworks some of the internal handling of changing users and auto sign in. 1. Most recently used server/user is migrated from `AppPreferences` to `MostRecentServerProvider` 2. Checking if a user profile is protected occurs at a lower level in `ServerRepository` so fewer services need to check for this 3. Now always defaults to the most recent server's user list in cases where the most recent user can't be switched to such as if the profile is protected, auto signin is disabled, or and error occurred The default implementation for `MostRecentServerProvider` uses Android's `SharedPreferences`. `SharedPreferences` was already was being used to track the most recent server for crash reporting (the crash reporting process cannot use the Hilt DI). This PR enhances this and adds a better API for saving/retrieving. This also removes the last bit of "user specific" data from `AppPreferences`. ### Related issues None specific, but there have been a few related to auto sign-in & intents that were recently fixed. This PR is a more detailed approach to those same problems. ### Testing Manually via the emulator. There are also unit tests for most (all?) of the different settings and state combinations. ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
a11cd9fb74
commit
f01c5d367c
19 changed files with 786 additions and 493 deletions
|
|
@ -9,6 +9,7 @@ import android.view.WindowManager
|
|||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -75,7 +76,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -84,7 +84,6 @@ import kotlinx.coroutines.sync.withLock
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -436,120 +435,27 @@ class MainActivityViewModel
|
|||
viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
try {
|
||||
val result = intent?.let { intentService.parseIntent(intent) }
|
||||
when (result) {
|
||||
is IntentResult.Error -> {
|
||||
val current = serverRepository.current.value
|
||||
val destination =
|
||||
if (current != null) {
|
||||
SetupDestination.UserList(current.server)
|
||||
} else {
|
||||
SetupDestination.ServerList
|
||||
}
|
||||
setupNavigationManager.navigateTo(destination)
|
||||
Timber.e("Error parsing intent: %s", result.message)
|
||||
showToast(context, result.message)
|
||||
return@withLock
|
||||
}
|
||||
handleAppUpgrade()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during app upgrade!")
|
||||
showToast(
|
||||
context,
|
||||
"Error during app upgrade: ${ex.localizedMessage}",
|
||||
)
|
||||
}
|
||||
|
||||
is IntentResult.Target -> {
|
||||
val current = serverRepository.current.value
|
||||
if (current != null) {
|
||||
Timber.i("Received valid intent, switching to AppContent")
|
||||
|
||||
if (result.addHomeToBackStack) {
|
||||
navigationManager.reloadHome()
|
||||
} else {
|
||||
navigationManager.backStack.clear()
|
||||
}
|
||||
navigationManager.backStack.addAll(result.destinations)
|
||||
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(current),
|
||||
)
|
||||
} else {
|
||||
// This should never happen, but just reset app state if it does
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
Timber.e("Error parsing intent, no user is active")
|
||||
showToast(context, "An error occurred parsing the intent")
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
IntentResult.NoOp,
|
||||
null,
|
||||
-> {
|
||||
// No-op, proceed below
|
||||
}
|
||||
try {
|
||||
val result = handleIntent(intent)
|
||||
if (result) {
|
||||
Timber.i("appStart was handled by intent")
|
||||
return@withLock
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error parsing intent")
|
||||
}
|
||||
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val profileProtected =
|
||||
serverRepository.current.value
|
||||
?.user
|
||||
?.isProtected == true
|
||||
if (prefs.signInAutomatically && !profileProtected) {
|
||||
val current =
|
||||
serverRepository.restoreSession(
|
||||
prefs.currentServerId?.toUUIDOrNull(),
|
||||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
)
|
||||
if (current != null) {
|
||||
if (current.user.isProtected) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
current.server,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Restored
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(
|
||||
current,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Did not restore
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
if (currentServer != null) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
currentServer,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
handleAppStart()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during appStart")
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
|
|
@ -574,6 +480,103 @@ class MainActivityViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an [Intent], navigating if needed
|
||||
*
|
||||
* @return true if the intent was handled, false if normal app start should proceed
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleIntent(intent: Intent?): Boolean {
|
||||
val result = intent?.let { intentService.parseIntent(intent) }
|
||||
when (result) {
|
||||
is IntentResult.Error -> {
|
||||
val current = serverRepository.current.value
|
||||
val destination =
|
||||
if (current != null) {
|
||||
SetupDestination.UserList(current.server)
|
||||
} else {
|
||||
SetupDestination.ServerList
|
||||
}
|
||||
setupNavigationManager.navigateTo(destination)
|
||||
Timber.e("Error parsing intent: %s", result.message)
|
||||
showToast(context, result.message)
|
||||
return true
|
||||
}
|
||||
|
||||
is IntentResult.Target -> {
|
||||
val current = serverRepository.current.value
|
||||
if (current != null) {
|
||||
Timber.i("Received valid intent, switching to AppContent")
|
||||
|
||||
if (result.addHomeToBackStack) {
|
||||
navigationManager.reloadHome()
|
||||
} else {
|
||||
navigationManager.backStack.clear()
|
||||
}
|
||||
navigationManager.backStack.addAll(result.destinations)
|
||||
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(current),
|
||||
)
|
||||
} else {
|
||||
// This should never happen, but just reset app state if it does
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
Timber.e("Error parsing intent, no user is active")
|
||||
showToast(context, "An error occurred parsing the intent")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
IntentResult.NoOp,
|
||||
null,
|
||||
-> {
|
||||
// Not handled
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAppUpgrade() {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle starting the app including auto sign in and navigating to the right starting page
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleAppStart() {
|
||||
val signInAutomatically = preferences.data.first().signInAutomatically
|
||||
val currentUser = serverRepository.current.value
|
||||
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
|
||||
// Hot reload
|
||||
Timber.v("Hot reload restoring state")
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(currentUser),
|
||||
)
|
||||
} else {
|
||||
val restoredSession =
|
||||
if (signInAutomatically) {
|
||||
Timber.v("Sign in automatically")
|
||||
serverRepository.restoreLastSession()
|
||||
} else {
|
||||
serverRepository.getMostRecentServer()
|
||||
}
|
||||
Timber.d("Restore last session result=%s", restoredSession)
|
||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Destination?.isPlayback: Boolean
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry
|
|||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
|
|
@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -75,8 +74,10 @@ class WholphinDreamService :
|
|||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
lifecycleScope.launchDefault {
|
||||
if (serverRepository.current.value == null) {
|
||||
val prefs = preferencesDataStore.data.first()
|
||||
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("Could not restore user session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer.None
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer.Server
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Represents the status of the most recently used server
|
||||
*
|
||||
* This can be read/written to shared preferences
|
||||
*/
|
||||
sealed interface MostRecentServer {
|
||||
/**
|
||||
* There is no recently used server
|
||||
*/
|
||||
data object None : MostRecentServer
|
||||
|
||||
/**
|
||||
* The most recently used server without a recent user
|
||||
*/
|
||||
data class Server(
|
||||
val serverId: UUID,
|
||||
val serverUrl: String,
|
||||
) : MostRecentServer
|
||||
|
||||
/**
|
||||
* Most recently used server and user
|
||||
*/
|
||||
data class ServerAndUser(
|
||||
val serverId: UUID,
|
||||
val serverUrl: String,
|
||||
val userId: UUID,
|
||||
val accessToken: String,
|
||||
) : MostRecentServer {
|
||||
override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(current: CurrentUser?): MostRecentServer =
|
||||
if (current != null && current.user.accessToken.isNotNullOrBlank()) {
|
||||
ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken)
|
||||
} else if (current != null) {
|
||||
Server(current.server.id, current.server.url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface MostRecentServerProvider {
|
||||
/**
|
||||
* Get the [MostRecentServer] from the shared preferences in [context]
|
||||
*/
|
||||
fun get(): MostRecentServer
|
||||
|
||||
/**
|
||||
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
|
||||
*/
|
||||
suspend fun save(current: CurrentUser)
|
||||
|
||||
/**
|
||||
* Clear the most recent server and user
|
||||
*/
|
||||
suspend fun clear()
|
||||
|
||||
/**
|
||||
* CLear the most recent user, leaving the server
|
||||
*/
|
||||
suspend fun clearUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MostRecentServerProvider] that stores in [SharedPreferences]
|
||||
*/
|
||||
class MostRecentServerSharedPreferences(
|
||||
context: Context,
|
||||
) : MostRecentServerProvider {
|
||||
private val prefs = getServerSharedPreferences(context)
|
||||
|
||||
/**
|
||||
* Get the [MostRecentServer] from the shared preferences in [context]
|
||||
*/
|
||||
override fun get(): MostRecentServer {
|
||||
val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
|
||||
val serverUrl = prefs.getString(SERVER_URL_KEY, null)
|
||||
val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull()
|
||||
val accessToken = prefs.getString(USER_TOKEN_KEY, null)
|
||||
return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) {
|
||||
ServerAndUser(serverId, serverUrl, userId, accessToken)
|
||||
} else if (serverId != null && serverUrl.isNotNullOrBlank()) {
|
||||
Server(serverId, serverUrl)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
|
||||
*/
|
||||
override suspend fun save(current: CurrentUser) {
|
||||
prefs.edit(true) {
|
||||
putString(SERVER_ID_KEY, current.server.id.toServerString())
|
||||
putString(SERVER_URL_KEY, current.server.url)
|
||||
putString(USER_ID_KEY, current.user.id.toServerString())
|
||||
putString(USER_TOKEN_KEY, current.user.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
prefs.edit(true) {
|
||||
remove(SERVER_ID_KEY)
|
||||
remove(SERVER_URL_KEY)
|
||||
remove(USER_ID_KEY)
|
||||
remove(USER_TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearUser() {
|
||||
prefs.edit(true) {
|
||||
remove(USER_ID_KEY)
|
||||
remove(USER_TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(
|
||||
"${context.packageName}_server",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SERVER_ID_KEY = "current.server.id"
|
||||
private const val SERVER_URL_KEY = "current.server.url"
|
||||
private const val USER_ID_KEY = "current.user.id"
|
||||
private const val USER_TOKEN_KEY = "current.user.accessToken"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of restoring a session
|
||||
*/
|
||||
sealed interface RestoredSession {
|
||||
val destination: SetupDestination
|
||||
|
||||
/**
|
||||
* Session could not be restored
|
||||
*/
|
||||
data object None : RestoredSession {
|
||||
override val destination: SetupDestination
|
||||
get() = SetupDestination.ServerList
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the server should be restored, so a user still need to be chosen
|
||||
*/
|
||||
data class ServerOnly(
|
||||
val server: JellyfinServer,
|
||||
) : RestoredSession {
|
||||
override val destination: SetupDestination
|
||||
get() = SetupDestination.UserList(server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Session was restored successfully
|
||||
*/
|
||||
data class Success(
|
||||
val currentUser: CurrentUser,
|
||||
) : RestoredSession {
|
||||
override val destination: SetupDestination
|
||||
get() = SetupDestination.AppContent(currentUser)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
|
@ -49,8 +44,8 @@ class ServerRepository
|
|||
val jellyfin: Jellyfin,
|
||||
val serverDao: JellyfinServerDao,
|
||||
val apiClient: ApiClient,
|
||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||
) {
|
||||
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||
val current: StateFlow<CurrentUser?> = _current
|
||||
|
|
@ -88,10 +83,11 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||
_current.value = null
|
||||
mostRecentServerProvider.clearUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user
|
||||
* Saves the server & user to the app database and updates the [ApiClient] to use this server & user
|
||||
*/
|
||||
suspend fun changeUser(
|
||||
server: JellyfinServer,
|
||||
|
|
@ -119,70 +115,101 @@ class ServerRepository
|
|||
)
|
||||
serverDao.addOrUpdateServer(updatedServer)
|
||||
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = updatedServer.id.toServerString()
|
||||
currentUserId = updatedUser.id.toServerString()
|
||||
}.build()
|
||||
}
|
||||
|
||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = ServerUserConfig(userDto)
|
||||
}
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
mostRecentServerProvider.save(currentUser)
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = ServerUserConfig(userDto)
|
||||
return@withContext currentUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a session for the given server & user such as when the app reopens
|
||||
* Try to restore the most recent session
|
||||
*
|
||||
* If user has a PIN, this returns false
|
||||
* Does not check if auto sign-in is enabled, but does check if the user profile is protected
|
||||
*/
|
||||
suspend fun restoreSession(
|
||||
suspend fun restoreLastSession(): RestoredSession {
|
||||
val recentServer = mostRecentServerProvider.get()
|
||||
Timber.d("restoreLastSession: recentServer=%s", recentServer)
|
||||
val result =
|
||||
when (recentServer) {
|
||||
MostRecentServer.None -> {
|
||||
RestoredSession.None
|
||||
}
|
||||
|
||||
is MostRecentServer.Server -> {
|
||||
val server = serverDao.getServer(recentServer.serverId)
|
||||
if (server != null) {
|
||||
RestoredSession.ServerOnly(server.server)
|
||||
} else {
|
||||
RestoredSession.None
|
||||
}
|
||||
}
|
||||
|
||||
is MostRecentServer.ServerAndUser -> {
|
||||
val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
|
||||
if (currentUser != null) {
|
||||
if (currentUser.user.isProtected) {
|
||||
RestoredSession.ServerOnly(currentUser.server)
|
||||
} else {
|
||||
RestoredSession.Success(currentUser)
|
||||
}
|
||||
} else {
|
||||
getMostRecentServerInternal(recentServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently used server, if any
|
||||
*/
|
||||
suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
|
||||
|
||||
suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
|
||||
val recentServer = recentServer ?: mostRecentServerProvider.get()
|
||||
val serverId =
|
||||
when (recentServer) {
|
||||
MostRecentServer.None -> null
|
||||
is MostRecentServer.Server -> recentServer.serverId
|
||||
is MostRecentServer.ServerAndUser -> recentServer.serverId
|
||||
}
|
||||
val server = serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
return if (server != null) {
|
||||
RestoredSession.ServerOnly(server)
|
||||
} else {
|
||||
RestoredSession.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to change to the given server & user. If the user's profile is protected, returns null
|
||||
*
|
||||
* @return the server/user or null if the session could not be restored
|
||||
*/
|
||||
suspend fun tryChangeUser(
|
||||
serverId: UUID?,
|
||||
userId: UUID?,
|
||||
): CurrentUser? {
|
||||
if (serverId == null || userId == null) {
|
||||
_current.value = null
|
||||
return null
|
||||
}
|
||||
val serverAndUsers =
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.getServer(serverId)
|
||||
}
|
||||
if (serverAndUsers != null) {
|
||||
val current = _current.value
|
||||
if (current != null && current.server.id == serverId && current.user.id == userId) {
|
||||
Timber.v("Restoring session for current user, so shortcut")
|
||||
apiClient.update(
|
||||
baseUrl = current.server.url,
|
||||
accessToken = current.user.accessToken,
|
||||
)
|
||||
return current
|
||||
): CurrentUser? =
|
||||
withContext(ioDispatcher) {
|
||||
return@withContext if (serverId == null || userId == null) {
|
||||
_current.value = null
|
||||
null
|
||||
} else {
|
||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||
if (user != null) {
|
||||
return changeUser(serverAndUsers.server, user)
|
||||
val serverAndUsers = serverDao.getServer(serverId)
|
||||
if (serverAndUsers != null) {
|
||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||
if (user != null && !user.isProtected) {
|
||||
changeUser(serverAndUsers.server, user)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
||||
withContext(ioDispatcher) {
|
||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
}
|
||||
|
||||
fun closeSession() {
|
||||
_current.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a successful [AuthenticationResult], switch to the user that just authenticated
|
||||
|
|
@ -244,13 +271,7 @@ class ServerRepository
|
|||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clearUser()
|
||||
apiClient.update(accessToken = null)
|
||||
}
|
||||
withContext(ioDispatcher) {
|
||||
|
|
@ -263,14 +284,7 @@ class ServerRepository
|
|||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clear()
|
||||
apiClient.update(baseUrl = null, accessToken = null)
|
||||
}
|
||||
withContext(ioDispatcher) {
|
||||
|
|
@ -279,14 +293,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun switchServerOrUser() {
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clear()
|
||||
}
|
||||
|
||||
suspend fun updateUserAuth(
|
||||
|
|
@ -335,17 +342,6 @@ class ServerRepository
|
|||
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(
|
||||
"${context.packageName}_server",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
const val SERVER_URL_KEY = "current.server"
|
||||
const val ACCESS_TOKEN_KEY = "current.accessToken"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import android.os.Build
|
|||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
|
|
@ -36,6 +38,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
|||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
|
|
@ -56,6 +59,7 @@ class AppUpgradeHandler
|
|||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
private val rememberedTabDao: RememberedTabDao,
|
||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||
) {
|
||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||
|
|
@ -442,5 +446,29 @@ class AppUpgradeHandler
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
|
||||
try {
|
||||
val prefs = appPreferences.data.firstOrNull()
|
||||
val serverId = prefs?.currentServerId?.toUUIDOrNull()
|
||||
val userId = prefs?.currentUserId?.toUUIDOrNull()
|
||||
if (serverId != null && userId != null) {
|
||||
val server = serverDao.getServer(serverId)
|
||||
val user = server?.users?.firstOrNull { it.id == userId }
|
||||
if (server != null && user != null) {
|
||||
Timber.i("Migrating most recent server")
|
||||
mostRecentServerProvider.save(CurrentUser(server.server, user))
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during migration for most recent server")
|
||||
}
|
||||
appPreferences.updateData {
|
||||
it.update {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
|
|
@ -188,9 +189,8 @@ class HomeSettingsService
|
|||
|
||||
/**
|
||||
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
|
||||
*
|
||||
* This is public only for testing
|
||||
*/
|
||||
@VisibleForTesting
|
||||
fun decode(element: JsonElement): HomePageSettings {
|
||||
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
|
||||
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
|
|
@ -114,6 +116,7 @@ class IntentService
|
|||
return IntentResult.Target(destinations, true)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal suspend fun prepare(intent: Intent): IntentResult? {
|
||||
val appPrefs = userPreferencesService.flow.first().appPreferences
|
||||
|
||||
|
|
@ -121,39 +124,33 @@ class IntentService
|
|||
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
|
||||
return if (userId != null && serverId != null) {
|
||||
Timber.v("Intent switches user")
|
||||
val user = serverRepository.serverDao.getUser(serverId, userId)
|
||||
if (user != null && !user.isProtected) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
?: IntentResult.Error("Error restoring user")
|
||||
val user = serverRepository.tryChangeUser(serverId, userId)
|
||||
if (user != null) {
|
||||
null
|
||||
} else {
|
||||
IntentResult.Error("Cannot switch to specified user")
|
||||
IntentResult.Error("Error restoring user")
|
||||
}
|
||||
} else {
|
||||
val profileProtected =
|
||||
serverRepository.current.value
|
||||
?.user
|
||||
?.isProtected == true
|
||||
if (appPrefs.signInAutomatically && !profileProtected) {
|
||||
Timber.v("No current user, so restoring last")
|
||||
val userId = appPrefs.currentUserId?.toUUIDOrNull()
|
||||
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
|
||||
} else if (appPrefs.signInAutomatically) {
|
||||
Timber.v("No current user, so restoring last")
|
||||
val result = serverRepository.restoreLastSession()
|
||||
Timber.v("Restored result=%s", result)
|
||||
when (result) {
|
||||
RestoredSession.None,
|
||||
is RestoredSession.ServerOnly,
|
||||
-> {
|
||||
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||
}
|
||||
|
||||
if (userId != null && serverId != null) {
|
||||
val current =
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
?: return IntentResult.Error("Error restoring user")
|
||||
if (current.user.isProtected) {
|
||||
is RestoredSession.Success -> {
|
||||
if (result.currentUser.user.isProtected) {
|
||||
IntentResult.Error("Could not auto sign-in, user is protected")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||
}
|
||||
} else {
|
||||
IntentResult.Error("Auto sign-in not enabled or user is protected")
|
||||
}
|
||||
} else {
|
||||
IntentResult.Error("Auto sign-in not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
|
@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
|||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -52,12 +54,8 @@ class LatestNextUpWorker
|
|||
try {
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
|
|||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
||||
|
||||
init {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -63,12 +64,8 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
|||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||
|
|
@ -188,6 +190,12 @@ object AppModule {
|
|||
fun seerrApi(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun mostRecentServerProvider(
|
||||
@ApplicationContext context: Context,
|
||||
): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
|
|||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
|
|
@ -70,12 +71,8 @@ class TvProviderWorker
|
|||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.media.MediaCodecList
|
|||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -198,7 +199,7 @@ class PlaybackViewModel
|
|||
|
||||
val currentUserDto = serverRepository.currentUserDtoFlow
|
||||
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
val initJob: Job
|
||||
|
||||
init {
|
||||
|
|
@ -1110,7 +1111,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
// Variables for tracking segment state
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
internal var segmentJob: Job? = null
|
||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||
private val outroShownSegments = mutableSetOf<UUID>()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
||||
import com.google.auto.service.AutoService
|
||||
|
|
@ -13,6 +13,7 @@ import org.acra.sender.ReportSender
|
|||
import org.acra.sender.ReportSenderException
|
||||
import org.acra.sender.ReportSenderFactory
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
|
|
@ -39,26 +40,10 @@ class CrashReportSender : ReportSender {
|
|||
errorContent: CrashReportData,
|
||||
) {
|
||||
Timber.v("Attempting to send crash report")
|
||||
val prefs = ServerRepository.getServerSharedPreferences(context)
|
||||
val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null)
|
||||
val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null)
|
||||
if (serverUrl != null && accessToken != null) {
|
||||
try {
|
||||
val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.build()
|
||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||
val api =
|
||||
createJellyfin {
|
||||
this.context = context
|
||||
clientInfo = AppModule.clientInfo(context)
|
||||
deviceInfo = DeviceModule.deviceInfo(context)
|
||||
apiClientFactory = okHttpFactory
|
||||
socketConnectionFactory = okHttpFactory
|
||||
minimumServerVersion = Jellyfin.minimumVersion
|
||||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||
|
||||
try {
|
||||
val recentServer = AppModule.mostRecentServerProvider(context).get()
|
||||
val api = recentServer.createApi(context)
|
||||
if (api != null) {
|
||||
val obj = JSONObject()
|
||||
for ((key, value) in errorContent.toMap()) {
|
||||
obj.put(key, value)
|
||||
|
|
@ -75,13 +60,32 @@ class CrashReportSender : ReportSender {
|
|||
|
||||
""".trimIndent() + jsonStr,
|
||||
).content.fileName
|
||||
Timber.i("Sent report to $serverUrl, filename=$filename")
|
||||
Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
throw ReportSenderException("Exception while sending crash report", ex)
|
||||
} else {
|
||||
throw ReportSenderException("Could not find valid server and/or credentials to use")
|
||||
}
|
||||
} else {
|
||||
throw ReportSenderException("Could not find valid server and/or credentials to use")
|
||||
} catch (ex: Exception) {
|
||||
throw ReportSenderException("Exception while sending crash report", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
||||
val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.build()
|
||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||
val api =
|
||||
createJellyfin {
|
||||
this.context = context
|
||||
clientInfo = AppModule.clientInfo(context)
|
||||
deviceInfo = DeviceModule.deviceInfo(context)
|
||||
apiClientFactory = okHttpFactory
|
||||
socketConnectionFactory = okHttpFactory
|
||||
minimumServerVersion = Jellyfin.minimumVersion
|
||||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||
api
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue