Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = emptyMap()
) {
val isConfigured: Boolean
get() = serverUrl.isNotBlank()
Expand All @@ -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<Boolean> = context.dataStore.data.map { preferences ->
preferences[notificationPermissionAskedKey] ?: false
Expand All @@ -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] ?: "{}")
)
}

Expand Down Expand Up @@ -150,7 +153,8 @@ class SettingsDataStore @Inject constructor(
httpBasicAuthUsername: String,
httpBasicAuthPassword: String,
acceptInvalidCerts: Boolean,
currencyCode: String
currencyCode: String,
customHeaders: Map<String, String> = emptyMap()
) {
context.dataStore.edit { preferences ->
preferences[serverUrlKey] = serverUrl
Expand All @@ -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<String, String> {
return try {
val result = mutableMapOf<String, String>()
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, String>): 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
Expand Down
17 changes: 13 additions & 4 deletions app/src/main/java/com/matedroid/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>
)

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -168,7 +170,8 @@ class TeslamateApiFactory(
apiToken: String,
acceptInvalidCerts: Boolean,
basicAuthUsername: String = "",
basicAuthPassword: String = ""
basicAuthPassword: String = "",
customHeaders: Map<String, String> = emptyMap()
): OkHttpClient {
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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<Boolean>() }
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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ data class SettingsUiState(
val acceptInvalidCerts: Boolean = false,
val currencyCode: String = "EUR",
val showShortDrivesCharges: Boolean = false,
val customHeaders: List<Pair<String, String>> = emptyList(),
val isLoading: Boolean = true,
val isTesting: Boolean = false,
val isSaving: Boolean = false,
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values-ca/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
<string name="settings_accept_invalid_certs">Accepta certificats no vàlids</string>
<string name="settings_accept_invalid_certs_hint">Activa per a certificats autofirmats</string>
<string name="settings_accept_invalid_certs_warning">Desactivar la validació de certificats fa les connexions vulnerables a atacs man-in-the-middle. Usa només en xarxes de confiança.</string>
<!-- Custom HTTP headers section in advanced network settings -->
<string name="settings_custom_headers_title">Capçaleres HTTP personalitzades</string>
<string name="settings_custom_headers_hint">Afegeix capçaleres personalitzades enviades amb cada sol·licitud API (p.ex., per a autenticació de proxy).</string>
<string name="settings_custom_headers_key_placeholder">Nom de capçalera</string>
<string name="settings_custom_headers_value_placeholder">Valor de capçalera</string>
<string name="settings_custom_headers_add">Afegeix capçalera</string>
<string name="settings_custom_headers_remove">Elimina capçalera</string>

<!-- Display settings section -->
<string name="settings_display_title">Configuració de pantalla</string>
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
<string name="settings_accept_invalid_certs">Aceptar certificados inválidos</string>
<string name="settings_accept_invalid_certs_hint">Activar para certificados autofirmados</string>
<string name="settings_accept_invalid_certs_warning">Desactivar la validación de certificados hace las conexiones vulnerables a ataques man-in-the-middle. Usa solo en redes de confianza.</string>
<!-- Custom HTTP headers section in advanced network settings -->
<string name="settings_custom_headers_title">Cabeceras HTTP personalizadas</string>
<string name="settings_custom_headers_hint">Añade cabeceras personalizadas enviadas con cada solicitud API (p.ej., para autenticación de proxy).</string>
<string name="settings_custom_headers_key_placeholder">Nombre de cabecera</string>
<string name="settings_custom_headers_value_placeholder">Valor de cabecera</string>
<string name="settings_custom_headers_add">Añadir cabecera</string>
<string name="settings_custom_headers_remove">Eliminar cabecera</string>

<!-- Display settings section -->
<string name="settings_display_title">Ajustes de pantalla</string>
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
<string name="settings_accept_invalid_certs">Accetta certificati non validi</string>
<string name="settings_accept_invalid_certs_hint">Abilita per certificati autofirmati</string>
<string name="settings_accept_invalid_certs_warning">Disabilitare la validazione dei certificati rende le connessioni vulnerabili ad attacchi man-in-the-middle. Usa solo su reti fidate.</string>
<!-- Custom HTTP headers section in advanced network settings -->
<string name="settings_custom_headers_title">Header HTTP personalizzati</string>
<string name="settings_custom_headers_hint">Aggiungi header personalizzati inviati con ogni richiesta API (es. per autenticazione proxy).</string>
<string name="settings_custom_headers_key_placeholder">Nome header</string>
<string name="settings_custom_headers_value_placeholder">Valore header</string>
<string name="settings_custom_headers_add">Aggiungi header</string>
<string name="settings_custom_headers_remove">Rimuovi header</string>

<!-- Display settings section -->
<string name="settings_display_title">Impostazioni schermo</string>
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
<string name="settings_accept_invalid_certs">接受无效证书</string>
<string name="settings_accept_invalid_certs_hint">为自签名证书启用此选项</string>
<string name="settings_accept_invalid_certs_warning">禁用证书验证会使连接容易受到中间人攻击。请仅在可信网络上使用。</string>
<!-- Custom HTTP headers section in advanced network settings -->
<string name="settings_custom_headers_title">自定义 HTTP 请求头</string>
<string name="settings_custom_headers_hint">添加随每次 API 请求发送的自定义请求头(例如,用于代理认证)。</string>
<string name="settings_custom_headers_key_placeholder">请求头名称</string>
<string name="settings_custom_headers_value_placeholder">请求头值</string>
<string name="settings_custom_headers_add">添加请求头</string>
<string name="settings_custom_headers_remove">删除请求头</string>

<!-- Display settings section -->
<string name="settings_display_title">显示设置</string>
Expand Down
Loading