android, desktop: update to Compose 1.7.0 (#5038)

* docs: correction

* android, desktop: update to Compose 1.7.0

- support image drag-and-drop from other applications right to a chat
(with and without transparent pixels - will be png or jpg)

* stable

* workaround

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2024-10-31 17:11:26 +00:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 78510b6fd3
commit 24090fe350
19 changed files with 176 additions and 51 deletions
@@ -21,7 +21,7 @@ actual fun ProvideWindowInsets(
actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean,
onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit,
onImage: (File) -> Unit,
onText: (String) -> Unit
): Modifier = this
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doAfterTextChanged
@@ -94,8 +95,8 @@ actual fun PlatformTextField(
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
AndroidView(modifier = Modifier, factory = { context ->
val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: OnReceiveContentListener?
@@ -148,8 +149,12 @@ actual fun PlatformTextField(
}
}
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
val workaround = WorkaroundFocusSearchLayout(context)
workaround.addView(editText)
workaround.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
workaround
}) {
val it = it.children.first() as EditText
it.setTextColor(textColor.toArgb())
it.setHintTextColor(hintColor.toArgb())
it.hint = placeholder
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -411,6 +410,7 @@ private fun ControlButton(icon: Painter, iconText: StringResource, enabled: Bool
@Composable
private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, content: @Composable () -> Unit) {
val ripple = remember { ripple(bounded = false, radius = size / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -419,7 +419,7 @@ private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, backg
onClick = action,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = size / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled
),
contentAlignment = Alignment.Center
@@ -6,7 +6,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,8 +39,8 @@ actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
val ripple = remember { ripple(bounded = true, 3000.dp) }
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = ripple, interactionSource = source)) {
GreenLine(call)
}
Box(
@@ -50,7 +49,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.clickable(onClick = onClick, indication = ripple, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
@@ -0,0 +1,41 @@
package chat.simplex.common.views.helpers
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
/**
* A workaround for the ANR issue on Compose 1.7.x.
* https://issuetracker.google.com/issues/369354336
* Code from:
* https://issuetracker.google.com/issues/369354336#comment8
*/
class WorkaroundFocusSearchLayout : FrameLayout {
constructor(
context: Context,
) : super(context)
constructor(
context: Context,
attrs: AttributeSet?,
) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
) : super(context, attrs, defStyleAttr)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun focusSearch(focused: View?, direction: Int): View? {
return null
}
}
@@ -27,7 +27,7 @@ expect fun ProvideWindowInsets(
expect fun Modifier.desktopOnExternalDrag(
enabled: Boolean = true,
onFiles: (List<File>) -> Unit = {},
onImage: (Painter) -> Unit = {},
onImage: (File) -> Unit = {},
onText: (String) -> Unit = {}
): Modifier
@@ -289,6 +289,7 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
@@ -642,14 +642,7 @@ fun ChatLayout(
.desktopOnExternalDrag(
enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
onImage = {
// TODO: file is not saved anywhere?!
val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit()
chatModel.filesToDelete.add(tmpFile)
val uri = tmpFile.toURI()
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(uri), null) }
},
onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } },
onText = {
// Need to parse HTML in order to correctly display the content
//composeState.value = composeState.value.copy(message = composeState.value.message + it)
@@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
@@ -423,6 +422,7 @@ private fun SendMsgButton(
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
@@ -431,7 +431,7 @@ private fun SendMsgButton(
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
)
.onRightClick { onLongClick?.invoke() },
contentAlignment = Alignment.Center
@@ -454,6 +454,7 @@ private fun SendMsgButton(
@Composable
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
@@ -461,7 +462,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
),
contentAlignment = Alignment.Center
) {
@@ -3,7 +3,6 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
@@ -22,13 +21,11 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.database.PassphraseStrength
import chat.simplex.common.views.database.validKey
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DefaultBasicTextField(
modifier: Modifier,
@@ -6,7 +6,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -107,6 +106,7 @@ fun <T> ExposedDropDownSettingWithIcon(
expanded.value = !expanded.value && enabled.value
}
) {
val ripple = remember { ripple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -115,7 +115,7 @@ fun <T> ExposedDropDownSettingWithIcon(
onClick = {},
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled.value
),
contentAlignment = Alignment.Center
@@ -87,6 +87,7 @@ fun TextEditor(
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
@@ -616,6 +616,7 @@ fun LinkTextView(link: String, share: Boolean) {
enabled = false,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
})
}
@@ -206,3 +206,35 @@ fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImag
}
return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null)
}
fun BufferedImage.saveInTmpFile(): File? {
val formats = arrayOf("jpg", "png")
for (format in formats) {
val tmpFile = File.createTempFile("image", ".$format", tmpDir)
try {
// May fail on JPG, using PNG as an alternative
val success = ImageIO.write(this, format, tmpFile)
if (success) {
tmpFile.deleteOnExit()
chatModel.filesToDelete.add(tmpFile)
return tmpFile
} else {
tmpFile.delete()
}
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
tmpFile.delete()
return null
}
}
return null
}
fun BufferedImage.hasAlpha(): Boolean {
for (x in 0 until width) {
for (y in 0 until height) {
if (getRGB(x, y) == 0) return true
}
}
return false
}
@@ -1,10 +1,17 @@
package chat.simplex.common.platform
import androidx.compose.foundation.contextMenuOpenDetector
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.draganddrop.DragData
import androidx.compose.ui.input.pointer.*
import java.awt.Image
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.image.BufferedImage
import java.io.File
import java.net.URI
@@ -23,16 +30,61 @@ actual fun ProvideWindowInsets(
actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean,
onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit,
onImage: (File) -> Unit,
onText: (String) -> Unit
): Modifier =
onExternalDrag(enabled) {
when(val data = it.dragData) {
// data.readFiles() returns filePath in URI format (where spaces replaces with %20). But it's an error-prone idea to work later
// with such format when everywhere we use absolutePath in File() format
is DragData.FilesList -> onFiles(data.readFiles().map { URI.create(it).toFile() })
is DragData.Image -> onImage(data.readImage())
is DragData.Text -> onText(data.readText())
): Modifier {
val callback = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
when (val data = event.dragData()) {
// data.readFiles() returns filePath in URI format (where spaces replaces with %20). But it's an error-prone idea to work later
// with such format when everywhere we use absolutePath in File() format
is DragData.FilesList -> {
val files = data.readFiles()
// When dragging and dropping an image from browser, it comes to FilesList section but no files inside
if (files.isNotEmpty()) {
onFiles(files.map { URI.create(it).toFile() })
} else {
try {
val transferable = event.awtTransferable
if (transferable.isDataFlavorSupported(DataFlavor.imageFlavor)) {
onImage(DragDataImageImpl(transferable).bufferedImage().saveInTmpFile() ?: return false)
} else {
return false
}
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
return false
}
}
}
is DragData.Image -> onImage(DragDataImageImpl(event.awtTransferable).bufferedImage().saveInTmpFile() ?: return false)
is DragData.Text -> onText(data.readText())
}
return true
}
}
}
return dragAndDropTarget(shouldStartDragAndDrop = { true }, target = callback)
}
// Copied from AwtDragData and modified
private class DragDataImageImpl(private val transferable: Transferable) {
fun bufferedImage(): BufferedImage = (transferable.getTransferData(DataFlavor.imageFlavor) as Image).bufferedImage()
private fun Image.bufferedImage(): BufferedImage {
if (this is BufferedImage && hasAlpha()) {
// Such image cannot be drawn as JPG, only PNG
return this
}
// Creating non-transparent image which can be drawn as JPG
val bufferedImage = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_RGB)
val g2 = bufferedImage.createGraphics()
try {
g2.drawImage(this, 0, 0, null)
} finally {
g2.dispose()
}
return bufferedImage
}
}
@@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.*
@@ -109,7 +110,7 @@ actual fun PlatformTextField(
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
autoCorrectEnabled = true
),
modifier = Modifier
.padding(vertical = 4.dp)
@@ -193,6 +194,7 @@ actual fun PlatformTextField(
interactionSource = remember { MutableInteractionSource() },
contentPadding = PaddingValues(),
visualTransformation = VisualTransformation.None,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
Spacer(Modifier.height(10.dp))
}
@@ -10,20 +10,20 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
object NoIndication : Indication {
private object NoIndicationInstance : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
}
}
@Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
return NoIndicationInstance
object NoIndication : IndicationNodeFactory {
// Should be as a class, not an object. Otherwise, crash
private class NoIndicationInstance : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() { drawContent() }
}
override fun create(interactionSource: InteractionSource): DelegatableNode = NoIndicationInstance()
override fun hashCode(): Int = -1
override fun equals(other: Any?) = other === this
}
@Composable
@@ -156,7 +156,9 @@ actual fun getFileSize(uri: URI): Long? = uri.toFile().length()
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? =
try {
ImageIO.read(uri.inputStream()).toComposeImageBitmap()
uri.inputStream().use {
ImageIO.read(it).toComposeImageBitmap()
}
} catch (e: Exception) {
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
if (withAlertOnException) showImageDecodingException()
+1 -3
View File
@@ -21,8 +21,6 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
@@ -34,4 +32,4 @@ desktop.version_code=74
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
compose.version=1.6.1
compose.version=1.7.0