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.
 
 
 
 
 
 

639 lines
28 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.Color
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.Spannable
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.text.style.ForegroundColorSpan
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
private val gatewayHost = "2.180.16.250:8089"
// 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.9)")
// 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" && state.source != "http_post") {
val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime
// If user recently edited/cleared on phone within 1200ms, block remote echo resurrecting old text!
if (timeSinceLocalEdit < 1200L || isCurrentlyRecording) {
return@StreamDictationClient
}
// Monotonic revision check
if (state.revision > 0 && state.revision < currentRevision) {
return@StreamDictationClient
}
if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") {
binding.tvMacStatus.text = "متصل به ${state.app} 🖥️"
}
// If state.text is empty and was sent by server_init, do not wipe local text if we already have content
if (state.text.isEmpty() && state.source == "server_init" && lastLocalText.isNotEmpty()) {
return@StreamDictationClient
}
if (state.text != lastLocalText) {
isApplyingRemoteUpdate = true
lastLocalText = state.text
currentRevision = state.revision
binding.etTranscript.setText(state.text)
binding.etTranscript.setTextColor(Color.WHITE)
val targetCursor = state.cursor.coerceIn(0, state.text.length)
binding.etTranscript.setSelection(targetCursor)
val wordCount = if (state.text.trim().isEmpty()) 0 else state.text.trim().split("\\s+".toRegex()).size
binding.tvCharCount.text = "$wordCount کلمه"
isApplyingRemoteUpdate = false
}
}
},
onPartialSpeechText = { livePartial ->
binding.tvInstruction.text = "🎙️ $livePartial"
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_blue))
// Live in-flight speech displayed in Slate Gray inside the transcript box
if (isCurrentlyRecording && livePartial.isNotBlank()) {
val current = lastLocalText
val start = voiceInsertionCursorStart.coerceIn(0, current.length)
val prefix = if (start > 0) current.substring(0, start) else ""
val suffix = if (start < current.length) current.substring(start) else ""
val needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n")
val liveFormatted = (if (needsPreSpace) " " else "") + livePartial.trim()
val spannable = SpannableStringBuilder().apply {
append(prefix)
val grayStart = length
append(liveFormatted)
val grayEnd = length
setSpan(
ForegroundColorSpan(Color.parseColor("#94A3B8")), // Sleek In-flight Gray
grayStart,
grayEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(suffix)
}
isApplyingRemoteUpdate = true
binding.etTranscript.setText(spannable)
binding.etTranscript.setSelection((start + liveFormatted.length).coerceIn(0, spannable.length))
isApplyingRemoteUpdate = false
}
},
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 {
// Revert to pure committed text if no speech was recognized
isApplyingRemoteUpdate = true
binding.etTranscript.setText(lastLocalText)
binding.etTranscript.setTextColor(Color.WHITE)
binding.etTranscript.setSelection(voiceInsertionCursorStart.coerceIn(0, lastLocalText.length))
isApplyingRemoteUpdate = false
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)
append(" ") // ALWAYS guarantee a trailing space after inserted speech
}
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()
currentRevision++
binding.etTranscript.setText(mergedText)
binding.etTranscript.setTextColor(Color.WHITE)
binding.etTranscript.setSelection(newCursor)
val wordCount = if (mergedText.trim().isEmpty()) 0 else mergedText.trim().split("\\s+".toRegex()).size
binding.tvCharCount.text = "$wordCount کلمه"
isApplyingRemoteUpdate = false
// Guarantees immediate 1:1 synchronization with Mac input box
streamDictationClient?.sendPhoneEdit(mergedText, newCursor)
lifecycleScope.launch {
sendDirectPaste(mergedText, newCursor)
}
AppLogger.log("Main", "تزریق گفتار در نشانگر و همگام‌سازی با مک: '$trimmedSpeech' (موقعیت جدید: $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 = ""
binding.tvCharCount.text = "0 کلمه"
voiceInsertionCursorStart = 0
voiceInsertionCursorEnd = 0
lastLocalUserEditTime = System.currentTimeMillis()
currentRevision++
isApplyingRemoteUpdate = false
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) }
pendingSyncRunnable = null
// Cleanly clear active box on Mac as well (Cmd+A -> Backspace)
streamDictationClient?.sendForceReplace("", 0)
lifecycleScope.launch {
sendDirectPaste("", 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, "2.180.16.250:8089", "2.180.16.250:8999", "116.16.16.19: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()
}
}