implement Hilt, DataStore, and Jellyfin SDK

- Setup Hilt for dependency injection and KSP for annotation processing.
- Add DataStore for user session management with JSON serialization.
- Integrate Jellyfin SDK and create a basic `JellyfinApiClient`.
- Replace `MainActivity` with `PurefinActivity` featuring a login flow and a placeholder home screen.
- Implement custom UI components: `PurefinComplexTextField`, `PurefinPasswordField`, and `PurefinTextButton`.
- Update version catalog with new dependencies and downgrade SDK versions to 35.
This commit is contained in:
2026-01-16 19:41:52 +01:00
parent d536c42d65
commit 3fd571824f
17 changed files with 725 additions and 66 deletions

View File

@@ -1,18 +1,22 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
} }
android { android {
namespace = "hu.bbara.purefin" namespace = "hu.bbara.purefin"
compileSdk { compileSdk = 35
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "hu.bbara.purefin" applicationId = "hu.bbara.purefin"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -23,8 +27,7 @@ android {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
"proguard-rules.pro"
) )
} }
} }
@@ -37,15 +40,28 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.jellyfin.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.datastore)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
@@ -53,4 +69,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.hilt.compiler)
} }

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<application <application
android:name=".PurefinApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -12,7 +12,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">
<activity <activity
android:name=".MainActivity" android:name=".PurefinActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">

View File

@@ -1,47 +0,0 @@
package hu.bbara.purefin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import hu.bbara.purefin.ui.theme.PurefinTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PurefinTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
PurefinTheme {
Greeting("Android")
}
}

View File

@@ -0,0 +1,90 @@
package hu.bbara.purefin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.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.unit.dp
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.session.UserSessionRepository
import hu.bbara.purefin.ui.theme.PurefinTheme
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class PurefinActivity : ComponentActivity() {
@Inject
lateinit var userSessionRepository: UserSessionRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PurefinTheme {
val scope = rememberCoroutineScope()
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
if (isLoggedIn) {
HomeScreen(logout = { scope.launch { userSessionRepository.setLoggedIn(false) } })
} else {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
LoginScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
}
@Composable
fun HomeScreen(modifier: Modifier = Modifier,
logout: () -> Unit) {
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Welcome to PureFin", style = MaterialTheme.typography.headlineLarge)
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(5) { index ->
StreamingCard("Show ${index + 1}", "Description for show ${index + 1}")
}
}
Button(onClick = logout) {
Text("Logout")
}
}
}
}
@Composable
fun StreamingCard(title: String, description: String) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(description, style = MaterialTheme.typography.bodySmall)
}
}
}

View File

@@ -0,0 +1,9 @@
package hu.bbara.purefin
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PurefinApplication : Application() {
}

View File

@@ -0,0 +1,38 @@
package hu.bbara.purefin.client
import android.content.Context
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.session.UserSessionRepository
import jakarta.inject.Inject
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
@Module
@InstallIn(SingletonComponent::class)
class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
) {
val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
}
suspend fun login(username: String, password: String): Boolean {
val api = jellyfin.createApi(baseUrl = userSessionRepository.getUrl())
val response = api.userApi.authenticateUserByName(username = username, password = password)
val authResult = response.content
//TODO set loggedIn false?
val token = authResult.accessToken ?: return false
userSessionRepository.updateAccessToken(accessToken = token)
userSessionRepository.setLoggedIn(true)
return true
}
}

View File

@@ -0,0 +1,70 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinComplexTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val JellyfinOrange = Color(0xFFBD542E)
val JellyfinBg = Color(0xFF141517)
val JellyfinSurface = Color(0xFF1E2124)
val TextSecondary = Color(0xFF9EA3A8)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = TextSecondary) },
leadingIcon = if (leadingIcon != null) {
{ Icon(leadingIcon, contentDescription = null, tint = TextSecondary) }
} else null,
trailingIcon = if (trailingIcon != null) {
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = TextSecondary) }
} else null,
visualTransformation = visualTransformation,
colors = TextFieldDefaults.colors(
focusedContainerColor = JellyfinSurface,
unfocusedContainerColor = JellyfinSurface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = JellyfinOrange,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
))
}
}

View File

@@ -0,0 +1,78 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinPasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
) {
val JellyfinOrange = Color(0xFFBD542E)
val JellyfinBg = Color(0xFF141517)
val JellyfinSurface = Color(0xFF1E2124)
val TextSecondary = Color(0xFF9EA3A8)
val showField = remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = TextSecondary) },
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = TextSecondary) },
trailingIcon =
{
IconButton(
onClick = { showField.value = !showField.value },
) {
Icon(Icons.Default.Visibility, contentDescription = null, tint = TextSecondary)
}
},
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
focusedContainerColor = JellyfinSurface,
unfocusedContainerColor = JellyfinSurface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = JellyfinOrange,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
}
}

View File

@@ -0,0 +1,26 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun PurefinTextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit // Slot API
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
content = content
)
}

View File

