Soniox Mobile to Mac - Real-time Voice Dictation & Remote Input Control
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

591 lines
26 KiB

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<TextView>(R.id.tvSheetLogs)
val scrollViewSheetLogs = sheetView.findViewById<ScrollView>(R.id.scrollViewSheetLogs)
val btnSheetCopyLogs = sheetView.findViewById<MaterialButton>(R.id.btnSheetCopyLogs)
val btnSheetClearLogs = sheetView.findViewById<MaterialButton>(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()
}
}