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"