Skip to content

Commit 4dc9a93

Browse files
committed
Timeout companion watch requests
1 parent 35d0c93 commit 4dc9a93

1 file changed

Lines changed: 45 additions & 24 deletions

File tree

companion/src/main/kotlin/com/fredapp/wbooksutil/WatchRepository.kt

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import com.google.android.gms.wearable.CapabilityClient
66
import com.google.android.gms.wearable.Node
77
import com.google.android.gms.wearable.Wearable
88
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.TimeoutCancellationException
910
import kotlinx.coroutines.tasks.await
1011
import kotlinx.coroutines.withContext
12+
import kotlinx.coroutines.withTimeout
13+
import kotlinx.coroutines.withTimeoutOrNull
1114
import java.io.InputStream
1215
import java.net.URLEncoder
1316

@@ -38,26 +41,26 @@ class WatchRepository(context: Context) {
3841
suspend fun fetchLibrary(): Result<LibrarySnapshot> = withContext(Dispatchers.IO) {
3942
val node = bestNode() ?: return@withContext Result.NoWatch
4043
runCatching {
41-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_LIST, ByteArray(0)).await()
44+
val bytes = sendRequest(node, WearProtocol.PATH_LIST, ByteArray(0))
4245
Result.Ok(LibraryListJson.decode(bytes))
43-
}.getOrElse { Result.Error(it.message ?: "Failed to fetch library") }
46+
}.getOrElse { it.toFetchResult("Failed to fetch library") }
4447
}
4548

4649
suspend fun fetchStats(): Result<StatsSummary> = withContext(Dispatchers.IO) {
4750
val node = bestNode() ?: return@withContext Result.NoWatch
4851
runCatching {
49-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_STATS, ByteArray(0)).await()
52+
val bytes = sendRequest(node, WearProtocol.PATH_STATS, ByteArray(0))
5053
Result.Ok(StatsJson.decode(bytes))
51-
}.getOrElse { Result.Error(it.message ?: "Failed to fetch stats") }
54+
}.getOrElse { it.toFetchResult("Failed to fetch stats") }
5255
}
5356

5457
suspend fun fetchSettings(): Result<SettingsSnapshot> = withContext(Dispatchers.IO) {
5558
val node = bestNode() ?: return@withContext Result.NoWatch
5659
runCatching {
57-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_SETTINGS_GET, ByteArray(0)).await()
60+
val bytes = sendRequest(node, WearProtocol.PATH_SETTINGS_GET, ByteArray(0))
5861
val snap = SettingsJson.decode(bytes) ?: return@runCatching Result.Error("Empty settings response")
5962
Result.Ok(snap)
60-
}.getOrElse { Result.Error(it.message ?: "Failed to fetch settings") }
63+
}.getOrElse { it.toFetchResult("Failed to fetch settings") }
6164
}
6265

