|
|
|
@ -88,7 +88,7 @@ class MainActivity : AppCompatActivity() { |
|
|
|
setupUI() |
|
|
|
checkPermissions() |
|
|
|
|
|
|
|
AppLogger.log("Main", "اپلیکیشن همگامسازی صوتی سانیاوکس راهاندازی شد (نسخه بهینهشده فوقسریع v5.2)") |
|
|
|
AppLogger.log("Main", "اپلیکیشن همگامسازی صوتی سانیاوکس راهاندازی شد (نسخه بهینهشده فوقسریع v5.4)") |
|
|
|
|
|
|
|
// Initialize Collaborative WebSocket Client |
|
|
|
streamDictationClient = StreamDictationClient( |
|
|
|
@ -104,8 +104,8 @@ class MainActivity : AppCompatActivity() { |
|
|
|
if (state.source != "android") { |
|
|
|
val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime |
|
|
|
|
|
|
|
// If user is actively typing or backspacing right now on phone, do not override with remote echo |
|
|
|
if (timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) { |
|
|
|
// If user is actively typing or recording right now on phone, do not override |
|
|
|
if ((timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) || isCurrentlyRecording) { |
|
|
|
return@StreamDictationClient |
|
|
|
} |
|
|
|
|
|
|
|
@ -114,7 +114,7 @@ class MainActivity : AppCompatActivity() { |
|
|
|
return@StreamDictationClient |
|
|
|
} |
|
|
|
|
|
|
|
// Ghost empty sync protection for opaque/Electron apps (e.g. Antigravity) |
|
|
|
// Ghost empty sync protection for opaque apps |
|
|
|
if (state.text.isEmpty() && lastLocalText.isNotEmpty() && state.source == "mac") { |
|
|
|
if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") { |
|
|
|
binding.tvMacStatus.text = "متصل به ${state.app} 🖥️" |
|
|
|
@ -175,18 +175,15 @@ class MainActivity : AppCompatActivity() { |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Dual-engine keyboard visibility detector (WindowInsets + OnGlobalLayoutListener) |
|
|
|
* Guarantees 100% detection on all Android versions and keyboards. |
|
|
|
* Dual-engine keyboard visibility detector |
|
|
|
*/ |
|
|
|
private fun setupKeyboardVisibilityDetection() { |
|
|
|
// Engine 1: Modern WindowInsets |
|
|
|
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> |
|
|
|
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) |
|
|
|
updateKeyboardUIMode(imeVisible) |
|
|
|
insets |
|
|
|
} |
|
|
|
|
|
|
|
// Engine 2: Global Layout Frame Calculation (Fallback for OEM soft keyboards) |
|
|
|
binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { |
|
|
|
private val r = Rect() |
|
|
|
override fun onGlobalLayout() { |
|
|
|
@ -204,14 +201,11 @@ class MainActivity : AppCompatActivity() { |
|
|
|
isKeyboardCurrentlyVisible = isKeyboardOpen |
|
|
|
|
|
|
|
if (isKeyboardOpen) { |
|
|
|
// KEYBOARD OPEN: Hide big mic circle, hide action buttons & divider completely! |
|
|
|
// Give 100% of available space above keyboard exclusively to the huge text editor box! |
|
|
|
binding.bottomVoiceSection.visibility = View.GONE |
|
|
|
binding.actionDivider.visibility = View.GONE |
|
|
|
binding.actionButtonsRow.visibility = View.GONE |
|
|
|
binding.tvSubtitle.visibility = View.GONE |
|
|
|
} else { |
|
|
|
// KEYBOARD CLOSED: Restore spacious layout with large glowing mic button & action bar |
|
|
|
binding.bottomVoiceSection.visibility = View.VISIBLE |
|
|
|
binding.actionDivider.visibility = View.VISIBLE |
|
|
|
binding.actionButtonsRow.visibility = View.VISIBLE |
|
|
|
@ -220,6 +214,13 @@ class MainActivity : AppCompatActivity() { |
|
|
|
} |
|
|
|
|
|
|
|
private fun insertSpeechAtCursor(speechText: String) { |
|
|
|
val trimmedSpeech = speechText.trim() |
|
|
|
|
|
|
|
// Suppress empty strings or lone quotes/punctuation marks |
|
|
|
if (trimmedSpeech.isEmpty() || trimmedSpeech.matches("^[\\s«»\\.\\,\\،\\؛\\؟\\!\\?\\:\\;\\-\\–—\\\"\\'\\(\\)\\[\\]\\{\\}]+$".toRegex())) { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
val current = binding.etTranscript.text?.toString() ?: "" |
|
|
|
val start = voiceInsertionCursorStart.coerceIn(0, current.length) |
|
|
|
val end = voiceInsertionCursorEnd.coerceIn(0, current.length) |
|
|
|
@ -227,10 +228,6 @@ class MainActivity : AppCompatActivity() { |
|
|
|
val prefix = if (start > 0) current.substring(0, start) else "" |
|
|
|
val suffix = if (end < current.length) current.substring(end) else "" |
|
|
|
|
|
|
|
val trimmedSpeech = speechText.trim() |
|
|
|
if (trimmedSpeech.isEmpty()) return |
|
|
|
|
|
|
|
// Smart spacing for Persian / English word boundaries |
|
|
|
val needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n") |
|
|
|
val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") && |
|
|
|
!suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") && |
|
|
|
@ -245,6 +242,10 @@ class MainActivity : AppCompatActivity() { |
|
|
|
val mergedText = "$prefix$formattedSpeech$suffix" |
|
|
|
val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) |
|
|
|
|
|
|
|
// Cancel any pending debounced sync to prevent double-paste |
|
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
|
pendingSyncRunnable = null |
|
|
|
|
|
|
|
isApplyingRemoteUpdate = true |
|
|
|
lastLocalText = mergedText |
|
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
|
@ -254,7 +255,7 @@ class MainActivity : AppCompatActivity() { |
|
|
|
|
|
|
|
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") |
|
|
|
|
|
|
|
// Send speech directly to Mac cursor (Cmd+V) |
|
|
|
// Send speech directly to Mac cursor (Cmd+V) exactly ONCE |
|
|
|
streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor) |
|
|
|
} |
|
|
|
|
|
|
|
@ -272,12 +273,11 @@ class MainActivity : AppCompatActivity() { |
|
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
|
val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) |
|
|
|
|
|
|
|
// Debounce sending to Mac by 60ms to allow 120Hz lag-free backspacing and typing |
|
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
|
pendingSyncRunnable = Runnable { |
|
|
|
streamDictationClient?.sendLocalSyncState(text, cur) |
|
|
|
} |
|
|
|
debounceHandler.postDelayed(pendingSyncRunnable!!, 60) |
|
|
|
debounceHandler.postDelayed(pendingSyncRunnable!!, 100) |
|
|
|
} |
|
|
|
} |
|
|
|
override fun afterTextChanged(s: Editable?) {} |
|
|
|
@ -318,8 +318,8 @@ class MainActivity : AppCompatActivity() { |
|
|
|
binding.tvInstruction.text = "در حال درج متن در مک..." |
|
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
|
|
|
|
|
|
|
// 1. Direct WebSocket broadcast |
|
|
|
streamDictationClient?.sendLocalSyncState(text, cur) |
|
|
|
// 1. Direct WebSocket broadcast with force_replace |
|
|
|
streamDictationClient?.sendForceReplace(text, cur) |
|
|
|
|
|
|
|
// 2. Direct HTTP Post guarantee |
|
|
|
lifecycleScope.launch { |
|
|
|
|