package com.soniox.remotemic import android.Manifest import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder import android.animation.ValueAnimator import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.graphics.Rect 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 import android.text.Editable import android.text.TextWatcher import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewTreeObserver import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ScrollView import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.soniox.remotemic.databinding.ActivityMainBinding import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var streamDictationClient: StreamDictationClient? = null private var pulseAnimator: ObjectAnimator? = null private var isCurrentlyRecording = false // Authoritative Public Gateway Server on MikroTik WAN (2.180.16.250:8999) private val gatewayHost = "2.180.16.250:8999" // Live Synchronized State (Collaborative Engine) private var currentRevision: Long = 0L private var isApplyingRemoteUpdate = false private var lastLocalText = "" private var lastLocalUserEditTime = 0L private var isKeyboardCurrentlyVisible = false private val debounceHandler = Handler(Looper.getMainLooper()) private var pendingSyncRunnable: Runnable? = null // Voice Insertion Anchor private var voiceInsertionCursorStart = 0 private var voiceInsertionCursorEnd = 0 private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> if (isGranted) { AppLogger.log("Main", "دسترسی میکروفون تأیید شد.") Toast.makeText(this, "دسترسی میکروفون تأیید شد", Toast.LENGTH_SHORT).show() } else { AppLogger.log("Main", "❌ دسترسی میکروفون رد شد!") Toast.makeText(this, "برای ضبط صدا به دسترسی میکروفون نیاز است", Toast.LENGTH_LONG).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) setupKeyboardVisibilityDetection() setupUI() checkPermissions() AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.6)") // Initialize Collaborative WebSocket Client streamDictationClient = StreamDictationClient( host = gatewayHost, onConnectionStateChanged = { connected -> binding.tvMacStatus.text = if (connected) "متصل به مک ✅" else "در حال اتصال ❌" binding.statusIndicator.backgroundTintList = ContextCompat.getColorStateList( this, if (connected) R.color.accent_green else R.color.accent_red ) }, onSyncStateReceived = { state -> // Drop echoes originated from phone itself if (state.source != "android") { val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime // If user is actively typing or recording right now on phone, do not override if ((timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) || isCurrentlyRecording) { return@StreamDictationClient } // Monotonic revision check if (state.revision > 0 && state.revision < currentRevision) { return@StreamDictationClient } // 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} 🖥️" } return@StreamDictationClient } if (state.text != lastLocalText) { isApplyingRemoteUpdate = true lastLocalText = state.text currentRevision = state.revision 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 { state.cursor.coerceIn(0, state.text.length) } binding.etTranscript.setSelection(targetCursor) isApplyingRemoteUpdate = false } if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") { binding.tvMacStatus.text = "متصل به ${state.app} 🖥️" } } }, onPartialSpeechText = { livePartial -> binding.tvInstruction.text = "🎙️ $livePartial" binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_blue)) }, onAudioLevel = { level -> if (isCurrentlyRecording) { val scale = 1.0f + (level * 0.22f) binding.viewGlow.scaleX = scale binding.viewGlow.scaleY = scale } }, onSpeechCompleted = { finalText -> vibrate(100) if (finalText.isNotEmpty()) { insertSpeechAtCursor(finalText) binding.tvInstruction.text = "✨ گفتار با مک همگام شد" binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) } else { binding.tvInstruction.text = "صدایی تشخیص داده نشد" binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) } }, onError = { errMsg -> binding.tvInstruction.text = errMsg binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) } ) } /** * Dual-engine keyboard visibility detector */ private fun setupKeyboardVisibilityDetection() { ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) updateKeyboardUIMode(imeVisible) insets } binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { private val r = Rect() override fun onGlobalLayout() { binding.rootLayout.getWindowVisibleDisplayFrame(r) val screenHeight = binding.rootLayout.rootView.height val keypadHeight = screenHeight - r.bottom val isKeyboardOpen = keypadHeight > screenHeight * 0.15 updateKeyboardUIMode(isKeyboardOpen) } }) } private fun updateKeyboardUIMode(isKeyboardOpen: Boolean) { if (isKeyboardCurrentlyVisible == isKeyboardOpen) return isKeyboardCurrentlyVisible = isKeyboardOpen if (isKeyboardOpen) { binding.bottomVoiceSection.visibility = View.GONE binding.actionDivider.visibility = View.GONE binding.actionButtonsRow.visibility = View.GONE binding.tvSubtitle.visibility = View.GONE } else { binding.bottomVoiceSection.visibility = View.VISIBLE binding.actionDivider.visibility = View.VISIBLE binding.actionButtonsRow.visibility = View.VISIBLE binding.tvSubtitle.visibility = View.VISIBLE } } 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) val prefix = if (start > 0) current.substring(0, start) else "" val suffix = if (end < current.length) current.substring(end) else "" val needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n") val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") && !suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") && !suffix.startsWith("؟") && !suffix.startsWith("!") && !suffix.startsWith(":") val formattedSpeech = buildString { if (needsPreSpace) append(" ") append(trimmedSpeech) if (needsPostSpace) append(" ") } val mergedText = "$prefix$formattedSpeech$suffix" val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) // Cancel any pending debounced sync pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable = null isApplyingRemoteUpdate = true lastLocalText = mergedText lastLocalUserEditTime = System.currentTimeMillis() binding.etTranscript.setText(mergedText) binding.etTranscript.setSelection(newCursor) isApplyingRemoteUpdate = false AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") // Broadcast full mirrored text to Mac streamDictationClient?.sendPhoneEdit(mergedText, newCursor) } private fun setupUI() { // 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) { val text = s?.toString() ?: "" val wordCount = if (text.trim().isEmpty()) 0 else text.trim().split("\\s+".toRegex()).size binding.tvCharCount.text = "$wordCount کلمه" if (!isApplyingRemoteUpdate && !isCurrentlyRecording) { lastLocalText = text lastLocalUserEditTime = System.currentTimeMillis() val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) // Immediate sync for newlines (\n), crisp 35ms debounce for general typing val isNewlineEdit = count == 1 && s?.subSequence(start, start + count)?.contains('\n') == true val delayMs = if (isNewlineEdit) 0L else 35L pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable = Runnable { streamDictationClient?.sendPhoneEdit(text, cur) } if (delayMs == 0L) { debounceHandler.post(pendingSyncRunnable!!) } else { debounceHandler.postDelayed(pendingSyncRunnable!!, delayMs) } } } override fun afterTextChanged(s: Editable?) {} }) // Clear Button (🗑️ پاک‌کردن) binding.btnClearText.setOnClickListener { isApplyingRemoteUpdate = true binding.etTranscript.setText("") lastLocalText = "" lastLocalUserEditTime = System.currentTimeMillis() isApplyingRemoteUpdate = false 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)) } // Copy Button binding.btnCopyText.setOnClickListener { val text = binding.etTranscript.text.toString() if (text.isNotEmpty()) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("SonioxText", text) clipboard.setPrimaryClip(clip) Toast.makeText(this, "متن در حافظه کپی شد", Toast.LENGTH_SHORT).show() } } // Force Send to Mac Button binding.btnSendToMac.setOnClickListener { val text = binding.etTranscript.text.toString() if (text.isEmpty()) { Toast.makeText(this, "متنی برای ارسال وجود ندارد", Toast.LENGTH_SHORT).show() return@setOnClickListener } val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) binding.tvInstruction.text = "در حال درج متن در مک..." binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) // 1. Direct WebSocket broadcast with force_replace streamDictationClient?.sendForceReplace(text, cur) // 2. Direct HTTP Post guarantee lifecycleScope.launch { val directPasteResult = sendDirectPaste(text, cur) vibrate(100) if (directPasteResult) { binding.tvInstruction.text = "✨ متن با موفقیت در مک تایپ شد" binding.tvInstruction.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.accent_green)) Toast.makeText(this@MainActivity, "متن در مک اعمال شد", Toast.LENGTH_SHORT).show() } } } // Open Logs Dialog Button binding.btnOpenLogs.setOnClickListener { showLogsBottomSheet() } // Touch listener for Large Main Mic Button binding.btnMic.setOnTouchListener { _, event -> handleMicTouch(event) } } private fun handleMicTouch(event: MotionEvent): Boolean { return when (event.action) { MotionEvent.ACTION_DOWN -> { if (checkAudioPermission()) { // Fast sync: ensure socket is connected immediately streamDictationClient?.connectWebSocket() val selStart = binding.etTranscript.selectionStart val selEnd = binding.etTranscript.selectionEnd val currentText = binding.etTranscript.text?.toString() ?: "" val totalLen = currentText.length voiceInsertionCursorStart = if (selStart in 0..totalLen) selStart else totalLen voiceInsertionCursorEnd = if (selEnd in 0..totalLen) selEnd else totalLen startRecording() } true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { if (isCurrentlyRecording) { // Update insertion point to where cursor was right before processing val selStart = binding.etTranscript.selectionStart val selEnd = binding.etTranscript.selectionEnd val currentText = binding.etTranscript.text?.toString() ?: "" val totalLen = currentText.length if (selStart in 0..totalLen && selEnd in 0..totalLen) { voiceInsertionCursorStart = selStart voiceInsertionCursorEnd = selEnd } stopRecordingAndProcess() } true } else -> false } } private suspend fun sendDirectPaste(text: String, cursor: Int): Boolean { return try { val client = OkHttpClient.Builder() .connectTimeout(2500, TimeUnit.MILLISECONDS) .writeTimeout(3000, TimeUnit.MILLISECONDS) .readTimeout(3000, TimeUnit.MILLISECONDS) .build() val json = JSONObject().apply { put("text", text) put("cursor_pos", cursor) put("action", "update_input") }.toString() val mediaType = "application/json; charset=utf-8".toMediaType() val body = json.toRequestBody(mediaType) val hosts = listOf(gatewayHost, "116.16.16.19:8999", "2.180.16.250:8999").distinct() var success = false for (h in hosts) { try { val req = Request.Builder() .url("http://$h/paste") .post(body) .build() client.newCall(req).execute().use { resp -> if (resp.isSuccessful) { success = true return@use } } if (success) break } catch (_: Exception) {} } success } catch (e: Exception) { AppLogger.log("Main", "خطای ارسال دستی: ${e.message}") false } } private fun showLogsBottomSheet() { val dialog = BottomSheetDialog(this) val sheetView = layoutInflater.inflate(R.layout.dialog_logs_sheet, null) dialog.setContentView(sheetView) val tvSheetLogs = sheetView.findViewById(R.id.tvSheetLogs) val scrollViewSheetLogs = sheetView.findViewById(R.id.scrollViewSheetLogs) val btnSheetCopyLogs = sheetView.findViewById(R.id.btnSheetCopyLogs) val btnSheetClearLogs = sheetView.findViewById(R.id.btnSheetClearLogs) tvSheetLogs.text = AppLogger.getAllLogs().ifEmpty { "هنوز لاگی ثبت نشده است." } scrollViewSheetLogs.post { scrollViewSheetLogs.fullScroll(View.FOCUS_DOWN) } val originalListener = AppLogger.onLogListener AppLogger.onLogListener = { newEntry -> runOnUiThread { if (newEntry.isEmpty()) { tvSheetLogs.text = "کنسول لاگ پاک شد." } else { val current = tvSheetLogs.text.toString() val updated = if (current == "آماده دریافت لاگ..." || current == "کنسول لاگ پاک شد.") { newEntry } else { "$current\n$newEntry" } tvSheetLogs.text = updated scrollViewSheetLogs.post { scrollViewSheetLogs.fullScroll(View.FOCUS_DOWN) } } } } btnSheetCopyLogs.setOnClickListener { val logs = AppLogger.getAllLogs() val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("SonioxLogs", logs) clipboard.setPrimaryClip(clip) Toast.makeText(this, "کل لاگ‌ها در حافظه کپی شد", Toast.LENGTH_SHORT).show() } btnSheetClearLogs.setOnClickListener { AppLogger.clear() tvSheetLogs.text = "کنسول لاگ پاک شد." } dialog.setOnDismissListener { AppLogger.onLogListener = originalListener } dialog.show() } private fun checkPermissions() { if (!checkAudioPermission()) { requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } private fun checkAudioPermission(): Boolean { return ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED } private fun startRecording() { isCurrentlyRecording = true vibrate(40) binding.tvInstruction.text = getString(R.string.release_to_type) binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_red)) binding.btnMic.setBackgroundResource(R.drawable.bg_mic_button_active) startPulseAnimation() streamDictationClient?.startRecording(voiceInsertionCursorStart) } private fun stopRecordingAndProcess() { isCurrentlyRecording = false vibrate(60) stopPulseAnimation() binding.btnMic.setBackgroundResource(R.drawable.bg_mic_button) binding.tvInstruction.text = "⏳ در حال درج و همگام‌سازی..." binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) streamDictationClient?.stopRecording(voiceInsertionCursorStart) } private fun startPulseAnimation() { val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.18f, 1.0f) val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.18f, 1.0f) val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.4f, 0.85f, 0.4f) pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewGlow, scaleX, scaleY, alpha).apply { duration = 900 repeatCount = ValueAnimator.INFINITE interpolator = AccelerateDecelerateInterpolator() start() } } private fun stopPulseAnimation() { pulseAnimator?.cancel() binding.viewGlow.scaleX = 1.0f binding.viewGlow.scaleY = 1.0f binding.viewGlow.alpha = 0.0f } private fun vibrate(durationMs: Long) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) } else { @Suppress("DEPRECATION") val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) } else { @Suppress("DEPRECATION") vibrator.vibrate(durationMs) } } } catch (e: Exception) {} } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && !isCurrentlyRecording) { if (checkAudioPermission()) { val selStart = binding.etTranscript.selectionStart val selEnd = binding.etTranscript.selectionEnd val totalLen = binding.etTranscript.text?.length ?: 0 voiceInsertionCursorStart = if (selStart >= 0) selStart else totalLen voiceInsertionCursorEnd = if (selEnd >= 0) selEnd else totalLen startRecording() return true } } return super.onKeyDown(keyCode, event) } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && isCurrentlyRecording) { stopRecordingAndProcess() return true } return super.onKeyUp(keyCode, event) } override fun onDestroy() { super.onDestroy() streamDictationClient?.release() } }