diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5c9d46..1bdb2942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Custom HTTP headers**: A new "Custom HTTP Headers" section in Advanced Network Settings lets you define arbitrary key-value header pairs that are sent with every API request. Useful for authenticating through reverse proxies or gateways that require headers like `X-API-Key` or `CF-Access-Client-Id`. + ## [1.9.0-beta1] - 2026-06-09 ### Added diff --git a/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt b/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt index 76a3ad6a..26c32d65 100644 --- a/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt +++ b/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt @@ -54,7 +54,8 @@ data class AppSettings( val currencyCode: String = "EUR", val showShortDrivesCharges: Boolean = false, val teslamateBaseUrl: String = "", - val lastSelectedCarId: Int? = null + val lastSelectedCarId: Int? = null, + val customHeaders: Map = emptyMap() ) { val isConfigured: Boolean get() = serverUrl.isNotBlank() @@ -79,6 +80,7 @@ class SettingsDataStore @Inject constructor( private val lastSelectedCarIdKey = intPreferencesKey("last_selected_car_id") private val carImageOverridesKey = stringPreferencesKey("car_image_overrides") private val notificationPermissionAskedKey = booleanPreferencesKey("notification_permission_asked") + private val customHeadersKey = stringPreferencesKey("custom_headers") val notificationPermissionAsked: Flow = context.dataStore.data.map { preferences -> preferences[notificationPermissionAskedKey] ?: false @@ -95,7 +97,8 @@ class SettingsDataStore @Inject constructor( currencyCode = preferences[currencyCodeKey] ?: "EUR", showShortDrivesCharges = preferences[showShortDrivesChargesKey] ?: false, teslamateBaseUrl = preferences[teslamateBaseUrlKey] ?: "", - lastSelectedCarId = preferences[lastSelectedCarIdKey] + lastSelectedCarId = preferences[lastSelectedCarIdKey], + customHeaders = parseCustomHeadersJson(preferences[customHeadersKey] ?: "{}") ) } @@ -150,7 +153,8 @@ class SettingsDataStore @Inject constructor( httpBasicAuthUsername: String, httpBasicAuthPassword: String, acceptInvalidCerts: Boolean, - currencyCode: String + currencyCode: String, + customHeaders: Map = emptyMap() ) { context.dataStore.edit { preferences -> preferences[serverUrlKey] = serverUrl @@ -160,9 +164,33 @@ class SettingsDataStore @Inject constructor( preferences[httpBasicAuthPasswordKey] = httpBasicAuthPassword preferences[acceptInvalidCertsKey] = acceptInvalidCerts preferences[currencyCodeKey] = currencyCode + preferences[customHeadersKey] = customHeadersToJson(customHeaders) } } + private fun parseCustomHeadersJson(jsonString: String): Map { + return try { + val result = mutableMapOf() + val obj = JSONObject(jsonString) + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + result[key] = obj.getString(key) + } + result + } catch (e: Exception) { + emptyMap() + } + } + + private fun customHeadersToJson(headers: Map): String { + val obj = JSONObject() + for ((key, value) in headers) { + obj.put(key, value) + } + return obj.toString() + } + suspend fun saveHttpBasicAuth(username: String, password: String) { context.dataStore.edit { preferences -> preferences[httpBasicAuthUsernameKey] = username diff --git a/app/src/main/java/com/matedroid/di/NetworkModule.kt b/app/src/main/java/com/matedroid/di/NetworkModule.kt index fa62e894..fbd40915 100644 --- a/app/src/main/java/com/matedroid/di/NetworkModule.kt +++ b/app/src/main/java/com/matedroid/di/NetworkModule.kt @@ -98,7 +98,8 @@ private data class ApiCacheKey( val acceptInvalidCerts: Boolean, val apiToken: String, val httpBasicAuthUsername: String, - val httpBasicAuthPassword: String + val httpBasicAuthPassword: String, + val customHeaders: Map ) /** @@ -128,14 +129,15 @@ class TeslamateApiFactory( val apiToken = settings.apiToken val basicAuthUsername = settings.httpBasicAuthUsername val basicAuthPassword = settings.httpBasicAuthPassword + val customHeaders = settings.customHeaders - val cacheKey = ApiCacheKey(normalizedUrl, useInsecure, apiToken, basicAuthUsername, basicAuthPassword) + val cacheKey = ApiCacheKey(normalizedUrl, useInsecure, apiToken, basicAuthUsername, basicAuthPassword, customHeaders) // Return cached API if available apiCache[cacheKey]?.let { return it } // Create new API instance - val okHttpClient = createOkHttpClient(apiToken, useInsecure, basicAuthUsername, basicAuthPassword) + val okHttpClient = createOkHttpClient(apiToken, useInsecure, basicAuthUsername, basicAuthPassword, customHeaders) val api = Retrofit.Builder() .baseUrl(normalizedUrl) @@ -168,7 +170,8 @@ class TeslamateApiFactory( apiToken: String, acceptInvalidCerts: Boolean, basicAuthUsername: String = "", - basicAuthPassword: String = "" + basicAuthPassword: String = "", + customHeaders: Map = emptyMap() ): OkHttpClient { val builder = OkHttpClient.Builder() .addInterceptor { chain -> @@ -181,6 +184,12 @@ class TeslamateApiFactory( if (apiToken.isNotBlank()) { requestBuilder.addHeader("Authorization", "Bearer $apiToken") } + // Custom headers are applied last so they can override built-in headers if needed + for ((key, value) in customHeaders) { + if (key.isNotBlank()) { + requestBuilder.header(key, value) + } + } chain.proceed(requestBuilder.build()) } .connectTimeout(1, TimeUnit.SECONDS) diff --git a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt index ff2c9b3b..5f9f7b8c 100644 --- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -129,6 +130,10 @@ fun SettingsScreen( onShowShortDrivesChargesChange = viewModel::updateShowShortDrivesCharges, onTestConnection = viewModel::testConnection, onSave = { viewModel.saveSettings(onNavigateToDashboard) }, + onAddCustomHeader = viewModel::addCustomHeader, + onRemoveCustomHeader = viewModel::removeCustomHeader, + onCustomHeaderKeyChange = viewModel::updateCustomHeaderKey, + onCustomHeaderValueChange = viewModel::updateCustomHeaderValue, onPalettePreview = onNavigateToPalettePreview, onForceResync = viewModel::forceResync, onSimulateTpmsWarning = viewModel::simulateTpmsWarning, @@ -200,6 +205,10 @@ private fun SettingsContent( onShowShortDrivesChargesChange: (Boolean) -> Unit, onTestConnection: () -> Unit, onSave: () -> Unit, + onAddCustomHeader: () -> Unit = {}, + onRemoveCustomHeader: (Int) -> Unit = {}, + onCustomHeaderKeyChange: (Int, String) -> Unit = { _, _ -> }, + onCustomHeaderValueChange: (Int, String) -> Unit = { _, _ -> }, onPalettePreview: () -> Unit = {}, onForceResync: () -> Unit = {}, onSimulateTpmsWarning: (TirePosition) -> Unit = {}, @@ -431,6 +440,92 @@ private fun SettingsContent( } } + Spacer(modifier = Modifier.height(16.dp)) + + // Custom HTTP Headers + Text( + text = stringResource(R.string.settings_custom_headers_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.settings_custom_headers_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + + // Per-row visibility state for header values; grows/shrinks with the list + val headerValueVisible = remember { mutableStateListOf() } + while (headerValueVisible.size < uiState.customHeaders.size) headerValueVisible.add(false) + while (headerValueVisible.size > uiState.customHeaders.size) headerValueVisible.removeLastOrNull() + + uiState.customHeaders.forEachIndexed { index, (key, value) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = key, + onValueChange = { onCustomHeaderKeyChange(index, it) }, + placeholder = { Text(stringResource(R.string.settings_custom_headers_key_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true, + enabled = !uiState.isTesting && !uiState.isSaving + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = value, + onValueChange = { onCustomHeaderValueChange(index, it) }, + placeholder = { Text(stringResource(R.string.settings_custom_headers_value_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true, + visualTransformation = if (headerValueVisible[index]) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { headerValueVisible[index] = !headerValueVisible[index] }) { + Icon( + imageVector = if (headerValueVisible[index]) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = stringResource( + if (headerValueVisible[index]) R.string.hide_password else R.string.show_password + ) + ) + } + }, + enabled = !uiState.isTesting && !uiState.isSaving + ) + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { onRemoveCustomHeader(index) }, + enabled = !uiState.isTesting && !uiState.isSaving + ) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = stringResource(R.string.settings_custom_headers_remove), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + + OutlinedButton( + onClick = onAddCustomHeader, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isTesting && !uiState.isSaving + ) { + Text(stringResource(R.string.settings_custom_headers_add)) + } + Spacer(modifier = Modifier.height(8.dp)) } diff --git a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt index 680f8637..3f0d48ee 100644 --- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt @@ -42,6 +42,7 @@ data class SettingsUiState( val acceptInvalidCerts: Boolean = false, val currencyCode: String = "EUR", val showShortDrivesCharges: Boolean = false, + val customHeaders: List> = emptyList(), val isLoading: Boolean = true, val isTesting: Boolean = false, val isSaving: Boolean = false, @@ -105,6 +106,7 @@ class SettingsViewModel @Inject constructor( acceptInvalidCerts = settings.acceptInvalidCerts, currencyCode = settings.currencyCode, showShortDrivesCharges = settings.showShortDrivesCharges, + customHeaders = settings.customHeaders.entries.map { it.key to it.value }, isLoading = false ) } @@ -158,6 +160,30 @@ class SettingsViewModel @Inject constructor( } } + fun addCustomHeader() { + _uiState.value = _uiState.value.copy( + customHeaders = _uiState.value.customHeaders + ("" to "") + ) + } + + fun removeCustomHeader(index: Int) { + _uiState.value = _uiState.value.copy( + customHeaders = _uiState.value.customHeaders.toMutableList().also { it.removeAt(index) } + ) + } + + fun updateCustomHeaderKey(index: Int, key: String) { + val updated = _uiState.value.customHeaders.toMutableList() + updated[index] = key to updated[index].second + _uiState.value = _uiState.value.copy(customHeaders = updated) + } + + fun updateCustomHeaderValue(index: Int, value: String) { + val updated = _uiState.value.customHeaders.toMutableList() + updated[index] = updated[index].first to value + _uiState.value = _uiState.value.copy(customHeaders = updated) + } + fun updateAcceptInvalidCerts(accept: Boolean) { _uiState.value = _uiState.value.copy( acceptInvalidCerts = accept, @@ -293,7 +319,10 @@ class SettingsViewModel @Inject constructor( httpBasicAuthUsername = _uiState.value.httpBasicAuthUsername, httpBasicAuthPassword = _uiState.value.httpBasicAuthPassword, acceptInvalidCerts = _uiState.value.acceptInvalidCerts, - currencyCode = _uiState.value.currencyCode + currencyCode = _uiState.value.currencyCode, + customHeaders = _uiState.value.customHeaders + .filter { (key, _) -> key.isNotBlank() } + .toMap() ) // Trigger sync after settings are saved (handles first-time setup) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4fe898c2..148094ea 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -237,6 +237,13 @@ Accepta certificats no vàlids Activa per a certificats autofirmats Desactivar la validació de certificats fa les connexions vulnerables a atacs man-in-the-middle. Usa només en xarxes de confiança. + + Capçaleres HTTP personalitzades + Afegeix capçaleres personalitzades enviades amb cada sol·licitud API (p.ex., per a autenticació de proxy). + Nom de capçalera + Valor de capçalera + Afegeix capçalera + Elimina capçalera Configuració de pantalla diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0ca69fea..e1c80513 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -237,6 +237,13 @@ Aceptar certificados inválidos Activar para certificados autofirmados Desactivar la validación de certificados hace las conexiones vulnerables a ataques man-in-the-middle. Usa solo en redes de confianza. + + Cabeceras HTTP personalizadas + Añade cabeceras personalizadas enviadas con cada solicitud API (p.ej., para autenticación de proxy). + Nombre de cabecera + Valor de cabecera + Añadir cabecera + Eliminar cabecera Ajustes de pantalla diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e890f849..9b31810a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -237,6 +237,13 @@ Accetta certificati non validi Abilita per certificati autofirmati Disabilitare la validazione dei certificati rende le connessioni vulnerabili ad attacchi man-in-the-middle. Usa solo su reti fidate. + + Header HTTP personalizzati + Aggiungi header personalizzati inviati con ogni richiesta API (es. per autenticazione proxy). + Nome header + Valore header + Aggiungi header + Rimuovi header Impostazioni schermo diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 441c8395..4d8a5897 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -237,6 +237,13 @@ 接受无效证书 为自签名证书启用此选项 禁用证书验证会使连接容易受到中间人攻击。请仅在可信网络上使用。 + + 自定义 HTTP 请求头 + 添加随每次 API 请求发送的自定义请求头(例如,用于代理认证)。 + 请求头名称 + 请求头值 + 添加请求头 + 删除请求头 显示设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a74226..f1f3d897 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -240,6 +240,13 @@ Accept invalid certificates Enable for self-signed certificates Disabling certificate validation makes connections vulnerable to man-in-the-middle attacks. Only use on trusted networks. + + Custom HTTP Headers + Add custom headers sent with every API request (e.g., for proxy authentication). + Header name + Header value + Add header + Remove header Display Settings