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 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.2)") // 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 backspacing right now on phone, do not override with remote echo if (timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) { return@StreamDictationClient } // Monotonic revision check if (state.revision > 0 && state.revision < currentRevision) { return@StreamDictationClient } // Ghost empty sync protection for opaque/Electron apps (e.g. Antigravity) 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 (WindowInsets + OnGlobalLayoutListener) * Guarantees 100% detection on all Android versions and keyboards. */ 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() { 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) { // 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 binding.tvSubtitle.visibility = View.VISIBLE } } private fun insertSpeechAtCursor(speechText: String) { 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 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(".") && !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) isApplyingRemoteUpdate = true lastLocalText = mergedText lastLocalUserEditTime = System.currentTimeMillis() binding.etTranscript.setText(mergedText) binding.etTranscript.setSelection(newCursor) isApplyingRemoteUpdate = false AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") // Send speech directly to Mac cursor (Cmd+V) streamDictationClient?.sendSpeechInsert(formattedSpeech, 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) // 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?) {} }) // Clear Button binding.btnClearText.setOnClickListener { isApplyingRemoteUpdate = true binding.etTranscript.setText("") lastLocalText = "" lastLocalUserEditTime = System.currentTimeMillis() isApplyingRemoteUpdate = false streamDictationClient?.sendLocalSyncState("", 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 streamDictationClient?.sendLocalSyncState(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()) { 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() } true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { if (isCurrentlyRecording) { stopRecordingAndProcess() } true } else -> false } } private suspend fun sendDirectPaste(text: String, cursor: Int): Boolean { return try { val client = OkHttpClient() 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 req = Request.Builder() .url("http://$gatewayHost/paste") .post(body) .build() client.newCall(req).execute().use { resp -> resp.isSuccessful } } 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() } }