Rewrite login flow

Rewrite auth to use mvvm and koin

Add add server button

Add catch for null ServerInfo in legacy saved credentials

Fix resource leaking in JsonFileSource

Enable improved logging for resource leaking

Add new login flow

Use KeyboardFocusChangeListener for handling keyboard hiding

Fix codacy issues

Add toolbar to server list screen

Fix padding on server list

Remove unused import

Fix back navigation in MainActivity

Fix crash due to missing ServerInfo

Convert StartupActivity to Kotlin

Move add server button to toolbar

Fix window not resizing in user login fragment

Add hints for username and password fields

Fix codacy issues

Remove old comment

Remove old login code

Test Codacy ignore

Use local serializer for JsonFileSource

Fix codacy issues

Move gson instance to koin module

Add prefill for username in login fragment

Add equals overrides for User and Server models

Update blank server name check

Add UserConfiguration and UserPolicy models

Move resource debug logging to JellyfinApplication

Suppress warnings

Fix formatting

Add loading state to server list

Prevent submission of empty login forms

Add basic error handling for invalid login

Suppress exception handling warnings

Remove hardcoded strings
This commit is contained in:
Bill Thornton
2020-09-17 12:41:18 -04:00
parent e3459cb35c
commit 33abd60eb0
49 changed files with 1441 additions and 842 deletions

View File

