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

Configure Feed

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

*: don't treat all errors as login errors, repost and likes working w/tinted background, counters that make sense

geesawra a2aca0a0 60b316af

+199 -29
+8 -2
.idea/deploymentTargetSelector.xml
··· 4 4 <selectionStates> 5 5 <SelectionState runConfigName="app"> 6 6 <option name="selectionMode" value="DROPDOWN" /> 7 - <DropdownSelection timestamp="2025-09-26T22:09:54.036774Z"> 7 + <DropdownSelection timestamp="2025-09-30T10:56:00.678251Z"> 8 8 <Target type="DEFAULT_BOOT"> 9 9 <handle> 10 - <DeviceId pluginId="LocalEmulator" identifier="path=/Users/gsora/.android/avd/Medium_Phone.avd" /> 10 + <DeviceId pluginId="PhysicalDevice" identifier="serial=57141FDCH007E3" /> 11 11 </handle> 12 12 </Target> 13 13 </DropdownSelection> 14 14 <DialogSelection /> 15 + </SelectionState> 16 + <SelectionState runConfigName="DefaultPreview"> 17 + <option name="selectionMode" value="DROPDOWN" /> 18 + </SelectionState> 19 + <SelectionState runConfigName="app (release)"> 20 + <option name="selectionMode" value="DROPDOWN" /> 15 21 </SelectionState> 16 22 </selectionStates> 17 23 </component>
+1
app/build.gradle.kts
··· 28 28 getDefaultProguardFile("proguard-android-optimize.txt"), 29 29 "proguard-rules.pro" 30 30 ) 31 + signingConfig = signingConfigs.getByName("debug") 31 32 } 32 33 } 33 34 compileOptions {
+1 -1
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 62 62 } else { 63 63 viewModel.uiState.skeets.distinctBy { it.post.cid }.forEach { skeet -> 64 64 item(key = skeet.post.cid.cid) { 65 - SkeetRowView(skeet) 65 + SkeetRowView(viewModel, skeet) 66 66 } 67 67 } 68 68
+9 -3
app/src/main/java/industries/geesawra/jerryno/SkeetRowView.kt
··· 31 31 import coil3.compose.AsyncImage 32 32 import coil3.request.ImageRequest 33 33 import coil3.request.crossfade 34 + import industries.geesawra.jerryno.datalayer.TimelineViewModel 34 35 import kotlinx.serialization.json.decodeFromJsonElement 35 36 import sh.christian.ozone.BlueskyJson 36 37 37 38 @Composable 38 - fun SkeetRowView(skeet: FeedViewPost) { 39 + fun SkeetRowView(viewModel: TimelineViewModel, skeet: FeedViewPost) { 39 40 val likes = skeet.post.likeCount 40 41 val reposts = skeet.post.repostCount 41 42 val replies = skeet.post.replyCount ··· 82 83 TimelinePostActionsView( 83 84 modifier = Modifier 84 85 .fillMaxWidth(), 86 + timelineViewModel = viewModel, 85 87 replies = replies, 86 88 likes = likes, 87 89 reposts = reposts, 88 - uri = "https://bsky.app/profile/${skeet.post.author.handle.handle}/post/${ 90 + postUrl = "https://bsky.app/profile/${skeet.post.author.handle.handle}/post/${ 89 91 skeet.post.uri.split( 90 92 "/" 91 93 ).last() 92 - }" 94 + }", 95 + uri = skeet.post.uri, 96 + cid = skeet.post.cid, 97 + reposted = skeet.post.viewer?.repost != null, 98 + liked = skeet.post.viewer?.like != null, 93 99 ) 94 100 95 101 HorizontalDivider(
+50 -11
app/src/main/java/industries/geesawra/jerryno/TimelinePostActionsView.kt
··· 9 9 import androidx.compose.material.icons.automirrored.filled.Reply 10 10 import androidx.compose.material.icons.automirrored.filled.ReplyAll 11 11 import androidx.compose.material.icons.filled.Repeat 12 + import androidx.compose.material.icons.filled.RepeatOn 12 13 import androidx.compose.material.icons.filled.Share 13 14 import androidx.compose.material.icons.filled.ThumbUp 15 + import androidx.compose.material.icons.filled.ThumbUpOffAlt 14 16 import androidx.compose.material3.Icon 15 17 import androidx.compose.material3.IconButton 16 18 import androidx.compose.material3.MaterialTheme ··· 19 21 import androidx.compose.runtime.getValue 20 22 import androidx.compose.runtime.mutableStateOf 21 23 import androidx.compose.runtime.remember 24 + import androidx.compose.runtime.saveable.rememberSaveable 22 25 import androidx.compose.runtime.setValue 23 26 import androidx.compose.ui.Alignment 24 27 import androidx.compose.ui.Modifier 28 + import androidx.compose.ui.graphics.Color 25 29 import androidx.compose.ui.graphics.vector.ImageVector 26 30 import androidx.compose.ui.platform.LocalContext 27 31 import androidx.compose.ui.unit.dp 32 + import industries.geesawra.jerryno.datalayer.TimelineViewModel 33 + import sh.christian.ozone.api.AtUri 34 + import sh.christian.ozone.api.Cid 28 35 29 36 30 37 @Composable 31 - private fun IconWithNumber(imageVector: ImageVector, contentDescription: String, number: Long?) { 38 + private fun IconWithNumber( 39 + imageVector: ImageVector, 40 + contentDescription: String, 41 + number: Long?, 42 + tint: Color 43 + ) { 32 44 Row( 33 45 horizontalArrangement = Arrangement.Center, 34 46 verticalAlignment = Alignment.CenterVertically ··· 40 52 imageVector, 41 53 contentDescription = contentDescription, 42 54 modifier = Modifier.size(15.dp), 43 - tint = MaterialTheme.colorScheme.onSurfaceVariant // Added tint 55 + tint = tint 44 56 ) 45 57 Text( 46 58 modifier = Modifier.padding(start = 2.dp), 47 59 text = (number ?: 0).toString(), 48 - color = MaterialTheme.colorScheme.onSurfaceVariant, // Added color 60 + color = tint, 49 61 maxLines = 1, 50 62 onTextLayout = { textLayout -> 51 63 if (textLayout.multiParagraph.didExceedMaxLines) { ··· 59 71 @Composable 60 72 fun TimelinePostActionsView( 61 73 modifier: Modifier = Modifier, 74 + timelineViewModel: TimelineViewModel, 75 + reposted: Boolean, 76 + liked: Boolean, 62 77 replies: Long?, 63 78 likes: Long?, 64 79 reposts: Long?, 65 - uri: String 80 + postUrl: String, 81 + uri: AtUri, 82 + cid: Cid, 66 83 ) { 67 84 68 85 Row( ··· 75 92 val sendIntent: Intent = Intent().apply { 76 93 action = Intent.ACTION_SEND 77 94 type = "text/plain" 78 - putExtra(Intent.EXTRA_TEXT, uri) 95 + putExtra(Intent.EXTRA_TEXT, postUrl) 79 96 } 80 97 ctx.startActivity( 81 98 Intent.createChooser(sendIntent, "Share Bluesky post") ··· 89 106 tint = MaterialTheme.colorScheme.onSurfaceVariant 90 107 ) 91 108 } 92 - 109 + 93 110 IconButton( 94 111 onClick = {} 95 112 ) { ··· 109 126 }(), 110 127 contentDescription = "Reply", 111 128 number = replies, 129 + MaterialTheme.colorScheme.onSurfaceVariant 112 130 ) 113 131 } 132 + 133 + var isLiked by rememberSaveable { mutableStateOf(liked) } 114 134 IconButton( 115 - onClick = {} 135 + onClick = { 136 + timelineViewModel.like(uri, cid) { 137 + isLiked = true 138 + likes?.inc() 139 + } 140 + } 116 141 ) { 117 142 IconWithNumber( 118 - Icons.Default.ThumbUp, 143 + if (isLiked) Icons.Default.ThumbUp else Icons.Default.ThumbUpOffAlt, 119 144 contentDescription = "Like", 120 - number = likes 145 + number = likes, 146 + tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 121 147 ) 122 148 } 149 + 150 + var isReposted by rememberSaveable { mutableStateOf(reposted) } 123 151 IconButton( 124 - onClick = {} 152 + onClick = { 153 + when (isReposted) { 154 + false -> timelineViewModel.repost(uri, cid) { 155 + isReposted = true 156 + reposts?.inc() 157 + } 158 + 159 + true -> {} 160 + } 161 + 162 + } 125 163 ) { 126 164 IconWithNumber( 127 - Icons.Default.Repeat, 165 + if (isReposted) Icons.Default.RepeatOn else Icons.Default.Repeat, 128 166 contentDescription = "Repost", 129 167 number = reposts, 168 + if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 130 169 ) 131 170 } 132 171 }
+8 -1
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 351 351 } 352 352 353 353 354 + LaunchedEffect(timelineViewModel.uiState.loginError) { 355 + timelineViewModel.uiState.loginError?.let { 356 + Toast.makeText(ctx, "Authentication error: $it", Toast.LENGTH_LONG) 357 + .show() 358 + loginError() 359 + } 360 + } 361 + 354 362 LaunchedEffect(timelineViewModel.uiState.error) { 355 363 timelineViewModel.uiState.error?.let { 356 364 Toast.makeText(ctx, "Error: $it", Toast.LENGTH_LONG) 357 365 .show() 358 - loginError() 359 366 } 360 367 } 361 368
+80 -6
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 16 16 import app.bsky.feed.GetFeedGeneratorsQueryParams 17 17 import app.bsky.feed.GetTimelineQueryParams 18 18 import app.bsky.feed.GetTimelineResponse 19 + import app.bsky.feed.Like 19 20 import app.bsky.feed.Post 20 21 import app.bsky.feed.PostEmbedUnion 22 + import app.bsky.feed.Repost 21 23 import com.atproto.identity.ResolveHandleQueryParams 22 24 import com.atproto.identity.ResolveHandleResponse 23 25 import com.atproto.repo.CreateRecordRequest 26 + import com.atproto.repo.StrongRef 24 27 import com.atproto.repo.UploadBlobResponse 25 28 import com.atproto.server.CreateSessionRequest 26 29 import com.atproto.server.CreateSessionResponse ··· 47 50 import sh.christian.ozone.api.AtUri 48 51 import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 49 52 import sh.christian.ozone.api.BlueskyAuthPlugin 53 + import sh.christian.ozone.api.Cid 50 54 import sh.christian.ozone.api.Did 51 55 import sh.christian.ozone.api.Handle 52 56 import sh.christian.ozone.api.Nsid ··· 58 62 PDSHost, 59 63 SessionData, 60 64 } 65 + 66 + class LoginException(message: String?) : Exception(message) 61 67 62 68 @Serializable // Added annotation 63 69 data class SessionData( ··· 387 393 cursor: String? = null 388 394 ): Result<GetTimelineResponse> { 389 395 return runCatching { 390 - create().getOrThrow() 396 + create().onFailure { 397 + return Result.failure(LoginException(it.message)) 398 + } 399 + 391 400 val timeline = client!!.getTimeline( 392 401 GetTimelineQueryParams( 393 402 algorithm = algorithm, ··· 409 418 410 419 suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 411 420 return runCatching { 412 - create().getOrThrow() 421 + create().onFailure { 422 + return Result.failure(LoginException(it.message)) 423 + } 413 424 414 425 var postEmbed: PostEmbedUnion? = null 415 426 ··· 461 472 462 473 suspend fun uploadImages(images: List<Uri>): Result<List<Blob>> { 463 474 return runCatching { 464 - create().getOrThrow() 475 + create().onFailure { 476 + return Result.failure(LoginException(it.message)) 477 + } 465 478 466 479 val uploadedBlobs = mutableListOf<Blob>() 467 480 ··· 484 497 485 498 suspend fun uploadVideo(video: Uri): Result<Blob> { 486 499 return runCatching { 487 - create().getOrThrow() 500 + create().onFailure { 501 + return Result.failure(LoginException(it.message)) 502 + } 488 503 489 504 val uploadedBlobs = mutableListOf<Blob>() 490 505 ··· 506 521 507 522 suspend fun feeds(): Result<List<GeneratorView>> { 508 523 return runCatching { 509 - create().getOrThrow() 510 - 524 + create().onFailure { 525 + return Result.failure(LoginException(it.message)) 526 + } 511 527 val prefs = client!!.getPreferences().requireResponse() 512 528 val feedUris = (prefs.preferences.first { 513 529 when (it) { ··· 525 541 ).requireResponse() 526 542 527 543 return Result.success(resp.feeds) 544 + } 545 + } 546 + 547 + suspend fun like(uri: AtUri, cid: Cid): Result<Unit> { 548 + return runCatching { 549 + create().onFailure { 550 + return Result.failure(LoginException(it.message)) 551 + } 552 + 553 + val like = BlueskyJson.encodeAsJsonContent( 554 + Like( 555 + subject = StrongRef(uri, cid), 556 + createdAt = Clock.System.now(), 557 + ) 558 + ) 559 + 560 + 561 + val likeRes = client!!.createRecord( 562 + CreateRecordRequest( 563 + repo = session!!.handle, 564 + collection = Nsid("app.bsky.feed.like"), 565 + record = like, 566 + ) 567 + ) 568 + 569 + return when (likeRes) { 570 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not like post: ${likeRes.error?.message}")) 571 + is AtpResponse.Success<*> -> Result.success(Unit) 572 + } 573 + } 574 + } 575 + 576 + suspend fun repost(uri: AtUri, cid: Cid): Result<Unit> { 577 + return runCatching { 578 + create().onFailure { 579 + return Result.failure(LoginException(it.message)) 580 + } 581 + 582 + val like = BlueskyJson.encodeAsJsonContent( 583 + Repost( 584 + subject = StrongRef(uri, cid), 585 + createdAt = Clock.System.now(), 586 + ) 587 + ) 588 + 589 + 590 + val likeRes = client!!.createRecord( 591 + CreateRecordRequest( 592 + repo = session!!.handle, 593 + collection = Nsid("app.bsky.feed.repost"), 594 + record = like, 595 + ) 596 + ) 597 + 598 + return when (likeRes) { 599 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not repost: ${likeRes.error?.message}")) 600 + is AtpResponse.Success<*> -> Result.success(Unit) 601 + } 528 602 } 529 603 } 530 604 }
+42 -5
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 1 1 package industries.geesawra.jerryno.datalayer 2 2 3 3 import android.net.Uri 4 - import android.util.Log 5 4 import androidx.compose.runtime.getValue 6 5 import androidx.compose.runtime.mutableStateOf 7 6 import androidx.compose.runtime.setValue ··· 15 14 import dagger.hilt.android.lifecycle.HiltViewModel 16 15 import kotlinx.coroutines.Job 17 16 import kotlinx.coroutines.launch 17 + import sh.christian.ozone.api.AtUri 18 + import sh.christian.ozone.api.Cid 18 19 19 20 20 21 data class TimelineUiState( ··· 28 29 val authenticated: Boolean = false, 29 30 val sessionChecked: Boolean = false, 30 31 32 + val loginError: String? = null, 31 33 val error: String? = null 32 34 ) 33 35 ··· 82 84 isFetchingMoreTimeline = false 83 85 ) 84 86 }.onFailure { 85 - uiState = uiState.copy(isFetchingMoreTimeline = false) 86 - Log.e("TimelineViewModel", "Failed to fetch timeline: ${it.message}") 87 + uiState = uiState.copy( 88 + isFetchingMoreTimeline = false, 89 + error = "Failed to fetch timeline: ${it.message}" 90 + ) 87 91 } 88 92 } 89 93 } ··· 95 99 } 96 100 97 101 suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 98 - return bskyConn.post(content, images, video) 102 + return bskyConn.post( 103 + content, 104 + images, 105 + video 106 + ) // TODO: maybe refactor this to use uistate.Error? 99 107 } 100 108 101 109 fun feeds() { 102 110 viewModelScope.launch { 103 111 bskyConn.feeds().onFailure { 104 - uiState = uiState.copy(error = it.message) 112 + uiState = when (it) { 113 + is LoginException -> uiState.copy(loginError = it.message) 114 + else -> uiState.copy(error = it.message) 115 + } 105 116 }.onSuccess { 106 117 uiState = uiState.copy(feeds = it) 107 118 } ··· 116 127 ) 117 128 reset() 118 129 fetchTimeline() 130 + } 131 + 132 + fun like(uri: AtUri, cid: Cid, then: () -> Unit) { 133 + viewModelScope.launch { 134 + bskyConn.like(uri, cid).onFailure { 135 + uiState = when (it) { 136 + is LoginException -> uiState.copy(loginError = it.message) 137 + else -> uiState.copy(error = it.message) 138 + } 139 + }.onSuccess { 140 + then() 141 + } 142 + } 143 + } 144 + 145 + fun repost(uri: AtUri, cid: Cid, then: () -> Unit) { 146 + viewModelScope.launch { 147 + bskyConn.repost(uri, cid).onFailure { 148 + uiState = when (it) { 149 + is LoginException -> uiState.copy(loginError = it.message) 150 + else -> uiState.copy(error = it.message) 151 + } 152 + }.onSuccess { 153 + then() 154 + } 155 + } 119 156 } 120 157 }