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.
548 lines
24 KiB
548 lines
24 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 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<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()
|
|
}
|
|
}
|