@@ -84,6 +84,10 @@ dependencies {
// Dependency Injection
val koinVersion = "2.1.6"
implementation("org.koin:koin-android-viewmodel:$koinVersion")
// Lifecycle extensions
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.2.0")
implementation("com.google.code.gson:gson:2.8.6")
// Media players
implementation("com.amazon.android:exoplayer:2.11.3")

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Needed for ChannelManager on older devices (< API 23) -->
@@ -41,7 +42,8 @@
android:label="@string/app_name"
android:logo="@drawable/app_logo"
android:noHistory="true"
android:screenOrientation="landscape">
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -52,10 +54,6 @@
<activity-alias
android:name=".startup.StartupActivity"
android:targetActivity=".ui.startup.StartupActivity" />
<activity
android:name=".ui.startup.SelectUserActivity"
android:label="@string/title_activity_select_user"
android:screenOrientation="landscape" />
<activity
android:name=".ui.playback.PlaybackOverlayActivity"
android:label="PlaybackOverlayActivity"
@@ -69,12 +67,8 @@
<activity
android:name=".ui.browsing.UserViewActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.startup.SelectServerActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.CollectionActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.ByLetterActivity"
@@ -119,10 +113,6 @@
android:name=".ui.playback.AudioNowPlayingActivity"
android:screenOrientation="landscape" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<activity
android:name=".ui.startup.DpadPwActivity"
android:label="@string/title_activity_dpad_pw"
android:screenOrientation="landscape" />
<receiver android:name=".util.RemoteControlReceiver">
<intent-filter>

View File

@@ -54,6 +54,19 @@ class JellyfinApplication : TvApp() {
// Initialize the logging library
Timber.plant(DebugTree())
Timber.i("Application object created")
// Enable improved logging for leaking resources
// https://wh0.github.io/2020/08/12/closeguard.html
if (BuildConfig.DEBUG) {
try {
Class.forName("dalvik.system.CloseGuard")
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
.invoke(null, true)
} catch (e: ReflectiveOperationException) {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException(e)
}
}
}
override fun attachBaseContext(base: Context?) {

View File

@@ -27,10 +27,8 @@ import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.inject;
public class TvApp extends Application {
public static final String DISPLAY_PREFS_APP_NAME = "ATV";
public static final String CREDENTIALS_PATH = "org.jellyfin.androidtv.login.json";
public static final int LIVE_TV_GUIDE_OPTION_ID = 1000;
public static final int LIVE_TV_RECORDINGS_OPTION_ID = 2000;
@@ -67,6 +65,7 @@ public class TvApp extends Application {
return app;
}
@Deprecated
public UserDto getCurrentUser() {
if (currentUser == null) {
Timber.e("Called getCurrentUser() but value was null.");
@@ -74,6 +73,7 @@ public class TvApp extends Application {
return currentUser;
}
@Deprecated
public void setCurrentUser(UserDto currentUser) {
this.currentUser = currentUser;
TvManager.clearCache();

View File

@@ -0,0 +1,17 @@
package org.jellyfin.androidtv.data.model
data class LoadingState private constructor(val status: Status, val message: String? = null){
companion object {
val PENDING = LoadingState(Status.PENDING)
val LOADING = LoadingState(Status.LOADING)
val SUCCESS = LoadingState(Status.SUCCESS)
fun error(msg: String? = null) = LoadingState(Status.ERROR, msg)
}
enum class Status {
PENDING,
LOADING,
SUCCESS,
ERROR
}
}

View File

@@ -1,16 +0,0 @@
package org.jellyfin.androidtv.data.model;
import org.jellyfin.apiclient.model.apiclient.ServerInfo;
import org.jellyfin.apiclient.model.dto.UserDto;
public class LogonCredentials {
private UserDto userDto;
public LogonCredentials(ServerInfo server, UserDto user) {
userDto = user;
}
public UserDto getUserDto() {
return userDto;
}
}

View File

@@ -0,0 +1,28 @@
package org.jellyfin.androidtv.data.model
import com.google.gson.annotations.SerializedName
import org.jellyfin.androidtv.util.apiclient.toServer
import org.jellyfin.androidtv.util.apiclient.toUser
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
@Deprecated("Used for reading legacy credentials file")
class LogonCredentials {
@SerializedName("serverInfo")
var server: Server? = null
private set
@SerializedName("userDto")
var user: User? = null
private set
constructor(server: Server?, user: User?) {
this.server = server
this.user = user
}
constructor(serverInfo: ServerInfo?, userDto: UserDto?) {
server = serverInfo?.toServer()
user = userDto?.toUser()
}
}

View File

@@ -0,0 +1,21 @@
package org.jellyfin.androidtv.data.model
import java.util.*
/**
* Server model to use locally in place of ServerInfo model in ApiClient.
*/
data class Server(
var id: String,
var name: String,
var address: String,
@Deprecated("This is only used for legacy stored credentials")
val userId: String? = null,
@Deprecated("This is only used for legacy stored credentials")
val accessToken: String? = null,
val dateLastAccessed: Date = Date(0)
) {
override fun equals(other: Any?) = (other is Server) && id == other.id
override fun hashCode() = id.hashCode()
}

View File

@@ -0,0 +1,34 @@
package org.jellyfin.androidtv.data.model
class ServerList(
var currentServerUsers: Map<Server, List<User>> = emptyMap(),
var currentServerUsersState: LoadingState = LoadingState.PENDING,
var discoveredServersUsers: Map<Server, List<User>> = emptyMap(),
var discoveredServersUsersState: LoadingState = LoadingState.PENDING,
var savedServersUsers: Map<Server, List<User>> = emptyMap(),
var savedServersUsersState: LoadingState = LoadingState.PENDING
) {
val state: LoadingState
get() {
return if (currentServerUsersState.status == LoadingState.error().status) {
currentServerUsersState
} else if (discoveredServersUsersState.status == LoadingState.error().status) {
discoveredServersUsersState
} else if (savedServersUsersState.status == LoadingState.error().status) {
savedServersUsersState
} else if (currentServerUsersState == LoadingState.LOADING ||
discoveredServersUsersState == LoadingState.LOADING ||
savedServersUsersState == LoadingState.LOADING) {
LoadingState.LOADING
} else if (currentServerUsersState == LoadingState.SUCCESS ||
discoveredServersUsersState == LoadingState.SUCCESS ||
savedServersUsersState == LoadingState.SUCCESS) {
LoadingState.SUCCESS
} else {
LoadingState.PENDING
}
}
val allServersUsers: Map<Server, List<User>>
get() = currentServerUsers + savedServersUsers + discoveredServersUsers
}

View File

@@ -0,0 +1,27 @@
package org.jellyfin.androidtv.data.model
/**
* User model to use locally in place of UserDto model in ApiClient.
*/
data class User(
val id: String,
val name: String,
val accessToken: String = "",
val serverId: String,
val primaryImageTag: String = "",
val hasPassword: Boolean = true,
val hasConfiguredPassword: Boolean = true,
val hasConfiguredEasyPassword: Boolean = false,
val configuration: UserConfiguration = UserConfiguration(),
val policy: UserPolicy = UserPolicy()
) {
override fun equals(other: Any?) = (other is User)
&& serverId == other.serverId
&& id == other.id
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + serverId.hashCode()
return result
}
}

View File

@@ -0,0 +1,21 @@
package org.jellyfin.androidtv.data.model
/**
* UserConfiguration model to use locally in place of UserConfiguration model in ApiClient.
*/
data class UserConfiguration(
val latestItemsExcludes: Array<String> = emptyArray()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UserConfiguration
if (!latestItemsExcludes.contentEquals(other.latestItemsExcludes)) return false
return true
}
override fun hashCode() = latestItemsExcludes.contentHashCode()
}

View File

@@ -0,0 +1,9 @@
package org.jellyfin.androidtv.data.model
/**
* UserPolicy model to use locally in place of UserPolicy model in ApiClient.
*/
data class UserPolicy(
val enableLiveTvAccess: Boolean = true,
val enableLiveTvManagement: Boolean = true
)

View File

@@ -0,0 +1,64 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.source.CredentialsFileSource
import org.jellyfin.androidtv.util.apiclient.callApi
import org.jellyfin.androidtv.util.apiclient.toServer
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.apiclient.model.system.PublicSystemInfo
import timber.log.Timber
interface ServerRepository {
suspend fun getServers(): List<Server>
suspend fun discoverServers(): List<Server>
suspend fun connect(address: String): Server
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val device: IDevice,
private val credentialsFileSource: CredentialsFileSource
) : ServerRepository {
override suspend fun getServers(): List<Server> {
val legacyCredentials = credentialsFileSource.read()
if (legacyCredentials?.server != null) {
// Augment saved ServerInfo with PublicSystemInfo
val api = jellyfin.createApi(serverAddress = legacyCredentials.server!!.address, device = device)
val systemInfo: PublicSystemInfo = callApi { callback ->
api.GetPublicSystemInfoAsync(callback)
}
legacyCredentials.server!!.apply {
name = systemInfo.serverName
id = systemInfo.id
}
return listOf(legacyCredentials.server!!)
}
// TODO: Add new method of saving credentials
return emptyList()
}
override suspend fun discoverServers() = withContext(Dispatchers.IO) {
jellyfin.discovery.discover().toList().map { it.toServer() }
}
override suspend fun connect(address: String): Server {
Timber.d("Creating api for server address %s", address)
val api = jellyfin.createApi(serverAddress = address, device = device)
val systemInfo: PublicSystemInfo = callApi { callback ->
api.GetPublicSystemInfoAsync(callback)
}
return systemInfo.toServer().apply {
// Use the entered address since SystemInfo can be wrong
this.address = address
}
}
}

View File

@@ -0,0 +1,49 @@
package org.jellyfin.androidtv.data.repository
import org.jellyfin.androidtv.data.model.LogonCredentials
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.data.source.CredentialsFileSource
import org.jellyfin.androidtv.util.apiclient.callApi
import org.jellyfin.androidtv.util.apiclient.getPublicUsers
import org.jellyfin.androidtv.util.apiclient.toServerInfo
import org.jellyfin.androidtv.util.apiclient.toUser
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.apiclient.model.users.AuthenticationResult
interface UserRepository {
suspend fun getUsers(server: Server): List<User>
suspend fun login(server: Server, username: String, password: String): AuthenticationResult
}
class UserRepositoryImpl(
private val jellyfin: Jellyfin,
private val apiClient: ApiClient,
private val device: IDevice,
private val credentialsFileSource: CredentialsFileSource
) : UserRepository {
override suspend fun getUsers(server: Server): List<User> {
val api = jellyfin.createApi(serverAddress = server.address, device = device)
return api.getPublicUsers()?.toList()?.map { it.toUser() } ?: emptyList()
}
override suspend fun login(server: Server, username: String, password: String): AuthenticationResult {
// TODO: Are both of these updates needed?
// Update the server address the apiClient uses
apiClient.EnableAutomaticNetworking(server.toServerInfo())
apiClient.ChangeServerLocation(server.address)
// Authenticate using given credentials
val result: AuthenticationResult = callApi { callback ->
apiClient.AuthenticateUserAsync(username, password, callback)
}
// Save the current login information
credentialsFileSource.write(LogonCredentials(server, result.user.toUser()))
return result
}
}

View File

@@ -0,0 +1,18 @@
package org.jellyfin.androidtv.data.source
import android.content.Context
import com.google.gson.Gson
import org.jellyfin.androidtv.data.model.LogonCredentials
const val CREDENTIALS_PATH = "org.jellyfin.androidtv.login.json"
@Deprecated("Method of retrieving credentials stored in a legacy format")
class CredentialsFileSource(
private val context: Context,
private val serializer: Gson
) : JsonFileSource<LogonCredentials>(
context,
CREDENTIALS_PATH,
serializer,
LogonCredentials::class.java
)

View File

@@ -0,0 +1,33 @@
package org.jellyfin.androidtv.data.source
import android.content.Context
import com.google.gson.Gson
import timber.log.Timber
import java.io.FileNotFoundException
open class JsonFileSource<T>(
private val context: Context,
private val fileName: String,
private val serializer: Gson,
private val clazz: Class<T>
) {
fun read(): T? {
try {
context.openFileInput(fileName).use {
val data = it.readBytes()
.toString(Charsets.UTF_8)
return serializer.fromJson(data, clazz)
}
} catch (ex: FileNotFoundException) {
Timber.e(ex, "File %s not found", fileName)
}
return null
}
fun write(t: T) {
context.openFileOutput(fileName, Context.MODE_PRIVATE).use {
it.write(serializer.toJson(t).toByteArray())
}
}
}

View File

@@ -1,14 +1,27 @@
package org.jellyfin.androidtv.di
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.data.eventhandling.TvApiEventListener
import org.jellyfin.androidtv.data.repository.ServerRepository
import org.jellyfin.androidtv.data.repository.ServerRepositoryImpl
import org.jellyfin.androidtv.data.repository.UserRepository
import org.jellyfin.androidtv.data.repository.UserRepositoryImpl
import org.jellyfin.androidtv.data.source.CredentialsFileSource
import org.jellyfin.androidtv.ui.startup.LoginViewModel
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.interaction.AndroidDevice
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.serialization.GsonJsonSerializer
import org.jellyfin.apiclient.serialization.ServerInfoDeserializer
import org.koin.android.ext.koin.androidApplication
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val appModule = module {
@@ -22,10 +35,38 @@ val appModule = module {
}
}
single<IDevice> {
AndroidDevice.fromContext(androidApplication())
}
single {
get<Jellyfin>().createApi(
device = AndroidDevice.fromContext(androidApplication()),
device = get(),
eventListener = TvApiEventListener()
)
}
single(named("CredentialsFileSerializer")) {
GsonBuilder()
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss")
.registerTypeAdapter(ServerInfo::class.java, ServerInfoDeserializer())
.setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
.create()
}
single {
CredentialsFileSource(androidApplication(), get(named("CredentialsFileSerializer")))
}
single<ServerRepository> {
ServerRepositoryImpl(get(), get(), get())
}
single<UserRepository> {
UserRepositoryImpl(get(), get(), get(), get())
}
viewModel {
LoginViewModel(get(), get())
}
}

View File

@@ -23,7 +23,6 @@ import org.jellyfin.androidtv.integration.ChannelManager;
import org.jellyfin.androidtv.preference.SystemPreferences;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
import org.jellyfin.androidtv.ui.shared.IMessageListener;
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef;
import org.jellyfin.androidtv.ui.browsing.IRowLoader;
import org.jellyfin.androidtv.ui.browsing.StdBrowseFragment;
@@ -33,7 +32,7 @@ import org.jellyfin.androidtv.ui.playback.AudioEventListener;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.androidtv.ui.shared.IMessageListener;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
@@ -48,7 +47,6 @@ import org.jellyfin.apiclient.model.querying.ItemSortBy;
import org.jellyfin.apiclient.model.querying.ItemsResult;
import org.jellyfin.apiclient.model.querying.NextUpQuery;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -97,11 +95,7 @@ public class HomeFragment extends StdBrowseFragment {
super.onActivityCreated(savedInstanceState);
// Save last login so we can get back proper context on entry
try {
AuthenticationHelper.saveLoginCredentials(new LogonCredentials(apiClient.getValue().getServerInfo(), TvApp.getApplication().getCurrentUser()), TvApp.CREDENTIALS_PATH);
} catch (IOException e) {
Timber.e(e, "Unable to save login credentials");
}
mApplication.setConfiguredAutoCredentials(new LogonCredentials(apiClient.getValue().getServerInfo(), TvApp.getApplication().getCurrentUser()));
// Init leanback home channels;
channelManager = new ChannelManager();

View File

@@ -13,11 +13,11 @@ import androidx.leanback.widget.RowPresenter;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.preference.PreferencesActivity;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.startup.SelectUserActivity;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.startup.StartupActivity;
public class HomeFragmentFooterRow extends HomeFragmentRow implements OnItemViewClickedListener {
private static final int LOGOUT = 0;
@@ -46,10 +46,11 @@ public class HomeFragmentFooterRow extends HomeFragmentRow implements OnItemView
switch (((GridButton) item).getId()) {
case LOGOUT:
TvApp app = TvApp.getApplication();
// Unset the current user
TvApp.getApplication().setCurrentUser(null);
// Open login activity
Intent selectUserIntent = new Intent(activity, SelectUserActivity.class);
Intent selectUserIntent = new Intent(activity, StartupActivity.class);
selectUserIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); // Disallow back button
activity.startActivity(selectUserIntent);
@@ -61,6 +62,5 @@ public class HomeFragmentFooterRow extends HomeFragmentRow implements OnItemView
activity.startActivity(settingsIntent);
break;
}
}
}

View File

@@ -9,7 +9,6 @@ import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.constant.Extras;
import org.jellyfin.androidtv.constant.ViewType;
import org.jellyfin.androidtv.data.model.ChapterItemInfo;
import org.jellyfin.androidtv.ui.shared.BaseActivity;
import org.jellyfin.androidtv.ui.browsing.BrowseRecordingsActivity;
import org.jellyfin.androidtv.ui.browsing.BrowseScheduleActivity;
import org.jellyfin.androidtv.ui.browsing.CollectionActivity;
@@ -21,15 +20,14 @@ import org.jellyfin.androidtv.ui.itemdetail.ItemListActivity;
import org.jellyfin.androidtv.ui.itemdetail.PhotoPlayerActivity;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideActivity;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.ui.shared.BaseActivity;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.dto.UserDto;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
import org.jellyfin.apiclient.model.library.PlayAccess;
import org.jellyfin.apiclient.model.livetv.ChannelInfoDto;
@@ -257,21 +255,6 @@ public class ItemLauncher {
});
break;
case Server:
//Log in to selected server
get(ApiClient.class).ChangeServerLocation(rowItem.getServerInfo().getAddress());
AuthenticationHelper.enterManualUser(activity);
break;
case User:
final UserDto user = rowItem.getUser();
if (user.getHasPassword()) {
Utils.processPasswordEntry(activity, user);
} else {
AuthenticationHelper.loginUser(user.getName(), "", get(ApiClient.class), activity);
}
break;
case SearchHint:
final SearchHint hint = rowItem.getSearchHint();

View File

@@ -2,16 +2,12 @@ package org.jellyfin.androidtv.ui.preference.category
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.data.model.LogonCredentials
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.LoginBehavior
import org.jellyfin.androidtv.ui.preference.dsl.OptionsScreen
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
import org.jellyfin.androidtv.ui.preference.dsl.enum
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper
import org.jellyfin.apiclient.interaction.ApiClient
import timber.log.Timber
import java.io.IOException
fun OptionsScreen.authenticationCategory(
userPreferences: UserPreferences,
@@ -23,13 +19,9 @@ fun OptionsScreen.authenticationCategory(
setTitle(R.string.pref_login_behavior_title)
bind {
set {
@Suppress("ControlFlowWithEmptyBody")
if (it == LoginBehavior.AUTO_LOGIN) {
try {
val credentials = LogonCredentials(apiClient.serverInfo, TvApp.getApplication().currentUser)
AuthenticationHelper.saveLoginCredentials(credentials, TvApp.CREDENTIALS_PATH)
} catch (e: IOException) {
Timber.e(e, "Unable to save logon credentials")
}
// FIXME: Fix autologin preference
}
userPreferences[UserPreferences.loginBehavior] = it
@@ -43,7 +35,7 @@ fun OptionsScreen.authenticationCategory(
// Auto login is disabled
userPreferences[UserPreferences.loginBehavior] != LoginBehavior.AUTO_LOGIN
// Or configured user is set to current user
|| configuredAutoCredentials.userDto.id == TvApp.getApplication().currentUser.id
|| configuredAutoCredentials.user?.id == TvApp.getApplication().currentUser.id
}
}
@@ -56,9 +48,9 @@ fun OptionsScreen.authenticationCategory(
// Auto login is enabled
userPreferences[UserPreferences.loginBehavior] == LoginBehavior.AUTO_LOGIN
// Configured user is set to current user
&& configuredAutoCredentials.userDto.id == TvApp.getApplication().currentUser.id
&& configuredAutoCredentials.user?.id == TvApp.getApplication().currentUser.id
// Configured user contains a password
&& configuredAutoCredentials.userDto.hasPassword
&& configuredAutoCredentials.user?.hasPassword ?: false
}
}

View File

@@ -0,0 +1,56 @@
package org.jellyfin.androidtv.ui.shared
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import kotlinx.android.synthetic.main.fragment_alert_dialog.*
import org.jellyfin.androidtv.R
@Suppress("LongParameterList")
open class AlertFragment(
@StringRes private val title: Int,
@StringRes private val description: Int? = null,
@StringRes private val confirmButtonText: Int? = null,
private val onConfirmCallback: () -> Unit = {},
@StringRes private val cancelButtonText: Int? = null,
private val onCancelCallback: () -> Unit = {},
private val onClose: () -> Unit = {}
) : Fragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val titleView = requireActivity().findViewById<TextView>(R.id.title)
titleView.setText(title)
val descriptionView = requireActivity().findViewById<TextView>(R.id.description)
if (description != null) {
descriptionView.setText(description)
} else {
descriptionView.visibility = View.GONE
}
val confirmButton = requireActivity().findViewById<Button>(R.id.confirm)
if (confirmButtonText != null) confirmButton.setText(confirmButtonText)
confirmButton.requestFocus()
confirmButton.setOnClickListener {
onConfirmCallback()
onClose()
}
val cancelButton = requireActivity().findViewById<Button>(R.id.cancel)
if (cancelButtonText != null) cancelButton.setText(cancelButtonText)
cancelButton.setOnClickListener {
onCancelCallback()
onClose()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_alert_dialog, container, false)
}
}

View File

@@ -7,9 +7,6 @@ import android.os.Bundle
import android.util.Log
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.ui.startup.DpadPwActivity
import org.jellyfin.androidtv.ui.startup.SelectServerActivity
import org.jellyfin.androidtv.ui.startup.SelectUserActivity
import org.jellyfin.androidtv.ui.startup.StartupActivity
private const val LOG_TAG = "AuthUserCallbacks"
@@ -37,9 +34,6 @@ class AuthenticatedUserCallbacks : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {
when (activity) {
// Ignore startup activities
is DpadPwActivity,
is SelectServerActivity,
is SelectUserActivity,
is StartupActivity -> return
// All other activities should have a current user
else -> {

View File

@@ -0,0 +1,14 @@
package org.jellyfin.androidtv.ui.shared
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import org.jellyfin.androidtv.R
open class ToolbarFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_toolbar, container, false)
}
}

View File

@@ -0,0 +1,47 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.text.InputType
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.shared.AlertFragment
import org.jellyfin.androidtv.ui.shared.KeyboardFocusChangeListener
class AddServerFragment(
private val onConfirmCallback: (url: String) -> Unit = {},
private val onCancelCallback: () -> Unit = {},
private val onClose: () -> Unit = {}
) : AlertFragment(
title = R.string.lbl_enter_server_address,
description = R.string.lbl_valid_server_address,
onCancelCallback = onCancelCallback,
onClose = onClose
) {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val contentView = requireActivity().findViewById<LinearLayout>(R.id.content)
// Build the url field
val address = EditText(activity)
address.hint = requireActivity().getString(R.string.lbl_ip_hint)
address.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
address.isSingleLine = true
address.onFocusChangeListener = KeyboardFocusChangeListener()
address.nextFocusDownId = R.id.confirm
address.requestFocus()
// Add the url field to the content view
contentView.addView(address)
// Override the default confirm button click listener to return the address field text
val confirmButton = requireActivity().findViewById<Button>(R.id.confirm)
confirmButton.setOnClickListener {
if (address.text.isNotBlank()) {
onConfirmCallback(address.text.toString())
onClose()
}
}
}
}

