A cheap attempt at a native Bluesky client for Android
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: Fix avatar loading, networking resilience, content warnings, and notification highlights

- Add error fallback to all AsyncImage calls so failed loads show placeholder instead of disappearing
- Bump Coil disk cache from 2% to 5%
- Fix session restore treating all errors as auth errors (only clear session on real auth failures)
- Fix mutex deadlock in create() using try/finally
- Close all one-off HttpClient instances to prevent resource leaks
- Add retry (3x exponential backoff) to all HTTP clients including login, PDS resolution, and uploads
- Bump video upload timeout to 5 minutes for mobile networks
- Increase all HTTP timeouts from 15s to 30s
- Clear error state after displaying snackbar to prevent stale errors
- Content warnings now show author info (avatar, name, handle) and only hide post content
- Highlight unread notifications with primaryContainer tint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

geesawra 4aca9d18 9aff658f

+246 -138
+1
app/src/main/java/industries/geesawra/monarch/AccountSwitcherSheet.kt
··· 83 83 .crossfade(true) 84 84 .build(), 85 85 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 86 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 86 87 contentDescription = "Avatar", 87 88 modifier = Modifier 88 89 .size(44.dp)
+2
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 514 514 .data(profile.avatar?.uri) 515 515 .build(), 516 516 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 517 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 517 518 contentDescription = "${profile.displayName ?: profile.handle.handle}'s avatar", 518 519 contentScale = ContentScale.Crop, 519 520 modifier = Modifier ··· 572 573 .data(imgUrl) 573 574 .build(), 574 575 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 576 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 575 577 contentScale = ContentScale.Crop, 576 578 contentDescription = "Link preview thumbnail", 577 579 modifier = Modifier
+2
app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 195 195 .crossfade(true) 196 196 .build(), 197 197 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 198 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 198 199 contentDescription = "Avatar", 199 200 modifier = Modifier 200 201 .size(avatarSize + 4.dp) ··· 234 235 .crossfade(true) 235 236 .build(), 236 237 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 238 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 237 239 contentDescription = "Avatar", 238 240 modifier = Modifier 239 241 .size(avatarSize)
+1 -1
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 94 94 .diskCache { 95 95 DiskCache.Builder() 96 96 .directory(context.cacheDir.resolve("image_cache")) 97 - .maxSizePercent(0.02) 97 + .maxSizePercent(0.05) 98 98 .build() 99 99 } 100 100 .build()
+4
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 269 269 LaunchedEffect(timelineViewModel.uiState.error) { 270 270 timelineViewModel.uiState.error?.let { 271 271 onError(it) 272 + timelineViewModel.clearError() 272 273 } 273 274 } 274 275 ··· 365 366 AsyncImage( 366 367 model = timelineViewModel.uiState.feedAvatar, 367 368 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 369 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 368 370 modifier = Modifier 369 371 .size(40.dp) 370 372 .clip(CircleShape), ··· 420 422 .crossfade(true) 421 423 .build(), 422 424 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 425 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 423 426 contentDescription = "Profile avatar", 424 427 contentScale = ContentScale.Crop, 425 428 modifier = Modifier ··· 705 708 .crossfade(true) 706 709 .build(), 707 710 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 711 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 708 712 modifier = Modifier 709 713 .size(20.dp) 710 714 .clip(CircleShape),
+4 -1
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 76 76 defaultElevation = if (notif.new() && viewModel.uiState.unreadNotificationsAmt != 0) 4.dp else 1.dp, 77 77 ), 78 78 colors = CardDefaults.elevatedCardColors( 79 - containerColor = MaterialTheme.colorScheme.surfaceContainerLow 79 + containerColor = if (notif.new() && viewModel.uiState.unreadNotificationsAmt != 0) 80 + MaterialTheme.colorScheme.primaryContainer 81 + else 82 + MaterialTheme.colorScheme.surfaceContainerLow 80 83 ), 81 84 modifier = Modifier.padding(horizontal = 16.dp) 82 85 ) {
+1
app/src/main/java/industries/geesawra/monarch/PostImageGallery.kt
··· 215 215 .crossfade(true) 216 216 .build(), 217 217 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 218 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 218 219 contentDescription = image.alt, 219 220 contentScale = ContentScale.Crop, 220 221 modifier = if (aspectRatio != null) {
+5
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 176 176 .crossfade(true) 177 177 .build(), 178 178 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 179 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 179 180 contentDescription = null, 180 181 contentScale = ContentScale.Crop, 181 182 modifier = Modifier ··· 374 375 .crossfade(true) 375 376 .build(), 376 377 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 378 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 377 379 contentDescription = "Profile banner", 378 380 contentScale = ContentScale.Crop, 379 381 modifier = Modifier ··· 405 407 .crossfade(true) 406 408 .build(), 407 409 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 410 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 408 411 contentDescription = "${profile.displayName ?: profile.handle.handle}'s avatar", 409 412 contentScale = ContentScale.Crop, 410 413 modifier = Modifier ··· 650 653 AsyncImage( 651 654 model = bannerUri ?: profile.banner?.uri, 652 655 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 656 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 653 657 contentDescription = "Banner", 654 658 contentScale = ContentScale.Crop, 655 659 modifier = Modifier ··· 672 676 AsyncImage( 673 677 model = avatarUri ?: profile.avatar?.uri, 674 678 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 679 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 675 680 contentDescription = "Avatar", 676 681 contentScale = ContentScale.Crop, 677 682 modifier = Modifier
+1
app/src/main/java/industries/geesawra/monarch/SearchView.kt
··· 370 370 .crossfade(true) 371 371 .build(), 372 372 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 373 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 373 374 contentDescription = "${actor.displayName ?: actor.handle.handle}'s avatar", 374 375 contentScale = ContentScale.Crop, 375 376 modifier = Modifier
+34 -25
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 123 123 } 124 124 125 125 val warningLabel = skeet.postLabels.firstOrNull { it.`val` in contentWarningLabels } 126 - if (warningLabel != null) { 127 - var revealed by remember { mutableStateOf(false) } 128 - val definition = labelDefinition(warningLabel.`val`) 129 - 130 - if (!revealed) { 131 - ContentWarningCard( 132 - label = definition.plaintext, 133 - onShow = { revealed = true }, 134 - wrapWithCard = !nested, 135 - ) 136 - return 137 - } 138 - } 126 + var contentRevealed by remember { mutableStateOf(warningLabel == null) } 139 127 140 128 val minSize = 40.dp 141 129 ··· 173 161 .crossfade(true) 174 162 .build(), 175 163 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 164 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 176 165 contentDescription = "Avatar", 177 166 modifier = Modifier 178 167 .size(minSize) ··· 194 183 ) 195 184 } 196 185 197 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 186 + if (!contentRevealed && warningLabel != null) { 187 + ContentWarningCard( 188 + label = labelDefinition(warningLabel.`val`).plaintext, 189 + onShow = { contentRevealed = true }, 190 + wrapWithCard = false, 191 + ) 192 + } else { 193 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 194 + } 198 195 } 199 196 } else { 200 197 // Top-level posts: two-column layout, thread line spans full height ··· 221 218 .crossfade(true) 222 219 .build(), 223 220 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 221 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 224 222 contentDescription = "Avatar", 225 223 modifier = Modifier 226 224 .size(minSize) ··· 267 265 labelerAvatar = { viewModel?.labelerAvatar(it) } 268 266 ) 269 267 270 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 268 + if (!contentRevealed && warningLabel != null) { 269 + ContentWarningCard( 270 + label = labelDefinition(warningLabel.`val`).plaintext, 271 + onShow = { contentRevealed = true }, 272 + wrapWithCard = false, 273 + ) 274 + } else { 275 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 271 276 272 - if (!disableEmbeds) { 273 - TimelinePostActionsView( 274 - onReplyTap = onReplyTap, 275 - modifier = Modifier 276 - .fillMaxWidth() 277 - .padding(top = 4.dp), 278 - timelineViewModel = viewModel, 279 - skeet = skeet, 280 - inThread = inThread 281 - ) 277 + if (!disableEmbeds) { 278 + TimelinePostActionsView( 279 + onReplyTap = onReplyTap, 280 + modifier = Modifier 281 + .fillMaxWidth() 282 + .padding(top = 4.dp), 283 + timelineViewModel = viewModel, 284 + skeet = skeet, 285 + inThread = inThread 286 + ) 287 + } 282 288 } 283 289 } 284 290 } ··· 491 497 .crossfade(true) 492 498 .build(), 493 499 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 500 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 494 501 contentScale = ContentScale.Crop, 495 502 alignment = Alignment.Center, 496 503 contentDescription = "External link thumbnail", ··· 679 686 .crossfade(true) 680 687 .build(), 681 688 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 689 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 682 690 contentDescription = null, 683 691 modifier = Modifier 684 692 .size(18.dp) ··· 878 886 .crossfade(true) 879 887 .build(), 880 888 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 889 + error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 881 890 contentDescription = definition.plaintext, 882 891 modifier = Modifier 883 892 .size(14.dp)
+187 -111
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 201 201 202 202 val httpClient = HttpClient(OkHttp) { 203 203 install(HttpTimeout) { 204 - requestTimeoutMillis = 15000 205 - connectTimeoutMillis = 15000 206 - socketTimeoutMillis = 15000 204 + requestTimeoutMillis = 30000 205 + connectTimeoutMillis = 30000 206 + socketTimeoutMillis = 30000 207 + } 208 + install(HttpRequestRetry) { 209 + maxRetries = 3 210 + retryIf { _, response -> 211 + response.status.value in 500..599 212 + } 213 + retryOnExceptionIf { _, cause -> 214 + cause is java.io.IOException 215 + } 216 + exponentialDelay() 207 217 } 208 218 } 209 219 ··· 214 224 path(did) 215 225 } 216 226 } 227 + 228 + httpClient.close() 217 229 218 230 if (rawDoc.status != HttpStatusCode.OK) { 219 231 return Result.failure(Exception("PLC lookup HTTP status code ${rawDoc.status}")) ··· 381 393 url(pdsURL) 382 394 } 383 395 install(HttpTimeout) { 384 - requestTimeoutMillis = 15000 385 - connectTimeoutMillis = 15000 386 - socketTimeoutMillis = 15000 396 + requestTimeoutMillis = 30000 397 + connectTimeoutMillis = 30000 398 + socketTimeoutMillis = 30000 399 + } 400 + install(HttpRequestRetry) { 401 + maxRetries = 3 402 + retryIf { _, response -> 403 + response.status.value in 500..599 404 + } 405 + retryOnExceptionIf { _, cause -> 406 + cause is java.io.IOException 407 + } 408 + exponentialDelay() 387 409 } 388 410 } 389 411 390 - val client = XrpcBlueskyApi(httpClient) 412 + try { 413 + val client = XrpcBlueskyApi(httpClient) 391 414 392 - val s = client.createSession(CreateSessionRequest(handle, password)) 393 - val sessionResponse: CreateSessionResponse = when (s) { 394 - is AtpResponse.Failure<*> -> { 395 - createMutex.unlock() 396 - return Result.failure( 397 - Exception( 398 - "Failed to create session: ${ 399 - s.error?.message?.toLowerCase( 400 - Locale.current 401 - ) 402 - }" 415 + val s = client.createSession(CreateSessionRequest(handle, password)) 416 + val sessionResponse: CreateSessionResponse = when (s) { 417 + is AtpResponse.Failure<*> -> { 418 + return Result.failure( 419 + Exception( 420 + "Failed to create session: ${ 421 + s.error?.message?.toLowerCase( 422 + Locale.current 423 + ) 424 + }" 425 + ) 403 426 ) 404 - ) 427 + } 428 + 429 + is AtpResponse.Success<CreateSessionResponse> -> s.response 405 430 } 406 431 407 - is AtpResponse.Success<CreateSessionResponse> -> s.response 432 + storeSessionData( 433 + pdsURL, 434 + appviewProxy, 435 + SessionData.fromCreateSessionResponse(sessionResponse) 436 + ) 437 + session = null 438 + this.client = null 439 + this.pdsClient = null 440 + 441 + return Result.success(Unit) 442 + } finally { 443 + httpClient.close() 444 + createMutex.unlock() 408 445 } 409 - 410 - storeSessionData( 411 - pdsURL, 412 - appviewProxy, 413 - SessionData.fromCreateSessionResponse(sessionResponse) 414 - ) 415 - session = null 416 - this.client = null 417 - this.pdsClient = null 418 - 419 - createMutex.unlock() 420 - return Result.success(Unit) 421 446 } 422 447 423 448 @Serializable ··· 439 464 headers["atproto-proxy"] = appviewProxy 440 465 } 441 466 install(HttpTimeout) { 442 - requestTimeoutMillis = 15000 443 - connectTimeoutMillis = 15000 444 - socketTimeoutMillis = 15000 467 + requestTimeoutMillis = 30000 468 + connectTimeoutMillis = 30000 469 + socketTimeoutMillis = 30000 445 470 } 446 471 install(HttpRequestRetry) { 447 - maxRetries = 5 472 + maxRetries = 3 448 473 retryIf { _, response -> 449 - response.status.value == 503 474 + response.status.value in 500..599 450 475 } 451 476 retryOnExceptionIf { _, cause -> 452 - cause.message?.contains("upstream service unavailable", ignoreCase = true) == true 477 + cause is java.io.IOException 453 478 } 454 479 exponentialDelay() 455 480 } ··· 470 495 url(pds) 471 496 } 472 497 install(HttpTimeout) { 473 - requestTimeoutMillis = 15000 474 - connectTimeoutMillis = 15000 475 - socketTimeoutMillis = 15000 498 + requestTimeoutMillis = 30000 499 + connectTimeoutMillis = 30000 500 + socketTimeoutMillis = 30000 476 501 } 477 502 install(HttpRequestRetry) { 478 - maxRetries = 5 503 + maxRetries = 3 479 504 retryIf { _, response -> 480 - response.status.value == 503 505 + response.status.value in 500..599 481 506 } 482 507 retryOnExceptionIf { _, cause -> 483 - cause.message?.contains("upstream service unavailable", ignoreCase = true) == true 508 + cause is java.io.IOException 484 509 } 485 510 exponentialDelay() 486 511 } ··· 497 522 appviewProxy: String, 498 523 token: SessionData, 499 524 ): Result<Unit> { 500 - return runCatching { 501 - val httpClient = HttpClient(OkHttp) { 502 - defaultRequest { 503 - url(pdsURL) 525 + val httpClient = HttpClient(OkHttp) { 526 + defaultRequest { 527 + url(pdsURL) 528 + } 529 + install(HttpTimeout) { 530 + requestTimeoutMillis = 30000 531 + connectTimeoutMillis = 30000 532 + socketTimeoutMillis = 30000 533 + } 534 + install(HttpRequestRetry) { 535 + maxRetries = 3 536 + retryIf { _, response -> 537 + response.status.value in 500..599 504 538 } 505 - install(HttpTimeout) { 506 - requestTimeoutMillis = 15000 507 - connectTimeoutMillis = 15000 508 - socketTimeoutMillis = 15000 539 + retryOnExceptionIf { _, cause -> 540 + cause is java.io.IOException 509 541 } 542 + exponentialDelay() 510 543 } 544 + } 511 545 546 + try { 512 547 val gs = httpClient.get { 513 548 headers["Authorization"] = "Bearer " + token.accessJwt 514 549 url { ··· 518 553 } 519 554 520 555 when (gs.status) { 521 - HttpStatusCode.OK -> run { 556 + HttpStatusCode.OK -> { 522 557 this.session = token 523 558 return Result.success(Unit) 524 559 } 525 560 526 - else -> run { 561 + else -> { 527 562 val body: String = gs.body() 528 563 529 564 val error: atpError = ··· 532 567 body 533 568 ) 534 569 if (error.error == "ExpiredToken") { 535 - return@run 570 + // fall through to refresh 571 + } else if (gs.status.value in 500..599) { 572 + return Result.failure(Exception("Server error during session check: ${gs.status}")) 573 + } else { 574 + cleanSessionData() 575 + return Result.failure(LoginException("Session checking failed, status code ${gs.status}: ${error.message}")) 536 576 } 537 - cleanSessionData() 538 - return Result.failure(Exception("Session checking failed, status code ${gs.status}: ${error.message}")) 539 577 } 540 578 } 541 579 ··· 548 586 } 549 587 550 588 when (rs.status) { 551 - HttpStatusCode.OK -> run { 589 + HttpStatusCode.OK -> { 552 590 val body: String = rs.body() 553 591 val rs: RefreshSessionResponse = 554 592 BlueskyJson.decodeFromString( ··· 561 599 return Result.success(Unit) 562 600 } 563 601 564 - else -> run { 602 + else -> { 565 603 val body: String = rs.body() 566 604 567 605 val error: atpError = ··· 569 607 atpError.serializer(), 570 608 body 571 609 ) 610 + if (rs.status.value in 500..599) { 611 + return Result.failure(Exception("Server error during token refresh: ${rs.status}")) 612 + } 572 613 cleanSessionData() 573 - return Result.failure(Exception("Login refresh failed, status code ${rs.status}: ${error.message}")) 614 + return Result.failure(LoginException("Login refresh failed, status code ${rs.status}: ${error.message}")) 574 615 } 575 616 } 576 - 617 + } catch (e: Exception) { 618 + return Result.failure(e) 619 + } finally { 620 + httpClient.close() 577 621 } 578 622 } 579 623 580 624 suspend fun create(): Result<Unit> { 581 - return runCatching { 582 - createMutex.lock() 625 + createMutex.lock() 626 + try { 583 627 if (session != null && client != null && pdsClient != null && pdsURL != null) { 584 - createMutex.unlock() 585 628 return Result.success(Unit) 586 629 } 587 630 ··· 601 644 val appviewProxy = appviewProxyFlow.first() 602 645 603 646 if (pdsURL.isEmpty() || sessionDataString.isEmpty() || appviewProxy.isEmpty()) { 604 - createMutex.unlock() 605 647 return Result.failure(Exception("No session data found")) 606 648 } 607 649 608 650 val sessionData = SessionData.decodeFromJson(sessionDataString) 609 651 610 652 refreshIfNeeded(pdsURL, appviewProxy, sessionData).onFailure { 611 - createMutex.unlock() 612 653 return Result.failure(it) 613 654 } 614 655 this.pdsURL = pdsURL ··· 621 662 sessionData, 622 663 ) 623 664 624 - val labelerMap = this.subscribedLabelers().getOrThrow() 665 + val labelerMap = this.subscribedLabelers().getOrDefault(emptyMap()) 625 666 buildLabelCache(labelerMap) 626 667 labelCacheFetchCount = 0 627 668 val labelers = labelerMap.keys.mapNotNull { it?.did } ··· 632 673 labelers 633 674 ) 634 675 676 + return Result.success(Unit) 677 + } catch (e: Exception) { 678 + return Result.failure(e) 679 + } finally { 635 680 createMutex.unlock() 636 681 } 637 682 } ··· 639 684 suspend fun fetchFeed(feed: String, cursor: String? = null): Result<Timeline> { 640 685 return runCatching { 641 686 create().onFailure { 642 - return Result.failure(LoginException(it.message)) 687 + return Result.failure(it) 643 688 } 644 689 645 690 val timeline = client!!.getFeed( ··· 666 711 ): Result<Timeline> { 667 712 return runCatching { 668 713 create().onFailure { 669 - return Result.failure(LoginException(it.message)) 714 + return Result.failure(it) 670 715 } 671 716 672 717 val timeline = client!!.getTimeline( ··· 699 744 ): Result<Unit> { 700 745 return runCatching { 701 746 create().onFailure { 702 - return Result.failure(LoginException(it.message)) 747 + return Result.failure(it) 703 748 } 704 749 705 750 var postEmbed: PostEmbedUnion? = null ··· 808 853 suspend fun fetchRecord(uri: AtUri): Result<JsonContent> { 809 854 return runCatching { 810 855 create().onFailure { 811 - return Result.failure(LoginException(it.message)) 856 + return Result.failure(it) 812 857 } 813 858 814 859 val ret = pdsClient!!.getRecord( ··· 829 874 suspend fun fetchActor(did: Did): Result<ProfileViewDetailed> { 830 875 return runCatching { 831 876 create().onFailure { 832 - return Result.failure(LoginException(it.message)) 877 + return Result.failure(it) 833 878 } 834 879 835 880 val ret = client!!.getProfile( ··· 848 893 suspend fun fetchSelf(): Result<ProfileViewDetailed> { 849 894 return runCatching { 850 895 create().onFailure { 851 - return Result.failure(LoginException(it.message)) 896 + return Result.failure(it) 852 897 } 853 898 854 899 return fetchActor(session!!.did) ··· 859 904 private suspend fun uploadBlobFromUrl(imageUrl: String): Blob? { 860 905 val httpClient = HttpClient(OkHttp) { 861 906 install(HttpTimeout) { 862 - requestTimeoutMillis = 10000 863 - connectTimeoutMillis = 5000 864 - socketTimeoutMillis = 10000 907 + requestTimeoutMillis = 30000 908 + connectTimeoutMillis = 30000 909 + socketTimeoutMillis = 30000 910 + } 911 + install(HttpRequestRetry) { 912 + maxRetries = 3 913 + retryIf { _, response -> 914 + response.status.value in 500..599 915 + } 916 + retryOnExceptionIf { _, cause -> 917 + cause is java.io.IOException 918 + } 919 + exponentialDelay() 865 920 } 866 921 } 867 - val response = httpClient.get(imageUrl) 868 - if (!response.status.isSuccess()) return null 869 - val bytes: ByteArray = response.body() 870 - httpClient.close() 871 - val uploadResponse = pdsClient!!.uploadBlob(bytes) 872 - return when (uploadResponse) { 873 - is AtpResponse.Failure<*> -> null 874 - is AtpResponse.Success<UploadBlobResponse> -> uploadResponse.response.blob 922 + try { 923 + val response = httpClient.get(imageUrl) 924 + if (!response.status.isSuccess()) return null 925 + val bytes: ByteArray = response.body() 926 + val uploadResponse = pdsClient!!.uploadBlob(bytes) 927 + return when (uploadResponse) { 928 + is AtpResponse.Failure<*> -> null 929 + is AtpResponse.Success<UploadBlobResponse> -> uploadResponse.response.blob 930 + } 931 + } finally { 932 + httpClient.close() 875 933 } 876 934 } 877 935 private data class MediaBlob( ··· 885 943 886 944 return runCatching { 887 945 create().onFailure { 888 - return Result.failure(LoginException(it.message)) 946 + return Result.failure(it) 889 947 } 890 948 891 949 val uploadedBlobs = mutableListOf<MediaBlob>() ··· 923 981 private suspend fun uploadVideo(video: Uri): Result<MediaBlob> { 924 982 return runCatching { 925 983 create().onFailure { 926 - return Result.failure(LoginException(it.message)) 984 + return Result.failure(it) 927 985 } 928 986 929 987 val retriever = MediaMetadataRetriever() ··· 967 1025 url("https://video.bsky.app") 968 1026 } 969 1027 install(HttpTimeout) { 970 - requestTimeoutMillis = 15000 971 - connectTimeoutMillis = 15000 972 - socketTimeoutMillis = 15000 1028 + requestTimeoutMillis = 300000 1029 + connectTimeoutMillis = 30000 1030 + socketTimeoutMillis = 300000 1031 + } 1032 + install(HttpRequestRetry) { 1033 + maxRetries = 3 1034 + retryIf { _, response -> 1035 + response.status.value in 500..599 1036 + } 1037 + retryOnExceptionIf { _, cause -> 1038 + cause is java.io.IOException 1039 + } 1040 + exponentialDelay() 973 1041 } 974 1042 install(ContentNegotiation) { 975 1043 register( ··· 1020 1088 } 1021 1089 1022 1090 else -> { 1091 + httpClient.close() 1023 1092 return Result.failure(Exception("Failed uploading video: status code ${rs.status}")) 1024 1093 } 1025 1094 } ··· 1031 1100 videoBskyAppClient.getJobStatus(GetJobStatusQueryParams(uploadRes!!.jobId)) 1032 1101 1033 1102 val resp = when (response) { 1034 - is AtpResponse.Failure<*> -> return Result.failure( 1035 - Exception("Failed video processing job status check: ${response.error}") 1036 - ) 1103 + is AtpResponse.Failure<*> -> { 1104 + httpClient.close() 1105 + return Result.failure( 1106 + Exception("Failed video processing job status check: ${response.error}") 1107 + ) 1108 + } 1037 1109 1038 1110 is AtpResponse.Success<GetJobStatusResponse> -> response.response.jobStatus 1039 1111 } ··· 1045 1117 1046 1118 when (resp.state) { 1047 1119 State.JOBSTATECOMPLETED -> {} // ignore, as we check blobk anyway 1048 - State.JOBSTATEFAILED -> return Result.failure(Exception("Video processing failed, ${resp.error}: ${resp.message}")) 1120 + State.JOBSTATEFAILED -> { 1121 + httpClient.close() 1122 + return Result.failure(Exception("Video processing failed, ${resp.error}: ${resp.message}")) 1123 + } 1049 1124 is State.Unknown -> delay(1000) 1050 1125 } 1051 1126 } catch (e: Exception) { 1052 - // Network or other error. Return the failure and exit the loop. 1127 + httpClient.close() 1053 1128 return Result.failure(e) 1054 1129 } 1055 1130 } 1056 1131 1057 1132 1133 + httpClient.close() 1058 1134 return Result.success( 1059 1135 MediaBlob( 1060 1136 blob = uploadedBlobs.first(), ··· 1068 1144 suspend fun feeds(): Result<List<GeneratorView>> { 1069 1145 return runCatching { 1070 1146 create().onFailure { 1071 - return Result.failure(LoginException(it.message)) 1147 + return Result.failure(it) 1072 1148 } 1073 1149 val prefs = pdsClient!!.getPreferences().requireResponse() 1074 1150 val savedFeeds = prefs.preferences.firstOrNull { ··· 1141 1217 ): Result<ListNotificationsResponse> { 1142 1218 return runCatching { 1143 1219 create().onFailure { 1144 - return Result.failure(LoginException(it.message)) 1220 + return Result.failure(it) 1145 1221 } 1146 1222 1147 1223 val ret = client!!.listNotifications( ··· 1159 1235 suspend fun updateSeenNotifications(): Result<Unit> { 1160 1236 return runCatching { 1161 1237 create().onFailure { 1162 - return Result.failure(LoginException(it.message)) 1238 + return Result.failure(it) 1163 1239 } 1164 1240 1165 1241 val ret = pdsClient!!.updateSeen( ··· 1178 1254 suspend fun getPosts(uri: List<AtUri>): Result<List<PostView>> { 1179 1255 return runCatching { 1180 1256 create().onFailure { 1181 - return Result.failure(LoginException(it.message)) 1257 + return Result.failure(it) 1182 1258 } 1183 1259 1184 1260 val ret = client!!.getPosts( ··· 1197 1273 suspend fun like(uri: AtUri, cid: Cid): Result<RKey> { 1198 1274 return runCatching { 1199 1275 create().onFailure { 1200 - return Result.failure(LoginException(it.message)) 1276 + return Result.failure(it) 1201 1277 } 1202 1278 1203 1279 val like = BlueskyJson.encodeAsJsonContent( ··· 1228 1304 suspend fun repost(uri: AtUri, cid: Cid): Result<RKey> { 1229 1305 return runCatching { 1230 1306 create().onFailure { 1231 - return Result.failure(LoginException(it.message)) 1307 + return Result.failure(it) 1232 1308 } 1233 1309 1234 1310 val like = BlueskyJson.encodeAsJsonContent( ··· 1267 1343 suspend fun getThread(uri: AtUri): Result<GetPostThreadResponse> { 1268 1344 return runCatching { 1269 1345 create().onFailure { 1270 - return Result.failure(LoginException(it.message)) 1346 + return Result.failure(it) 1271 1347 } 1272 1348 1273 1349 val res = client!!.getPostThread( ··· 1286 1362 suspend fun searchActorsTypeahead(query: String): Result<List<ProfileViewBasic>> { 1287 1363 return runCatching { 1288 1364 create().onFailure { 1289 - return Result.failure(LoginException(it.message)) 1365 + return Result.failure(it) 1290 1366 } 1291 1367 1292 1368 val res = client!!.searchActorsTypeahead( ··· 1310 1386 ): Result<Timeline> { 1311 1387 return runCatching { 1312 1388 create().onFailure { 1313 - return Result.failure(LoginException(it.message)) 1389 + return Result.failure(it) 1314 1390 } 1315 1391 1316 1392 val ret = client!!.getAuthorFeed( ··· 1334 1410 suspend fun follow(did: Did): Result<RKey> { 1335 1411 return runCatching { 1336 1412 create().onFailure { 1337 - return Result.failure(LoginException(it.message)) 1413 + return Result.failure(it) 1338 1414 } 1339 1415 1340 1416 val follow = BlueskyJson.encodeAsJsonContent( ··· 1368 1444 suspend fun muteActor(did: Did): Result<Unit> { 1369 1445 return runCatching { 1370 1446 create().onFailure { 1371 - return Result.failure(LoginException(it.message)) 1447 + return Result.failure(it) 1372 1448 } 1373 1449 1374 1450 val ret = pdsClient!!.muteActor( ··· 1385 1461 suspend fun getProfileRecord(): Result<Profile> { 1386 1462 return runCatching { 1387 1463 create().onFailure { 1388 - return Result.failure(LoginException(it.message)) 1464 + return Result.failure(it) 1389 1465 } 1390 1466 1391 1467 val ret = pdsClient!!.getRecord( ··· 1413 1489 ): Result<Unit> { 1414 1490 return runCatching { 1415 1491 create().onFailure { 1416 - return Result.failure(LoginException(it.message)) 1492 + return Result.failure(it) 1417 1493 } 1418 1494 1419 1495 // First get the current profile to preserve fields we don't change ··· 1478 1554 ): Result<Pair<List<PostView>, String?>> { 1479 1555 return runCatching { 1480 1556 create().onFailure { 1481 - return Result.failure(LoginException(it.message)) 1557 + return Result.failure(it) 1482 1558 } 1483 1559 1484 1560 val ret = client!!.searchPosts( ··· 1506 1582 ): Result<Pair<List<app.bsky.actor.ProfileView>, String?>> { 1507 1583 return runCatching { 1508 1584 create().onFailure { 1509 - return Result.failure(LoginException(it.message)) 1585 + return Result.failure(it) 1510 1586 } 1511 1587 1512 1588 val ret = client!!.searchActors( ··· 1529 1605 suspend fun unmuteActor(did: Did): Result<Unit> { 1530 1606 return runCatching { 1531 1607 create().onFailure { 1532 - return Result.failure(LoginException(it.message)) 1608 + return Result.failure(it) 1533 1609 } 1534 1610 1535 1611 val ret = pdsClient!!.unmuteActor( ··· 1546 1622 private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> { 1547 1623 return runCatching { 1548 1624 create().onFailure { 1549 - return Result.failure(LoginException(it.message)) 1625 + return Result.failure(it) 1550 1626 } 1551 1627 1552 1628 val delRes = pdsClient!!.deleteRecord(
+4
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 129 129 } 130 130 } 131 131 132 + fun clearError() { 133 + uiState = uiState.copy(error = null) 134 + } 135 + 132 136 fun labelDisplayName(label: Label): String = bskyConn.labelDisplayName(label) 133 137 fun labelDescription(label: Label): String? = bskyConn.labelDescription(label) 134 138 fun labelerAvatar(label: Label): String? = bskyConn.labelerAvatar(label)