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.

*: redesign image view, prepare for image/video upload

geesawra c646a5a5 88a3cc2e

+290 -83
+2 -1
app/src/main/java/industries/geesawra/jerryno/LoginView.kt
··· 89 89 currentPDS = BlueskyConn.pdsForHandle(handle).getOrElse { 90 90 Toast.makeText( 91 91 ctx, 92 - it.message ?: "Unknown error", 92 + it.message ?: "Error: ${it.toString()}", 93 93 Toast.LENGTH_LONG 94 94 ) 95 95 .show() 96 + lookingUpPDS = false 96 97 return@launch 97 98 } 98 99 lookingUpPDS = false
+78
app/src/main/java/industries/geesawra/jerryno/PostImageGallery.kt
··· 1 + package industries.geesawra.jerryno 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Row 5 + import androidx.compose.foundation.layout.fillMaxWidth 6 + import androidx.compose.foundation.shape.RoundedCornerShape 7 + import androidx.compose.runtime.Composable 8 + import androidx.compose.ui.Modifier 9 + import androidx.compose.ui.draw.clip 10 + import androidx.compose.ui.layout.ContentScale 11 + import androidx.compose.ui.unit.dp 12 + import coil3.compose.AsyncImage 13 + 14 + data class Image( 15 + val url: String, 16 + val alt: String, 17 + ) 18 + 19 + @Composable 20 + //fun PostImageGallery( 21 + // modifier: Modifier = Modifier, 22 + // images: List<Image>, 23 + //) { 24 + // val columns = when (images.size) { 25 + // 1 -> GridCells.Fixed(1) // One image gets one full-width column 26 + // else -> GridCells.Fixed(2) // Two or more images get two columns 27 + // } 28 + // 29 + // LazyVerticalGrid( 30 + // horizontalArrangement = Arrangement.spacedBy(8.dp), 31 + // verticalArrangement = Arrangement.spacedBy(8.dp), 32 + // modifier = modifier, 33 + // columns = columns, 34 + // userScrollEnabled = false, 35 + // content = { 36 + // items(images.size) { index -> 37 + // val img = images[index] 38 + // 39 + // AsyncImage( 40 + // model = ImageRequest.Builder(LocalContext.current) 41 + // .data(img.url) 42 + // .crossfade(true) 43 + // .build(), 44 + // contentScale = ContentScale.Crop, 45 + // contentDescription = img.alt, 46 + // modifier = Modifier 47 + // .fillMaxWidth() 48 + // .heightIn(max = 75.dp) 49 + // .clip(RoundedCornerShape(12.dp)) 50 + // ) 51 + // } 52 + // } 53 + // ) 54 + //} 55 + fun PostImageGallery( 56 + modifier: Modifier = Modifier, 57 + images: List<Image>, 58 + ) { 59 + Row( 60 + modifier = modifier.fillMaxWidth(), 61 + // This automatically adds 4.dp of space between each image 62 + horizontalArrangement = Arrangement.spacedBy(8.dp) 63 + ) { 64 + // We take the first 4 images and give them each a weight 65 + images.take(4).forEach { image -> 66 + AsyncImage( 67 + model = image.url, 68 + contentDescription = image.alt, 69 + contentScale = ContentScale.Crop, // Fills the space 70 + modifier = Modifier 71 + // 1. Give each image an equal share of the width 72 + .weight(1f) 73 + // 3. Apply rounded corners 74 + .clip(RoundedCornerShape(12.dp)) 75 + ) 76 + } 77 + } 78 + }
+33 -44
app/src/main/java/industries/geesawra/jerryno/SkeetRowView.kt
··· 2 2 3 3 import androidx.compose.foundation.layout.Arrangement 4 4 import androidx.compose.foundation.layout.Column 5 - import androidx.compose.foundation.layout.PaddingValues 6 5 import androidx.compose.foundation.layout.Row 7 6 import androidx.compose.foundation.layout.fillMaxSize 8 7 import androidx.compose.foundation.layout.fillMaxWidth 9 - import androidx.compose.foundation.layout.height 8 + import androidx.compose.foundation.layout.heightIn 10 9 import androidx.compose.foundation.layout.padding 11 10 import androidx.compose.foundation.layout.size 12 11 import androidx.compose.foundation.layout.sizeIn 13 - import androidx.compose.foundation.lazy.grid.GridCells 14 - import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 15 12 import androidx.compose.foundation.shape.CircleShape 16 - import androidx.compose.foundation.shape.RoundedCornerShape 17 13 import androidx.compose.material3.Card 14 + import androidx.compose.material3.FilterChip 18 15 import androidx.compose.material3.HorizontalDivider 19 16 import androidx.compose.material3.MaterialTheme 20 17 import androidx.compose.material3.Surface ··· 23 20 import androidx.compose.ui.Alignment 24 21 import androidx.compose.ui.Modifier 25 22 import androidx.compose.ui.draw.clip 26 - import androidx.compose.ui.layout.ContentScale 27 23 import androidx.compose.ui.platform.LocalContext 28 24 import androidx.compose.ui.text.font.FontWeight 29 25 import androidx.compose.ui.unit.dp ··· 122 118 } 123 119 124 120 Card( 125 - modifier = Modifier.padding(top = 8.dp) 121 + modifier = Modifier 122 + .heightIn(max = 180.dp) 123 + .fillMaxWidth() 124 + .padding(8.dp) 126 125 ) { 127 126 when (embed) { 128 127 is PostViewEmbedUnion.ImagesView -> { 129 128 val img = embed.value.images 130 129 131 - LazyVerticalGrid( 132 - columns = GridCells.Fixed({ 133 - when (img.size) { 134 - 1 -> 1 135 - else -> 2 136 - } 137 - }()), 130 + PostImageGallery( 138 131 modifier = Modifier 139 - .height(200.dp) 140 - .fillMaxWidth(), 141 - userScrollEnabled = false, 142 - content = { 143 - items(img.size) { index -> 144 - val img = img[index] 145 - 146 - val pv = { 147 - val v = 12.dp 148 - when (index % 2 == 0) { 149 - true -> PaddingValues(v) 150 - false -> PaddingValues(top = v, end = v, bottom = v) 151 - } 152 - }() 153 - 154 - AsyncImage( 155 - model = ImageRequest.Builder(LocalContext.current) 156 - .data(img.thumb.toString()) 157 - .crossfade(true) 158 - .build(), 159 - contentScale = ContentScale.Crop, 160 - contentDescription = img.alt, 161 - modifier = Modifier 162 - .fillMaxWidth() 163 - .height(200.dp) 164 - .padding(pv) 165 - .clip(RoundedCornerShape(12.dp)) 166 - ) 167 - } 168 - } 132 + .fillMaxSize() 133 + .padding(8.dp), 134 + images = img.map { 135 + Image(url = it.thumb.uri, alt = it.alt) 136 + }, 169 137 ) 170 138 } 171 139 ··· 215 183 style = MaterialTheme.typography.bodySmall, 216 184 modifier = Modifier 217 185 .padding(top = 4.dp), 186 + ) 187 + 188 + skeet.post.author.labels.forEach { 189 + it.neg?.let { it -> 190 + if (!it) { 191 + return@forEach 192 + } 193 + } 194 + if (it.`val`.startsWith("!")) { 195 + return@forEach 196 + } 218 197 198 + FilterChip( 199 + leadingIcon = { 200 + }, 201 + enabled = true, 202 + onClick = {}, 203 + selected = true, 204 + label = { 205 + Text(text = it.`val`) 206 + } 219 207 ) 208 + } 220 209 221 210 skeet.reply?.let { 222 211 it
+40 -5
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 3 // import androidx.compose.foundation.layout.height // Will be removed for the sheet content Box 4 + import android.net.Uri 4 5 import android.widget.Toast 5 6 import androidx.activity.compose.rememberLauncherForActivityResult 6 7 import androidx.activity.result.PickVisualMediaRequest ··· 14 15 import androidx.compose.foundation.layout.Spacer 15 16 import androidx.compose.foundation.layout.WindowInsets 16 17 import androidx.compose.foundation.layout.consumeWindowInsets 18 + import androidx.compose.foundation.layout.fillMaxHeight 17 19 import androidx.compose.foundation.layout.fillMaxSize 18 20 import androidx.compose.foundation.layout.fillMaxWidth 19 - import androidx.compose.foundation.layout.height 21 + import androidx.compose.foundation.layout.heightIn 20 22 import androidx.compose.foundation.layout.ime 21 23 import androidx.compose.foundation.layout.padding 22 24 import androidx.compose.foundation.layout.size ··· 32 34 import androidx.compose.material3.BottomSheetScaffold 33 35 import androidx.compose.material3.Button 34 36 import androidx.compose.material3.ButtonDefaults 37 + import androidx.compose.material3.Card 35 38 import androidx.compose.material3.ExperimentalMaterial3Api 36 39 import androidx.compose.material3.FloatingActionButton 37 40 import androidx.compose.material3.HorizontalDivider ··· 101 104 val focusRequester = remember { FocusRequester() } 102 105 val keyboardController = LocalSoftwareKeyboardController.current 103 106 val context = LocalContext.current 107 + val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 104 108 105 109 LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 106 110 if (scaffoldState.bottomSheetState.isVisible) { ··· 108 112 keyboardController?.show() 109 113 } else { 110 114 keyboardController?.hide() 115 + mediaSelected.value = mapOf() 111 116 } 112 117 } 118 + 113 119 114 120 val pickMedia = 115 - rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(4)) { uris -> 121 + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> 116 122 if (uris.isEmpty()) { 117 123 return@rememberLauncherForActivityResult 118 124 } ··· 142 148 "Can only post up to 1 video or 4 pictures", 143 149 Toast.LENGTH_SHORT 144 150 ).show() 151 + 152 + return@rememberLauncherForActivityResult 145 153 } 154 + 155 + mediaSelected.value = urisMap 146 156 } 147 157 148 158 ··· 160 170 Column( 161 171 modifier = Modifier 162 172 .fillMaxWidth() 163 - .height(350.dp), 173 + .fillMaxHeight(), 164 174 horizontalAlignment = Alignment.CenterHorizontally 165 175 ) { 166 176 Text( ··· 188 198 }, 189 199 modifier = Modifier 190 200 .fillMaxWidth() 191 - .focusRequester(focusRequester) 192 - .weight(1f), // TextField takes available space, crucial for resizing 201 + .weight(1f) 202 + .focusRequester(focusRequester), 193 203 label = { 194 204 if (wasEdited.value) { 195 205 Text( ··· 206 216 isError = postText.length > maxChars 207 217 ) 208 218 219 + Spacer(modifier = Modifier.padding(4.dp)) 220 + 221 + if (mediaSelected.value.isNotEmpty()) { 222 + Card( 223 + modifier = Modifier 224 + .heightIn(max = 180.dp) 225 + .fillMaxWidth() 226 + .padding(8.dp) 227 + ) { 228 + PostImageGallery( 229 + modifier = Modifier 230 + .fillMaxSize() 231 + .padding(8.dp), 232 + images = mediaSelected.value.keys.map { 233 + Image( 234 + url = it.toString(), 235 + alt = "" 236 + ) 237 + }, 238 + ) 239 + } 240 + } 241 + 209 242 Spacer(modifier = Modifier.padding(4.dp)) // Reduced spacer, was Modifier.height(8.dp) 243 + 210 244 211 245 Row( 212 246 modifier = Modifier.fillMaxWidth(), ··· 220 254 ) { 221 255 Icon(Icons.Default.Attachment, contentDescription = "Attach media") 222 256 } 257 + 223 258 Button( 224 259 onClick = { 225 260 if (postText.isNotBlank() && postText.length <= maxChars) {
+130 -29
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 1 1 package industries.geesawra.jerryno.datalayer 2 2 3 3 import android.content.Context 4 + import android.net.Uri 4 5 import android.util.Log 5 6 import androidx.compose.ui.text.intl.Locale 6 7 import androidx.compose.ui.text.toLowerCase 7 8 import androidx.datastore.preferences.core.edit 8 9 import androidx.datastore.preferences.core.stringPreferencesKey 9 10 import androidx.datastore.preferences.preferencesDataStore 11 + import app.bsky.embed.Images 12 + import app.bsky.embed.ImagesImage 10 13 import app.bsky.feed.GetTimelineQueryParams 11 14 import app.bsky.feed.GetTimelineResponse 12 15 import app.bsky.feed.Post 16 + import app.bsky.feed.PostEmbedUnion 13 17 import com.atproto.identity.ResolveHandleQueryParams 14 18 import com.atproto.identity.ResolveHandleResponse 15 19 import com.atproto.repo.CreateRecordRequest 20 + import com.atproto.repo.UploadBlobResponse 16 21 import com.atproto.server.CreateSessionRequest 17 22 import com.atproto.server.CreateSessionResponse 18 23 import com.atproto.server.GetSessionResponse ··· 23 28 import io.ktor.client.plugins.HttpTimeout 24 29 import io.ktor.client.plugins.defaultRequest 25 30 import io.ktor.client.request.get 31 + import io.ktor.http.HttpStatusCode 26 32 import io.ktor.http.URLProtocol 27 33 import io.ktor.http.path 28 34 import kotlinx.coroutines.flow.Flow ··· 38 44 import sh.christian.ozone.api.Did 39 45 import sh.christian.ozone.api.Handle 40 46 import sh.christian.ozone.api.Nsid 47 + import sh.christian.ozone.api.model.Blob 41 48 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 42 49 import sh.christian.ozone.api.response.AtpResponse 43 50 ··· 130 137 } 131 138 } 132 139 133 - val rawDoc: String = httpClient.get { 140 + val rawDoc = httpClient.get { 134 141 url { 135 142 protocol = URLProtocol.HTTPS 136 143 host = "plc.directory" 137 144 path(did) 138 145 } 139 - }.body() 146 + } 147 + 148 + if (rawDoc.status != HttpStatusCode.OK) { 149 + return Result.failure(Exception("PLC lookup HTTP status code ${rawDoc.status}")) 150 + } 140 151 141 - val solvedDoc: didDoc = BlueskyJson.decodeFromString(didDoc.serializer(), rawDoc) 152 + val body: String = rawDoc.body() 153 + 154 + val solvedDoc: didDoc = BlueskyJson.decodeFromString(didDoc.serializer(), body) 142 155 143 156 for (ps in solvedDoc.service) { 144 157 if (ps.id == "#atproto_pds" && ps.type == "AtprotoPersonalDataServer") { ··· 252 265 defaultRequest { 253 266 url(pdsURL) 254 267 } 268 + 255 269 install(HttpTimeout) { 256 - requestTimeoutMillis = 15000 257 - connectTimeoutMillis = 15000 258 - socketTimeoutMillis = 15000 270 + requestTimeoutMillis = 30000 271 + connectTimeoutMillis = 30000 272 + socketTimeoutMillis = 30000 259 273 } 260 274 } 261 275 ··· 279 293 280 294 // No session, try to refresh 281 295 val rs = authClient.refreshSession() 296 + Log.d("ASD", rs.toString()) 282 297 when (rs) { 283 298 is AtpResponse.Failure<*> -> return Result.failure(Exception("Could not refresh session, maybe login again?")) 284 299 is AtpResponse.Success<RefreshSessionResponse> -> { ··· 313 328 } 314 329 } 315 330 316 - suspend fun post(content: String) { 317 - create().getOrThrow() 331 + suspend fun post(content: String): Result<Unit> { 332 + return runCatching { 333 + create().getOrThrow() 318 334 319 - val r = BlueskyJson.encodeAsJsonContent( 320 - Post( 321 - text = content, 322 - createdAt = Clock.System.now(), 323 - // embed = PostEmbedUnion.Images( 324 - // value = Images( 325 - // images = List<ImagesImage>(2, init = { 326 - // ImagesImage( 327 - // image = Blob() 328 - // ) 329 - // }) 330 - // ) 331 - // ) 335 + val r = BlueskyJson.encodeAsJsonContent( 336 + Post( 337 + text = content, 338 + createdAt = Clock.System.now(), 339 + ) 340 + ) 341 + 342 + val postRes = client!!.createRecord( 343 + CreateRecordRequest( 344 + repo = session!!.handle, // Use handle from the session 345 + collection = Nsid("app.bsky.feed.post"), 346 + record = r, 347 + ) 348 + ) 349 + return when (postRes) { 350 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 351 + is AtpResponse.Success<*> -> Result.success(Unit) 352 + } 353 + } 354 + } 355 + 356 + suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 357 + return runCatching { 358 + create().getOrThrow() 359 + 360 + val blobs = mutableListOf<Blob>() 361 + 362 + if (images != null) { 363 + blobs += uploadImages(images).getOrThrow() 364 + } 365 + 366 + if (video != null) { 367 + blobs += uploadVideo(video).getOrThrow() 368 + } 369 + 370 + val r = BlueskyJson.encodeAsJsonContent( 371 + Post( 372 + text = content, 373 + createdAt = Clock.System.now(), 374 + embed = PostEmbedUnion.Images( 375 + value = Images( 376 + images = blobs.map { 377 + ImagesImage( 378 + image = it, 379 + alt = "", 380 + ) 381 + } 382 + ) 383 + ) 384 + ) 332 385 ) 333 - ) 334 - client!!.createRecord( 335 - CreateRecordRequest( 336 - repo = session!!.handle, // Use handle from the session 337 - collection = Nsid("app.bsky.feed.post"), 338 - record = r, 386 + 387 + val postRes = client!!.createRecord( 388 + CreateRecordRequest( 389 + repo = session!!.handle, // Use handle from the session 390 + collection = Nsid("app.bsky.feed.post"), 391 + record = r, 392 + ) 339 393 ) 340 - ) 394 + return when (postRes) { 395 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 396 + is AtpResponse.Success<*> -> Result.success(Unit) 397 + } 398 + } 399 + } 400 + 401 + suspend fun uploadImages(images: List<Uri>): Result<List<Blob>> { 402 + return runCatching { 403 + create().getOrThrow() 404 + 405 + val uploadedBlobs = mutableListOf<Blob>() 406 + 407 + images.forEach { 408 + context.contentResolver.openInputStream(it)?.use { inputStream -> 409 + val byteArray = inputStream.readBytes() 410 + val blob = client!!.uploadBlob(byteArray) 411 + when (blob) { 412 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading image: ${blob.error}")) 413 + is AtpResponse.Success<UploadBlobResponse> -> { 414 + uploadedBlobs.add(blob.response.blob) 415 + } 416 + } 417 + } 418 + } 419 + 420 + return Result.success(uploadedBlobs) 421 + } 341 422 } 342 423 343 - // suspend fun uploadImages(images: List<>) 424 + suspend fun uploadVideo(video: Uri): Result<List<Blob>> { 425 + return runCatching { 426 + create().getOrThrow() 427 + 428 + val uploadedBlobs = mutableListOf<Blob>() 429 + 430 + context.contentResolver.openInputStream(video)?.use { inputStream -> 431 + val byteArray = inputStream.readBytes() 432 + val blob = client!!.uploadBlob(byteArray) 433 + when (blob) { 434 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${blob.error}")) 435 + is AtpResponse.Success<UploadBlobResponse> -> { 436 + uploadedBlobs.add(blob.response.blob) 437 + } 438 + } 439 + } 440 + 441 + 442 + return Result.success(uploadedBlobs) 443 + } 444 + } 344 445 }
+7 -4
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 1 1 package industries.geesawra.jerryno.datalayer 2 2 3 + import android.net.Uri 3 4 import android.util.Log 4 5 import androidx.compose.runtime.getValue 5 6 import androidx.compose.runtime.mutableStateOf ··· 21 22 val cursor: String? = null, 22 23 val authenticated: Boolean = false, 23 24 val sessionChecked: Boolean = false, 24 - val authError: String = "" 25 + val authError: String = "", 26 + val postError: String? = null 25 27 ) 26 28 27 29 @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) ··· 76 78 } 77 79 } 78 80 79 - fun post(content: String, then: suspend () -> Unit) { 81 + fun post(content: String, images: List<Uri>? = null, video: Uri? = null) { 80 82 viewModelScope.launch { 81 - bskyConn.post(content) 82 - then() 83 + bskyConn.post(content, images, video).onFailure { 84 + uiState = uiState.copy(postError = it.message) 85 + } 83 86 } 84 87 } 85 88 }