View File

@@ -1,183 +0,0 @@
package org.jellyfin.androidtv.ui.startup;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.media.ToneGenerator;
import android.os.Bundle;
import android.text.InputType;
import android.view.KeyEvent;
import android.widget.EditText;
import android.widget.TextView;
import androidx.fragment.app.FragmentActivity;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.model.dto.UserDto;
import org.jellyfin.apiclient.serialization.GsonJsonSerializer;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.get;
public class DpadPwActivity extends FragmentActivity {
private long lastKeyDown = Long.MAX_VALUE;
private long lastKeyUp = 0;
private boolean keyUpDetected = true;
private boolean processed = false;
private int lastKey;
private int longPressSensitivity = 600;
private int doubleClickSensitivity = 350;
private String password = "";
TextView title;
TextView pwField;
UserDto user;
String directItemId;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dpad_pw);
title = (TextView)findViewById(R.id.dpad_pw_text);
pwField = (TextView)findViewById(R.id.dpad_pw_display);
user = get(GsonJsonSerializer.class).DeserializeFromString(getIntent().getStringExtra("User"), UserDto.class);
directItemId = getIntent().getStringExtra("ItemId");
title.setText(title.getText() + " for "+ user.getName());
pwField.setText(password);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
keyUpDetected = true;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_0:
case KeyEvent.KEYCODE_1:
case KeyEvent.KEYCODE_2:
case KeyEvent.KEYCODE_3:
case KeyEvent.KEYCODE_4:
case KeyEvent.KEYCODE_5:
case KeyEvent.KEYCODE_6:
case KeyEvent.KEYCODE_7:
case KeyEvent.KEYCODE_8:
case KeyEvent.KEYCODE_9:
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && System.currentTimeMillis() - lastKeyDown > longPressSensitivity) {
if (processed) return true; //some controllers appear to double send the up on a long press
Timber.d("Password finished");
Utils.makeTone(ToneGenerator.TONE_CDMA_ANSWER, 200);
processed = true;
AuthenticationHelper.loginUser(user.getName(), password, get(ApiClient.class), this, directItemId);
return true;
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && System.currentTimeMillis() - lastKeyDown > longPressSensitivity) {
Timber.d("Password clear");
password = "";
pwField.setText(password);
processed = false;
return true;
}
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && System.currentTimeMillis() - lastKeyDown > longPressSensitivity) {
Timber.d("Requesting dialog...");
final EditText password = new EditText(this);
final Activity activity = this;
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
new AlertDialog.Builder(this)
.setTitle(R.string.password_prompt)
.setMessage(getString(R.string.password_prompt_message, user.getName()))
.setView(password)
.setPositiveButton(R.string.lbl_ok, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String pw = password.getText().toString();
AuthenticationHelper.loginUser(user.getName(), pw, get(ApiClient.class), activity, directItemId);
}
}).show();
return true;
}
lastKeyDown = Long.MAX_VALUE;
if (lastKey == keyCode && System.currentTimeMillis() - lastKeyUp <= doubleClickSensitivity) {
lastKeyUp = 0;
Utils.beep(300);
//Remove the single-click value
password = password.substring(0,password.length()-1);
processKey(keyCode, true, event);
} else {
lastKeyUp = System.currentTimeMillis();
Utils.beep();
processKey(keyCode, false, event);
}
processed = false;
return true;
default:
processed = false;
return super.onKeyUp(keyCode, event);
}
}
private void processKey(int keyCode, boolean doubleClick, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
password += doubleClick ? 6 : 1;
break;
case KeyEvent.KEYCODE_DPAD_LEFT:
password += doubleClick ? 7 : 2;
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
password += doubleClick ? 8 : 3;
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
password += doubleClick ? 9 : 4;
break;
case KeyEvent.KEYCODE_DPAD_CENTER:
password += doubleClick ? 5 : 0;
break;
case KeyEvent.KEYCODE_0:
case KeyEvent.KEYCODE_1:
case KeyEvent.KEYCODE_2:
case KeyEvent.KEYCODE_3:
case KeyEvent.KEYCODE_4:
case KeyEvent.KEYCODE_5:
case KeyEvent.KEYCODE_6:
case KeyEvent.KEYCODE_7:
case KeyEvent.KEYCODE_8:
case KeyEvent.KEYCODE_9:
password += (char)event.getUnicodeChar();
break;
}
//TvApp.getApplication().getLogger().Debug(password);
pwField.setText(password);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_UP:
if (!keyUpDetected) return false;
lastKeyDown = System.currentTimeMillis();
lastKey = keyCode;
keyUpDetected = false;
return true;
default:
return super.onKeyDown(keyCode, event);
}
}
}

