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 4a99790..6017083 100644 --- a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt +++ b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt @@ -88,7 +88,7 @@ class MainActivity : AppCompatActivity() { setupUI() checkPermissions() - AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.4)") + AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.5)") // Initialize Collaborative WebSocket Client streamDictationClient = StreamDictationClient( @@ -160,7 +160,7 @@ class MainActivity : AppCompatActivity() { vibrate(100) if (finalText.isNotEmpty()) { insertSpeechAtCursor(finalText) - binding.tvInstruction.text = "✨ گفتار در نشانگر درج و با مک همگام شد" + binding.tvInstruction.text = "✨ گفتار با مک همگام شد" binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) } else { binding.tvInstruction.text = "صدایی تشخیص داده نشد" @@ -242,7 +242,7 @@ 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 + // Cancel any pending debounced sync pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable = null @@ -255,8 +255,8 @@ class MainActivity : AppCompatActivity() { AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") - // Send speech directly to Mac cursor (Cmd+V) exactly ONCE - streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor) + // Broadcast full mirrored text to Mac + streamDictationClient?.sendPhoneEdit(mergedText, newCursor) } private fun setupUI() { @@ -273,24 +273,29 @@ class MainActivity : AppCompatActivity() { lastLocalUserEditTime = System.currentTimeMillis() val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) + // 80ms debounce: sends clean replacement to Mac in real-time as user types/deletes pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable = Runnable { - streamDictationClient?.sendLocalSyncState(text, cur) + streamDictationClient?.sendPhoneEdit(text, cur) } - debounceHandler.postDelayed(pendingSyncRunnable!!, 100) + debounceHandler.postDelayed(pendingSyncRunnable!!, 80) } } override fun afterTextChanged(s: Editable?) {} }) - // Clear Button + // Clear Button (🗑️ پاک‌کردن) binding.btnClearText.setOnClickListener { isApplyingRemoteUpdate = true binding.etTranscript.setText("") lastLocalText = "" lastLocalUserEditTime = System.currentTimeMillis() isApplyingRemoteUpdate = false - streamDictationClient?.sendLocalSyncState("", 0) + + pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } + pendingSyncRunnable = null + + streamDictationClient?.sendPhoneEdit("", 0) binding.tvInstruction.text = getString(R.string.hold_to_speak) binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) } diff --git a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt index ec762e0..35f1235 100644 --- a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt +++ b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt @@ -74,7 +74,7 @@ class StreamDictationClient( val req = Request.Builder() .url(wsUrl) - .header("User-Agent", "SonioxAndroidRemote/5.2") + .header("User-Agent", "SonioxAndroidRemote/5.4") .build() activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { @@ -154,18 +154,19 @@ class StreamDictationClient( } /** - * Sends speech chunk directly for cursor insertion on Mac (Cmd+V) + * Broadcasts phone's live text to Mac for immediate real-time mirroring */ - fun sendSpeechInsert(speechText: String, cursor: Int) { + fun sendPhoneEdit(text: String, cursor: Int, selection: Int = 0) { if (!isConnected.get() || activeWebSocket == null) { connectWebSocket() } val payload = JSONObject().apply { - put("type", "insert_speech") - put("action", "insert_speech") + put("type", "phone_input_edit") + put("action", "phone_input_edit") put("source", "android") - put("text", speechText) + put("text", text) put("cursor", cursor) + put("selection", selection) put("timestamp", System.currentTimeMillis() / 1000.0) }.toString() activeWebSocket?.send(payload) @@ -190,24 +191,6 @@ class StreamDictationClient( activeWebSocket?.send(payload) } - /** - * Broadcasts phone's updated text & cursor to Mac - */ - fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) { - if (!isConnected.get() || activeWebSocket == null) { - connectWebSocket() - } - val payload = JSONObject().apply { - put("type", "sync_state") - put("source", "android") - put("text", text) - put("cursor", cursor) - put("selection", selection) - put("timestamp", System.currentTimeMillis() / 1000.0) - }.toString() - activeWebSocket?.send(payload) - } - @SuppressLint("MissingPermission") fun startRecording(cursorPos: Int): String { if (isRecording.get()) return currentSessionId diff --git a/mac/src/FocusedInputSync.swift b/mac/src/FocusedInputSync.swift index 0c05547..b940901 100644 --- a/mac/src/FocusedInputSync.swift +++ b/mac/src/FocusedInputSync.swift @@ -114,7 +114,7 @@ public final class FocusedInputSync { var targetElem: AXUIElement? - // 1. Try system-wide focused element + // 1. System-wide focused element var focusedObj: CFTypeRef? if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, let obj = focusedObj { @@ -127,7 +127,7 @@ public final class FocusedInputSync { } } - // 2. Try App focused element + // 2. App focused element if targetElem == nil { if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, let obj = focusedObj { @@ -141,7 +141,7 @@ public final class FocusedInputSync { } } - // 3. Try App focused window's focused element + // 3. App focused window element if targetElem == nil { var focusedWinObj: CFTypeRef? if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success, @@ -162,7 +162,7 @@ public final class FocusedInputSync { return (targetElem, appName) } - /// Inspects current focused element and returns state snapshot if changed on Mac + /// Inspects current focused element on Mac and returns state snapshot if user typed on Mac public func inspectCurrentState() -> MacInputState? { let now = Date().timeIntervalSince1970 if isApplyingRemoteChange || now < remoteChangeExpiryTime { @@ -243,19 +243,19 @@ public final class FocusedInputSync { return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision) } - /// Applies updated full text or inserts speech directly into active Mac input + /// Perfectly mirrors the full text to the active Mac input box in real-time @discardableResult - public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = false) -> Bool { + public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool { isApplyingRemoteChange = true - remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60 + remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.40 defer { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.60) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { self.isApplyingRemoteChange = false } } - // 1. Clean and normalize text + // Normalize text var cleanText = text.components(separatedBy: .newlines).joined(separator: " ") cleanText = cleanText.replacingOccurrences(of: "\t", with: " ") cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) @@ -265,50 +265,89 @@ public final class FocusedInputSync { lastObservedText = cleanText lastObservedCursor = targetCursor - let (_, appName) = getFocusedElement() + let (elemOpt, appName) = getFocusedElement() lastObservedApp = appName - print("FocusedInputSync: 🚀 Injecting text into '\(appName)' via Clean Quartz CGEvent (replace: \(isFullReplace), len: \(cleanText.count))") - return pasteViaCleanKeystroke(cleanText, isFullReplace: isFullReplace) + // Try fast direct native AX write if supported (TextEdit, Notes, Finder) + if let elem = elemOpt { + let setErr = AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, cleanText as CFTypeRef) + if setErr == .success { + print("FocusedInputSync: ✅ Updated native AXValue for \(appName)") + return true + } + } + + // Universal Native Replace (Cmd+A -> Cmd+V / Backspace) + print("FocusedInputSync: 🔄 Syncing full text to '\(appName)' (len: \(cleanText.count))") + return executeCleanFullReplace(cleanText) } /** - * Clean, Universal Quartz CGEvent Keystroke Engine - * Sets pasteboard, sends clean Cmd+A (if replace) and Cmd+V (virtualKey 9 with .maskCommand). + * Replaces the entire content of active input box cleanly in real-time. + * If text is empty: Cmd+A -> Backspace. + * If text is non-empty: Cmd+A -> Cmd+V. */ - private func pasteViaCleanKeystroke(_ text: String, isFullReplace: Bool) -> Bool { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - + private func executeCleanFullReplace(_ text: String) -> Bool { let src = CGEventSource(stateID: .hidSystemState) let kVK_ANSI_A: CGKeyCode = 0 let kVK_ANSI_V: CGKeyCode = 9 + let kVK_Delete: CGKeyCode = 51 - if isFullReplace { + if text.isEmpty { // Select all: Cmd + A if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) { aDown.flags = .maskCommand aDown.post(tap: .cghidEventTap) aDown.post(tap: .cgSessionEventTap) } - usleep(12000) - + usleep(8000) if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) { aUp.flags = .maskCommand aUp.post(tap: .cghidEventTap) aUp.post(tap: .cgSessionEventTap) } - usleep(25000) // 25ms settling delay for selection + usleep(15000) + + // Backspace to clear + if let delDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: true) { + delDown.post(tap: .cghidEventTap) + delDown.post(tap: .cgSessionEventTap) + } + usleep(8000) + if let delUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: false) { + delUp.post(tap: .cghidEventTap) + delUp.post(tap: .cgSessionEventTap) + } + return true + } + + // Set clipboard + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + // 1. Select all: Cmd + A + if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) { + aDown.flags = .maskCommand + aDown.post(tap: .cghidEventTap) + aDown.post(tap: .cgSessionEventTap) + } + usleep(8000) + + if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) { + aUp.flags = .maskCommand + aUp.post(tap: .cghidEventTap) + aUp.post(tap: .cgSessionEventTap) } + usleep(18000) // 18ms for selection to settle - // Paste: Cmd + V + // 2. Paste: Cmd + V if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) { vDown.flags = .maskCommand vDown.post(tap: .cghidEventTap) vDown.post(tap: .cgSessionEventTap) } - usleep(15000) + usleep(12000) if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) { vUp.flags = .maskCommand @@ -316,7 +355,6 @@ public final class FocusedInputSync { vUp.post(tap: .cgSessionEventTap) } - print("FocusedInputSync: ✅ Clean Cmd+V Posted successfully for text: '\(text.prefix(25))...'") return true } } diff --git a/mac/src/RelayClient.swift b/mac/src/RelayClient.swift index 1866b27..09a76ed 100644 --- a/mac/src/RelayClient.swift +++ b/mac/src/RelayClient.swift @@ -17,12 +17,9 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate { private var monitorTimerSource: DispatchSourceTimer? private let monitorQueue = DispatchQueue(label: "com.soniox.macsync.monitor", qos: .userInteractive) - // Callback: (text, cursor, isFullReplace) public var onRemoteUpdateReceived: ((String, Int?, Bool) -> Void)? - // Dedup guard: prevent rapid duplicate keystrokes within 200ms - private var lastInjectedText: String = "" - private var lastInjectedTime: Double = 0 + private var lastObservedInjectedText: String? = nil override private init() { super.init() @@ -111,43 +108,18 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate { let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? "" let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int - let now = Date().timeIntervalSince1970 - - // 1. Live Voice Dictation from Phone Mic: Paste ONLY new speech at active cursor (Cmd+V) - if action == "insert_speech" || action == "speech_insert" { - guard !updateText.isEmpty else { return } - - // Dedup exact duplicate within 250ms - if updateText == lastInjectedText && (now - lastInjectedTime) < 0.25 { + if action == "sync_state" || action == "phone_input_edit" || action == "update_input" || action == "force_replace" || action == "insert_speech" || action == "paste" { + // Drop identical consecutive payloads + if updateText == lastObservedInjectedText && action != "force_replace" { return } - lastInjectedText = updateText - lastInjectedTime = now - - print("RelayClient: 🎙️ Voice Dictation Paste: '\(updateText.prefix(30))...' (Cmd+V at cursor)") - DispatchQueue.main.async { - self.onRemoteUpdateReceived?(updateText, cursor, false) - } - } - - // 2. Explicit "Send to Mac" Button on Phone: Full Replace (Cmd+A + Cmd+V) - else if action == "update_input" || action == "force_replace" || action == "paste" { - guard !updateText.isEmpty else { return } - - lastInjectedText = updateText - lastInjectedTime = now + lastObservedInjectedText = updateText - print("RelayClient: 🚀 Force Replace All: '\(updateText.prefix(30))...' (Cmd+A + Cmd+V)") + print("RelayClient: ⚡ Applying live phone edit: '\(updateText.prefix(30))...' (len: \(updateText.count))") DispatchQueue.main.async { self.onRemoteUpdateReceived?(updateText, cursor, true) } } - - // 3. Passive State Synchronization (typing on phone keyboard): - // Updates room state cache only — DOES NOT trigger keystrokes on Mac! - else if action == "sync_state" || action == "phone_input_edit" { - // Passive sync only - } } private func startInputMonitoring() {