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
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 34
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 35
versionName = (project.findProperty("appVersionName") as String?) ?: "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down Expand Up @@ -171,4 +171,7 @@ dependencies {
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// Drives ResponsesHttp against a real socket so the retry/transport-mapping rules are tested
// end-to-end rather than by hand-constructing the error wrappers. Matches the okhttp version.
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pulseloop.coach.openai

import com.pulseloop.coach.attachments.CoachImagePayload
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import okhttp3.MediaType.Companion.toMediaType
Expand Down Expand Up @@ -51,21 +52,58 @@ internal object ResponsesHttp {
* [ResponsesError.Transport] on network failure and [ResponsesError.Http]
* (with the error body) on a non-2xx status.
*/
fun post(url: String, body: ByteArray, headers: Map<String, String> = emptyMap()): String {
suspend fun post(url: String, body: ByteArray, headers: Map<String, String> = emptyMap()): String {
val builder = okhttp3.Request.Builder()
.url(url)
.post(okhttp3.RequestBody.create(jsonMediaType, body))
for ((name, value) in headers) builder.header(name, value)
val request = builder.build()

val response = try {
client.newCall(builder.build()).execute()
} catch (e: Exception) {
throw ResponsesError.Transport(e)
for (attempt in 0..MAX_UNSENT_RETRIES) {
try {
// Both the call and the body read live inside the try. A read timeout can fire
// while the body is still streaming, and if that escaped uncaught it would reach
// CoachTurnError as a bare SocketTimeoutException — bypassing the transport copy
// and printing the JDK's one-word "timeout" again, the exact bug this fixes.
// `use` closes the response on every path, including a mid-read failure.
return client.newCall(request).execute().use { response ->
val text = response.body?.string() ?: ""
if (!response.isSuccessful) throw ResponsesError.Http(response.code, text)
text
}
} catch (e: ResponsesError) {
// An HTTP status is an answer from the provider, not a transport failure. Never
// retried, and never re-wrapped as Transport by the catch below.
throw e
} catch (e: Exception) {
// Only retry failures that provably never reached the provider: DNS resolution and
// TCP connect. A momentary DNS miss (radio handover, a VPN or private-DNS resolver
// still coming up) otherwise kills the whole turn and burns the user's message.
//
// A read timeout is deliberately NOT retried. OkHttp reports connect and read
// timeouts as the same SocketTimeoutException, so we cannot tell "never sent" from
// "sent, answer lost" — and re-sending the latter bills the user's API key for a
// generation that already ran.
if (!isProvablyUnsent(e) || attempt == MAX_UNSENT_RETRIES) throw ResponsesError.Transport(e)
// delay(), not Thread.sleep(): the turn is cancellable (the user leaves the coach
// screen, WorkManager stops the summary worker), and a blocking sleep would keep an
// IO thread parked and then fire the remaining doomed attempts anyway.
delay(RETRY_BACKOFF_MS shl attempt) // 400ms, 800ms
}
}
val text = response.body?.string() ?: ""
if (!response.isSuccessful) throw ResponsesError.Http(response.code, text)
return text
// Unreachable — the final attempt either returns or throws — but keeps the compiler happy.
throw IllegalStateException("request never ran")
}

/**
* True when [e] means the request never left the device, so re-sending it is side-effect free.
* `UnknownHostException` is DNS; `ConnectException` is a refused/unreachable TCP connect.
*/
internal fun isProvablyUnsent(e: Throwable): Boolean =
e is java.net.UnknownHostException || e is java.net.ConnectException

private const val MAX_UNSENT_RETRIES = 2
private const val RETRY_BACKOFF_MS = 400L
}

/** One parsed Responses-API function tool spec, provider-neutral. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ data class CoachTurnError(
reason = "No API key is configured for the selected provider. Add one in Settings → AI Coach.")
is ResponsesError.Transport -> CoachTurnError(
code = "Network",
reason = error.underlying.message ?: "The network request failed.")
reason = transportReason(error.underlying))
is ResponsesError.Http -> CoachTurnError(
code = "HTTP ${error.status}",
reason = cleanReason(error.body, error.status))
Expand All @@ -63,6 +63,44 @@ data class CoachTurnError(
else -> CoachTurnError(code = "Error", reason = error.message ?: "Something went wrong.")
}

/**
* Turns a raw transport exception into something the user can act on.
*
* The JDK's own strings are useless in a chat bubble — a blocked resolver surfaces as
* `Unable to resolve host "generativelanguage.googleapis.com": No address associated with
* hostname`, and a socket timeout as the single word `timeout`. Neither hints that the
* usual causes are an active VPN, a Private DNS entry that doesn't resolve Google hosts, or
* simply no connectivity. The underlying text is still appended so a bug report keeps it.
*/
internal fun transportReason(underlying: Throwable): String {
val detail = underlying.message?.trim().orEmpty()
return when (underlying) {
is java.net.UnknownHostException -> {
val host = hostFrom(detail)
"Couldn't look up ${host ?: "the provider"}. Your device can't resolve it right " +
"now — check your connection, and whether a VPN or a Private DNS setting is " +
"blocking it. (${detail.ifEmpty { "unknown host" }})"
}
// OkHttp reports connect and read timeouts as this same exception, and only the
// message tells them apart ("failed to connect to … after 30000ms" vs "timeout").
// The copy therefore stays neutral about which one happened, and the raw text is
// appended like every other branch so a bug report can still distinguish them.
is java.net.SocketTimeoutException ->
"The provider took too long to answer. Check your connection and try again — a " +
"VPN or a weak signal will do this. (${detail.ifEmpty { "timeout" }})"
is javax.net.ssl.SSLException ->
"The secure connection to the provider failed. This is usually a VPN, a proxy, " +
"or a network that intercepts traffic. (${detail.ifEmpty { "TLS error" }})"
is java.net.ConnectException ->
"Couldn't connect to the provider. (${detail.ifEmpty { "connection refused" }})"
else -> detail.ifEmpty { "The network request failed." }
}
}

/** Pulls `example.com` out of `Unable to resolve host "example.com": …`, if present. */
private fun hostFrom(message: String): String? =
Regex("\"([^\"]+)\"").find(message)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }

/** Extracts a readable message from a provider error body, which is
* usually JSON like `{"error":{"message":"..."}}` (OpenAI/OpenRouter) or
* `{"error":{"message":"...","status":"..."}}` (Gemini). Falls back to
Expand Down
53 changes: 48 additions & 5 deletions app/src/main/java/com/pulseloop/ui/components/TodayTiles.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.pulseloop.ui.theme.PulseColors
Expand All @@ -33,8 +35,44 @@ import com.pulseloop.ui.theme.PulseColors

/** Shared sizing so every Today tile is identical (TodayTileMetrics in Swift). */
object TodayTileMetrics {
val height = 168.dp
/** The design height, at the default font scale (1.0). */
private val baseHeight = 168.dp
val corner = 20.dp

/**
* The tile height, grown with the user's font-size setting.
*
* The grid is a fixed-height design, but everything inside a tile is sized in `sp` — so a tile
* stops fitting its own contents the moment the user raises the font scale. That is what clipped
* the Activity tile's third row (calories, issue #24 round two): the three label/value pairs need
* `3 × (12 + 18)sp + 2 × 3dp`, and only ~108dp of the 168dp tile is left after the 16dp padding,
* the eyebrow row, and the 8dp spacer. They stop fitting at roughly **fontScale 1.15**.
*
* **This is not a density/DPI problem.** dp and sp both scale by density, so a 420dpi Pixel 8 and
* a 480dpi Pixel 10 Pro XL lay out identically at the same font scale — which is why this passed
* verification on the Pixel 8. Only `fontScale` moves sp relative to dp. Shrinking the text again
* would just move the breaking point; the container has to grow instead.
*
* Sampling the scale at 16.sp (the Activity value size) rather than reading `fontScale` directly
* matters on Android 14+, where font scaling is non-linear and every sp size has its own curve —
* `fontScale` alone would under-report the growth of the text that actually overflows.
*
* Clamped at the bottom so a small-text user still gets the designed layout, and at the top so an
* accessibility-max setting can't produce an absurd grid. The clamp is safe: at the 2.0 ceiling the
* values block needs 186dp and a 1.6×-clamped tile still offers ~199dp.
*
* **Only helps tiles whose content is a plain sp-measured column** — Activity, Sleep, Chart. The
* gauge tiles are unaffected by this: `GaugeTile` and `BpRingColumn` pin `VitalRingGauge` to a dp
* literal (108.dp / 66.dp) and derive the centre font sizes from it (`size.value * 0.30f`), inside
* a `Box(modifier.size(size))` — so their centre text still overflows its ring at a high font
* scale while the tile around it has spare room. Pre-existing and separate; fixing it means making
* the gauge size font-scale-aware too, not making the tile taller.
*/
val height: Dp
@Composable get() {
val scale = with(LocalDensity.current) { 16.sp.toDp() / 16.dp }
return baseHeight * scale.coerceIn(1f, 1.6f)
}
}

/**
Expand Down Expand Up @@ -110,10 +148,15 @@ fun ActivityTile(
strokeWidth = 9.dp,
ringSpacing = 4.dp,
)
// Three metrics (steps/distance/calories) share the fixed-height tile. At 22.sp the
// third value overflowed and clipped the calories row (issue #24). Keep each pair
// compact — 16.sp value, tight line heights, and no font padding (Compose's default
// includeFontPadding adds several dp per line) — so all three fit with margin.
// Three metrics (steps/distance/calories) share one tile. At 22.sp the third value
// overflowed and clipped the calories row (issue #24), so each pair is compact —
// 16.sp value, tight line heights, and no font padding (Compose's default
// includeFontPadding adds several dp per line).
//
// That alone was not enough: this block is measured in sp while the tile was a fixed
// 168.dp, so calories clipped again on a device with a raised font scale. Shrinking the
// text further would only move the breaking point — [TodayTileMetrics.height] now grows
// the tile with the font scale instead. Don't re-pin the height to a dp literal.
val compact = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false))
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) {
values.forEach { value ->
Expand Down
63 changes: 63 additions & 0 deletions app/src/test/java/com/pulseloop/coach/CoachTurnErrorTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pulseloop.coach.orchestration

import com.pulseloop.coach.openai.ResponsesError
import com.pulseloop.coach.openai.ResponsesHttp
import org.junit.Assert.*
import org.junit.Test

Expand Down Expand Up @@ -57,6 +58,68 @@ class CoachTurnErrorTest {
assertEquals("timeout", e.reason)
}

/**
* The real-world failure this copy exists for: a Pixel with an active VPN produced
* `Unable to resolve host "generativelanguage.googleapis.com": No address associated with
* hostname` verbatim in the chat bubble. The bubble must name the host and point at the cause.
*/
@Test
fun testUnknownHostNamesTheHostAndSuggestsVpnOrDns() {
val underlying = java.net.UnknownHostException(
"Unable to resolve host \"generativelanguage.googleapis.com\": " +
"No address associated with hostname",
)
val e = CoachTurnError.from(ResponsesError.Transport(underlying))
assertEquals("Network", e.code)
assertTrue(e.reason.contains("generativelanguage.googleapis.com"))
assertTrue(e.reason.contains("VPN"))
assertTrue(e.reason.contains("Private DNS"))
// The raw text survives for bug reports.
assertTrue(e.reason.contains("No address associated with hostname"))
}

@Test
fun testSocketTimeoutIsNotJustTheWordTimeout() {
val e = CoachTurnError.from(ResponsesError.Transport(java.net.SocketTimeoutException("timeout")))
assertEquals("Network", e.code)
assertNotEquals("timeout", e.reason)
assertTrue(e.reason.contains("took too long to answer"))
}

/**
* OkHttp reports connect and read timeouts as the same exception, so the raw message is the
* only thing that tells a bug report which one happened. Every other transport branch appends
* it; this one used to drop it.
*/
@Test
fun testSocketTimeoutKeepsTheRawTextForBugReports() {
val connect = CoachTurnError.from(
ResponsesError.Transport(
java.net.SocketTimeoutException("failed to connect to api.openai.com after 30000ms"),
),
)
assertTrue(connect.reason.contains("failed to connect to api.openai.com after 30000ms"))

val read = CoachTurnError.from(ResponsesError.Transport(java.net.SocketTimeoutException("timeout")))
assertTrue(read.reason.contains("(timeout)"))
}

@Test
fun testUnknownHostWithoutAQuotedHostStillReads() {
val e = CoachTurnError.from(ResponsesError.Transport(java.net.UnknownHostException("")))
assertTrue(e.reason.contains("the provider"))
}

/** DNS and TCP connect never reached the provider, so re-sending is free. A read timeout may
* have already billed a generation — retrying it would charge the user twice. */
@Test
fun testOnlyProvablyUnsentFailuresAreRetryable() {
assertTrue(ResponsesHttp.isProvablyUnsent(java.net.UnknownHostException("dns")))
assertTrue(ResponsesHttp.isProvablyUnsent(java.net.ConnectException("refused")))
assertFalse(ResponsesHttp.isProvablyUnsent(java.net.SocketTimeoutException("timeout")))
assertFalse(ResponsesHttp.isProvablyUnsent(java.io.IOException("closed")))
}

@Test
fun testUnknownErrorFallsBackToMessage() {
val e = CoachTurnError.from(IllegalStateException("weird"))
Expand Down
Loading
Loading