View File

@@ -0,0 +1,118 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import androidx.annotation.DrawableRes
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.leanback.app.RowsSupportFragment
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.ListRowPresenter
import androidx.leanback.widget.OnItemViewClickedListener
import androidx.leanback.widget.Presenter
import androidx.leanback.widget.Row
import androidx.leanback.widget.RowPresenter
import androidx.lifecycle.Observer
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.model.ServerList
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.ui.GridButton
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter
import org.koin.android.viewmodel.ext.android.sharedViewModel
import timber.log.Timber
class ListServerFragment : RowsSupportFragment() {
private companion object {
private const val ADD_USER = 1
private const val SELECT_USER = 2
}
private val loginViewModel: LoginViewModel by sharedViewModel()
private val itemViewClickedListener = OnItemViewClickedListener() { _: Presenter.ViewHolder, item: Any, _: RowPresenter.ViewHolder, _: Row ->
if (item is UserGridButton) {
if (item.user.hasPassword) {
// Open login fragment
navigate(UserLoginFragment(
user = item.user,
onConfirmCallback = { username: String, password: String ->
GlobalScope.launch {
loginViewModel.login(server = item.server, username = username, password = password)
}
},
onClose = { parentFragmentManager.popBackStack() }
))
} else {
GlobalScope.launch {
loginViewModel.login(item.server, item.user.name, "")
}
}
} else if (item is AddUserGridButton) {
// Open login fragment
navigate(UserLoginFragment(
onConfirmCallback = { username: String, password: String ->
GlobalScope.launch {
loginViewModel.login(server = item.server, username = username, password = password)
}
},
onClose = { parentFragmentManager.popBackStack() }
))
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
requireView().updatePadding(top = 20)
buildRows(emptyMap())
val serverObserver = Observer<ServerList> { serverList ->
if (serverList.allServersUsers.isNotEmpty()) buildRows(serverList.allServersUsers)
}
loginViewModel.serverList.observe(viewLifecycleOwner, serverObserver)
onItemViewClickedListener = itemViewClickedListener
}
private fun buildRows(usersByServer: Map<Server, List<User>>) {
val rowAdapter = ArrayObjectAdapter(ListRowPresenter())
adapter = rowAdapter
usersByServer.forEach { (server: Server, userList: List<User>) ->
Timber.d("Adding server row %s", server.name)
val userListAdapter = ArrayObjectAdapter(GridButtonPresenter())
userList.forEach { user ->
userListAdapter.add(UserGridButton(server, user, SELECT_USER, user.name, R.drawable.tile_port_person))
}
userListAdapter.add(AddUserGridButton(server, ADD_USER, requireContext().getString(R.string.lbl_manual_login), R.drawable.tile_edit))
rowAdapter.add(ListRow(
HeaderItem(usersByServer.keys.indexOf(server).toLong(),
if (server.name.isNotBlank()) server.name else server.address),
userListAdapter
))
}
// Ensure the server rows get focus
requireView().requestFocus()
}
private fun navigate(fragment: Fragment) {
parentFragmentManager.beginTransaction()
.replace(R.id.content_view, fragment)
.addToBackStack(this::class.simpleName)
.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) : GridButton(id, text, imageId)

View File

@@ -0,0 +1,108 @@
package org.jellyfin.androidtv.ui.startup
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import org.jellyfin.androidtv.data.model.LoadingState
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.model.ServerList
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.data.repository.ServerRepository
import org.jellyfin.androidtv.data.repository.UserRepository
import org.jellyfin.androidtv.util.apiclient.toUser
import timber.log.Timber
class LoginViewModel(
private val serverRepository: ServerRepository,
private val userRepository: UserRepository
) : ViewModel() {
private val _currentServer = MutableLiveData<Server>()
val currentServer: LiveData<Server>
get() = _currentServer
private val _currentUser = MutableLiveData<User>()
val currentUser: LiveData<User>
get() = _currentUser
private val _serverList = MediatorLiveData<ServerList>()
val serverList: LiveData<ServerList>
get() = _serverList
private val _loadingCurrentServer = MutableLiveData<LoadingState>()
private val currentServerUsers = _currentServer.switchMap { server ->
liveData {
_loadingCurrentServer.value = LoadingState.LOADING
emit(hashMapOf(server to userRepository.getUsers(server)))
_loadingCurrentServer.value = LoadingState.SUCCESS
}
}
private val _loadingDiscoveredServers = MutableLiveData<LoadingState>()
private val discoveredServers = liveData {
_loadingDiscoveredServers.value = LoadingState.LOADING
emit(serverRepository.discoverServers()
.associateWith { userRepository.getUsers(it) })
_loadingDiscoveredServers.value = LoadingState.SUCCESS
}
private val _loadingSavedServers = MutableLiveData<LoadingState>()
private val savedServers = liveData {
_loadingSavedServers.value = LoadingState.LOADING
emit(serverRepository.getServers()
.associateWith { userRepository.getUsers(it) })
_loadingSavedServers.value = LoadingState.SUCCESS
}
init {
// Add all loading states
_serverList.addSource(_loadingCurrentServer) {
_serverList.value = (_serverList.value ?: ServerList()).apply { currentServerUsersState = it }
}
_serverList.addSource(_loadingDiscoveredServers) {
_serverList.value = (_serverList.value ?: ServerList()).apply { discoveredServersUsersState = it }
}
_serverList.addSource(_loadingSavedServers) {
_serverList.value = (_serverList.value ?: ServerList()).apply { savedServersUsersState = it }
}
// Add all the server data
_serverList.addSource(currentServerUsers) {
_serverList.value = (_serverList.value ?: ServerList()).apply { currentServerUsers = it }
}
_serverList.addSource(discoveredServers) {
_serverList.value = (_serverList.value ?: ServerList()).apply { discoveredServersUsers = it }
}
_serverList.addSource(savedServers) {
_serverList.value = (_serverList.value ?: ServerList()).apply { savedServersUsers = it }
}
}
suspend fun connect(address: String) {
@Suppress("TooGenericExceptionCaught")
try {
Timber.d("Connecting to server %s", address)
val connectResult = serverRepository.connect(address)
Timber.d("Connected to server %s %s", connectResult.name, connectResult.address)
_currentServer.postValue(connectResult)
} catch (ex: RuntimeException) {
Timber.w(ex, "Failed to connect to server $address")
// TODO: Show error messaging
}
}
suspend fun login(server: Server, username: String, password: String) {
@Suppress("TooGenericExceptionCaught")
try {
val authResult = userRepository.login(server, username, password)
Timber.d("User authenticated %s %s", authResult.user.name, authResult.accessToken)
_currentServer.postValue(server)
_currentUser.postValue(authResult.user.toUser())
} catch (ex: RuntimeException) {
Timber.w(ex, "Failed to login as user $username")
// TODO: Show error messaging
}
}
}

View File

@@ -1,15 +0,0 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import org.jellyfin.androidtv.ui.shared.BaseActivity
class SelectServerActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportFragmentManager
.beginTransaction()
.replace(android.R.id.content, SelectServerFragment())
.commit()
}
}

