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 androidx.core.content.edit
import com.example.mitbringsl.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the user's session state and local device identification.
*
* 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.
* Manages the user's session state, token storage, and self-hosted server URL.
*/
@Singleton
class SessionManager @Inject constructor(
@ -30,11 +26,26 @@ class SessionManager @Inject constructor(
private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank())
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)
/**
* Returns the authenticated user's ID if logged in, or [LOCAL_USER_ID] for local-only mode.
*/
fun getUserId(): String {
val remoteId = prefs.getString("user_id", null)
if (!remoteId.isNullOrBlank()) return remoteId

View file

@ -1,18 +1,16 @@
package com.example.mitbringsl.data.remote.api
import com.example.mitbringsl.data.auth.SessionManager
import okhttp3.Interceptor
import okhttp3.Response
/**
* 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 {
val token = tokenProvider()
val token = sessionManager.getToken()
val request = if (token.isNullOrBlank()) {
chain.request()
} 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
import android.content.Context
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.DynamicBaseUrlInterceptor
import com.example.mitbringsl.data.remote.api.MitbringslApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
@ -21,10 +21,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
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
@Singleton
fun provideJson(): Json = Json {
@ -35,15 +31,7 @@ object NetworkModule {
@Provides
@Singleton
fun provideTokenProvider(@ApplicationContext context: Context): () -> String? = {
// Read token from SharedPreferences (written after login).
context.getSharedPreferences("session", Context.MODE_PRIVATE)
.getString("token", null)
}
@Provides
@Singleton
fun provideOkHttpClient(tokenProvider: () -> String?): OkHttpClient {
fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
@ -52,7 +40,8 @@ object NetworkModule {
}
}
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.addInterceptor(DynamicBaseUrlInterceptor(sessionManager))
.addInterceptor(AuthInterceptor(sessionManager))
.addInterceptor(logging)
.build()
}

View file

@ -10,9 +10,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -36,8 +38,11 @@ fun AuthScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail()
var showServerUrlDialog by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val gradientBrush = Brush.verticalGradient(
colors = listOf(
@ -59,7 +64,7 @@ fun AuthScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
// App Logo / Title Header
Surface(
@ -83,13 +88,35 @@ fun AuthScreen(
)
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
Card(
@ -120,9 +147,7 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
viewModel.sessionManager.clearSession()
},
onClick = { viewModel.sessionManager.clearSession() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
@ -133,7 +158,11 @@ fun AuthScreen(
}
} else {
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,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
@ -166,6 +195,24 @@ fun AuthScreen(
}
}
if (uiState.isOidcMode) {
// OIDC / Authentik Token Field
OutlinedTextField(
value = uiState.oidcTokenInput,
onValueChange = viewModel::onOidcTokenInputChanged,
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(
@ -216,6 +263,7 @@ fun AuthScreen(
}
)
)
}
Spacer(modifier = Modifier.height(24.dp))
@ -239,7 +287,11 @@ fun AuthScreen(
)
} else {
Text(
text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden",
text = when {
uiState.isOidcMode -> "Mit Authentik / OIDC verbinden"
uiState.isRegisteringMode -> "Registrieren"
else -> "Anmelden"
},
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
@ -248,18 +300,34 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp))
// Toggle Login / Register mode
// Toggle Buttons (E-Mail vs OIDC / Authentik)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = viewModel::toggleMode) {
Text(
text = if (uiState.isRegisteringMode) {
"Bereits ein Konto? Hier anmelden"
} else {
"Noch kein Konto? Hier registrieren"
},
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
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
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@ -276,4 +344,52 @@ fun AuthScreen(
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 passwordInput: String = "",
val displayNameInput: String = "",
val oidcTokenInput: String = "",
val isRegisteringMode: Boolean = false,
val isOidcMode: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val serverUrlInput: String = "",
)
@HiltViewModel
@ -28,9 +31,20 @@ class AuthViewModel @Inject constructor(
val sessionManager: SessionManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
private val _uiState = MutableStateFlow(
AuthUiState(serverUrlInput = sessionManager.getServerUrl())
)
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) {
_uiState.update { it.copy(emailInput = value, errorMessage = null) }
}
@ -43,10 +57,24 @@ class AuthViewModel @Inject constructor(
_uiState.update { it.copy(displayNameInput = value, errorMessage = null) }
}
fun onOidcTokenInputChanged(value: String) {
_uiState.update { it.copy(oidcTokenInput = value, errorMessage = null) }
}
fun toggleMode() {
_uiState.update {
it.copy(
isRegisteringMode = !it.isRegisteringMode,
isOidcMode = false,
errorMessage = null
)
}
}
fun toggleOidcMode() {
_uiState.update {
it.copy(
isOidcMode = !it.isOidcMode,
errorMessage = null
)
}
@ -54,6 +82,16 @@ class AuthViewModel @Inject constructor(
fun submit() {
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()) {
_uiState.update { it.copy(errorMessage = "Bitte E-Mail und Passwort ausfüllen.") }
return