Выделение текста + фикс ANR при записи ГС
This commit is contained in:
@@ -30,7 +30,6 @@ data class Message(
|
|||||||
val replyToMessageId: String? = null
|
val replyToMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/** UI модель диалога */
|
|
||||||
data class Dialog(
|
data class Dialog(
|
||||||
val opponentKey: String,
|
val opponentKey: String,
|
||||||
val opponentTitle: String,
|
val opponentTitle: String,
|
||||||
|
|||||||
@@ -615,6 +615,8 @@ fun ChatDetailScreen(
|
|||||||
showImageViewer,
|
showImageViewer,
|
||||||
showMediaPicker,
|
showMediaPicker,
|
||||||
showEmojiPicker,
|
showEmojiPicker,
|
||||||
|
textSelectionHelper.isActive,
|
||||||
|
textSelectionHelper.movingHandle,
|
||||||
pendingCameraPhotoUri,
|
pendingCameraPhotoUri,
|
||||||
pendingGalleryImages,
|
pendingGalleryImages,
|
||||||
showInAppCamera,
|
showInAppCamera,
|
||||||
@@ -624,6 +626,8 @@ fun ChatDetailScreen(
|
|||||||
showImageViewer ||
|
showImageViewer ||
|
||||||
showMediaPicker ||
|
showMediaPicker ||
|
||||||
showEmojiPicker ||
|
showEmojiPicker ||
|
||||||
|
textSelectionHelper.isActive ||
|
||||||
|
textSelectionHelper.movingHandle ||
|
||||||
pendingCameraPhotoUri != null ||
|
pendingCameraPhotoUri != null ||
|
||||||
pendingGalleryImages.isNotEmpty() ||
|
pendingGalleryImages.isNotEmpty() ||
|
||||||
showInAppCamera ||
|
showInAppCamera ||
|
||||||
|
|||||||
@@ -480,8 +480,9 @@ fun MessageBubble(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
|
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
|
||||||
if (isSystemSafeChat) return@pointerInput
|
if (isSystemSafeChat) return@pointerInput
|
||||||
|
if (textSelectionHelper?.isActive == true) return@pointerInput
|
||||||
// 🔥 Простой горизонтальный свайп для reply
|
// 🔥 Простой горизонтальный свайп для reply
|
||||||
// Используем detectHorizontalDragGestures который лучше работает со
|
// Используем detectHorizontalDragGestures который лучше работает со
|
||||||
// скроллом
|
// скроллом
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ class TextSelectionHelper {
|
|||||||
var endHandleX by mutableFloatStateOf(0f)
|
var endHandleX by mutableFloatStateOf(0f)
|
||||||
var endHandleY 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
|
// Overlay position in window — set by TextSelectionOverlay
|
||||||
var overlayWindowX = 0f
|
var overlayWindowX = 0f
|
||||||
var overlayWindowY = 0f
|
var overlayWindowY = 0f
|
||||||
@@ -330,17 +333,18 @@ private fun FloatingToolbarPopup(
|
|||||||
|
|
||||||
val info = helper.layoutInfo ?: return
|
val info = helper.layoutInfo ?: return
|
||||||
val layout = info.layout
|
val layout = info.layout
|
||||||
|
val density = LocalDensity.current
|
||||||
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||||
|
|
||||||
// Toolbar position: centered above selection, in overlay-local coords
|
// Toolbar positioned ABOVE selection top, in overlay-local coordinates
|
||||||
// startHandleX/endHandleX are already overlay-local
|
|
||||||
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||||
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||||
(info.windowY - helper.overlayWindowY)
|
(info.windowY - helper.overlayWindowY)
|
||||||
val toolbarHeight = 40f // approximate toolbar height in px
|
// Toolbar is ~48dp tall + 8dp gap above selection
|
||||||
val toolbarWidth = 160f // approximate toolbar width in px
|
val toolbarOffsetPx = with(density) { 56.dp.toPx() }
|
||||||
val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f)
|
val toolbarWidthPx = with(density) { 200.dp.toPx() }
|
||||||
val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f)
|
val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
|
||||||
|
val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
|
||||||
|
|
||||||
Popup(
|
Popup(
|
||||||
alignment = Alignment.TopStart,
|
alignment = Alignment.TopStart,
|
||||||
@@ -395,6 +399,36 @@ fun TextSelectionOverlay(
|
|||||||
val handleInsetPx = with(density) { HandleInset.toPx() }
|
val handleInsetPx = with(density) { HandleInset.toPx() }
|
||||||
val highlightCornerPx = with(density) { HighlightCorner.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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -477,18 +511,22 @@ fun TextSelectionOverlay(
|
|||||||
val startLine = layout.getLineForOffset(startOffset)
|
val startLine = layout.getLineForOffset(startOffset)
|
||||||
val endLine = layout.getLineForOffset(endOffset)
|
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) {
|
for (line in startLine..endLine) {
|
||||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY
|
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV
|
||||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY
|
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV
|
||||||
val left = if (line == startLine) {
|
val left = if (line == startLine) {
|
||||||
layout.getPrimaryHorizontal(startOffset) + offsetX
|
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
|
||||||
} else {
|
} else {
|
||||||
layout.getLineLeft(line) + offsetX
|
layout.getLineLeft(line) + offsetX - padH
|
||||||
}
|
}
|
||||||
val right = if (line == endLine) {
|
val right = if (line == endLine) {
|
||||||
layout.getPrimaryHorizontal(endOffset) + offsetX
|
layout.getPrimaryHorizontal(endOffset) + offsetX + padH
|
||||||
} else {
|
} else {
|
||||||
layout.getLineRight(line) + offsetX
|
layout.getLineRight(line) + offsetX + padH
|
||||||
}
|
}
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = HighlightColor,
|
color = HighlightColor,
|
||||||
|
|||||||
@@ -1147,31 +1147,37 @@ fun MessageInputBar(
|
|||||||
voiceRecordStartedAtMs = 0L
|
voiceRecordStartedAtMs = 0L
|
||||||
voiceElapsedMs = 0L
|
voiceElapsedMs = 0L
|
||||||
voiceWaves = emptyList()
|
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()
|
resetGestureState()
|
||||||
setRecordUiState(RecordUiState.IDLE, "stop(send=$send)")
|
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(
|
inputJumpLog(
|
||||||
"stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
|
"stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
|
||||||
"emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
|
"emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
|
||||||
@@ -1203,8 +1209,13 @@ fun MessageInputBar(
|
|||||||
recorder.setAudioEncodingBitRate(32_000)
|
recorder.setAudioEncodingBitRate(32_000)
|
||||||
recorder.setAudioSamplingRate(48_000)
|
recorder.setAudioSamplingRate(48_000)
|
||||||
recorder.setOutputFile(output.absolutePath)
|
recorder.setOutputFile(output.absolutePath)
|
||||||
|
recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit
|
||||||
recorder.prepare()
|
recorder.prepare()
|
||||||
recorder.start()
|
recorder.start()
|
||||||
|
recorder.setOnErrorListener { _, what, extra ->
|
||||||
|
inputJumpLog("MediaRecorder error what=$what extra=$extra")
|
||||||
|
stopVoiceRecording(send = false)
|
||||||
|
}
|
||||||
|
|
||||||
voiceRecorder = recorder
|
voiceRecorder = recorder
|
||||||
voiceOutputFile = output
|
voiceOutputFile = output
|
||||||
|
|||||||
Reference in New Issue
Block a user