View File

@@ -1,109 +0,0 @@
package org.jellyfin.androidtv.ui.startup;
import android.os.Bundle;
import android.view.KeyEvent;
import android.widget.Toast;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.ui.shared.BaseActivity;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.ui.shared.IKeyListener;
import org.jellyfin.androidtv.ui.shared.IMessageListener;
import org.jellyfin.androidtv.ui.browsing.CustomBrowseFragment;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.apiclient.model.apiclient.ServerInfo;
import java.util.ArrayList;
import java.util.List;
public class SelectServerFragment extends CustomBrowseFragment {
private static final int ENTER_MANUALLY = 0;
private List<ServerInfo> mServers = new ArrayList<>();
@Override
public void onActivityCreated(Bundle savedInstanceState) {
// TODO Don't read from extras but retrieve server list ourselves
String[] passedItems = getActivity().getIntent().getStringArrayExtra("Servers");
if (passedItems != null) {
for (String json : passedItems) {
//mServers.add(SerializerRepository.INSTANCE.getSerializer().DeserializeFromString(json, ServerInfo.class));
}
}
mActivity = (BaseActivity) getActivity();
super.onActivityCreated(savedInstanceState);
}
@Override
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
super.addAdditionalRows(rowAdapter);
HeaderItem serverHeader = new HeaderItem(rowAdapter.size(), mApplication.getString(R.string.lbl_select_server));
ItemRowAdapter serverAdapter = new ItemRowAdapter(mServers.toArray(new ServerInfo[0]), new CardPresenter(), rowAdapter);
serverAdapter.Retrieve();
rowAdapter.add(new ListRow(serverHeader, serverAdapter));
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), mApplication.getString(R.string.lbl_other_options));
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
gridRowAdapter.add(new GridButton(ENTER_MANUALLY, mApplication.getString(R.string.lbl_enter_manually), R.drawable.tile_edit));
rowAdapter.add(new ListRow(gridHeader, gridRowAdapter));
}
@Override
protected void setupEventListeners() {
super.setupEventListeners();
mClickedListener.registerListener(new ItemViewClickedListener());
if (mActivity != null) {
mActivity.registerKeyListener(new IKeyListener() {
@Override
public boolean onKeyUp(int key, KeyEvent event) {
return KeyProcessor.HandleKey(key, mCurrentItem, mActivity);
}
});
mActivity.registerMessageListener(new IMessageListener() {
@Override
public void onMessageReceived(CustomMessage message) {
switch (message) {
case RemoveCurrentItem:
((ItemRowAdapter) mCurrentRow.getAdapter()).remove(mCurrentItem);
break;
}
}
});
}
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (item instanceof GridButton) {
switch (((GridButton) item).getId()) {
case ENTER_MANUALLY:
AuthenticationHelper.enterManualServerAddress(getActivity());
break;
default:
Toast.makeText(getActivity(), item.toString(), Toast.LENGTH_SHORT)
.show();
break;
}
}
}
}
}

View File

@@ -1,15 +0,0 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
class SelectUserActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportFragmentManager
.beginTransaction()
.replace(android.R.id.content, SelectUserFragment())
.commit()
}
}

View File

@@ -1,94 +0,0 @@
package org.jellyfin.androidtv.ui.startup
import android.content.Intent
import android.widget.Toast
import androidx.leanback.widget.*
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.browsing.CustomBrowseFragment
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter
import org.jellyfin.androidtv.ui.GridButton
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper
import org.jellyfin.apiclient.interaction.EmptyResponse
class SelectUserFragment : CustomBrowseFragment() {
override fun addAdditionalRows(rowAdapter: ArrayObjectAdapter) {
super.addAdditionalRows(rowAdapter)
val manualEntryRowItem = BaseRowItem(
GridButton(ENTER_MANUALLY, getString(R.string.lbl_enter_manually), R.drawable.tile_edit)
)
val usersHeader = HeaderItem(rowAdapter.size().toLong(), getString(R.string.lbl_select_user))
val usersAdapter = ItemRowAdapter(CardPresenter(), rowAdapter)
usersAdapter.add(manualEntryRowItem)
usersAdapter.setRetrieveFinishedListener(
UserRetrieveFinishedListener(usersAdapter, manualEntryRowItem)
)
usersAdapter.Retrieve()
rowAdapter.add(ListRow(usersHeader, usersAdapter))
val gridHeader = HeaderItem(rowAdapter.size().toLong(), getString(R.string.lbl_other_options))
val mGridPresenter = GridButtonPresenter()
val gridRowAdapter = ArrayObjectAdapter(mGridPresenter)
gridRowAdapter.add(
GridButton(
SWITCH_SERVER,
getString(R.string.lbl_switch_server),
R.drawable.tile_port_server
)
)
rowAdapter.add(ListRow(gridHeader, gridRowAdapter))
}
override fun setupEventListeners() {
super.setupEventListeners()
mClickedListener.registerListener(ItemViewClickedListener())
}
private inner class ItemViewClickedListener : OnItemViewClickedListener {
override fun onItemClicked(itemViewHolder: Presenter.ViewHolder, item: Any,
rowViewHolder: RowPresenter.ViewHolder, row: Row) {
if (item is GridButton) {
when (item.id) {
SWITCH_SERVER -> {
// Present server selection
val serverIntent = Intent(activity, SelectServerActivity::class.java)
serverIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
requireActivity().startActivity(serverIntent)
}
// Manual login
ENTER_MANUALLY -> AuthenticationHelper.enterManualUser(activity)
else -> Toast.makeText(activity, item.toString(), Toast.LENGTH_SHORT).show()
}
} else if (item is BaseRowItem) {
if (item.gridButton != null &&
item.gridButton.id == ENTER_MANUALLY) {
// Manual login
AuthenticationHelper.enterManualUser(activity)
}
}
}
}
private class UserRetrieveFinishedListener(
private val usersAdapter: ItemRowAdapter,
private val rowItem: BaseRowItem
) : EmptyResponse() {
override fun onResponse() {
usersAdapter.remove(rowItem)
usersAdapter.add(rowItem)
}
}
companion object {
private const val ENTER_MANUALLY = 0
private const val SWITCH_SERVER = 3
}
}

View File

@@ -0,0 +1,14 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import org.jellyfin.androidtv.R
class SplashFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_startup, container, false)
}
}

View File

@@ -1,122 +0,0 @@
package org.jellyfin.androidtv.ui.startup;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.ui.browsing.MainActivity;
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity;
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.get;
public class StartupActivity extends FragmentActivity {
private static final int NETWORK_PERMISSION = 1;
private TvApp application;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.fragment_startup);
application = (TvApp) getApplicationContext();
//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");
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_NETWORK_STATE, Manifest.permission.INTERNET}, NETWORK_PERMISSION);
} else {
Timber.i("Basic network permissions are granted");
start();
}
}
private void start() {
if (application.getCurrentUser() != null && MediaManager.isPlayingAudio()) {
openNextActivity();
} else {
//clear audio queue in case left over from last run
MediaManager.clearAudioQueue();
MediaManager.clearVideoQueue();
establishConnection();
}
}
private void openNextActivity() {
// workaround...
Activity self = this;
String itemId = getIntent().getStringExtra("ItemId");
boolean itemIsUserView = getIntent().getBooleanExtra("ItemIsUserView", false);
if (itemId != null) {
if (itemIsUserView) {
get(ApiClient.class).GetItemAsync(itemId, get(ApiClient.class).getCurrentUserId(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto item) {
ItemLauncher.launchUserView(item, self, true);
}
@Override
public void onError(Exception exception) {
// go straight into last connection
Intent intent = new Intent(application, MainActivity.class);
startActivity(intent);
finish();
}
});
} else {
//Can just go right into details
Intent detailsIntent = new Intent(this, FullDetailsActivity.class);
detailsIntent.putExtra("ItemId", getIntent().getStringExtra("ItemId"));
startActivity(detailsIntent);
finish();
}
} else {
// go straight into last connection
Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);
finish();
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == NETWORK_PERMISSION) {// If request is cancelled, the result arrays are empty.
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// permission was granted
start();
} else {
// permission denied! Disable the app.
Utils.showToast(this, "Application cannot continue without network");
finish();
}
}
}
private void establishConnection() {
// Ask for server information
AuthenticationHelper.enterManualServerAddress(this);
}
}

View File