@@ -0,0 +1,178 @@
package hu.bbara.purefin.login.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.login.viewmodel.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
loginViewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val JellyfinOrange = Color(0xFFBD542E)
val JellyfinBg = Color(0xFF141517)
val JellyfinSurface = Color(0xFF1E2124)
val TextSecondary = Color(0xFF9EA3A8)
// Observe ViewModel state
val serverUrl by loginViewModel.url.collectAsState(initial = "")
val username by loginViewModel.username.collectAsState()
val password by loginViewModel.password.collectAsState()
val coroutineScope = rememberCoroutineScope()
Column(
modifier = modifier
.fillMaxSize()
.background(JellyfinBg)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
// Logo Section
Box(
modifier = Modifier
.size(100.dp)
.background(JellyfinOrange, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie, // Replace with actual logo resource
contentDescription = "Logo",
tint = Color.White,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = TextSecondary,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp))
// Form Section
Text(
text = "Connect to Server",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = TextSecondary,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
PurefinComplexTextField(
label = "Server URL",
value = serverUrl,
onValueChange = { coroutineScope.launch { loginViewModel.setUrl(it) } },
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = { loginViewModel.setUsername(it) },
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = password,
onValueChange = { loginViewModel.setPassword(it) },
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
loginViewModel.login()
}
}
)
Spacer(modifier = Modifier.weight(0.5f))
// Footer Links
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = TextSecondary)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = TextSecondary)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@@ -0,0 +1,53 @@
package hu.bbara.purefin.login.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
@ApplicationContext private val currentContext: Context
) : ViewModel() {
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow()
val url: Flow<String> = userSessionRepository.session.map {
it.url
}
suspend fun setUrl(url: String) {
userSessionRepository.updateServerUrl(url)
}
fun setUsername(username: String) {
_username.value = username
}
fun setPassword(password: String) {
_password.value = password
}
suspend fun clearFields() {
userSessionRepository.updateServerUrl("");
_username.value = ""
_password.value = ""
}
suspend fun login(): Boolean {
return jellyfinApiClient.login(username.value, password.value)
}
}

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.session
import kotlinx.serialization.Serializable
@Serializable
data class UserSession(
val accessToken: String,
val url: String,
val loggedIn: Boolean
)

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.session
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 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)
class UserSessionModule {
@Provides
@Singleton
fun provideUserProfileDataStore(
@ApplicationContext context: Context
): DataStore<UserSession> {
return DataStoreFactory.create(
serializer = UserSessionSerializer,
produceFile = { context.dataStoreFile("user_session.json") },
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { UserSessionSerializer.defaultValue }
)
)
}
@Provides
@Singleton
fun provideUserSessionRepository(
userSessionDataStore: DataStore<UserSession>
): UserSessionRepository {
return UserSessionRepository(userSessionDataStore)
}
}

View File

@@ -0,0 +1,44 @@
package hu.bbara.purefin.session
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class UserSessionRepository @Inject constructor(
private val userSessionDataStore: DataStore<UserSession>
) {
val session: Flow<UserSession> = userSessionDataStore.data
val serverUrl: Flow<String> = session
.map { it.url }
.distinctUntilChanged()
suspend fun getUrl(): String = serverUrl.first()
val accessToken: Flow<String> = session
.map { it.accessToken }
.distinctUntilChanged()
suspend fun updateAccessToken(accessToken: String) {
userSessionDataStore.updateData {
it.copy(accessToken = accessToken)
}
}
val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged()
suspend fun setLoggedIn(isLoggedIn: Boolean) {
userSessionDataStore.updateData {
it.copy(loggedIn = isLoggedIn)
}
}
suspend fun updateServerUrl(serverUrl: String) {
userSessionDataStore.updateData {
it.copy(url = serverUrl)
}
}
}

View File

@@ -0,0 +1,32 @@
package hu.bbara.purefin.session
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
object UserSessionSerializer : Serializer<UserSession> {
override val defaultValue: UserSession
get() = UserSession(accessToken = "", url = "", loggedIn = false)
override suspend fun readFrom(input: InputStream): UserSession {
try {
return Json.decodeFromString<UserSession>(
input.readBytes().decodeToString()
)
} catch (serialization: SerializationException) {
throw CorruptionException("proto", serialization)
}
}
override suspend fun writeTo(t: UserSession, output: OutputStream) {
output.write(
Json.encodeToString(t)
.encodeToByteArray()
)
}
}

View File

@@ -1,5 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
} }

View File

@@ -1,13 +1,19 @@
[versions] [versions]
agp = "9.0.0" agp = "8.7.3"
coreKtx = "1.10.1" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.2.1"
espressoCore = "3.5.1" espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.8.0" activityCompose = "1.9.3"
kotlin = "2.0.21" kotlin = "2.1.0"
composeBom = "2024.09.00" composeBom = "2024.12.01"
jellyfin-core = "1.8.5"
hilt = "2.54"
hiltNavigationCompose = "1.2.0"
ksp = "2.1.0-1.0.29"
datastore = "1.1.1"
kotlinxSerializationJson = "1.7.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -15,6 +21,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -24,8 +31,19 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
jellyfin-core = { group = "org.jellyfin.sdk", name = "jellyfin-core", version.ref = "jellyfin-core" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }