Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support sending GIFs from keyboard [WPB-1715] #3416

Merged
merged 4 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class CheckAssetRestrictionsViewModel @Inject constructor() : ViewModel() {
assetType = it.assetBundle.assetType,
maxLimitInMB = it.assetSizeExceeded!!,
savedToDevice = false,
multipleAssets = true
multipleAssets = importedMediaList.size > 1,
)
} ?: onSuccess(importedMediaList.map { it.assetBundle })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
Expand Down Expand Up @@ -230,48 +231,63 @@ private fun Content(
)
}

LazyRow(
if (previewState.assetBundleList.size > 1) {
ThumbnailsRow(
previewState = previewState,
onSelected = onSelected,
onRemoveAsset = onRemoveAsset
)
}
}
}
}

@Composable
private fun BoxScope.ThumbnailsRow(
previewState: ImagesPreviewState,
onSelected: (index: Int) -> Unit,
onRemoveAsset: (index: Int) -> Unit
) {
LazyRow(
modifier = Modifier
.padding(bottom = dimensions().spacing8x)
.height(dimensions().spacing80x)
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x),
contentPadding = PaddingValues(start = dimensions().spacing16x, end = dimensions().spacing16x)
) {
items(
count = previewState.assetBundleList.size,
) { index ->
Box(
modifier = Modifier
.padding(bottom = dimensions().spacing8x)
.height(dimensions().spacing80x)
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x),
contentPadding = PaddingValues(start = dimensions().spacing16x, end = dimensions().spacing16x)
.width(dimensions().spacing80x)
.fillMaxHeight()
) {
items(
count = previewState.assetBundleList.size,
) { index ->
Box(
modifier = Modifier
.width(dimensions().spacing80x)
.fillMaxHeight()
) {
AssetTilePreview(
modifier = Modifier
.size(dimensions().spacing64x)
.align(Alignment.Center),
assetBundle = previewState.assetBundleList[index].assetBundle,
isSelected = previewState.selectedIndex == index,
showOnlyExtension = true,
onClick = { onSelected(index) }
)
AssetTilePreview(
modifier = Modifier
.size(dimensions().spacing64x)
.align(Alignment.Center),
assetBundle = previewState.assetBundleList[index].assetBundle,
isSelected = previewState.selectedIndex == index,
showOnlyExtension = true,
onClick = { onSelected(index) }
)

if (previewState.assetBundleList.size > 1) {
RemoveIcon(
modifier = Modifier.align(Alignment.TopEnd),
onClick = {
onRemoveAsset(index)
},
contentDescription = stringResource(id = R.string.remove_asset_description)
)
}
if (previewState.assetBundleList[index].assetSizeExceeded != null) {
ErrorIcon(
stringResource(id = R.string.asset_attention_description),
modifier = Modifier.align(Alignment.Center)
)
}
}
if (previewState.assetBundleList.size > 1) {
RemoveIcon(
modifier = Modifier.align(Alignment.TopEnd),
onClick = {
onRemoveAsset(index)
},
contentDescription = stringResource(id = R.string.remove_asset_description)
)
}
if (previewState.assetBundleList[index].assetSizeExceeded != null) {
ErrorIcon(
stringResource(id = R.string.asset_attention_description),
modifier = Modifier.align(Alignment.Center)
)
}
}
}
Expand All @@ -280,7 +296,7 @@ private fun Content(

@PreviewMultipleThemes
@Composable
fun PreviewImagesPreviewScreen() {
fun PreviewImagesPreviewScreenMultipleAssets() {
WireTheme {
Content(
previewState = ImagesPreviewState(
Expand Down Expand Up @@ -341,3 +357,34 @@ fun PreviewImagesPreviewScreen() {
)
}
}

@PreviewMultipleThemes
@Composable
fun PreviewImagesPreviewScreenSingleAsset() {
WireTheme {
Content(
previewState = ImagesPreviewState(
ConversationId("value", "domain"),
selectedIndex = 0,
conversationName = "Conversation",
assetBundleList = persistentListOf(
ImportedMediaAsset(
AssetBundle(
"key",
"image/png",
"".toPath(),
20,
"preview.png",
assetType = AttachmentType.IMAGE
),
assetSizeExceeded = null
)
),
),
onNavigationPressed = {},
onSendMessages = {},
onSelected = {},
onRemoveAsset = {}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -54,6 +59,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
Expand All @@ -69,11 +75,12 @@ import com.wire.android.ui.home.conversations.model.UriAsset
import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState
import com.wire.android.ui.home.messagecomposer.state.InputType
import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder
import com.wire.android.util.isImage
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.data.message.SelfDeletionTimer
import kotlin.math.roundToInt

@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Suppress("ComplexMethod")
@Composable
fun EnabledMessageComposer(
Expand All @@ -95,6 +102,7 @@ fun EnabledMessageComposer(
tempWritableImageUri: Uri?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val density = LocalDensity.current
val navBarHeight = BottomNavigationBarHeight()
val isImeVisible = WindowInsets.isImeVisible
Expand Down Expand Up @@ -234,7 +242,27 @@ fun EnabledMessageComposer(

AdditionalOptionSubMenuState.RecordAudio -> {}
}
},
}
.contentReceiver(
receiveContentListener = { transferableContent ->
if (transferableContent.hasMediaType(MediaType.Image)) {
val imageUriList = mutableListOf<Uri>()
transferableContent
.consume { item ->
// Only use URIs with images
(item.uri != null && item.uri.isImage(context))
.also { hasImageUri ->
if (hasImageUri) imageUriList.add(item.uri)
}
}
.also {
onImagesPicked(imageUriList)
}
} else {
transferableContent
}
}
),
)

val mentionSearchResult = messageComposerViewState.value.mentionSearchResult
Expand Down
36 changes: 20 additions & 16 deletions app/src/main/kotlin/com/wire/android/util/FileUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -201,29 +201,33 @@ suspend fun Uri.resampleImageAndCopyToTempPath(
dispatcher: DispatcherProvider = DefaultDispatcherProvider()
): Long {
return withContext(dispatcher.io()) {
var size: Long
val originalImage = toByteArray(context, dispatcher)
if (originalImage.isEmpty()) return@withContext 0L // if the image is empty, resampling it would cause an exception

ImageUtil.resample(originalImage, sizeClass).let { processedImage ->
val file = tempCachePath.toFile()
try {
size = processedImage.size.toLong()
file.setWritable(true)
file.outputStream().use { it.write(processedImage) }
} catch (e: FileNotFoundException) {
appLogger.e("[ResampleImage] Cannot find file ${file.path}", e)
throw e
} catch (e: IOException) {
appLogger.e("[ResampleImage] I/O error while writing the image", e)
throw e
}
val mimeType = [email protected](context)
if (mimeType == "image/gif") {
// GIFs are not resampled, it takes long and usually GIFs are small enough to be shared as is.
// If the GIF is too large, the user will be informed about that, just like for all other files.
originalImage.writeToFile(tempCachePath.toFile())
} else {
ImageUtil.resample(originalImage, sizeClass).writeToFile(tempCachePath.toFile())
}

size
}
}

private fun ByteArray.writeToFile(file: File): Long =
try {
file.setWritable(true)
file.outputStream().use { it.write(this) }
this.size.toLong()
} catch (e: FileNotFoundException) {
appLogger.e("[ResampleImage] Cannot find file ${file.path}", e)
throw e
} catch (e: IOException) {
appLogger.e("[ResampleImage] I/O error while writing the image", e)
throw e
}

fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
else -> uri.path?.let(::File)?.name
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/kotlin/com/wire/android/util/ImageUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ fun Uri.toBitmap(context: Context): Bitmap? {
}
}

/**
* Checks whether it is the URI of the image
*/
fun Uri.isImage(context: Context): Boolean = isImageFile(this.getMimeType(context))

/**
* Rotates the image to its [ExifInterface.ORIENTATION_NORMAL] in case it's rotated with a different orientation than
* landscape or portrait See more about exif interface at:
Expand Down
Loading