@@ -0,0 +1,152 @@
package org.jellyfin.androidtv.ui.startup
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.data.model.LoadingState
import org.jellyfin.androidtv.data.model.ServerList
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.util.Utils
import org.jellyfin.androidtv.util.apiclient.toUserDto
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.interaction.Response
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.koin.android.viewmodel.ext.android.viewModel
import org.koin.java.KoinJavaComponent.get
import timber.log.Timber
class StartupActivity : FragmentActivity() {
private companion object {
private const val NETWORK_PERMISSION = 1
private const val ITEM_ID = "ItemId"
}
private var application: TvApp? = null
private val loginViewModel: LoginViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.fragment_content_view)
supportFragmentManager.beginTransaction()
.replace(R.id.content_view, SplashFragment())
.commit()
application = applicationContext as TvApp
//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")
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_NETWORK_STATE, Manifest.permission.INTERNET), NETWORK_PERMISSION)
} else {
Timber.i("Basic network permissions are granted")
val loadingObserver = Observer<ServerList> { serverList ->
Timber.d("LoadingState: %s", serverList.state.toString())
if (serverList.state == LoadingState.SUCCESS) start()
}
loginViewModel.serverList.observe(this, loadingObserver)
}
// Navigate to home screen when user has logged in
val currentUserObserver = Observer<User> { user: User? ->
// TODO: This should be removed in favor of fragments getting the current user directly
application!!.currentUser = user?.toUserDto()
// User has been logged in continue to home screen
// TODO: We should use a fragment for this, but the current fragment depends on BaseActivity
val intent = Intent(application, MainActivity::class.java)
startActivity(intent)
finish()
}
loginViewModel.currentUser.observe(this, currentUserObserver)
}
private fun start() {
if (application!!.currentUser != null && MediaManager.isPlayingAudio()) {
openNextActivity()
} else {
//clear audio queue in case left over from last run
MediaManager.clearAudioQueue()
MediaManager.clearVideoQueue()
establishConnection()
}
}
private fun openNextActivity() {
val itemId = intent.getStringExtra(ITEM_ID)
val itemIsUserView = intent.getBooleanExtra("ItemIsUserView", false)
if (itemId != null) {
if (itemIsUserView) {
get(ApiClient::class.java).GetItemAsync(itemId, get(ApiClient::class.java).currentUserId, object : Response<BaseItemDto?>() {
override fun onResponse(item: BaseItemDto?) {
ItemLauncher.launchUserView(item, this@StartupActivity, true)
}
override fun onError(exception: Exception) {
// go straight into last connection
val intent = Intent(application, MainActivity::class.java)
startActivity(intent)
finish()
}
})
} else {
//Can just go right into details
val detailsIntent = Intent(this, FullDetailsActivity::class.java)
detailsIntent.putExtra(ITEM_ID, intent.getStringExtra(ITEM_ID))
startActivity(detailsIntent)
finish()
}
} else {
// go straight into last connection
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (requestCode == NETWORK_PERMISSION) { // If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// permission was granted
start()
} else {
// permission denied! Disable the app.
Utils.showToast(this, "Application cannot continue without network")
finish()
}
}
}
private fun establishConnection() {
// Ask for server information
supportFragmentManager.beginTransaction()
.replace(R.id.content_view, StartupToolbarFragment(
onAddServerClicked = {
supportFragmentManager.beginTransaction()
.replace(R.id.content_view, AddServerFragment(
onConfirmCallback = {
GlobalScope.launch { loginViewModel.connect(it) }
},
onClose = { supportFragmentManager.popBackStack() }
))
.addToBackStack(StartupToolbarFragment::class.simpleName)
.commit()
}
))
.add(R.id.content_view, ListServerFragment())
.commit()
}
}

View File

@@ -0,0 +1,22 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.shared.ToolbarFragment
class StartupToolbarFragment(
private val onAddServerClicked: () -> Unit = {}
) : ToolbarFragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
requireActivity().findViewById<LinearLayout>(R.id.toolbar_start).apply {
this.addView(Button(requireContext()).apply {
text = context.getString(R.string.lbl_add_server)
setOnClickListener { onAddServerClicked() }
})
}
}
}

View File

@@ -0,0 +1,63 @@
package org.jellyfin.androidtv.ui.startup
import android.os.Bundle
import android.text.InputType
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.ui.shared.AlertFragment
import org.jellyfin.androidtv.ui.shared.KeyboardFocusChangeListener
class UserLoginFragment(
private val user: User? = null,
private val onConfirmCallback: (username: String, password: String) -> Unit = { _: String, _: String -> },
onCancelCallback: () -> Unit = {},
private val onClose: () -> Unit = {}
) : AlertFragment(
title = R.string.lbl_sign_in,
onCancelCallback = onCancelCallback,
onClose = onClose
) {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val contentView = requireActivity().findViewById<LinearLayout>(R.id.content)
// Build the username field
val username = EditText(activity)
username.hint = getString(R.string.lbl_enter_user_name)
username.isSingleLine = true
if (user != null) {
username.setText(user.name)
username.isEnabled = false
username.inputType = InputType.TYPE_NULL
} else {
username.onFocusChangeListener = KeyboardFocusChangeListener()
username.requestFocus()
}
// Add the username field to the content view
contentView.addView(username)
// Build the password field
val password = EditText(activity)
password.hint = getString(R.string.lbl_enter_user_pw)
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
password.isSingleLine = true
password.onFocusChangeListener = KeyboardFocusChangeListener()
password.nextFocusDownId = R.id.confirm
if (user != null) password.requestFocus()
// Add the password field to the content view
contentView.addView(password)
// Override the default confirm button click listener to return the address field text
val confirmButton = requireActivity().findViewById<Button>(R.id.confirm)
confirmButton.setOnClickListener {
if (username.text.isNotBlank()) {
onConfirmCallback(username.text.toString(), password.text.toString())
onClose()
}
}
}
}

View File

