Feature: Self-Hosted Server URL configuration & Authentik OIDC support

- DynamicBaseUrlInterceptor: dynamically rewrites Retrofit HTTP requests to custom server URL
- SessionManager: persist custom server_url setting (e.g. https://mitbringsl.mydomain.com)
- AuthScreen & ViewModel: add Server URL chip & edit dialog + Authentik / OIDC login mode
- NetworkModule: inject SessionManager into DynamicBaseUrlInterceptor & AuthInterceptor
- ADB reinstall & launch verified 
This commit is contained in:
Tronax 2026-08-05 20:32:08 +02:00
parent 04e5c55c2a
commit 47b24dfcdf
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
6 changed files with 284 additions and 95 deletions

View file

@ -2,20 +2,16 @@ package com.example.mitbringsl.data.auth
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import com.example.mitbringsl.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Manages the user's session state and local device identification. * Manages the user's session state, token storage, and self-hosted server URL.
*
* Local-First design: Users do NOT need an account to use the app locally.
* When not logged in, [getUserId] returns a stable local ID ("local_user").
* Logging in is 100% optional and only required to sync across devices.
*/ */
@Singleton @Singleton
class SessionManager @Inject constructor( class SessionManager @Inject constructor(
@ -30,11 +26,26 @@ class SessionManager @Inject constructor(
private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank()) private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank())
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
fun getServerUrl(): String {
return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL
}
fun saveServerUrl(url: String) {
val cleanUrl = if (!url.startsWith("http://") && !url.startsWith("https://")) {
"https://$url"
} else {
url
}.trim().trimEnd('/')
prefs.edit { putString("server_url", cleanUrl) }
_serverUrl.value = cleanUrl
}
fun getToken(): String? = prefs.getString("token", null) fun getToken(): String? = prefs.getString("token", null)
/**
* Returns the authenticated user's ID if logged in, or [LOCAL_USER_ID] for local-only mode.
*/
fun getUserId(): String { fun getUserId(): String {
val remoteId = prefs.getString("user_id", null) val remoteId = prefs.getString("user_id", null)
if (!remoteId.isNullOrBlank()) return remoteId if (!remoteId.isNullOrBlank()) return remoteId

View file

@ -1,18 +1,16 @@
package com.example.mitbringsl.data.remote.api package com.example.mitbringsl.data.remote.api
import com.example.mitbringsl.data.auth.SessionManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
/** /**
* OkHttp interceptor that injects the session Bearer token into every request. * OkHttp interceptor that injects the session Bearer token into every request.
*
* [tokenProvider] is a lambda so the interceptor always reads the most recent
* token without needing to be recreated after login.
*/ */
class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { class AuthInterceptor(private val sessionManager: SessionManager) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider() val token = sessionManager.getToken()
val request = if (token.isNullOrBlank()) { val request = if (token.isNullOrBlank()) {
chain.request() chain.request()
} else { } else {

View file

@ -0,0 +1,37 @@
package com.example.mitbringsl.data.remote.api
import com.example.mitbringsl.data.auth.SessionManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
/**
* Interceptor that dynamically redirects requests to the user's configured self-hosted server URL.
*/
class DynamicBaseUrlInterceptor(
private val sessionManager: SessionManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val customServerUrl = sessionManager.getServerUrl().trim().trimEnd('/')
if (customServerUrl.isBlank()) {
return chain.proceed(original)
}
val targetUrl = customServerUrl.toHttpUrlOrNull() ?: return chain.proceed(original)
val newUrl = original.url.newBuilder()
.scheme(targetUrl.scheme)
.host(targetUrl.host)
.port(targetUrl.port)
.build()
val newRequest = original.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
}
}

View file

@ -1,13 +1,13 @@
package com.example.mitbringsl.di package com.example.mitbringsl.di
import android.content.Context
import com.example.mitbringsl.BuildConfig import com.example.mitbringsl.BuildConfig
import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.remote.api.AuthInterceptor import com.example.mitbringsl.data.remote.api.AuthInterceptor
import com.example.mitbringsl.data.remote.api.DynamicBaseUrlInterceptor
import com.example.mitbringsl.data.remote.api.MitbringslApi import com.example.mitbringsl.data.remote.api.MitbringslApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -21,10 +21,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object NetworkModule { object NetworkModule {
/**
* Lax JSON decoder: ignores unknown keys so the app doesn't crash when the
* backend adds new fields, and handles missing optional fields gracefully.
*/
@Provides @Provides
@Singleton @Singleton
fun provideJson(): Json = Json { fun provideJson(): Json = Json {
@ -35,15 +31,7 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideTokenProvider(@ApplicationContext context: Context): () -> String? = { fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient {
// Read token from SharedPreferences (written after login).
context.getSharedPreferences("session", Context.MODE_PRIVATE)
.getString("token", null)
}
@Provides
@Singleton
fun provideOkHttpClient(tokenProvider: () -> String?): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) { level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY HttpLoggingInterceptor.Level.BODY
@ -52,7 +40,8 @@ object NetworkModule {
} }
} }
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider)) .addInterceptor(DynamicBaseUrlInterceptor(sessionManager))
.addInterceptor(AuthInterceptor(sessionManager))
.addInterceptor(logging) .addInterceptor(logging)
.build() .build()
} }

View file

@ -10,9 +10,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -36,8 +38,11 @@ fun AuthScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail() val userEmail = viewModel.sessionManager.getUserEmail()
var showServerUrlDialog by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val gradientBrush = Brush.verticalGradient( val gradientBrush = Brush.verticalGradient(
colors = listOf( colors = listOf(
@ -59,7 +64,7 @@ fun AuthScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(24.dp))
// App Logo / Title Header // App Logo / Title Header
Surface( Surface(
@ -83,13 +88,35 @@ fun AuthScreen(
) )
Text( Text(
text = "Melde dich an, um deine Listen geräteübergreifend zu synchronisieren.", text = "Verbinde dich mit deinem Server oder Authentik für geräteübergreifenden Sync.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(16.dp))
// Server URL Chip
AssistChip(
onClick = { showServerUrlDialog = true },
label = {
Text(
text = "Server: $currentServerUrl",
style = MaterialTheme.typography.labelSmall
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
},
shape = RoundedCornerShape(20.dp)
)
Spacer(modifier = Modifier.height(20.dp))
// Auth Card // Auth Card
Card( Card(
@ -120,9 +147,7 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onClick = { viewModel.sessionManager.clearSession() },
viewModel.sessionManager.clearSession()
},
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error containerColor = MaterialTheme.colorScheme.error
), ),
@ -133,7 +158,11 @@ fun AuthScreen(
} }
} else { } else {
Text( Text(
text = if (uiState.isRegisteringMode) "Konto erstellen" else "Anmelden", text = when {
uiState.isOidcMode -> "Authentik / OIDC Login"
uiState.isRegisteringMode -> "Konto erstellen"
else -> "Anmelden"
},
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
@ -166,57 +195,76 @@ fun AuthScreen(
} }
} }
// Display Name (only in register mode) if (uiState.isOidcMode) {
if (uiState.isRegisteringMode) { // OIDC / Authentik Token Field
OutlinedTextField( OutlinedTextField(
value = uiState.displayNameInput, value = uiState.oidcTokenInput,
onValueChange = viewModel::onDisplayNameChanged, onValueChange = viewModel::onOidcTokenInputChanged,
label = { Text("Anzeigename (optional)") }, label = { Text("OIDC ID-Token (JWT)") },
placeholder = { Text("eyJhbGci...") },
singleLine = false,
maxLines = 4,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
viewModel.submit()
})
)
} else {
// Display Name (only in register mode)
if (uiState.isRegisteringMode) {
OutlinedTextField(
value = uiState.displayNameInput,
onValueChange = viewModel::onDisplayNameChanged,
label = { Text("Anzeigename (optional)") },
singleLine = true,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(12.dp))
}
// Email Field
OutlinedTextField(
value = uiState.emailInput,
onValueChange = viewModel::onEmailChanged,
label = { Text("E-Mail Adresse") },
singleLine = true, singleLine = true,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Password Field
OutlinedTextField(
value = uiState.passwordInput,
onValueChange = viewModel::onPasswordChanged,
label = { Text("Passwort") },
singleLine = true,
shape = RoundedCornerShape(16.dp),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.submit()
}
)
)
} }
// Email Field
OutlinedTextField(
value = uiState.emailInput,
onValueChange = viewModel::onEmailChanged,
label = { Text("E-Mail Adresse") },
singleLine = true,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
Spacer(modifier = Modifier.height(12.dp))
// Password Field
OutlinedTextField(
value = uiState.passwordInput,
onValueChange = viewModel::onPasswordChanged,
label = { Text("Passwort") },
singleLine = true,
shape = RoundedCornerShape(16.dp),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.submit()
}
)
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Submit Button // Submit Button
@ -239,7 +287,11 @@ fun AuthScreen(
) )
} else { } else {
Text( Text(
text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden", text = when {
uiState.isOidcMode -> "Mit Authentik / OIDC verbinden"
uiState.isRegisteringMode -> "Registrieren"
else -> "Anmelden"
},
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 16.sp fontSize = 16.sp
) )
@ -248,17 +300,33 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Toggle Login / Register mode // Toggle Buttons (E-Mail vs OIDC / Authentik)
TextButton(onClick = viewModel::toggleMode) { Row(
Text( modifier = Modifier.fillMaxWidth(),
text = if (uiState.isRegisteringMode) { horizontalArrangement = Arrangement.SpaceBetween
"Bereits ein Konto? Hier anmelden" ) {
} else { TextButton(onClick = viewModel::toggleMode) {
"Noch kein Konto? Hier registrieren" Text(
}, text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodySmall
) )
}
TextButton(onClick = viewModel::toggleOidcMode) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (uiState.isOidcMode) "Passwort Login" else "Authentik / OIDC",
style = MaterialTheme.typography.bodySmall
)
}
}
} }
} }
@ -276,4 +344,52 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
} }
// Server URL Dialog
if (showServerUrlDialog) {
var tempUrl by remember { mutableStateOf(currentServerUrl) }
AlertDialog(
onDismissRequest = { showServerUrlDialog = false },
title = { Text("Self-Hosted Server URL") },
text = {
Column {
Text(
text = "Gib die URL deines Mitbringsl Backend-Servers ein:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = tempUrl,
onValueChange = { tempUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://mitbringsl.example.com") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
if (tempUrl.isNotBlank()) {
viewModel.saveServerUrl(tempUrl)
showServerUrlDialog = false
}
},
shape = RoundedCornerShape(12.dp)
) {
Text("Speichern")
}
},
dismissButton = {
TextButton(onClick = { showServerUrlDialog = false }) {
Text("Abbrechen")
}
},
shape = RoundedCornerShape(24.dp)
)
}
} }

View file

@ -17,9 +17,12 @@ data class AuthUiState(
val emailInput: String = "", val emailInput: String = "",
val passwordInput: String = "", val passwordInput: String = "",
val displayNameInput: String = "", val displayNameInput: String = "",
val oidcTokenInput: String = "",
val isRegisteringMode: Boolean = false, val isRegisteringMode: Boolean = false,
val isOidcMode: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val serverUrlInput: String = "",
) )
@HiltViewModel @HiltViewModel
@ -28,9 +31,20 @@ class AuthViewModel @Inject constructor(
val sessionManager: SessionManager, val sessionManager: SessionManager,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState()) private val _uiState = MutableStateFlow(
AuthUiState(serverUrlInput = sessionManager.getServerUrl())
)
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
fun onServerUrlChanged(value: String) {
_uiState.update { it.copy(serverUrlInput = value) }
}
fun saveServerUrl(url: String) {
sessionManager.saveServerUrl(url)
_uiState.update { it.copy(serverUrlInput = sessionManager.getServerUrl(), errorMessage = null) }
}
fun onEmailChanged(value: String) { fun onEmailChanged(value: String) {
_uiState.update { it.copy(emailInput = value, errorMessage = null) } _uiState.update { it.copy(emailInput = value, errorMessage = null) }
} }
@ -43,10 +57,24 @@ class AuthViewModel @Inject constructor(
_uiState.update { it.copy(displayNameInput = value, errorMessage = null) } _uiState.update { it.copy(displayNameInput = value, errorMessage = null) }
} }
fun onOidcTokenInputChanged(value: String) {
_uiState.update { it.copy(oidcTokenInput = value, errorMessage = null) }
}
fun toggleMode() { fun toggleMode() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isRegisteringMode = !it.isRegisteringMode, isRegisteringMode = !it.isRegisteringMode,
isOidcMode = false,
errorMessage = null
)
}
}
fun toggleOidcMode() {
_uiState.update {
it.copy(
isOidcMode = !it.isOidcMode,
errorMessage = null errorMessage = null
) )
} }
@ -54,6 +82,16 @@ class AuthViewModel @Inject constructor(
fun submit() { fun submit() {
val state = _uiState.value val state = _uiState.value
if (state.isOidcMode) {
if (state.oidcTokenInput.isBlank()) {
_uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") }
return
}
onOidcTokenReceived(provider = "generic", idToken = state.oidcTokenInput.trim())
return
}
if (state.emailInput.isBlank() || state.passwordInput.isBlank()) { if (state.emailInput.isBlank() || state.passwordInput.isBlank()) {
_uiState.update { it.copy(errorMessage = "Bitte E-Mail und Passwort ausfüllen.") } _uiState.update { it.copy(errorMessage = "Bitte E-Mail und Passwort ausfüllen.") }
return return