A cheap attempt at a native Bluesky client for Android

Compose: include aspect ratio with image and video uploads

Include the aspect ratio when uploading images and videos. This helps clients display the media correctly before it has fully loaded.

The `Compressor` was updated to return a `CompressedImage` data class containing the width and height, in addition to the byte array.

For videos, `MediaMetadataRetriever` is now used to extract the width, height, and rotation to correctly determine the video's dimensions.

+88 -23
+53 -19
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 3 3 package industries.geesawra.monarch.datalayer 4 4 5 5 import android.content.Context 6 + import android.media.MediaMetadataRetriever 6 7 import android.net.Uri 7 8 import android.util.Log 8 9 import androidx.compose.ui.text.intl.Locale ··· 15 16 import app.bsky.actor.GetProfileResponse 16 17 import app.bsky.actor.PreferencesUnion 17 18 import app.bsky.actor.ProfileViewDetailed 19 + import app.bsky.embed.AspectRatio 18 20 import app.bsky.embed.Images 19 21 import app.bsky.embed.ImagesImage 20 22 import app.bsky.embed.Record ··· 148 150 } 149 151 150 152 data class Timeline( 151 - public val cursor: String? = null, 152 - public val feed: List<FeedViewPost>, 153 + val cursor: String? = null, 154 + val feed: List<FeedViewPost>, 153 155 ) 154 156 155 157 class BlueskyConn(val context: Context) { ··· 537 539 value = Images( 538 540 images = blobs.map { 539 541 ImagesImage( 540 - image = it, 542 + image = it.blob, 541 543 alt = "", 544 + aspectRatio = AspectRatio(it.width, it.height) 542 545 ) 543 546 } 544 547 ) ··· 549 552 val blob = uploadVideo(video).getOrThrow() 550 553 postEmbed = PostEmbedUnion.Video( 551 554 value = Video( 552 - video = blob, 555 + video = blob.blob, 553 556 alt = "", 557 + aspectRatio = AspectRatio(blob.width, blob.height) 554 558 ) 555 559 ) 556 560 } ··· 619 623 } 620 624 } 621 625 622 - suspend fun uploadImages(images: List<Uri>): Result<List<Blob>> { 626 + private data class MediaBlob( 627 + val blob: Blob, 628 + val width: Long, 629 + val height: Long, 630 + ) 631 + 632 + private suspend fun uploadImages(images: List<Uri>): Result<List<MediaBlob>> { 623 633 val maxImageSize = 1000000 624 634 625 635 return runCatching { ··· 627 637 return Result.failure(LoginException(it.message)) 628 638 } 629 639 630 - val uploadedBlobs = mutableListOf<Blob>() 640 + val uploadedBlobs = mutableListOf<MediaBlob>() 631 641 632 642 val compressor = Compressor(context) 633 643 634 644 images.forEach { 635 645 context.contentResolver.openInputStream(it)?.use { inputStream -> 636 - val byteArray = run { 646 + val compressedImage = run { 637 647 inputStream.mark(0) 638 - 639 648 val c = compressor.compressImage(it, maxImageSize.toLong()) 640 - 641 - c?.let { 642 - return@run c 643 - } 644 - 645 - inputStream.reset() 646 - return@run inputStream.readBytes() 649 + return@run c 647 650 } 648 651 649 - val blob = client!!.uploadBlob(byteArray) 652 + val blob = client!!.uploadBlob(compressedImage.data) 650 653 when (blob) { 651 654 is AtpResponse.Failure<*> -> return Result.failure(Exception("Failed uploading image: ${blob.error}")) 652 655 is AtpResponse.Success<UploadBlobResponse> -> { 653 - uploadedBlobs.add(blob.response.blob) 656 + uploadedBlobs.add( 657 + MediaBlob( 658 + blob = blob.response.blob, 659 + width = compressedImage.width, 660 + height = compressedImage.height 661 + ) 662 + ) 654 663 } 655 664 } 656 665 } ··· 660 669 } 661 670 } 662 671 663 - suspend fun uploadVideo(video: Uri): Result<Blob> { 672 + private suspend fun uploadVideo(video: Uri): Result<MediaBlob> { 664 673 return runCatching { 665 674 create().onFailure { 666 675 return Result.failure(LoginException(it.message)) 667 676 } 677 + 678 + val retriever = MediaMetadataRetriever() 679 + retriever.setDataSource(context, video) 680 + val width = 681 + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) 682 + ?.toIntOrNull() ?: 0 683 + val height = 684 + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) 685 + ?.toIntOrNull() ?: 0 686 + val rotation = 687 + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) 688 + ?.toIntOrNull() ?: 0 689 + 690 + val dimensions = if (rotation == 90 || rotation == 270) { 691 + Pair(height, width) 692 + } else { 693 + Pair(width, height) 694 + } 695 + retriever.release() 668 696 669 697 val uploadedBlobs = mutableListOf<Blob>() 670 698 ··· 776 804 } 777 805 778 806 779 - return Result.success(uploadedBlobs.first()) 807 + return Result.success( 808 + MediaBlob( 809 + blob = uploadedBlobs.first(), 810 + width = dimensions.first.toLong(), 811 + height = dimensions.second.toLong() 812 + ) 813 + ) 780 814 } 781 815 } 782 816
+35 -4
app/src/main/java/industries/geesawra/monarch/datalayer/Compressor.kt
··· 26 26 import kotlin.coroutines.resume 27 27 import kotlin.coroutines.resumeWithException 28 28 import kotlin.io.path.Path 29 + import kotlin.io.path.createTempFile 29 30 import kotlin.math.roundToInt 30 31 31 32 // Adapted from: 32 33 // http://github.com/philipplackner/ImageCompression/blob/master/app/src/main/java/com/plcoding/imagecompression/ImageCompressor.kt 33 34 35 + data class CompressedImage( 36 + val data: ByteArray, 37 + val width: Long, 38 + val height: Long, 39 + ) { 40 + override fun equals(other: Any?): Boolean { 41 + if (this === other) return true 42 + if (javaClass != other?.javaClass) return false 43 + 44 + other as CompressedImage 45 + 46 + if (width != other.width) return false 47 + if (height != other.height) return false 48 + if (!data.contentEquals(other.data)) return false 49 + 50 + return true 51 + } 52 + 53 + override fun hashCode(): Int { 54 + var result = width.hashCode() 55 + result = 31 * result + height.hashCode() 56 + result = 31 * result + data.contentHashCode() 57 + return result 58 + } 59 + } 60 + 34 61 class Compressor( 35 62 private val context: Context 36 63 ) { 37 64 suspend fun compressImage( 38 65 contentUri: Uri, 39 66 compressionThreshold: Long 40 - ): ByteArray? { 67 + ): CompressedImage { 41 68 return withContext(Dispatchers.IO) { 42 69 val mimeType = context.contentResolver.getType(contentUri) 43 70 val inputBytes = context ··· 45 72 .openInputStream(contentUri) 46 73 ?.use { inputStream -> 47 74 inputStream.readBytes() 48 - } ?: return@withContext null 75 + }!! 49 76 50 77 ensureActive() 51 78 ··· 78 105 compressFormat != Bitmap.CompressFormat.PNG 79 106 ) 80 107 81 - outputBytes 108 + CompressedImage( 109 + data = outputBytes, 110 + width = bitmap.width.toLong(), 111 + height = bitmap.height.toLong() 112 + ) 82 113 } 83 114 } 84 115 } ··· 117 148 } 118 149 119 150 val tempFile = 120 - kotlin.io.path.createTempFile( 151 + createTempFile( 121 152 directory = Path(context.cacheDir.toString()), 122 153 prefix = "kotlinTemp", 123 154 suffix = ".tmp",