@@ -1,16 +1,10 @@
package org.jellyfin.androidtv.util;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.res.TypedArray;
import android.media.AudioManager;
import android.media.ToneGenerator;
import android.text.InputType;
import android.view.View;
import android.widget.EditText;
import android.widget.PopupMenu;
import android.widget.Toast;
@@ -19,11 +13,6 @@ import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
import org.jellyfin.androidtv.ui.startup.DpadPwActivity;
import org.jellyfin.androidtv.util.apiclient.AuthenticationHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.model.dto.UserDto;
import org.jellyfin.apiclient.serialization.GsonJsonSerializer;
import java.util.Arrays;
import java.util.Iterator;
@@ -180,34 +169,6 @@ public class Utils {
return themeColor;
}
public static void processPasswordEntry(Activity activity, UserDto user) {
processPasswordEntry(activity, user, null);
}
public static void processPasswordEntry(final Activity activity, final UserDto user, final String directItemId) {
if (get(UserPreferences.class).get(UserPreferences.Companion.getPasswordDPadEnabled())) {
Intent pwIntent = new Intent(activity, DpadPwActivity.class);
pwIntent.putExtra("User", get(GsonJsonSerializer.class).SerializeToString(user));
pwIntent.putExtra("ItemId", directItemId);
pwIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
activity.startActivity(pwIntent);
} else {
Timber.d("Requesting dialog...");
final EditText password = new EditText(activity);
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
new AlertDialog.Builder(activity)
.setTitle(R.string.password_prompt)
.setMessage(TvApp.getApplication().getString(R.string.password_prompt_message, user.getName()))
.setView(password)
.setPositiveButton(R.string.lbl_ok, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String pw = password.getText().toString();
AuthenticationHelper.loginUser(user.getName(), pw, get(ApiClient.class), activity, directItemId);
}
}).show();
}
}
public static boolean downMixAudio() {
AudioManager am = (AudioManager) TvApp.getApplication().getSystemService(Context.AUDIO_SERVICE);
if (am.isBluetoothA2dpOn()) {

View File

@@ -4,6 +4,7 @@ import org.jellyfin.androidtv.TvApp
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.interaction.Response
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.querying.ItemsResult
import org.jellyfin.apiclient.model.querying.NextUpQuery
import kotlin.coroutines.resume
@@ -19,6 +20,16 @@ suspend fun ApiClient.getNextUpEpisodes(query: NextUpQuery): ItemsResult? = susp
})
}
/**
* Coroutine capable version of the "getPublicUsers" function
*/
suspend fun ApiClient.getPublicUsers(): Array<UserDto>? = suspendCoroutine { continuation ->
GetPublicUsersAsync(object : Response<Array<UserDto>>() {
override fun onResponse(response: Array<UserDto>?) = continuation.resume(response!!)
override fun onError(exception: Exception?) = continuation.resume(null)
})
}
/**
* Coroutine capable version of the "getUserViews" function
* Uses the userId of the currently signed in user
@@ -29,6 +40,7 @@ suspend fun ApiClient.getUserViews(): ItemsResult? = suspendCoroutine { continua
override fun onError(exception: Exception?) = continuation.resume(null)
})
}
/**
* Adds a coroutine capable version of the "GetItem" function
* Uses the userId of the currently signed in user
@@ -39,3 +51,10 @@ suspend fun ApiClient.getItem(id: String): BaseItemDto? = suspendCoroutine { con
override fun onError(exception: Exception?) = continuation.resume(null)
})
}
suspend fun <T : Any> callApi(init: (callback: Response<T>) -> Unit): T = suspendCoroutine { continuation ->
init(object : Response<T>() {
override fun onResponse(response: T) = continuation.resumeWith(Result.success(response))
override fun onError(exception: Exception) = continuation.resumeWith(Result.failure(exception))
})
}

View File

@@ -1,128 +0,0 @@
package org.jellyfin.androidtv.util.apiclient;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.text.InputType;
import android.widget.EditText;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.data.model.LogonCredentials;
import org.jellyfin.androidtv.ui.browsing.MainActivity;
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity;
import org.jellyfin.androidtv.ui.shared.KeyboardFocusChangeListener;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.apiclient.ServerInfo;
import org.jellyfin.apiclient.model.users.AuthenticationResult;
import org.jellyfin.apiclient.serialization.GsonJsonSerializer;
import java.io.IOException;
import java.io.OutputStream;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.get;
public class AuthenticationHelper {
public static void enterManualServerAddress(final Activity activity) {
final EditText address = new EditText(activity);
address.setHint(activity.getString(R.string.lbl_ip_hint));
address.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
address.setOnFocusChangeListener(new KeyboardFocusChangeListener());
new AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.lbl_enter_server_address))
.setMessage(activity.getString(R.string.lbl_valid_server_address))
.setView(address)
.setNegativeButton(activity.getString(R.string.lbl_cancel), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
// Do nothing.
}
}).setPositiveButton(activity.getString(R.string.lbl_ok), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String addressValue = address.getText().toString();
Timber.d("Entered address: %s", addressValue);
ServerInfo info = new ServerInfo();
info.setAddress(addressValue);
get(ApiClient.class).EnableAutomaticNetworking(info);
AuthenticationHelper.enterManualUser(activity);
}
}).show();
}
public static void enterManualUser(final Activity activity) {
final EditText userName = new EditText(activity);
userName.setInputType(InputType.TYPE_CLASS_TEXT);
userName.setOnFocusChangeListener(new KeyboardFocusChangeListener());
new AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.lbl_enter_user_name))
.setView(userName)
.setNegativeButton(activity.getString(R.string.lbl_cancel), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
// Do nothing.
}
}).setPositiveButton(activity.getString(R.string.lbl_ok), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String userValue = userName.getText().toString();
Timber.d("Entered user: %s", userValue);
final EditText userPw = new EditText(activity);
userPw.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
userPw.setOnFocusChangeListener(new KeyboardFocusChangeListener());
new AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.lbl_enter_user_pw))
.setView(userPw)
.setNegativeButton(activity.getString(R.string.lbl_cancel), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
// Do nothing.
}
}).setPositiveButton(activity.getString(R.string.lbl_ok), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
loginUser(userName.getText().toString(), userPw.getText().toString(), get(ApiClient.class), activity);
}
}).show();
}
}).show();
}
public static void loginUser(String userName, String pw, ApiClient apiClient, final Activity activity) {
loginUser(userName, pw, apiClient, activity, null);
}
public static void loginUser(String userName, String pw, ApiClient apiClient, final Activity activity, final String directEntryItemId) {
apiClient.AuthenticateUserAsync(userName, pw, new Response<AuthenticationResult>() {
@Override
public void onResponse(AuthenticationResult authenticationResult) {
TvApp application = TvApp.getApplication();
Timber.d("Signed in as %s", authenticationResult.getUser().getName());
application.setCurrentUser(authenticationResult.getUser());
if (directEntryItemId == null) {
Intent intent = new Intent(activity, MainActivity.class);
activity.startActivity(intent);
} else {
Intent intent = new Intent(activity, FullDetailsActivity.class);
intent.putExtra("ItemId", directEntryItemId);
activity.startActivity(intent);
}
}
@Override
public void onError(Exception exception) {
super.onError(exception);
Timber.e(exception, "Error logging in");
Utils.showToast(activity, activity.getString(R.string.msg_invalid_id_pw));
}
});
}
public static void saveLoginCredentials(LogonCredentials creds, String fileName) throws IOException {
TvApp app = TvApp.getApplication();
OutputStream credsFile = app.openFileOutput(fileName, Context.MODE_PRIVATE);
credsFile.write(get(GsonJsonSerializer.class).SerializeToString(creds).getBytes());
credsFile.close();
app.setConfiguredAutoCredentials(creds);
}
}

View File

@@ -0,0 +1,65 @@
package org.jellyfin.androidtv.util.apiclient
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.data.model.UserConfiguration
import org.jellyfin.androidtv.data.model.UserPolicy
import org.jellyfin.apiclient.discovery.DiscoveryServerInfo
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.system.PublicSystemInfo
fun DiscoveryServerInfo.toServerInfo(): ServerInfo = ServerInfo().apply {
id = this@toServerInfo.id
name = this@toServerInfo.name
address = this@toServerInfo.address
}
fun DiscoveryServerInfo.toServer(): Server = Server(id, name, address)
fun PublicSystemInfo.toServer(): Server = Server(id, serverName, localAddress)
fun ServerInfo.toServer(): Server = Server(id, name, address, userId, accessToken, dateLastAccessed)
fun Server.toServerInfo(): ServerInfo = ServerInfo().apply {
id = this@toServerInfo.id
name = this@toServerInfo.name
address = this@toServerInfo.address
userId = this@toServerInfo.userId
accessToken = this@toServerInfo.accessToken
dateLastAccessed = this@toServerInfo.dateLastAccessed
}
fun UserDto.toUser(): User = User(
id = id,
name = name,
serverId = serverId,
primaryImageTag = primaryImageTag ?: "",
hasPassword = hasPassword,
hasConfiguredPassword = hasConfiguredPassword,
hasConfiguredEasyPassword = hasConfiguredEasyPassword,
configuration = UserConfiguration(
latestItemsExcludes = configuration.latestItemsExcludes
),
policy = UserPolicy(
enableLiveTvAccess = policy.enableLiveTvAccess,
enableLiveTvManagement = policy.enableLiveTvManagement
)
)
fun User.toUserDto(): UserDto = UserDto().apply {
id = this@toUserDto.id
name = this@toUserDto.name
serverId = this@toUserDto.serverId
primaryImageTag = this@toUserDto.primaryImageTag
hasPassword = this@toUserDto.hasPassword
hasConfiguredPassword = this@toUserDto.hasConfiguredPassword
hasConfiguredEasyPassword = this@toUserDto.hasConfiguredEasyPassword
configuration = org.jellyfin.apiclient.model.configuration.UserConfiguration().apply {
latestItemsExcludes = this@toUserDto.configuration.latestItemsExcludes
}
policy = org.jellyfin.apiclient.model.users.UserPolicy().apply {
enableLiveTvAccess = this@toUserDto.policy.enableLiveTvAccess
enableLiveTvManagement = this@toUserDto.policy.enableLiveTvManagement
}
}

View File

@@ -1,53 +0,0 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.startup.DpadPwActivity">
<ImageView
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:id="@+id/imageView8"
android:src="@drawable/dpad_description"
android:layout_alignParentBottom="true"
android:scaleType="centerCrop" />
<TextView android:text="@string/enter_password" android:paddingLeft="20sp" android:paddingTop="20sp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/dpad_pw_text"
android:textSize="32sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/dpad_entry_desc"
android:paddingLeft="30sp"
android:paddingRight="30sp"
android:layout_marginTop="37dp"
android:layout_below="@+id/dpad_pw_text"
android:layout_alignParentStart="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/dpad_pw_cancel"
android:layout_alignParentBottom="true"
android:layout_alignStart="@+id/imageView8"
android:layout_marginBottom="30sp"
android:paddingLeft="30sp"
android:maxWidth="450sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceLarge"
android:text="000"
android:id="@+id/dpad_pw_display"
android:layout_centerVertical="true"
android:layout_alignParentStart="true"
android:layout_marginStart="125dp"
android:password="true"
android:textStyle="bold"
android:textSize="72sp" />
</RelativeLayout>

View File

@@ -0,0 +1,76 @@
<?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"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginHorizontal="48dp"
android:layout_marginVertical="27dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="220dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Leanback.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Alert Title" />
<LinearLayout
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="16dp"
android:orientation="vertical"
app:layout_constraintBottom_toTopOf="@id/actions"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title">
<TextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="This is the description of the alert." />
</LinearLayout>
<LinearLayout
android:id="@+id/actions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<Button
android:id="@+id/cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/jellyfin_button"
android:text="@string/lbl_cancel" />
<Space
android:layout_width="8dp"
android:layout_height="0dp" />
<Button
android:id="@+id/confirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/jellyfin_button"
android:text="@string/lbl_ok" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/content_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
tools:context=".ui.startup.StartupActivity" />

View File

@@ -2,8 +2,7 @@
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"
tools:context=".ui.startup.StartupActivity">
android:layout_height="match_parent">
<ImageView
android:id="@+id/fragment_startup_logo"

View File

@@ -0,0 +1,53 @@
<?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"
android:layout_width="match_parent"
android:layout_height="95dp"
android:background="?android:attr/windowBackground">
<LinearLayout
android:id="@+id/toolbar_start"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="48dp"
android:layout_marginTop="27dp"
android:layout_marginBottom="27dp"
android:gravity="start"
android:orientation="horizontal"
app:layout_constraintEnd_toStartOf="@+id/toolbar_end"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/toolbar_logo"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:src="@drawable/app_logo_transparent" />
</LinearLayout>
<LinearLayout
android:id="@+id/toolbar_end"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginTop="27dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="27dp"
android:gravity="end"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/toolbar_start">
<TextClock
android:id="@+id/toolbar_clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:fontFamily="sans-serif-light"
android:format12Hour="h:mm"
android:format24Hour="k:mm"
android:textSize="20sp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -413,4 +413,7 @@
<string name="play_with_exo_player">Play with ExoPlayer</string>
<string name="play_with_external_app">Play with external app</string>
<string name="play_with">Play with</string>
<string name="lbl_add_server">Add Server</string>
<string name="lbl_sign_in">Please Sign In</string>
<string name="lbl_manual_login">Manual Login</string>
</resources>

View File

@@ -0,0 +1,72 @@
package org.jellyfin.androidtv.data.model
import org.junit.Assert.*
import org.junit.Test
class ServerListTest {
@Test
fun defaultState() {
val serverList = ServerList()
assertTrue(serverList.currentServerUsers.isEmpty())
assertEquals(LoadingState.PENDING.status, serverList.currentServerUsersState.status)
assertTrue(serverList.discoveredServersUsers.isEmpty())
assertEquals(LoadingState.PENDING.status, serverList.discoveredServersUsersState.status)
assertTrue(serverList.savedServersUsers.isEmpty())
assertEquals(LoadingState.PENDING.status, serverList.savedServersUsersState.status)
assertEquals(LoadingState.PENDING.status, serverList.state.status)
assertTrue(serverList.allServersUsers.isEmpty())
}
@Test
fun errorState() {
val errorState = LoadingState.error("ERROR!")
var serverList = ServerList(
currentServerUsersState = errorState,
discoveredServersUsersState = LoadingState.SUCCESS,
savedServersUsersState = LoadingState.LOADING
)
assertEquals(errorState, serverList.state)
serverList = ServerList(
currentServerUsersState = LoadingState.SUCCESS,
discoveredServersUsersState = errorState,
savedServersUsersState = LoadingState.LOADING
)
assertEquals(errorState, serverList.state)
serverList = ServerList(
currentServerUsersState = LoadingState.LOADING,
discoveredServersUsersState = LoadingState.SUCCESS,
savedServersUsersState = errorState
)
assertEquals(errorState, serverList.state)
}
@Test
fun loadingState() {
var serverList = ServerList(
currentServerUsersState = LoadingState.LOADING,
discoveredServersUsersState = LoadingState.SUCCESS
)
assertEquals(LoadingState.LOADING, serverList.state)
serverList = ServerList(
currentServerUsersState = LoadingState.SUCCESS,
discoveredServersUsersState = LoadingState.LOADING
)
assertEquals(LoadingState.LOADING, serverList.state)
serverList = ServerList(
currentServerUsersState = LoadingState.SUCCESS,
savedServersUsersState = LoadingState.LOADING
)
assertEquals(LoadingState.LOADING, serverList.state)
}
@Test
fun successState() {
var serverList = ServerList(currentServerUsersState = LoadingState.SUCCESS)
assertEquals(LoadingState.SUCCESS, serverList.state)
serverList = ServerList(discoveredServersUsersState = LoadingState.SUCCESS)
assertEquals(LoadingState.SUCCESS, serverList.state)
serverList = ServerList(savedServersUsersState = LoadingState.SUCCESS)
assertEquals(LoadingState.SUCCESS, serverList.state)
}
}

View File

@@ -0,0 +1,151 @@
package org.jellyfin.androidtv.util.apiclient
import org.jellyfin.androidtv.data.model.Server
import org.jellyfin.androidtv.data.model.User
import org.jellyfin.androidtv.data.model.UserConfiguration
import org.jellyfin.androidtv.data.model.UserPolicy
import org.jellyfin.apiclient.discovery.DiscoveryServerInfo
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.system.PublicSystemInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.*
class ModelExtensionsTest {
private companion object {
private const val SERVER_ID = "id"
private const val SERVER_URL = "https://example.com"
private const val SERVER_NAME = "Test Server"
private const val USER_ID = "userId"
private const val USER_NAME = "User Name"
private const val ACCESS_TOKEN = "token"
private const val IMAGE_TAG = "tag"
private val EXCLUDE_ITEMS = arrayOf("exclude")
}
@Test
fun discoveryServerInfoToServerInfo() {
val serverInfo = DiscoveryServerInfo(SERVER_ID, SERVER_URL, SERVER_NAME, "").toServerInfo()
assertEquals(SERVER_ID, serverInfo.id)
assertEquals(SERVER_URL, serverInfo.address)
assertEquals(SERVER_NAME, serverInfo.name)
}
@Test
fun discoverServerInfoToServer() {
val server = DiscoveryServerInfo(SERVER_ID, SERVER_URL, SERVER_NAME, "").toServer()
assertEquals(SERVER_ID, server.id)
assertEquals(SERVER_URL, server.address)
assertEquals(SERVER_NAME, server.name)
}
@Test
fun publicSystemInfoToServer() {
val server = PublicSystemInfo().apply {
id = SERVER_ID
serverName = SERVER_NAME
localAddress = SERVER_URL
}.toServer()
assertEquals(SERVER_ID, server.id)
assertEquals(SERVER_URL, server.address)
assertEquals(SERVER_NAME, server.name)
}
@Test
fun serverInfoToServer() {
val server = ServerInfo().apply {
id = SERVER_ID
address = SERVER_URL
name = SERVER_NAME
}.toServer()
assertEquals(SERVER_ID, server.id)
assertEquals(SERVER_URL, server.address)
assertEquals(SERVER_NAME, server.name)
}
@Test
fun serverToServerInfo() {
val instant = LocalDate.of(2020, 10, 1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
val serverInfo = Server(
SERVER_ID,
SERVER_NAME,
SERVER_URL,
USER_ID,
ACCESS_TOKEN,
Date.from(instant)
).toServerInfo()
assertEquals(SERVER_ID, serverInfo.id)
assertEquals(SERVER_NAME, serverInfo.name)
assertEquals(SERVER_URL, serverInfo.address)
assertEquals(USER_ID, serverInfo.userId)
assertEquals(ACCESS_TOKEN, serverInfo.accessToken)
assertEquals(instant, serverInfo.dateLastAccessed.toInstant())
}
@Test
fun userDtoToUser() {
val user = UserDto().apply {
id = USER_ID
name = USER_NAME
serverId = SERVER_ID
primaryImageTag = IMAGE_TAG
hasPassword = true
hasConfiguredPassword = true
hasConfiguredEasyPassword = false
policy = org.jellyfin.apiclient.model.users.UserPolicy().apply {
enableLiveTvAccess = false
enableLiveTvManagement = false
}
configuration = org.jellyfin.apiclient.model.configuration.UserConfiguration().apply {
latestItemsExcludes = EXCLUDE_ITEMS
}
}.toUser()
assertEquals(USER_ID, user.id)
assertEquals(USER_NAME, user.name)
assertEquals(SERVER_ID, user.serverId)
assertEquals(IMAGE_TAG, user.primaryImageTag)
assertTrue(user.hasPassword)
assertTrue(user.hasConfiguredPassword)
assertFalse(user.hasConfiguredEasyPassword)
assertFalse(user.policy.enableLiveTvAccess)
assertFalse(user.policy.enableLiveTvManagement)
assertTrue(EXCLUDE_ITEMS.contentEquals(user.configuration.latestItemsExcludes))
}
@Test
fun userToUserDto() {
val userDto = User(
id = USER_ID,
name = USER_NAME,
serverId = SERVER_ID,
primaryImageTag = IMAGE_TAG,
hasPassword = true,
hasConfiguredPassword = true,
hasConfiguredEasyPassword = false,
configuration = UserConfiguration(
latestItemsExcludes = EXCLUDE_ITEMS
),
policy = UserPolicy(
enableLiveTvAccess = false,
enableLiveTvManagement = false
)
).toUserDto()
assertEquals(USER_ID, userDto.id)
assertEquals(USER_NAME, userDto.name)
assertEquals(SERVER_ID, userDto.serverId)
assertEquals(IMAGE_TAG, userDto.primaryImageTag)
assertTrue(userDto.hasPassword)
assertTrue(userDto.hasConfiguredPassword)
assertFalse(userDto.hasConfiguredEasyPassword)
assertTrue(EXCLUDE_ITEMS.contentEquals(userDto.configuration.latestItemsExcludes))
assertFalse(userDto.policy.enableLiveTvAccess)
assertFalse(userDto.policy.enableLiveTvManagement)
}
}