Выделение текста + фикс ANR при записи ГС
This commit is contained in:
@@ -30,7 +30,6 @@ data class Message(
|
||||
val replyToMessageId: String? = null
|
||||
)
|
||||
|
||||
/** UI модель диалога */
|
||||
data class Dialog(
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
|
||||
@@ -615,6 +615,8 @@ fun ChatDetailScreen(
|
||||
showImageViewer,
|
||||
showMediaPicker,
|
||||
showEmojiPicker,
|
||||
textSelectionHelper.isActive,
|
||||
textSelectionHelper.movingHandle,
|
||||
pendingCameraPhotoUri,
|
||||
pendingGalleryImages,
|
||||
showInAppCamera,
|
||||
@@ -624,6 +626,8 @@ fun ChatDetailScreen(
|
||||
showImageViewer ||
|
||||
showMediaPicker ||
|
||||
showEmojiPicker ||
|
||||
textSelectionHelper.isActive ||
|
||||
textSelectionHelper.movingHandle ||
|
||||
pendingCameraPhotoUri != null ||
|
||||
pendingGalleryImages.isNotEmpty() ||
|
||||
showInAppCamera ||
|
||||
|
||||
@@ -480,8 +480,9 @@ fun MessageBubble(
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
|
||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
|
||||
if (isSystemSafeChat) return@pointerInput
|
||||
if (textSelectionHelper?.isActive == true) return@pointerInput
|
||||
// 🔥 Простой горизонтальный свайп для reply
|
||||
// Используем detectHorizontalDragGestures который лучше работает со
|
||||
// скроллом
|
||||
|
||||
@@ -85,6 +85,9 @@ class TextSelectionHelper {
|
||||
var endHandleX by mutableFloatStateOf(0f)
|
||||
var endHandleY by mutableFloatStateOf(0f)
|
||||
|
||||
// Back gesture callback — registered/unregistered by overlay
|
||||
var backCallback: Any? = null
|
||||
|
||||
// Overlay position in window — set by TextSelectionOverlay
|
||||
var overlayWindowX = 0f
|
||||
var overlayWindowY = 0f
|
||||
@@ -330,17 +333,18 @@ private fun FloatingToolbarPopup(
|
||||
|
||||
val info = helper.layoutInfo ?: return
|
||||
val layout = info.layout
|
||||
val density = LocalDensity.current
|
||||
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||
|
||||
// Toolbar position: centered above selection, in overlay-local coords
|
||||
// startHandleX/endHandleX are already overlay-local
|
||||
// Toolbar positioned ABOVE selection top, in overlay-local coordinates
|
||||
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||
(info.windowY - helper.overlayWindowY)
|
||||
val toolbarHeight = 40f // approximate toolbar height in px
|
||||
val toolbarWidth = 160f // approximate toolbar width in px
|
||||
val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f)
|
||||
val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f)
|
||||
// Toolbar is ~48dp tall + 8dp gap above selection
|
||||
val toolbarOffsetPx = with(density) { 56.dp.toPx() }
|
||||
val toolbarWidthPx = with(density) { 200.dp.toPx() }
|
||||
val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
|
||||
val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
@@ -395,6 +399,36 @@ fun TextSelectionOverlay(
|
||||
val handleInsetPx = with(density) { HandleInset.toPx() }
|
||||
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
|
||||
|
||||
// Block predictive back gesture completely during text selection.
|
||||
// BackHandler alone doesn't prevent the swipe animation on Android 13+
|
||||
// with enableOnBackInvokedCallback=true. We must register an
|
||||
// OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it.
|
||||
val activity = LocalContext.current as? android.app.Activity
|
||||
LaunchedEffect(helper.isActive) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) {
|
||||
if (helper.isActive) {
|
||||
val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ }
|
||||
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
|
||||
android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb
|
||||
)
|
||||
helper.backCallback = cb
|
||||
} else {
|
||||
helper.backCallback?.let { cb ->
|
||||
runCatching {
|
||||
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(
|
||||
cb as android.window.OnBackInvokedCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
helper.backCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback for Android < 13
|
||||
androidx.activity.compose.BackHandler(enabled = helper.isActive) {
|
||||
// consumed — no navigation back while selecting
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -477,18 +511,22 @@ fun TextSelectionOverlay(
|
||||
val startLine = layout.getLineForOffset(startOffset)
|
||||
val endLine = layout.getLineForOffset(endOffset)
|
||||
|
||||
// Padding around highlight for breathing room
|
||||
val padH = 3.dp.toPx()
|
||||
val padV = 2.dp.toPx()
|
||||
|
||||
for (line in startLine..endLine) {
|
||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY
|
||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY
|
||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV
|
||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV
|
||||
val left = if (line == startLine) {
|
||||
layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
|
||||
} else {
|
||||
layout.getLineLeft(line) + offsetX
|
||||
layout.getLineLeft(line) + offsetX - padH
|
||||
}
|
||||
val right = if (line == endLine) {
|
||||
layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||
layout.getPrimaryHorizontal(endOffset) + offsetX + padH
|
||||
} else {
|
||||
layout.getLineRight(line) + offsetX
|
||||
layout.getLineRight(line) + offsetX + padH
|
||||
}
|
||||
drawRoundRect(
|
||||
color = HighlightColor,
|
||||
|
||||
@@ -1147,31 +1147,37 @@ fun MessageInputBar(
|
||||
voiceRecordStartedAtMs = 0L
|
||||
voiceElapsedMs = 0L
|
||||
voiceWaves = emptyList()
|
||||
|
||||
var recordedOk = false
|
||||
if (recorder != null) {
|
||||
recordedOk = runCatching {
|
||||
recorder.stop()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
runCatching { recorder.reset() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
|
||||
if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) {
|
||||
val voiceHex =
|
||||
runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
|
||||
if (voiceHex.isNotBlank()) {
|
||||
onSendVoiceMessage(
|
||||
voiceHex,
|
||||
durationSnapshot,
|
||||
compressVoiceWaves(wavesSnapshot, 35)
|
||||
)
|
||||
}
|
||||
}
|
||||
runCatching { outputFile?.delete() }
|
||||
resetGestureState()
|
||||
setRecordUiState(RecordUiState.IDLE, "stop(send=$send)")
|
||||
|
||||
// Heavy I/O off main thread to prevent ANR
|
||||
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
var recordedOk = false
|
||||
if (recorder != null) {
|
||||
recordedOk = runCatching {
|
||||
recorder.stop()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
runCatching { recorder.reset() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
|
||||
if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) {
|
||||
val voiceHex =
|
||||
runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
|
||||
if (voiceHex.isNotBlank()) {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
||||
onSendVoiceMessage(
|
||||
voiceHex,
|
||||
durationSnapshot,
|
||||
compressVoiceWaves(wavesSnapshot, 35)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching { outputFile?.delete() }
|
||||
}
|
||||
|
||||
inputJumpLog(
|
||||
"stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
|
||||
"emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
|
||||
@@ -1203,8 +1209,13 @@ fun MessageInputBar(
|
||||
recorder.setAudioEncodingBitRate(32_000)
|
||||
recorder.setAudioSamplingRate(48_000)
|
||||
recorder.setOutputFile(output.absolutePath)
|
||||
recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
recorder.setOnErrorListener { _, what, extra ->
|
||||
inputJumpLog("MediaRecorder error what=$what extra=$extra")
|
||||
stopVoiceRecording(send = false)
|
||||
}
|
||||
|
||||
voiceRecorder = recorder
|
||||
voiceOutputFile = output
|
||||
|
||||
Reference in New Issue
Block a user