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.
 
 
 
 

523 lines
22 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
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var streamDictationClient: StreamDictationClient? = null
private var pulseAnimator: ObjectAnimator? = null
private var isCurrentlyRecording = false
// Authoritative Permanent Gateway Server on Linux (116.16.16.19:8999)
private val gatewayHost = "116.16.16.19: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", "اپلیکیشن راه‌اندازی شد (طراحی ریسپانسیو و فوکوس کامل اینپوت‌باکس روی کیبورد)")
// 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 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
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 ->
val scale = 1.0f + (level * 0.35f)
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 formattedSpeech = if (prefix.isNotEmpty() && !prefix.endsWith(" ") && !speechText.startsWith(" ")) {
" $speechText"
} else {
speechText
}
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", "تزریق گفتار در نشانگر: '$speechText' (موقعیت جدید: $newCursor)")
streamDictationClient?.sendLocalSyncState(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)
// 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<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.25f, 1.0f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.25f, 1.0f)
val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.3f, 0.7f, 0.3f)
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewGlow, scaleX, scaleY, alpha).apply {
duration = 1000
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.3f
}
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()
}
}