Basics for user prefs, logging in, etc

This commit is contained in:
Damontecres 2025-09-21 19:56:40 -04:00
parent 763521aabf
commit 76d78246db
No known key found for this signature in database
15 changed files with 613 additions and 35 deletions

4
.editorconfig Normal file
View file

@ -0,0 +1,4 @@
root = true
[*.kt]
ktlint_function_naming_ignore_when_annotated_with=Composable

View file

@ -1,10 +1,13 @@
import com.google.protobuf.gradle.id
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.protobuf)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.room)
alias(libs.plugins.protobuf)
}
android {
@ -24,9 +27,11 @@ android {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
debug {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@ -39,6 +44,29 @@ android {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"
}
generateProtoTasks {
all().forEach {
it.plugins {
id("java") {
option("lite")
}
}
it.builtins {
id("kotlin") {
option("lite")
}
}
}
}
}
dependencies {
@ -67,7 +95,9 @@ dependencies {
implementation(libs.coil.gif)
implementation(libs.coil.svg)
implementation(libs.jellyfin.sdk)
implementation(libs.jellyfin.core)
implementation(libs.jellyfin.api)
implementation(libs.jellyfin.api.okhttp)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
@ -77,6 +107,10 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt.android)
implementation(libs.androidx.room.common.jvm)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.compose.material3)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -4,14 +4,13 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class DolphinApplication: Application() {
class DolphinApplication : Application() {
init {
instance = this
}
companion object{
companion object {
lateinit var instance: DolphinApplication
private set
}
}

View file

@ -1,50 +1,64 @@
package com.github.damontecres.dolphin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.tv.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.datastore.core.DataStore
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.ServerLoginPage
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var serverRepository: ServerRepository
@Inject
lateinit var userPreferencesDataStore: DataStore<UserPreferences>
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val scope = rememberCoroutineScope()
DolphinTheme {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape
shape = RectangleShape,
) {
Greeting("Android")
val preferences by userPreferencesDataStore.data.collectAsState(null)
preferences?.let { preferences ->
if (preferences.currentServerId.isNotBlank() && preferences.currentUserId.isNotBlank()) {
scope.launch {
serverRepository.restoreSession(
preferences.currentServerId,
preferences.currentUserId,
)
}
} else {
ServerLoginPage(modifier = Modifier.fillMaxSize())
}
val server = serverRepository.currentServer
val user = serverRepository.currentUser
if (server != null && user != null) {
Text("Logged in as ${user.name} on ${server.url}")
}
}
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
DolphinTheme {
Greeting("Android")
}
}

View file

@ -0,0 +1,9 @@
package com.github.damontecres.dolphin.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [JellyfinServer::class, JellyfinUser::class], version = 1, exportSchema = false)
abstract class DolphinDatabase : RoomDatabase() {
abstract fun serverDao(): JellyfinServerDao
}

View file

@ -0,0 +1,76 @@
package com.github.damontecres.dolphin.data
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
@Entity(tableName = "servers")
data class JellyfinServer(
@PrimaryKey val id: String,
val name: String,
val url: String,
)
@Entity(
tableName = "users",
primaryKeys = ["id", "serverId"],
foreignKeys = [
ForeignKey(
entity = JellyfinServer::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("serverId"),
onDelete = ForeignKey.CASCADE,
),
],
)
data class JellyfinUser(
@ColumnInfo(index = true)
val id: String,
val name: String?,
@ColumnInfo(index = true)
val serverId: String,
val accessToken: String,
)
data class JellyfinServerUsers(
@Embedded val server: JellyfinServer,
@Relation(
parentColumn = "id",
entityColumn = "serverId",
)
val users: List<JellyfinUser>,
)
@Dao
interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addServer(vararg servers: JellyfinServer)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(vararg users: JellyfinUser)
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: String)
@Query("DELETE FROM users WHERE serverId = :serverId AND id = :userId")
fun deleteUser(
serverId: String,
userId: String,
)
@Transaction
@Query("SELECT * FROM servers")
fun getServers(): List<JellyfinServerUsers>
@Transaction
@Query("SELECT * FROM servers WHERE id = :serverId")
fun getServer(serverId: String): JellyfinServerUsers?
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.dolphin.data
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userApi
import javax.inject.Inject
class ServerRepository
@Inject
constructor(
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
) {
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
serverDao.addServer(server)
}
changeServer(server)
}
suspend fun addAndChangeUser(
server: JellyfinServer,
user: JellyfinUser,
) {
if (server.id != user.serverId) {
throw IllegalStateException()
}
withContext(Dispatchers.IO) {
serverDao.addServer(server)
serverDao.addUser(user)
}
changeUser(server, user)
}
fun changeServer(server: JellyfinServer) {
apiClient.update(baseUrl = server.url, accessToken = null)
_currentServer = server
}
suspend fun changeUser(
server: JellyfinServer,
user: JellyfinUser,
) {
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
try {
apiClient.userApi
.getCurrentUser()
.content.name
_currentServer = server
_currentUser = user
} catch (e: InvalidStatusException) {
// TODO
if (e.status == 401) {
// Unauthorized
_currentServer = null
_currentUser = null
return
}
}
}
suspend fun restoreSession(
serverId: String,
userId: String,
): Boolean {
val serverAndUsers =
withContext(Dispatchers.IO) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) {
changeUser(serverAndUsers.server, user)
return true
}
}
return false
}
}

View file

