Merge pull request #730 from nielsvanvelzen/auth-ui

Screen based login UI and tons of fixes
This commit is contained in:
Bill Thornton
2021-03-03 15:52:25 -05:00
committed by GitHub
16 changed files with 545 additions and 193 deletions

View File

@@ -99,6 +99,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$androidxLifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
implementation("androidx.window:window:1.0.0-alpha02")
implementation("androidx.viewpager:viewpager:1.0.0")
// Dependency Injection
val koinVersion = "2.2.0"

View File

@@ -23,7 +23,7 @@ import java.util.*
class AuthenticationRepository(
private val application: JellyfinApplication,
private val jellyfin: Jellyfin,
private val apiClient: ApiClient,
private val api: ApiClient,
private val device: IDevice,
private val accountManagerHelper: AccountManagerHelper,
private val authenticationStore: AuthenticationStore,
@@ -40,7 +40,8 @@ class AuthenticationRepository(
serverId = authInfo?.server ?: server, name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
}
}
@@ -49,7 +50,7 @@ class AuthenticationRepository(
val current = authenticationStore.getServer(id)
if (current != null)
authenticationStore.putServer(id, current.copy(name = name, address = address))
authenticationStore.putServer(id, current.copy(name = name, address = address, lastUsed = Date().time))
else
authenticationStore.putServer(id, AuthenticationStoreServer(name, address))
}
@@ -61,9 +62,19 @@ class AuthenticationRepository(
* @return Whether the user information can be retrieved.
*/
private suspend fun setActiveSession(user: User, server: Server): Boolean {
apiClient.setDevice(AuthenticationDevice(device, user.name))
apiClient.SetAuthenticationInfo(user.accessToken, user.id.toString())
apiClient.EnableAutomaticNetworking(ServerInfo().apply {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
// Set user in apiclient
api.setDevice(AuthenticationDevice(device, user.name))
api.SetAuthenticationInfo(user.accessToken, user.id.toString())
api.EnableAutomaticNetworking(ServerInfo().apply {
id = server.id.toString()
name = server.name
address = server.address
@@ -74,7 +85,7 @@ class AuthenticationRepository(
// Suppressed because the old apiclient is unreliable
@Suppress("TooGenericExceptionCaught")
try {
val userDto = callApi<UserDto?> { callback -> apiClient.GetUserAsync(user.id.toString(), callback) }
val userDto = callApi<UserDto?> { callback -> api.GetUserAsync(user.id.toString(), callback) }
if (userDto != null) {
application.currentUser = userDto
return true
@@ -152,7 +163,15 @@ class AuthenticationRepository(
authenticationStore.putUser(server.id, userId, updatedUser)
accountManagerHelper.putAccount(AccountManagerAccount(userId, server.id, updatedUser.name, result.accessToken))
val user = PrivateUser(userId, server.id, updatedUser.name, result.accessToken, result.user.hasPassword, result.user.primaryImageTag)
val user = PrivateUser(
id = userId,
serverId = server.id,
name = updatedUser.name,
accessToken = result.accessToken,
requirePassword = result.user.hasPassword,
imageTag = result.user.primaryImageTag,
lastUsed = Date().time,
)
val authenticated = setActiveSession(user, server)
if (authenticated) emit(AuthenticatedState)
else emit(RequireSignInState)

View File

@@ -1,7 +1,6 @@
package org.jellyfin.androidtv.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import org.jellyfin.androidtv.auth.model.*
import org.jellyfin.androidtv.util.apiclient.callApi
@@ -12,15 +11,18 @@ import org.jellyfin.androidtv.util.toUUID
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.discovery.DiscoveryServerInfo
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.system.PublicSystemInfo
import timber.log.Timber
import java.util.*
interface ServerRepository {
fun getServers(discovery: Boolean = true, stored: Boolean = true): Flow<Set<Server>>
fun getServersWithUsers(discovery: Boolean = true, stored: Boolean = true): Flow<Map<Server, Set<User>>>
suspend fun getStoredServers(): List<Server>
fun getDiscoveryServers(): Flow<Server>
suspend fun migrateLegacyCredentials()
fun removeServer(serverId: UUID)
suspend fun gerServerUsers(server: Server): Set<User>
fun removeServer(serverId: UUID): Unit
fun addServer(address: String): Flow<ServerAdditionState>
}
@@ -31,95 +33,36 @@ class ServerRepositoryImpl(
private val authenticationStore: AuthenticationStore,
private val legacyAccountMigration: LegacyAccountMigration
) : ServerRepository {
@OptIn(ExperimentalCoroutinesApi::class)
private fun getDiscoveryServers(): Flow<Server> = flow {
emitAll(jellyfin.discovery.discover().map(DiscoveryServerInfo::toServer))
override suspend fun getStoredServers() = authenticationRepository.getServers()
override fun getDiscoveryServers() = flow {
val servers = jellyfin.discovery.discover().map(DiscoveryServerInfo::toServer)
emitAll(servers)
}.flowOn(Dispatchers.IO)
private fun getStoredServers(): Flow<Server> = flow {
authenticationRepository.getServers().forEach { server -> emit(server) }
}.flowOn(Dispatchers.IO)
private suspend fun getServerPublicUsers(server: Server): List<PublicUser> = jellyfin
.createApi(server.address, device = device)
.getPublicUsers()
?.toList()
.orEmpty()
.map(UserDto::toPublicUser)
private fun getPublicUsersForServer(server: Server): Flow<PublicUser> = flow {
jellyfin.createApi(server.address, device = device).getPublicUsers()?.forEach { userDto ->
emit(userDto.toPublicUser())
}
}
private fun getServerStoredUsers(server: Server): List<PrivateUser> = authenticationRepository
.getUsers(server.id)
.orEmpty()
private fun getStoredUsersForServer(server: Server): Flow<PrivateUser> = flow {
authenticationRepository.getUsers(server.id)?.forEach { user -> emit(user) }
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getServers(discovery: Boolean, stored: Boolean): Flow<Set<Server>> = flow {
// Migrate old servers and users to new store before reading them from the new store
legacyAccountMigration.migrate()
// Start by emitting an empty collection
val servers = mutableSetOf<Server>()
val flows = mutableListOf<Flow<Server>>()
emit(servers)
// Add discovered servers
if (discovery) flows += getDiscoveryServers().onEach { server ->
// Only add if not already found in storage
if (servers.none { it.id == server.id }) {
servers.add(server)
emit(servers)
}
}
// Add stored servers
if (stored) flows += getStoredServers().onEach { server ->
// Remove existing server with id
// only happens for servers added via discovery
servers.removeAll { it.id == server.id }
servers += server
emit(servers)
}
// Wait for all flows to complete
flows.forEach { it.collect() }
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun getUsers(server: Server): Set<User> {
override suspend fun gerServerUsers(server: Server): Set<User> {
val users = mutableSetOf<User>()
val flows = mutableListOf<Flow<User>>()
flows += getPublicUsersForServer(server).onEach { user ->
// Only add if not already found in storage
if (users.none { it.id == user.id }) {
users.add(user)
}
users.addAll(getServerStoredUsers(server))
getServerPublicUsers(server).forEach { user ->
if (users.none { it.id == user.id }) users.add(user)
}
flows += getStoredUsersForServer(server).onEach { user ->
// Remove existing server with id
// only happens for servers added via discovery
users.removeAll { it.id == user.id }
users += user
}
// Wait for all flows to complete
flows.forEach { it.collect() }
return users
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getServersWithUsers(discovery: Boolean, stored: Boolean): Flow<Map<Server, Set<User>>> = flow {
val userFlows = mutableMapOf<UUID, Set<User>>()
getServers(discovery, stored).collect { servers ->
emit(servers.map { server ->
if (server.id !in userFlows)
userFlows[server.id] = getUsers(server)
server to userFlows[server.id]!!
}.toMap())
}
}
override suspend fun migrateLegacyCredentials() = legacyAccountMigration.migrate()
override fun removeServer(serverId: UUID) {
authenticationStore.removeServer(serverId)

View File

@@ -33,7 +33,8 @@ data class PrivateUser(
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?
override val imageTag: String?,
val lastUsed: Long,
) : User()
/**
@@ -45,5 +46,5 @@ data class PublicUser(
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?
override val imageTag: String?,
) : User()

View File

@@ -20,7 +20,7 @@ import org.jellyfin.androidtv.util.toUUID
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import java.util.*
class AddServerFragment(
class AddServerAlertFragment(
private val onServerAdded: (serverId: UUID) -> Unit = {},
private val onCancelCallback: () -> Unit = {},
private val onClose: () -> Unit = {}
@@ -30,6 +30,10 @@ class AddServerFragment(
onCancelCallback = onCancelCallback,
onClose = onClose
) {
companion object {
const val ARG_SERVER_ADDRESS = "server_address"
}
private val loginViewModel: LoginViewModel by sharedViewModel()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@@ -78,6 +82,12 @@ class AddServerFragment(
}
}
arguments?.getString(ARG_SERVER_ADDRESS)?.let { serverAddress ->
address.setText(serverAddress)
address.isEnabled = false
confirm.callOnClick()
}
return view
}
}

View File

@@ -0,0 +1,109 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.flow.collect
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.databinding.FragmentAddServerScreenBinding
import org.jellyfin.androidtv.databinding.ItemDiscoveryServerBinding
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class AddServerScreenFragment : Fragment() {
private lateinit var binding: FragmentAddServerScreenBinding
private val loginViewModel: LoginViewModel by sharedViewModel()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = FragmentAddServerScreenBinding.inflate(inflater, container, false)
// Discovery
binding.discoveryServers.setHasFixedSize(true)
val discoveryServerAdapter = DiscoveryServerAdapter { server ->
requireActivity()
.supportFragmentManager
.beginTransaction()
.replace(
R.id.content_view,
AddServerAlertFragment(onClose = {
requireActivity().supportFragmentManager.popBackStack()
}).apply {
arguments = bundleOf(
AddServerAlertFragment.ARG_SERVER_ADDRESS to server.address
)
}
)
.addToBackStack(null)
.commit()
}
binding.discoveryServers.adapter = discoveryServerAdapter
lifecycleScope.launchWhenCreated {
binding.discoveryProgressIndicator.visibility = View.VISIBLE
binding.discoveryServers.isFocusable = false
loginViewModel.discoveredServers.collect { server ->
discoveryServerAdapter.addServer(server)
binding.discoveryServers.isFocusable = true
}
binding.discoveryProgressIndicator.visibility = View.GONE
binding.discoveryNoneFound.visibility = if (discoveryServerAdapter.servers.isEmpty()) View.VISIBLE else View.GONE
}
// Manual
binding.enterServerAddress.setOnClickListener { (requireActivity() as StartupActivity).addServer() }
// App info
binding.appVersion.text = "jellyfin-androidtv ${BuildConfig.VERSION_NAME} ${BuildConfig.BUILD_TYPE}"
return binding.root
}
class DiscoveryServerAdapter(
var serverClickListener: (server: Server) -> Unit = {}
) : RecyclerView.Adapter<DiscoveryServerAdapter.ViewHolder>() {
private var _servers = mutableListOf<Server>()
val servers: List<Server> = _servers
fun addServer(server: Server) {
_servers.add(server)
notifyItemInserted(_servers.size - 1)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemDiscoveryServerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) = with(holder.binding) {
val server = servers[position]
// Set data
serverName.text = server.name
serverAddress.text = server.address
// FIXME Show server version. It is not exposed with the current apiclient in the DiscoveryServerInfo class
serverVersion.visibility = View.GONE
// Set actions
root.setOnClickListener {
serverClickListener.invoke(server)
}
}
override fun getItemCount(): Int = servers.size
inner class ViewHolder(
val binding: ItemDiscoveryServerBinding
) : RecyclerView.ViewHolder(binding.root) {
val root = binding.root
}
}
}

View File

@@ -1,27 +1,49 @@
package org.jellyfin.androidtv.ui.startup
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.AuthenticationRepository
import org.jellyfin.androidtv.auth.ServerRepository
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.LoginState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerAdditionState
import org.jellyfin.androidtv.auth.model.User
import java.util.*
class LoginViewModel(
private val serverRepository: ServerRepository,
private val authenticationRepository: AuthenticationRepository,
) : ViewModel() {
// All available servers and users
private val _servers = serverRepository.getServersWithUsers(
discovery = true,
stored = true
).asLiveData()
val servers: LiveData<Map<Server, Set<User>>> get() = _servers
val discoveredServers: Flow<Server>
get() = serverRepository.getDiscoveryServers()
fun addServer(address: String): LiveData<ServerAdditionState> = serverRepository.addServer(address).asLiveData()
private val _storedServers = MutableLiveData<List<Server>>()
val storedServers: LiveData<List<Server>>
get() = _storedServers
init {
// Initial values
viewModelScope.launch {
_storedServers.postValue(serverRepository.getStoredServers())
}
}
suspend fun getServer(id: UUID) = serverRepository.getStoredServers()
.find { it.id == id }
suspend fun getUsers(server: Server) = serverRepository.gerServerUsers(server)
fun addServer(address: String) = liveData {
serverRepository.addServer(address).onEach {
// Reload stored servers when new server is added
if (it is ConnectedState) _storedServers.postValue(serverRepository.getStoredServers())
emit(it)
}.collect()
}
fun authenticate(user: User, server: Server): LiveData<LoginState> = authenticationRepository.authenticateUser(user, server).asLiveData()

View File

@@ -0,0 +1,75 @@
package org.jellyfin.androidtv.ui.startup
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.databinding.FragmentServerListBinding
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class OverviewFragment : Fragment() {
private val loginViewModel: LoginViewModel by sharedViewModel()
private lateinit var binding: FragmentServerListBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = FragmentServerListBinding.inflate(inflater, container, false)
// Create adapter for screens
val serverAdapter = ServerAdapter(requireContext(), childFragmentManager)
binding.serverView.adapter = serverAdapter
// Show server list after loading so the "add server" fragment doesn't pop up
binding.serverView.visibility = View.GONE
loginViewModel.storedServers.observe(viewLifecycleOwner) { servers ->
binding.serverView.visibility = View.VISIBLE
serverAdapter.servers = servers
}
return binding.root
}
private class ServerAdapter(
private val context: Context,
fragmentManager: FragmentManager,
) : FragmentStatePagerAdapter(
fragmentManager,
BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT,
) {
private val comparator = compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name }
private var _servers = emptyList<Server>()
var servers
set(value) {
_servers = value.sortedWith(comparator)
notifyDataSetChanged()
}
get() = _servers
override fun getCount() = servers.size + 1
override fun getItem(position: Int) = when {
// Last page is always used to add servers
position == servers.size -> AddServerScreenFragment()
else -> ServerFragment().apply {
val server = servers[position]
arguments = bundleOf(
ServerFragment.ARG_SERVER_ID to server.id.toString(),
)
}
}
override fun getPageTitle(position: Int) = when {
// Last page is always used to add servers
position == servers.size -> context.getString(R.string.connect_title)
else -> servers.getOrNull(position)?.name
}
}
}

View File

@@ -1,38 +1,32 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.leanback.app.RowsSupportFragment
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.OnItemViewClickedListener
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
import org.jellyfin.androidtv.auth.model.RequireSignInState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.model.*
import org.jellyfin.androidtv.ui.GridButton
import org.jellyfin.androidtv.ui.presentation.CustomListRowPresenter
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import timber.log.Timber
import java.util.*
class ListServerFragment : RowsSupportFragment() {
private companion object {
private const val ADD_USER = 1
private const val SELECT_USER = 2
class ServerFragment : RowsSupportFragment() {
companion object {
const val ARG_SERVER_ID = "server_id"
}
private val loginViewModel: LoginViewModel by sharedViewModel()
private val rowAdapter = MutableObjectAdapter<ListRow>(CustomListRowPresenter())
private val userComparator = compareByDescending<User> { if (it is PrivateUser) it.lastUsed else 0L }.thenBy { it.name }
private val itemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
if (item is UserGridButton) {
@@ -43,7 +37,7 @@ class ListServerFragment : RowsSupportFragment() {
}
RequireSignInState -> {
// Open login fragment
navigate(UserLoginFragment(
navigate(UserLoginAlertFragment(
server = item.server,
user = item.user,
))
@@ -59,7 +53,7 @@ class ListServerFragment : RowsSupportFragment() {
}
} else if (item is AddUserGridButton) {
// Open login fragment
navigate(UserLoginFragment(
navigate(UserLoginAlertFragment(
server = item.server
))
}
@@ -70,62 +64,65 @@ class ListServerFragment : RowsSupportFragment() {
adapter = rowAdapter
onItemViewClickedListener = itemViewClickedListener
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val serverId = UUID.fromString(arguments?.getString(ARG_SERVER_ID))
lifecycleScope.launch {
val server = loginViewModel.getServer(serverId) ?: return@launch
val users = loginViewModel.getUsers(server).sortedWith(userComparator)
loginViewModel.servers.observe(viewLifecycleOwner) { servers ->
buildRows(servers)
// Fragment may be unloaded at this point, verify by checking for context
if (context != null) buildRow(server, users)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return super.onCreateView(inflater, container, savedInstanceState)?.apply {
updatePadding(top = 20)
private fun buildRow(server: Server, users: List<User>) {
Timber.d("Creating server row %s", server.name)
val userListAdapter = MutableObjectAdapter<GridButton>(GridButtonPresenter())
users.forEachIndexed { index, user ->
userListAdapter.add(UserGridButton(
server = server,
user = user,
id = index + 1,
text = user.name,
imageId = R.drawable.tile_port_person,
imageUrl = loginViewModel.getUserImage(server, user),
))
}
userListAdapter.add(AddUserGridButton(
server = server,
id = 0,
text = requireContext().getString(R.string.lbl_manual_login),
imageId = R.drawable.tile_edit,
))
val row = ListRow(
HeaderItem(server.name.ifBlank { server.address }),
userListAdapter,
)
rowAdapter.add(row)
}
private fun buildRows(servers: Map<Server, Set<User>>) {
servers.forEach { (server, users) ->
// Convert the UUID of the server to a long to get a unique id
// to make sure a server can't be added multiple times
val uniqueRowId = server.id.mostSignificantBits and Long.MAX_VALUE
val exists = rowAdapter.any { it.id == uniqueRowId }
// Already added, don't add it again
if (exists) return@forEach
Timber.d("Creating server row %s", server.name)
val userListAdapter = MutableObjectAdapter<GridButton>(GridButtonPresenter())
users.forEach { user ->
userListAdapter.add(UserGridButton(server, user, SELECT_USER, user.name, R.drawable.tile_port_person, loginViewModel.getUserImage(server, user)))
}
userListAdapter.add(AddUserGridButton(server, ADD_USER, requireContext().getString(R.string.lbl_manual_login), R.drawable.tile_edit))
val row = ListRow(
uniqueRowId,
HeaderItem(if (server.name.isNotBlank()) server.name else server.address),
userListAdapter
)
rowAdapter.add(row)
}
override fun onResume() {
super.onResume()
// Ensure the server rows get focus
// FIXME Ideally not done in current fragment as this changes the focus when the screen changes
requireView().requestFocus()
}
private fun navigate(fragment: Fragment) {
parentFragmentManager.beginTransaction()
requireActivity()
.supportFragmentManager
.beginTransaction()
.replace(R.id.content_view, fragment)
.addToBackStack(this::class.simpleName)
.addToBackStack(null)
.commit()
}
private class AddUserGridButton(val server: Server, id: Int, text: String, @DrawableRes imageId: Int) : GridButton(id, text, imageId)
private class UserGridButton(val server: Server, val user: User, id: Int, text: String, @DrawableRes imageId: Int, imageUrl: String?) : GridButton(id, text, imageId, imageUrl)
}

View File

@@ -8,8 +8,11 @@ import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.auth.ServerRepository
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
@@ -31,6 +34,7 @@ class StartupActivity : FragmentActivity() {
private var application: TvApp? = null
private val apiClient: ApiClient by inject()
private val serverRepository: ServerRepository by inject()
private var isLoaded = false
override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,7 +47,12 @@ class StartupActivity : FragmentActivity() {
}
application = applicationContext as TvApp
//Ensure basic permissions
// Migrate old credentials
lifecycleScope.launch {
serverRepository.migrateLegacyCredentials()
}
// Ensure basic permissions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED
|| ContextCompat.checkSelfPermission(this, Manifest.permission.INTERNET) != PackageManager.PERMISSION_GRANTED)) {
Timber.i("Requesting network permissions")
@@ -116,8 +125,7 @@ class StartupActivity : FragmentActivity() {
fun addServer() {
supportFragmentManager.beginTransaction()
.addToBackStack(null)
.replace(R.id.content_view, AddServerFragment(
onServerAdded = { id -> },
.replace(R.id.content_view, AddServerAlertFragment(
onClose = { supportFragmentManager.popBackStack() }
))
.commit()
@@ -126,7 +134,7 @@ class StartupActivity : FragmentActivity() {
private fun showServerList() {
supportFragmentManager.beginTransaction()
.replace(R.id.content_view, StartupToolbarFragment())
.add(R.id.content_view, ListServerFragment())
.add(R.id.content_view, OverviewFragment())
.commit()
}
}

View File

@@ -17,7 +17,7 @@ import org.jellyfin.androidtv.ui.shared.AlertFragment
import org.jellyfin.androidtv.ui.shared.KeyboardFocusChangeListener
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class UserLoginFragment(
class UserLoginAlertFragment(
private val server: Server,
private val user: User? = null,
private val onClose: () -> Unit = {}

View File

@@ -1,30 +0,0 @@
package org.jellyfin.androidtv.util
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* Collect all items emitted from flow of type T to a List<T> and return it as a LiveData instance.
* The LiveData is updated for each item emitted from the flow.
*
* To remove duplicate entries use the [distinctUntilChanged] or [distinctUntilChangedBy] functions.
*/
fun <T> Flow<T>.asLiveDataCollection(
context: CoroutineContext = EmptyCoroutineContext
): LiveData<List<T>> {
val list = mutableListOf<T>()
val liveData = MediatorLiveData<List<T>>()
liveData.addSource(asLiveData(context)) {
list.add(it)
liveData.value = list
}
return liveData
}

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingStart="@dimen/activity_horizontal_margin"
android:paddingEnd="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<TextView
android:id="@+id/discovery_title"
style="@style/Widget.Jellyfin.Row.Header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
android:text="@string/discovered_servers_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/discovery_progress_indicator"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@id/discovery_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/discovery_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/discovery_servers"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/discovery_title"
tools:itemCount="3"
tools:listitem="@layout/item_discovery_server" />
<TextView
android:id="@+id/discovery_none_found"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:gravity="center"
android:text="@string/discovered_servers_empty"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/discovery_servers"
app:layout_constraintStart_toStartOf="@id/discovery_servers"
app:layout_constraintTop_toTopOf="@id/discovery_servers" />
</androidx.constraintlayout.widget.ConstraintLayout>
<Space
android:layout_width="16dp"
android:layout_height="match_parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<TextView
android:id="@+id/connect_manually_title"
style="@style/Widget.Jellyfin.Row.Header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start"
android:text="@string/connect_manually_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/enter_server_address"
style="@style/Button.Default"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/connect_manually_by_address"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connect_manually_title" />
<TextView
android:id="@+id/help_title"
style="@style/Widget.Jellyfin.Row.Header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:gravity="start"
android:text="@string/login_help_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/enter_server_address" />
<TextView
android:id="@+id/help_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/login_help_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_title" />
<TextView
android:id="@+id/app_version"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/lb_grey"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_description"
tools:text="App version" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.viewpager.widget.ViewPager xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/server_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager.widget.PagerTitleStrip
android:id="@+id/dddd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:paddingStart="@dimen/activity_horizontal_margin"
android:paddingEnd="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin" />
</androidx.viewpager.widget.ViewPager>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Button.Default"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:padding="8dp">
<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_cloud"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="#73FFFFFF" />
<TextView
android:id="@+id/server_name"
style="@style/TextAppearance.AppCompat.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="ServerName" />
<TextView
android:id="@+id/server_address"
style="@style/TextAppearance.AppCompat.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/server_name"
app:layout_constraintTop_toBottomOf="@id/server_name"
tools:text="http://192.168.1.1:8096" />
<TextView
android:id="@+id/server_version"
style="@style/TextAppearance.AppCompat.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/server_name"
tools:text="Jellyfin 10.7.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -454,4 +454,11 @@
<string name="pref_developer_link">Developer options</string>
<string name="pref_developer_link_description">Advanced and experimental features</string>
<string name="lbl_hidden">Hidden</string>
<string name="discovered_servers_title">Discovered servers</string>
<string name="discovered_servers_empty">No servers discoverd on local network.</string>
<string name="connect_manually_title">Connect manually</string>
<string name="connect_manually_by_address">Enter server address</string>
<string name="login_help_title">Need help?</string>
<string name="login_help_description">Jellyfin requires a server to connect with. Visit our documentation at docs.jellyfin.org to get started with Jellyfin.</string>
<string name="connect_title">Connect</string>
</resources>