Skip to content
Merged
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: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@ jobs:
if: steps.tag_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BODY: ${{ steps.notes.outputs.body }}
run: |
BASE="${{ steps.version.outputs.artifact_base }}"
EXTRA_ASSETS=""
if [ -f "app/build/outputs/mapping/release/mapping.txt" ]; then
EXTRA_ASSETS="app/build/outputs/mapping/release/mapping.txt"
fi
echo '${{ steps.notes.outputs.body }}' > release_notes.txt
printf '%s\n' "$RELEASE_BODY" > release_notes.txt
gh release create "${{ steps.version.outputs.tag }}" \
--title "${{ steps.version.outputs.tag }}" \
--notes-file release_notes.txt \
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [1.2.3] - 2026-07-23

### Security

- Profile credentials (Nightscout API tokens) are now excluded from Android backup so they are no longer included in cloud/adb backups
- Android Auto host validation now restricts to Google's known Auto/Automotive hosts in release builds instead of accepting any host
- Cleartext HTTP is now explicitly permitted via a network security config for self-hosted Nightscout instances reached over a VPN/LAN without TLS; the profile editor warns when an `http://` URL is entered

### Fixed

- Duplicate glucose screens no longer get pushed onto the navigation stack when profiles change while a screen is covered by another
- mmol/L values and graph time labels now render consistently regardless of device locale
- Threshold fetch no longer fails entirely when target-range values are missing from the Nightscout status response; sensible defaults are used instead
- mg/dL delta display now rounds instead of truncating, matching the main reading
- Release workflow no longer breaks when changelog notes contain an apostrophe (shell quoting fix)

### Changed

- `NightscoutApiFactory`'s per-host API client cache is now thread-safe

