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.

Bluesky: kinda sorta video upload thing

geesawra 5870c0fb 273670be

+255 -8
+3
app/build.gradle.kts
··· 74 74 implementation("me.saket.telephoto:zoomable:0.17.0") 75 75 implementation("me.saket.telephoto:zoomable-image-coil3:0.17.0") 76 76 implementation("androidx.browser:browser:1.9.0") 77 + implementation("androidx.media3:media3-transformer:1.8.0") 78 + implementation("androidx.media3:media3-effect:1.8.0") 79 + implementation("androidx.media3:media3-common:1.8.0") 77 80 implementation(libs.androidx.compose.animation.core.lint) 78 81 implementation(libs.androidx.material3) 79 82 ksp("com.google.dagger:hilt-compiler:2.57.2")
+82 -8
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 22 22 import app.bsky.feed.PostEmbedUnion 23 23 import app.bsky.feed.PostReplyRef 24 24 import app.bsky.feed.Repost 25 + import app.bsky.video.GetJobStatusQueryParams 26 + import app.bsky.video.GetJobStatusResponse 27 + import app.bsky.video.State 28 + import app.bsky.video.UploadVideoResponse 25 29 import com.atproto.identity.ResolveHandleQueryParams 26 30 import com.atproto.identity.ResolveHandleResponse 27 31 import com.atproto.repo.CreateRecordRequest ··· 31 35 import com.atproto.repo.UploadBlobResponse 32 36 import com.atproto.server.CreateSessionRequest 33 37 import com.atproto.server.CreateSessionResponse 38 + import com.atproto.server.GetServiceAuthQueryParams 39 + import com.atproto.server.GetServiceAuthResponse 34 40 import com.atproto.server.RefreshSessionResponse 35 41 import industries.geesawra.monarch.rkey 36 42 import io.ktor.client.HttpClient ··· 43 49 import io.ktor.http.HttpStatusCode 44 50 import io.ktor.http.URLProtocol 45 51 import io.ktor.http.path 52 + import kotlinx.coroutines.delay 46 53 import kotlinx.coroutines.flow.Flow 47 54 import kotlinx.coroutines.flow.first 48 55 import kotlinx.coroutines.flow.map ··· 62 69 import sh.christian.ozone.api.model.Blob 63 70 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 64 71 import sh.christian.ozone.api.response.AtpResponse 72 + import kotlin.time.Duration 65 73 66 74 enum class AuthData { 67 75 PDSHost, ··· 186 194 var client: AuthenticatedXrpcBlueskyApi? = null 187 195 var session: SessionData? = null 188 196 var createMutex: Mutex = Mutex() 197 + var pdsURL: String? = null 189 198 190 199 suspend fun storeSessionData(pdsURL: String, session: SessionData) { 191 200 context.dataStore.edit { settings -> ··· 351 360 suspend fun create(): Result<Unit> { 352 361 return runCatching { 353 362 createMutex.lock() 354 - if (session != null && client != null) { 363 + if (session != null && client != null && pdsURL != null) { 355 364 createMutex.unlock() 356 365 return Result.success(Unit) 357 366 } ··· 379 388 return Result.failure(it) 380 389 } 381 390 391 + this.pdsURL = pdsURL 392 + 382 393 createMutex.unlock() 383 394 } 384 395 } ··· 482 493 483 494 val uploadedBlobs = mutableListOf<Blob>() 484 495 485 - val compressor = ImageCompressor(context) 496 + val compressor = Compressor(context) 486 497 487 498 images.forEach { 488 499 context.contentResolver.openInputStream(it)?.use { inputStream -> ··· 521 532 522 533 val uploadedBlobs = mutableListOf<Blob>() 523 534 524 - context.contentResolver.openInputStream(video)?.use { inputStream -> 535 + val uploadVideoTicket = client!!.getServiceAuth( 536 + GetServiceAuthQueryParams( 537 + aud = Did("did:web:" + pdsURL!!.toUri().host!!), 538 + exp = Clock.System.now().plus(Duration.parse("30m")).epochSeconds, 539 + lxm = Nsid("com.atproto.repo.uploadBlob"), 540 + ) 541 + ) 542 + 543 + val serviceAuth = when (uploadVideoTicket) { 544 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${uploadVideoTicket.error}")) 545 + is AtpResponse.Success<GetServiceAuthResponse> -> uploadVideoTicket.response.token 546 + } 547 + 548 + val httpClient = HttpClient(OkHttp) { 549 + defaultRequest { 550 + url("https://video.bsky.app") 551 + } 552 + install(HttpTimeout) { 553 + requestTimeoutMillis = 15000 554 + connectTimeoutMillis = 15000 555 + socketTimeoutMillis = 15000 556 + } 557 + } 558 + 559 + val client = AuthenticatedXrpcBlueskyApi( 560 + httpClient, 561 + BlueskyAuthPlugin.Tokens(serviceAuth, serviceAuth) 562 + ) 563 + 564 + val uploadRes = context.contentResolver.openInputStream(video)?.use { inputStream -> 525 565 val byteArray = inputStream.readBytes() 526 - val blob = client!!.uploadBlob(byteArray) 527 - when (blob) { 528 - is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${blob.error}")) 529 - is AtpResponse.Success<UploadBlobResponse> -> { 530 - uploadedBlobs.add(blob.response.blob) 566 + 567 + val videoUploadTicket = client.uploadVideo(byteArray) 568 + when (videoUploadTicket) { 569 + is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading video: ${videoUploadTicket.error}")) 570 + is AtpResponse.Success<UploadVideoResponse> -> { 571 + return@use videoUploadTicket.response.jobStatus 572 + } 573 + } 574 + } 575 + 576 + while (true) { 577 + try { 578 + val response = 579 + client.getJobStatus(GetJobStatusQueryParams(uploadRes!!.jobId)) 580 + 581 + val resp = when (response) { 582 + is AtpResponse.Failure<*> -> return Result.failure( 583 + Exception("Failed uploading video: ${response.error}") 584 + ) 585 + 586 + is AtpResponse.Success<GetJobStatusResponse> -> response.response.jobStatus 587 + } 588 + 589 + if (resp.blob != null) { 590 + uploadedBlobs.add(resp.blob!!) 591 + break 531 592 } 593 + 594 + when (resp.state) { 595 + State.JOBSTATECOMPLETED -> { 596 + uploadedBlobs.add(resp.blob!!) 597 + break 598 + } 599 + 600 + State.JOBSTATEFAILED -> return Result.failure(Exception("Failed uploading video, ${resp.error}: ${resp.message}")) 601 + is State.Unknown -> delay(1000) 602 + } 603 + } catch (e: Exception) { 604 + // Network or other error. Return the failure and exit the loop. 605 + return Result.failure(e) 532 606 } 533 607 } 534 608
+170
app/src/main/java/industries/geesawra/monarch/datalayer/Compressor.kt
··· 1 + package industries.geesawra.monarch.datalayer 2 + 3 + import android.content.Context 4 + import android.graphics.Bitmap 5 + import android.graphics.BitmapFactory 6 + import android.net.Uri 7 + import android.provider.OpenableColumns 8 + import androidx.annotation.OptIn 9 + import androidx.core.net.toUri 10 + import androidx.media3.common.MediaItem 11 + import androidx.media3.common.util.UnstableApi 12 + import androidx.media3.effect.ScaleAndRotateTransformation 13 + import androidx.media3.transformer.Composition 14 + import androidx.media3.transformer.EditedMediaItem 15 + import androidx.media3.transformer.Effects 16 + import androidx.media3.transformer.ExportException 17 + import androidx.media3.transformer.ExportResult 18 + import androidx.media3.transformer.Transformer 19 + import kotlinx.coroutines.Dispatchers 20 + import kotlinx.coroutines.ensureActive 21 + import kotlinx.coroutines.isActive 22 + import kotlinx.coroutines.suspendCancellableCoroutine 23 + import kotlinx.coroutines.withContext 24 + import java.io.ByteArrayOutputStream 25 + import java.io.File 26 + import kotlin.coroutines.resume 27 + import kotlin.coroutines.resumeWithException 28 + import kotlin.io.path.Path 29 + import kotlin.math.roundToInt 30 + 31 + // Adapted from: 32 + // http://github.com/philipplackner/ImageCompression/blob/master/app/src/main/java/com/plcoding/imagecompression/ImageCompressor.kt 33 + 34 + class Compressor( 35 + private val context: Context 36 + ) { 37 + suspend fun compressImage( 38 + contentUri: Uri, 39 + compressionThreshold: Long 40 + ): ByteArray? { 41 + return withContext(Dispatchers.IO) { 42 + val mimeType = context.contentResolver.getType(contentUri) 43 + val inputBytes = context 44 + .contentResolver 45 + .openInputStream(contentUri) 46 + ?.use { inputStream -> 47 + inputStream.readBytes() 48 + } ?: return@withContext null 49 + 50 + ensureActive() 51 + 52 + withContext(Dispatchers.Default) { 53 + val bitmap = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size) 54 + 55 + ensureActive() 56 + 57 + val compressFormat = when (mimeType) { 58 + "image/png" -> Bitmap.CompressFormat.PNG 59 + "image/jpeg" -> Bitmap.CompressFormat.JPEG 60 + "image/webp" -> 61 + Bitmap.CompressFormat.WEBP_LOSSLESS 62 + 63 + else -> Bitmap.CompressFormat.JPEG 64 + } 65 + 66 + var outputBytes: ByteArray 67 + var quality = 90 68 + 69 + do { 70 + ByteArrayOutputStream().use { outputStream -> 71 + bitmap.compress(compressFormat, quality, outputStream) 72 + outputBytes = outputStream.toByteArray() 73 + quality -= (quality * 0.1).roundToInt() 74 + } 75 + } while (isActive && 76 + outputBytes.size > compressionThreshold && 77 + quality > 5 && 78 + compressFormat != Bitmap.CompressFormat.PNG 79 + ) 80 + 81 + outputBytes 82 + } 83 + } 84 + } 85 + 86 + fun getVideoSizeFromUri(context: Context, uri: Uri): Long? { 87 + // Use the ContentResolver to query the Uri's metadata 88 + val cursor = context.contentResolver.query(uri, null, null, null, null) 89 + 90 + // The 'use' block ensures the cursor is automatically closed 91 + cursor?.use { 92 + if (it.moveToFirst()) { 93 + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) 94 + // The size column may not exist for all Uris, so check for -1 95 + if (sizeIndex != -1) { 96 + return it.getLong(sizeIndex) 97 + } 98 + } 99 + } 100 + // If we couldn't get the size, return null 101 + return null 102 + } 103 + 104 + @OptIn(UnstableApi::class) 105 + suspend fun compressVideo( 106 + contentUri: Uri, 107 + ): Uri? { 108 + return withContext(Dispatchers.IO) { 109 + val sizeLimitBytes = 100 * 1024 * 1024L 110 + 111 + val videoSizeBytes = withContext(Dispatchers.IO) { 112 + getVideoSizeFromUri(context, contentUri) 113 + } 114 + 115 + if (videoSizeBytes == null || videoSizeBytes <= sizeLimitBytes) { 116 + return@withContext null 117 + } 118 + 119 + val tempFile = 120 + kotlin.io.path.createTempFile( 121 + directory = Path(context.cacheDir.toString()), 122 + prefix = "kotlinTemp", 123 + suffix = ".tmp", 124 + ).toFile() 125 + 126 + File.createTempFile("compressor_output", ".mp4", context.cacheDir) 127 + 128 + val scaleAndRotateTransformation = ScaleAndRotateTransformation.Builder() 129 + .setScale(0.3f, 0.3f) // Scale x and y by 50% 130 + .build() 131 + 132 + ensureActive() 133 + 134 + suspendCancellableCoroutine { continuation -> 135 + val inputMediaItem = MediaItem.fromUri(contentUri) 136 + val editedMediaItem = 137 + EditedMediaItem.Builder(inputMediaItem).setEffects( 138 + Effects( 139 + listOf(), 140 + listOf(scaleAndRotateTransformation) 141 + ) 142 + ).build() 143 + 144 + val transformer = 145 + Transformer.Builder(context).addListener(object : Transformer.Listener { 146 + override fun onCompleted( 147 + composition: Composition, 148 + exportResult: ExportResult 149 + ) { 150 + continuation.resume(value = tempFile.toUri()) 151 + } 152 + 153 + override fun onError( 154 + composition: Composition, 155 + exportResult: ExportResult, 156 + exportException: ExportException 157 + ) { 158 + continuation.resumeWithException(exportException) 159 + } 160 + }).build() 161 + 162 + transformer.start(editedMediaItem, tempFile.toString()) 163 + 164 + continuation.invokeOnCancellation { 165 + transformer.cancel() 166 + } 167 + } 168 + } 169 + } 170 + }