From 5008ffb6ba0f59ec15dfe85591c97e30d1d01586 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:33:13 -0400 Subject: [PATCH 1/2] Allow cleartext loopback traffic in wear debug builds Mirror the phone module's debug network security config in the wear module, so debug watch builds can talk to a local dev server over plain http on localhost/127.0.0.1/10.0.2.2. Release builds are unaffected. --- sheaf/wear/src/debug/AndroidManifest.xml | 5 +++++ .../wear/src/debug/res/xml/network_security_config.xml | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 sheaf/wear/src/debug/AndroidManifest.xml create mode 100644 sheaf/wear/src/debug/res/xml/network_security_config.xml diff --git a/sheaf/wear/src/debug/AndroidManifest.xml b/sheaf/wear/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..01dfc0a --- /dev/null +++ b/sheaf/wear/src/debug/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/sheaf/wear/src/debug/res/xml/network_security_config.xml b/sheaf/wear/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..14720d4 --- /dev/null +++ b/sheaf/wear/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + 10.0.2.2 + + localhost + 127.0.0.1 + + From 605b119a569f8b206427fb845e975a449cdce2c7 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:15:59 -0400 Subject: [PATCH 2/2] Fix wear "Failed to load": read response bodies inside the IO context OkHttp's execute() returns once headers arrive; body.string() still reads from the socket, so reading it on Main threw NetworkOnMainThreadException for any response that wasn't already buffered - reliably for member lists, intermittently for login. Drain bodies in the same IO context as the call, and log the previously swallowed failures in the store and login screen. --- .../lupine/sheaf/wear/data/WearApiClient.kt | 57 ++++++++++++------- .../lupine/sheaf/wear/data/WearStore.kt | 1 + .../wear/presentation/WearLoginScreen.kt | 1 + 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearApiClient.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearApiClient.kt index b581c5b..637a710 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearApiClient.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearApiClient.kt @@ -51,14 +51,29 @@ class WearApiClient(private val auth: WearAuthManager) { private fun url(path: String) = "${auth.baseUrl.trimEnd('/')}$path" // Executes the built request, auto-retries once after a 401 by refreshing the token. + // Result of a fully-drained HTTP exchange. The body is read inside the + // IO context along with the request itself: OkHttp's execute() returns + // once headers arrive, and body.string() still reads from the socket, + // so calling it on Main throws NetworkOnMainThreadException whenever + // the body wasn't already buffered - which for anything bigger than a + // couple of segments is always. + private class RawResponse(val code: Int, val body: String?) + + private suspend fun call(buildRequest: () -> Request): RawResponse = + withContext(Dispatchers.IO) { + http.newCall(buildRequest()).execute().use { response -> + RawResponse(response.code, runCatching { response.body?.string() }.getOrNull()) + } + } + private suspend fun execute(buildRequest: () -> Request): String { - var response = withContext(Dispatchers.IO) { http.newCall(buildRequest()).execute() } + var response = call(buildRequest) if (response.code == 401) { tryRefreshToken() - response = withContext(Dispatchers.IO) { http.newCall(buildRequest()).execute() } + response = call(buildRequest) } - if (!response.isSuccessful) throw WearApiException(response.code) - return response.body!!.string() + if (response.code !in 200..299) throw WearApiException(response.code) + return response.body ?: throw WearApiException(response.code) } private suspend fun tryRefreshToken() { @@ -77,16 +92,17 @@ class WearApiClient(private val auth: WearAuthManager) { if (current != refreshAtEntry) return try { val body = """{"refresh_token":"$current"}""" - val request = Request.Builder() - .url(url("/v1/auth/refresh")) - .post(body.toRequestBody("application/json".toMediaType())) - .build() - val response = withContext(Dispatchers.IO) { http.newCall(request).execute() } - if (!response.isSuccessful) { + val response = call { + Request.Builder() + .url(url("/v1/auth/refresh")) + .post(body.toRequestBody("application/json".toMediaType())) + .build() + } + if (response.code !in 200..299 || response.body == null) { auth.clearCredentials() throw WearApiException(401) } - val pair = moshi.adapter(TokenPair::class.java).fromJson(response.body!!.string())!! + val pair = moshi.adapter(TokenPair::class.java).fromJson(response.body)!! auth.saveCredentials(auth.baseUrl, pair.accessToken, pair.refreshToken) } catch (e: WearApiException) { throw e @@ -104,19 +120,16 @@ class WearApiClient(private val auth: WearAuthManager) { suspend fun login(baseUrl: String, email: String, password: String) { val cleanUrl = baseUrl.trimEnd('/') val bodyJson = moshi.adapter(LoginBody::class.java).toJson(LoginBody(email, password)) - val response = withContext(Dispatchers.IO) { - http.newCall( - Request.Builder() - .url("$cleanUrl/v1/auth/login") - .post(bodyJson.toRequestBody("application/json".toMediaType())) - .build() - ).execute() + val response = call { + Request.Builder() + .url("$cleanUrl/v1/auth/login") + .post(bodyJson.toRequestBody("application/json".toMediaType())) + .build() } - if (!response.isSuccessful) { - val errorBody = runCatching { response.body?.string() }.getOrNull() - throw WearApiException(response.code, errorBody) + if (response.code !in 200..299) { + throw WearApiException(response.code, response.body) } - val pair = moshi.adapter(TokenPair::class.java).fromJson(response.body!!.string())!! + val pair = moshi.adapter(TokenPair::class.java).fromJson(response.body!!)!! auth.saveCredentials(cleanUrl, pair.accessToken, pair.refreshToken) } diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt index 90e6a64..3a03f6f 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt @@ -97,6 +97,7 @@ class WearStore( systems.lupine.sheaf.wear.complications.WearLoadStatus.OK, ) } catch (e: Exception) { + android.util.Log.e("WearStore", "refreshNow failed", e) error.value = e.message ?: "Failed to load" systems.lupine.sheaf.wear.complications.writeLoadStatus( context, diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/presentation/WearLoginScreen.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/presentation/WearLoginScreen.kt index 07de57a..698aecf 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/presentation/WearLoginScreen.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/presentation/WearLoginScreen.kt @@ -54,6 +54,7 @@ fun WearLoginScreen( error = null runCatching { apiClient.login(serverUrl.trim(), email.trim(), password) } .onFailure { e -> + android.util.Log.e("WearLogin", "login failed", e) error = when { e is WearApiException && e.code == 401 -> "Incorrect email or password" else -> e.message ?: "Login failed"