diff --git a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt index b97ee0f..8eaf4fb 100644 --- a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt +++ b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt @@ -10,6 +10,8 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager @@ -49,10 +51,14 @@ class MainActivity : AppCompatActivity() { // Authoritative Permanent Gateway Server on Linux (116.16.16.19:8999) private val gatewayHost = "116.16.16.19:8999" - // Live Synchronized State (Google Docs / Figma style) + // Live Synchronized State (Collaborative Engine) private var currentRevision: Long = 0L private var isApplyingRemoteUpdate = false private var lastLocalText = "" + private var lastLocalUserEditTime = 0L + + private val debounceHandler = Handler(Looper.getMainLooper()) + private var pendingSyncRunnable: Runnable? = null // Voice Insertion Anchor private var voiceInsertionCursorStart = 0 @@ -79,7 +85,7 @@ class MainActivity : AppCompatActivity() { setupUI() checkPermissions() - AppLogger.log("Main", "اپلیکیشن راه‌اندازی شد (همگام‌سازی بلادرنگ Google Docs/Figma Style با مک)") + AppLogger.log("Main", "اپلیکیشن راه‌اندازی شد (همگام‌سازی بلادرنگ بدون باگ پاک‌کردن)") // Initialize Collaborative WebSocket Client streamDictationClient = StreamDictationClient( @@ -91,16 +97,23 @@ class MainActivity : AppCompatActivity() { ) }, onSyncStateReceived = { state -> - // Apply update from Mac if source != android and revision is newer + // Drop echoes and do not interrupt active user editing on phone 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()) { + return@StreamDictationClient + } + if (state.text != lastLocalText) { isApplyingRemoteUpdate = true lastLocalText = state.text currentRevision = state.revision - // Preserve cursor safely val currentCursor = binding.etTranscript.selectionStart binding.etTranscript.setText(state.text) + val targetCursor = if (binding.etTranscript.hasFocus() && currentCursor >= 0) { currentCursor.coerceIn(0, state.text.length) } else { @@ -143,20 +156,15 @@ class MainActivity : AppCompatActivity() { ) } - /** - * Dynamically adjusts the layout when virtual keyboard opens or closes - */ private fun setupKeyboardInsetHandling() { ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) if (imeVisible) { - // Keyboard OPEN: Hide huge bottom circle, expand editor box to full height directly above keyboard binding.bottomVoiceSection.visibility = View.GONE binding.tvSubtitle.visibility = View.GONE binding.btnMiniMic.visibility = View.VISIBLE } else { - // Keyboard CLOSED: Restore spacious layout with large glowing mic button binding.bottomVoiceSection.visibility = View.VISIBLE binding.tvSubtitle.visibility = View.VISIBLE binding.btnMiniMic.visibility = View.GONE @@ -166,9 +174,6 @@ class MainActivity : AppCompatActivity() { } } - /** - * Slices speech text right at the exact cursor/selection location - */ private fun insertSpeechAtCursor(speechText: String) { val current = binding.etTranscript.text?.toString() ?: "" val start = voiceInsertionCursorStart.coerceIn(0, current.length) @@ -188,18 +193,18 @@ class MainActivity : AppCompatActivity() { isApplyingRemoteUpdate = true lastLocalText = mergedText + lastLocalUserEditTime = System.currentTimeMillis() binding.etTranscript.setText(mergedText) binding.etTranscript.setSelection(newCursor) isApplyingRemoteUpdate = false AppLogger.log("Main", "تزریق گفتار در نشانگر: '$speechText' (موقعیت جدید: $newCursor)") - // Broadcast updated state to Mac immediately! streamDictationClient?.sendLocalSyncState(mergedText, newCursor) } private fun setupUI() { - // Real-time TextWatcher for local keyboard typing + // Real-time TextWatcher for local keyboard typing & backspacing binding.etTranscript.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { @@ -207,11 +212,17 @@ class MainActivity : AppCompatActivity() { val wordCount = if (text.trim().isEmpty()) 0 else text.trim().split("\\s+".toRegex()).size binding.tvCharCount.text = "$wordCount کلمه" - // If typed locally by user, broadcast to Mac immediately! if (!isApplyingRemoteUpdate && !isCurrentlyRecording) { lastLocalText = text + lastLocalUserEditTime = System.currentTimeMillis() val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) - streamDictationClient?.sendLocalSyncState(text, cur) + + // 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) } } override fun afterTextChanged(s: Editable?) {} @@ -222,6 +233,7 @@ class MainActivity : AppCompatActivity() { isApplyingRemoteUpdate = true binding.etTranscript.setText("") lastLocalText = "" + lastLocalUserEditTime = System.currentTimeMillis() isApplyingRemoteUpdate = false streamDictationClient?.sendLocalSyncState("", 0) binding.tvInstruction.text = getString(R.string.hold_to_speak) diff --git a/mac/src/FocusedInputSync.swift b/mac/src/FocusedInputSync.swift index dc64894..a66b068 100644 --- a/mac/src/FocusedInputSync.swift +++ b/mac/src/FocusedInputSync.swift @@ -26,8 +26,11 @@ public final class FocusedInputSync { private let systemWideElement: AXUIElement private var isApplyingRemoteChange: Bool = false - private var lastObservedHash: Int = 0 + private var lastObservedText: String = "" + private var lastObservedCursor: Int = -1 + private var lastObservedApp: String = "" private var localRevision: Int64 = 0 + private var remoteChangeExpiryTime: Double = 0 private init() { self.systemWideElement = AXUIElementCreateSystemWide() @@ -113,7 +116,11 @@ public final class FocusedInputSync { /// Inspects the current focused element and returns a state snapshot if changed public func inspectCurrentState() -> MacInputState? { - guard !isApplyingRemoteChange else { return nil } + let now = Date().timeIntervalSince1970 + if isApplyingRemoteChange || now < remoteChangeExpiryTime { + return nil + } + guard let (elem, appName) = getFocusedElement() else { return nil } // Extract text @@ -156,10 +163,14 @@ public final class FocusedInputSync { } } - // Check hash to prevent echo loops - let currentHash = "\(appName)_\(text)_\(cursor)_\(selLen)".hashValue - guard currentHash != lastObservedHash else { return nil } - lastObservedHash = currentHash + // Check if text or cursor actually changed on Mac + if text == lastObservedText && cursor == lastObservedCursor && appName == lastObservedApp { + return nil + } + + lastObservedText = text + lastObservedCursor = cursor + lastObservedApp = appName localRevision += 1 return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision) @@ -169,22 +180,30 @@ public final class FocusedInputSync { @discardableResult public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool { isApplyingRemoteChange = true + remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.45 // 450ms quiet window + defer { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { self.isApplyingRemoteChange = false } } - // 1. Flatten all newlines and carriage returns + // 1. Clean and normalize text var cleanText = text.components(separatedBy: .newlines).joined(separator: " ") cleanText = cleanText.replacingOccurrences(of: "\t", with: " ") cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) cleanText = cleanText.trimmingCharacters(in: .whitespacesAndNewlines) + let targetCursor = cursor ?? cleanText.count + lastObservedText = cleanText + lastObservedCursor = targetCursor + guard let (elem, appName) = getFocusedElement() else { // Fallback: clipboard paste + lastObservedApp = NSWorkspace.shared.frontmostApplication?.localizedName ?? "App" return pasteViaKeystroke(cleanText, isFullReplace: isFullReplace) } + lastObservedApp = appName if isFullReplace { // Try setting AXValue directly @@ -196,7 +215,6 @@ public final class FocusedInputSync { AXUIElementSetAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, axRange) } } - lastObservedHash = "\(appName)_\(cleanText)_\(cursor ?? cleanText.count)_0".hashValue print("FocusedInputSync: ✅ Updated AXValue for \(appName)") return true } @@ -204,7 +222,6 @@ public final class FocusedInputSync { // Try setting selected text let setSelErr = AXUIElementSetAttributeValue(elem, kAXSelectedTextAttribute as CFString, cleanText as CFTypeRef) if setSelErr == .success { - lastObservedHash = "\(appName)_\(cleanText)_\(cursor ?? cleanText.count)_0".hashValue print("FocusedInputSync: ✅ Inserted text via AXSelectedText for \(appName)") return true } @@ -228,7 +245,7 @@ public final class FocusedInputSync { aDown?.post(tap: .cghidEventTap) aUp?.post(tap: .cghidEventTap) - usleep(25000) + usleep(20000) } // Paste: Cmd + V