## [1.2.2] - 2026-07-22

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ android {
applicationId = "de.autosugar"
minSdk = 26
targetSdk = 36
versionCode = 7
versionName = "1.2.2"
versionCode = 8
versionName = "1.2.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/Theme.AutoSugar"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true">

<!-- Phone-side entry point -->
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/java/de/autosugar/car/AutoSugarCarAppService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package de.autosugar.car
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.validation.HostValidator
import de.autosugar.BuildConfig
import de.autosugar.data.repository.NightscoutRepository
import de.autosugar.data.storage.AppPreferencesDataStore
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -15,7 +16,13 @@ class AutoSugarCarAppService : CarAppService() {
@Inject lateinit var appPrefs: AppPreferencesDataStore

override fun createHostValidator(): HostValidator =
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
if (BuildConfig.DEBUG) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}

override fun onCreateSession(): Session = AutoSugarSession(repository, appPrefs)
}
6 changes: 3 additions & 3 deletions app/src/main/java/de/autosugar/car/GlucoseGraphRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ private fun drawTimeLabelsAndDropPins(
if (i % 2 != 1) return@forEachIndexed
val label = when (unit) {
GlucoseUnit.MG_DL -> sgv.toInt().toString()
GlucoseUnit.MMOL_L -> "%.0f".format(sgv / 18f)
GlucoseUnit.MMOL_L -> "%.0f".format(Locale.US, sgv / 18f)
}
canvas.drawText(label, yLabelX, y - 5f, labelPaint)
}
Expand All @@ -142,7 +142,7 @@ private fun drawTimeLabelsAndDropPins(
labelPaint.color = Color.argb(160, 200, 200, 200)
labelPaint.textAlign = Paint.Align.CENTER
canvas.drawText(
"%02d:30".format(cal.get(java.util.Calendar.HOUR_OF_DAY)),
"%02d:30".format(Locale.US, cal.get(java.util.Calendar.HOUR_OF_DAY)),
xOf(tHalf), pad + plotH - 4f, labelPaint,
)
tHalf += msPerHour
Expand Down Expand Up @@ -194,7 +194,7 @@ private fun drawDropPinsAndHourLines(
labelPaint.color = Color.argb(160, 200, 200, 200)
labelPaint.textAlign = Paint.Align.CENTER
canvas.drawText(
"%02d:00".format(cal.get(java.util.Calendar.HOUR_OF_DAY)),
"%02d:00".format(Locale.US, cal.get(java.util.Calendar.HOUR_OF_DAY)),
x, pad + plotH - 4f, labelPaint,
)
}
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/java/de/autosugar/car/GlucoseScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ class GlucoseScreen(
}

val delta = currentEntry.delta ?: return
val projected15 = sgv + delta * 3 // 3 readings × ~5 min = 15 min ahead
// Assumes ~5-min reading cadence: 3 readings × 5 min = 15 min ahead. Sources
// posting at other intervals will skew this projection.
val projected15 = sgv + delta * 3

if (projected15 > thresholds.bgHigh && sgv <= thresholds.bgHigh &&
now - lastPredictedHighAlertMs > alertCooldownMs
Expand All @@ -157,7 +159,7 @@ class GlucoseScreen(
}
}

// region TabTemplate (CarApi >= 6, 2–5 profiles)
// region TabTemplate (CarApi >= 6, 2–4 profiles)

@RequiresCarApi(6)
private fun buildTabTemplate(): Template {
Expand Down Expand Up @@ -222,7 +224,9 @@ class GlucoseScreen(
.build()
).build()
profiles.size in 2..5 -> {
// Numbered icon fallback when TabTemplate is unavailable
// Numbered icon fallback when TabTemplate is unavailable (CarApi < 6).
// The upper bound of 5 here is unreachable in practice since the ">4"
// branch above already matches size 5 — this only ever runs for 2..4.
profiles.forEachIndexed { index, profile ->
builder.addAction(
Action.Builder()
Expand Down
18 changes: 9 additions & 9 deletions app/src/main/java/de/autosugar/car/LoadingScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import de.autosugar.R
import de.autosugar.data.repository.NightscoutRepository
import de.autosugar.data.storage.AppPreferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

class LoadingScreen(
Expand All @@ -18,17 +19,16 @@ class LoadingScreen(

init {
lifecycleScope.launch {
repository.profilesFlow.collect { profiles ->
val nextScreen = when {
profiles.isEmpty() -> NoProfilesScreen(carContext, repository, appPrefs)
else -> {
val activeId = repository.activeProfileId.value ?: profiles.first().id
repository.setActiveProfile(activeId)
GlucoseScreen(carContext, repository, appPrefs, activeId)
}
val profiles = repository.profilesFlow.first()
val nextScreen = when {
profiles.isEmpty() -> NoProfilesScreen(carContext, repository, appPrefs)
else -> {
val activeId = repository.activeProfileId.value ?: profiles.first().id
repository.setActiveProfile(activeId)
GlucoseScreen(carContext, repository, appPrefs, activeId)
}
screenManager.push(nextScreen)
}
screenManager.push(nextScreen)
}
}

Expand Down
12 changes: 5 additions & 7 deletions app/src/main/java/de/autosugar/car/NoProfilesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import de.autosugar.R
import de.autosugar.data.repository.NightscoutRepository
import de.autosugar.data.storage.AppPreferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

class NoProfilesScreen(
Expand All @@ -18,13 +19,10 @@ class NoProfilesScreen(

init {
lifecycleScope.launch {
repository.profilesFlow.collect { profiles ->
if (profiles.isNotEmpty()) {
val id = profiles.first().id
repository.setActiveProfile(id)
screenManager.push(GlucoseScreen(carContext, repository, appPrefs, id))
}
}
val profiles = repository.profilesFlow.first { it.isNotEmpty() }
val id = profiles.first().id
repository.setActiveProfile(id)
screenManager.push(GlucoseScreen(carContext, repository, appPrefs, id))
}
}

Expand Down
7 changes: 4 additions & 3 deletions app/src/main/java/de/autosugar/data/model/GlucoseEntry.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.autosugar.data.model

import java.util.Locale
import kotlin.math.roundToInt

data class GlucoseEntry(
Expand All @@ -17,7 +18,7 @@ data class GlucoseEntry(
/** Returns the display value converted to the requested unit. */
fun displayValue(unit: GlucoseUnit): String = when (unit) {
GlucoseUnit.MG_DL -> sgv.roundToInt().toString()
GlucoseUnit.MMOL_L -> "%.1f".format(sgv / 18.0)
GlucoseUnit.MMOL_L -> "%.1f".format(Locale.US, sgv / 18.0)
}

/** Returns the delta converted to the requested unit with sign prefix. */
Expand All @@ -29,8 +30,8 @@ data class GlucoseEntry(
}
val sign = if (converted >= 0) "+" else ""
return when (unit) {
GlucoseUnit.MG_DL -> "$sign${converted.toInt()}"
GlucoseUnit.MMOL_L -> "$sign${"%.1f".format(converted)}"
GlucoseUnit.MG_DL -> "$sign${converted.roundToInt()}"
GlucoseUnit.MMOL_L -> "$sign${"%.1f".format(Locale.US, converted)}"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -19,11 +20,11 @@ class NightscoutApiFactory @Inject constructor() {
.build()

/** Cache of one Retrofit-backed API instance per normalized base URL. */
private val cache = mutableMapOf<String, NightscoutApi>()
private val cache = ConcurrentHashMap<String, NightscoutApi>()

fun get(baseUrl: String): NightscoutApi {
val normalized = baseUrl.trimEnd('/') + "/"
return cache.getOrPut(normalized) { buildApi(normalized) }
return cache.computeIfAbsent(normalized) { buildApi(it) }
}

fun invalidate(baseUrl: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ class NightscoutRepository @Inject constructor(
val t = api.getStatus(token = profile.apiToken.ifBlank { null }).settings?.thresholds
GlucoseThresholds(
bgLow = t?.bgLow?.roundToInt() ?: 70,
bgTargetBottom = t?.bgTargetBottom?.roundToInt() ?: error("bgTargetBottom not in status response"),
bgTargetTop = t?.bgTargetTop?.roundToInt() ?: error("bgTargetTop not in status response"),
bgTargetBottom = t?.bgTargetBottom?.roundToInt() ?: 70,
bgTargetTop = t?.bgTargetTop?.roundToInt() ?: 180,
bgHigh = t?.bgHigh?.roundToInt() ?: 180,
)
}
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,14 @@ fun ProfileEditScreen(
}

val isLoading = uiState is ProfileEditUiState.Loading
val urlScheme = runCatching { java.net.URI(baseUrl.trim()).scheme }.getOrNull()
val isValidUrl = runCatching {
val uri = java.net.URI(baseUrl.trim())
uri.scheme in listOf("http", "https") &&
!uri.host.isNullOrEmpty() &&
uri.host.contains('.')
}.getOrDefault(false)
val isCleartextUrl = isValidUrl && urlScheme == "http"
val canSave = !isLoading && displayName.isNotBlank() && isValidUrl

val title = if (profileId == null) {
Expand Down Expand Up @@ -180,9 +182,11 @@ fun ProfileEditScreen(
imeAction = ImeAction.Next,
),
isError = baseUrl.isNotBlank() && !isValidUrl,
supportingText = if (baseUrl.isNotBlank() && !isValidUrl) {
{ Text(stringResource(R.string.error_invalid_url)) }
} else null,
supportingText = when {
baseUrl.isNotBlank() && !isValidUrl -> { { Text(stringResource(R.string.error_invalid_url)) } }
isCleartextUrl -> { { Text(stringResource(R.string.warning_cleartext_url)) } }
else -> null
},
enabled = !isLoading,
)
OutlinedTextField(
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">الإصدار %s</string>
<string name="error_invalid_url">يجب أن يبدأ بـ http:// أو https://</string>
<string name="warning_token_overpowered">يمتلك هذا الرمز المميز صلاحيات الكتابة. يحتاج AutoSugar إلى صلاحيات القراءة فقط — يُنصح باستخدام رمز مميز للقراءة فقط لمزيد من الأمان.</string>
<string name="warning_cleartext_url">غير مشفر — استخدمه فقط على شبكة موثوقة أو VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Version %s</string>
<string name="error_invalid_url">Muss mit http:// oder https:// beginnen</string>
<string name="warning_token_overpowered">Dieses Token hat Schreibrechte. AutoSugar benötigt nur Lesezugriff – ein Token mit reinen Leserechten wird für mehr Sicherheit empfohlen.</string>
<string name="warning_cleartext_url">Unverschlüsselt – nur in einem vertrauenswürdigen Netzwerk oder VPN verwenden</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Versión %s</string>
<string name="error_invalid_url">Debe comenzar con http:// o https://</string>
<string name="warning_token_overpowered">Este token tiene permisos de escritura. AutoSugar solo requiere acceso de lectura — se recomienda un token de solo lectura para mayor seguridad.</string>
<string name="warning_cleartext_url">Sin cifrar — usar solo en una red de confianza o VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Version %s</string>
<string name="error_invalid_url">Doit commencer par http:// ou https://</string>
<string name="warning_token_overpowered">Ce jeton dispose de permissions en écriture. AutoSugar ne nécessite qu\'un accès en lecture — un jeton en lecture seule est recommandé pour plus de sécurité.</string>
<string name="warning_cleartext_url">Non chiffré — à utiliser uniquement sur un réseau de confiance ou VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-hi/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">संस्करण %s</string>
<string name="error_invalid_url">http:// या https:// से शुरू होना चाहिए</string>
<string name="warning_token_overpowered">इस टोकन में लिखने की अनुमतियाँ हैं। AutoSugar को केवल पढ़ने की पहुँच चाहिए — बेहतर सुरक्षा के लिए रीड-ओनली टोकन की अनुशंसा की जाती है।</string>
<string name="warning_cleartext_url">असुरक्षित — केवल किसी विश्वसनीय नेटवर्क या VPN पर उपयोग करें</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Versione %s</string>
<string name="error_invalid_url">Deve iniziare con http:// o https://</string>
<string name="warning_token_overpowered">Questo token ha permessi di scrittura. AutoSugar richiede solo l\'accesso in lettura — si consiglia un token di sola lettura per una maggiore sicurezza.</string>
<string name="warning_cleartext_url">Non crittografato — da usare solo su una rete affidabile o VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">バージョン %s</string>
<string name="error_invalid_url">http:// または https:// で始まる必要があります</string>
<string name="warning_token_overpowered">このトークンには書き込み権限があります。AutoSugar が必要とするのは読み取りアクセスのみです — セキュリティ向上のため、読み取り専用トークンの使用を推奨します。</string>
<string name="warning_cleartext_url">暗号化されていません — 信頼できるネットワークまたは VPN 上でのみ使用してください</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Versie %s</string>
<string name="error_invalid_url">Moet beginnen met http:// of https://</string>
<string name="warning_token_overpowered">Dit token heeft schrijfrechten. AutoSugar heeft alleen leestoegang nodig — een alleen-lezen token wordt aanbevolen voor betere beveiliging.</string>
<string name="warning_cleartext_url">Niet versleuteld — alleen gebruiken op een vertrouwd netwerk of VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-pt/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">Versão %s</string>
<string name="error_invalid_url">Deve começar com http:// ou https://</string>
<string name="warning_token_overpowered">Este token tem permissões de escrita. O AutoSugar requer apenas acesso de leitura — um token somente leitura é recomendado para maior segurança.</string>
<string name="warning_cleartext_url">Não criptografado — usar apenas em uma rede confiável ou VPN</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
<string name="label_app_version">版本 %s</string>
<string name="error_invalid_url">必须以 http:// 或 https:// 开头</string>
<string name="warning_token_overpowered">此令牌具有写入权限。AutoSugar 仅需要读取访问权限——建议使用只读令牌以提高安全性。</string>
<string name="warning_cleartext_url">未加密——仅可在受信任的网络或 VPN 上使用</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@
<string name="label_app_version">Version %s</string>
<string name="error_invalid_url">Must start with http:// or https://</string>
<string name="warning_token_overpowered">This token has write permissions. AutoSugar only requires read access — a read-only token is recommended for better security.</string>
<string name="warning_cleartext_url">Unencrypted — only use on a trusted or VPN network</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Legacy Auto Backup rules (API 26-30). See data_extraction_rules.xml for API 31+.
https://developer.android.com/guide/topics/data/autobackup#XMLSyntax
-->
<full-backup-content>
<exclude domain="file" path="datastore/profiles.preferences_pb" />
</full-backup-content>
9 changes: 9 additions & 0 deletions app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/profiles.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/profiles.preferences_pb" />
</device-transfer>
</data-extraction-rules>
9 changes: 9 additions & 0 deletions app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nightscout instances are frequently self-hosted on a home network or LAN and reached
over a VPN without a public TLS certificate. Cleartext is intentionally permitted here;
the editor UI warns the user when they enter an http:// URL.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
Loading
Loading