6366
/**
@@ -69,19 +72,19 @@ class WatchRepository(context: Context) {
6972
val node = bestNode() ?: return@withContext Result.NoWatch
7073
runCatching {
7174
val payload = SettingsJson.encodeSetRequest(key, value)
72-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_SETTINGS_SET, payload).await()
75+
val bytes = sendRequest(node, WearProtocol.PATH_SETTINGS_SET, payload)
7376
val snap = SettingsJson.decode(bytes) ?: return@runCatching Result.Error("Empty settings response")
7477
Result.Ok(snap)
75-
}.getOrElse { Result.Error(it.message ?: "Failed to update setting") }
78+
}.getOrElse { it.toActionError("Failed to update setting") }
7679
}
7780

7881
suspend fun deleteBook(id: String): Result<LibrarySnapshot> = withContext(Dispatchers.IO) {
7982
val node = bestNode() ?: return@withContext Result.NoWatch
8083
runCatching {
8184
val payload = org.json.JSONObject().put("id", id).toString().toByteArray(Charsets.UTF_8)
82-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_DELETE, payload).await()
85+
val bytes = sendRequest(node, WearProtocol.PATH_DELETE, payload)
8386
Result.Ok(LibraryListJson.decode(bytes))
84-
}.getOrElse { Result.Error(it.message ?: "Delete failed") }
87+
}.getOrElse { it.toActionError("Delete failed") }
8588
}
8689

8790
suspend fun uploadBook(
@@ -170,19 +173,19 @@ class WatchRepository(context: Context) {
170173
val node = bestNode() ?: return@withContext Result.NoWatch
171174
runCatching {
172175
val payload = """{"name":${jsonString(name)}}""".toByteArray(Charsets.UTF_8)
173-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_MKDIR, payload).await()
176+
val bytes = sendRequest(node, WearProtocol.PATH_MKDIR, payload)
174177
Result.Ok(LibraryListJson.decode(bytes))
175-
}.getOrElse { Result.Error(it.message ?: "mkdir failed") }
178+
}.getOrElse { it.toActionError("mkdir failed") }
176179
}
177180

178181
suspend fun renameFolder(oldName: String, newName: String): Result<LibrarySnapshot> = withContext(Dispatchers.IO) {
179182
val node = bestNode() ?: return@withContext Result.NoWatch
180183
runCatching {
181184
val payload = """{"from":${jsonString(oldName)},"to":${jsonString(newName)}}"""
182185
.toByteArray(Charsets.UTF_8)
183-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_RENAME, payload).await()
186+
val bytes = sendRequest(node, WearProtocol.PATH_RENAME, payload)
184187
Result.Ok(LibraryListJson.decode(bytes))
185-
}.getOrElse { Result.Error(it.message ?: "rename failed") }
188+
}.getOrElse { it.toActionError("rename failed") }
186189
}
187190

188191
suspend fun moveBook(bookId: String, targetFolder: String): Result<LibrarySnapshot> =
@@ -191,9 +194,9 @@ class WatchRepository(context: Context) {
191194
runCatching {
192195
val payload = """{"id":${jsonString(bookId)},"folder":${jsonString(targetFolder)}}"""
193196
.toByteArray(Charsets.UTF_8)
194-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_MOVE, payload).await()
197+
val bytes = sendRequest(node, WearProtocol.PATH_MOVE, payload)
195198
Result.Ok(LibraryListJson.decode(bytes))
196-
}.getOrElse { Result.Error(it.message ?: "move failed") }
199+
}.getOrElse { it.toActionError("move failed") }
197200
}
198201

199202
suspend fun reorderBooks(folder: String, orderedIds: List<String>): Result<LibrarySnapshot> =
@@ -203,9 +206,9 @@ class WatchRepository(context: Context) {
203206
val order = orderedIds.joinToString(",", prefix = "[", postfix = "]") { jsonString(it) }
204207
val payload = """{"folder":${jsonString(folder)},"order":$order}"""
205208
.toByteArray(Charsets.UTF_8)
206-
val bytes = messageClient.sendRequest(node.id, WearProtocol.PATH_REORDER, payload).await()
209+
val bytes = sendRequest(node, WearProtocol.PATH_REORDER, payload)
207210
Result.Ok(LibraryListJson.decode(bytes))
208-
}.getOrElse { Result.Error(it.message ?: "reorder failed") }
211+
}.getOrElse { it.toActionError("reorder failed") }
209212
}
210213

211214
suspend fun hasReachableWatch(): Boolean = withContext(Dispatchers.IO) {
@@ -214,20 +217,36 @@ class WatchRepository(context: Context) {
214217

215218
/** Find a connected node that has the wBooks watch app installed. */
216219
private suspend fun bestNode(): Node? {
217-
val info = runCatching {
218-
capabilityClient
219-
.getCapability(WBOOKS_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
220-
.await()
221-
}.getOrNull()
220+
val info = withTimeoutOrNull(NODE_LOOKUP_TIMEOUT_MS) {
221+
runCatching {
222+
capabilityClient
223+
.getCapability(WBOOKS_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
224+
.await()
225+
}.getOrNull()
226+
}
222227
val capabilityNode = info
223228
?.nodes
224229
?.let { nodes -> nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() }
225230
if (capabilityNode != null) return capabilityNode
226231

227-
val connectedNodes = runCatching { nodeClient.connectedNodes.await() }.getOrNull().orEmpty()
232+
val connectedNodes = withTimeoutOrNull(NODE_LOOKUP_TIMEOUT_MS) {
233+
runCatching { nodeClient.connectedNodes.await() }.getOrNull().orEmpty()
234+
}.orEmpty()
228235
return connectedNodes.firstOrNull { it.isNearby } ?: connectedNodes.firstOrNull()
229236
}
230237

238+
private suspend fun sendRequest(node: Node, path: String, payload: ByteArray): ByteArray =
239+
withTimeout(REQUEST_TIMEOUT_MS) {
240+
messageClient.sendRequest(node.id, path, payload).await()
241+
}
242+
243+
private fun <T> Throwable.toFetchResult(fallback: String): Result<T> =
244+
if (this is TimeoutCancellationException) Result.NoWatch
245+
else Result.Error(message ?: fallback)
246+
247+
private fun <T> Throwable.toActionError(fallback: String): Result<T> =
248+
Result.Error(if (this is TimeoutCancellationException) "Watch did not respond." else message ?: fallback)
249+
231250
companion object {
232251
/** Must match the capability the watch app advertises in res/values/wear.xml. */
233252
const val WBOOKS_CAPABILITY = "wbooks_receiver"
@@ -260,5 +279,7 @@ class WatchRepository(context: Context) {
260279
"docx",
261280
"odt",
262281
)
282+
private const val NODE_LOOKUP_TIMEOUT_MS = 5_000L
283+
private const val REQUEST_TIMEOUT_MS = 12_000L
263284
}
264285
}

0 commit comments

Comments
 (0)