@ -0,0 +1,53 @@
package com.github.damontecres.dolphin.hilt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.github.damontecres.dolphin.data.DolphinDatabase
import com.github.damontecres.dolphin.data.JellyfinServerDao
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.UserPreferencesSerializer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun database(
@ApplicationContext context: Context,
): DolphinDatabase =
Room
.databaseBuilder(
context,
DolphinDatabase::class.java,
"dolphin",
).build()
@Provides
@Singleton
fun serverDao(db: DolphinDatabase): JellyfinServerDao = db.serverDao()
@Provides
@Singleton
fun userPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
produceFile = { context.dataStoreFile("user_preferences.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { UserPreferences.getDefaultInstance() },
),
)
}

View file

@ -0,0 +1,57 @@
package com.github.damontecres.dolphin.hilt
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import com.github.damontecres.dolphin.BuildConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@Module
@InstallIn(SingletonComponent::class)
object DolphinModule {
@Provides
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@Provides
fun okHttpFactory(okHttpClient: OkHttpClient) = OkHttpFactory(okHttpClient)
@Provides
fun jellyfin(
okHttpFactory: OkHttpFactory,
@ApplicationContext context: Context,
): Jellyfin =
createJellyfin {
this.context = context
clientInfo =
ClientInfo(
name = "Dolphin",
version = BuildConfig.VERSION_NAME,
)
deviceInfo =
DeviceInfo(
id = @SuppressLint("HardwareIds") Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID),
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
)
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion
}
@Provides
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
}

View file

@ -0,0 +1,27 @@
package com.github.damontecres.dolphin.preferences
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
class UserPreferencesSerializer
@Inject
constructor() : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences {
try {
return UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(
t: UserPreferences,
output: OutputStream,
) = t.writeTo(output)
}

View file

@ -0,0 +1,39 @@
package com.github.damontecres.dolphin.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.github.damontecres.dolphin.preferences.UserPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userApi
import javax.inject.Inject
@HiltViewModel
class MainViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
init {
viewModelScope.launch {
api.userApi.getCurrentUser().content.configuration?.let {
it.orderedViews
}
}
}
fun todo() {
}
}
@Composable
fun MainPage(
preferences: UserPreferences,
modifier: Modifier,
viewModel: MainViewModel = viewModel(),
) {
}

View file

@ -0,0 +1,159 @@
package com.github.damontecres.dolphin.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.UserPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.userApi
import javax.inject.Inject
@HiltViewModel
class ServerManagementViewModel
@Inject
constructor(
val serverRepository: ServerRepository,
val userPreferencesDataStore: DataStore<UserPreferences>,
val jellyfin: Jellyfin,
) : ViewModel() {
fun addServer() {
}
suspend fun loginWithPassword(
serverUrl: String,
username: String,
password: String,
) {
try {
val api = jellyfin.createApi(baseUrl = serverUrl)
val authenticationResult by api.userApi.authenticateUserByName(
username = username,
password = password,
)
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val server =
authenticationResult.serverId?.let {
JellyfinServer(
id = it,
name = serverUrl,
url = serverUrl,
)
}
if (server != null) {
val user =
authenticationResult.user?.let {
JellyfinUser(
id = it.id.toString(),
name = it.name,
serverId = server.id,
accessToken = accessToken,
)
}
if (user != null) {
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = server.id
currentUserId = user.id
}.build()
}
serverRepository.addAndChangeUser(server, user)
}
}
}
// Print session information
println(authenticationResult.sessionInfo)
} catch (err: InvalidStatusException) {
if (err.status == 401) {
// Username or password is incorrect
println("Invalid user")
}
}
}
}
@Composable
fun ServerLoginPage(
modifier: Modifier = Modifier,
viewModel: ServerManagementViewModel = viewModel(),
) {
val scope = rememberCoroutineScope()
ServerLoginForm(
modifier = modifier,
onSubmit = { serverUrl, username, password ->
scope.launch {
// Handle form submission
viewModel.loginWithPassword(serverUrl, username, password)
}
},
)
}
@Composable
fun ServerLoginForm(
modifier: Modifier = Modifier,
onSubmit: (serverUrl: String, username: String, password: String) -> Unit,
) {
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = modifier.padding(16.dp)) {
TextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { onSubmit(serverUrl, username, password) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Submit")
}
}
}

View file

@ -0,0 +1,10 @@
syntax = "proto3";
option java_package = "com.github.damontecres.dolphin.preferences";
option java_multiple_files = true;
message UserPreferences {
// The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1;
string current_user_id = 2;
}

View file

@ -1,7 +1,7 @@
[versions]
agp = "8.13.0"
kotlin = "2.2.20"
ksp = "2.2.20-2.0.3"
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken
coreKtx = "1.17.0"
appcompat = "1.7.1"
composeBom = "2025.09.00"
@ -11,7 +11,7 @@ lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
androidx-media3 = "1.8.0"
coil = "3.3.0"
jellyfin-sdk = "1.6.8"
jellyfin-sdk = "1.7.0-beta.6"
nav3Core = "1.0.0-alpha09"
lifecycleViewmodelNav3 = "1.0.0-alpha04"
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
@ -21,6 +21,8 @@ kotlinx-coroutines-android = "1.10.2"
kotlinx-serialization = "1.9.0"
protobuf-javalite = "4.32.1"
hilt = "2.57.1"
room = "2.8.0"
material3 = "1.3.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -57,13 +59,19 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
jellyfin-sdk = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin-sdk" }
jellyfin-core = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin-sdk" }
jellyfin-api = { module = "org.jellyfin.sdk:jellyfin-api", version.ref = "jellyfin-sdk" }
jellyfin-api-okhttp = { module = "org.jellyfin.sdk:jellyfin-api-okhttp-jvm", version.ref = "jellyfin-sdk" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
androidx-room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@ -74,3 +82,4 @@ kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization"
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }

View file

@ -27,4 +27,3 @@ dependencyResolutionManagement {
rootProject.name = "Dolphin"
include(":app")