Big steps
This commit is contained in:
parent
bf94132c1c
commit
372d684650
@ -42,6 +42,8 @@ dependencies {
|
|||||||
implementation("com.google.android.material:material:1.9.0")
|
implementation("com.google.android.material:material:1.9.0")
|
||||||
implementation("androidx.room:room-common:2.5.2")
|
implementation("androidx.room:room-common:2.5.2")
|
||||||
implementation("androidx.room:room-ktx:2.5.2")
|
implementation("androidx.room:room-ktx:2.5.2")
|
||||||
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.10")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@ -22,5 +26,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.view.im" android:resource="@xml/method" />
|
<meta-data android:name="android.view.im" android:resource="@xml/method" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.PreferencesActivity"
|
||||||
|
android:label="@string/app_preferences_name"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
@ -1,7 +1,9 @@
|
|||||||
package net.mezimmah.wkt9
|
package net.mezimmah.wkt9
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
import android.inputmethodservice.InputMethodService
|
import android.inputmethodservice.InputMethodService
|
||||||
|
import android.media.MediaRecorder
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@ -10,6 +12,7 @@ import android.view.ViewConfiguration
|
|||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@ -21,6 +24,7 @@ import net.mezimmah.wkt9.db.AppDatabase
|
|||||||
import net.mezimmah.wkt9.inputmode.InputMode
|
import net.mezimmah.wkt9.inputmode.InputMode
|
||||||
import net.mezimmah.wkt9.inputmode.AlphaInputMode
|
import net.mezimmah.wkt9.inputmode.AlphaInputMode
|
||||||
import net.mezimmah.wkt9.inputmode.NumericInputMode
|
import net.mezimmah.wkt9.inputmode.NumericInputMode
|
||||||
|
import net.mezimmah.wkt9.inputmode.Status
|
||||||
import net.mezimmah.wkt9.inputmode.WordInputMode
|
import net.mezimmah.wkt9.inputmode.WordInputMode
|
||||||
import net.mezimmah.wkt9.inputmode.WKT9InputMode
|
import net.mezimmah.wkt9.inputmode.WKT9InputMode
|
||||||
import net.mezimmah.wkt9.keypad.KeyCodeMapping
|
import net.mezimmah.wkt9.keypad.KeyCodeMapping
|
||||||
@ -28,6 +32,9 @@ import net.mezimmah.wkt9.keypad.KeyEventResult
|
|||||||
import net.mezimmah.wkt9.keypad.KeyLayout
|
import net.mezimmah.wkt9.keypad.KeyLayout
|
||||||
import net.mezimmah.wkt9.keypad.Keypad
|
import net.mezimmah.wkt9.keypad.Keypad
|
||||||
import net.mezimmah.wkt9.t9.T9
|
import net.mezimmah.wkt9.t9.T9
|
||||||
|
import net.mezimmah.wkt9.voice.Whisper
|
||||||
|
import okio.IOException
|
||||||
|
import java.io.File
|
||||||
import java.lang.StringBuilder
|
import java.lang.StringBuilder
|
||||||
|
|
||||||
class WKT9: InputMethodService() {
|
class WKT9: InputMethodService() {
|
||||||
@ -39,9 +46,10 @@ class WKT9: InputMethodService() {
|
|||||||
private lateinit var settingDao: SettingDao
|
private lateinit var settingDao: SettingDao
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
private val job = SupervisorJob()
|
private val queryScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
private val scope = CoroutineScope(Dispatchers.Main + job)
|
|
||||||
private var queryJob: Job? = null
|
private var queryJob: Job? = null
|
||||||
|
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private var ioJob: Job? = null
|
||||||
|
|
||||||
private var cursorPosition = 0
|
private var cursorPosition = 0
|
||||||
private var longPressTimeout = 700
|
private var longPressTimeout = 700
|
||||||
@ -62,9 +70,16 @@ class WKT9: InputMethodService() {
|
|||||||
private var composing = false
|
private var composing = false
|
||||||
private val candidates: MutableList<String> = mutableListOf()
|
private val candidates: MutableList<String> = mutableListOf()
|
||||||
private var candidateIndex = 0
|
private var candidateIndex = 0
|
||||||
|
private var sentenceStart = false
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
private lateinit var inputView: View
|
private lateinit var inputView: View
|
||||||
|
private var toast: Toast? = null
|
||||||
|
|
||||||
|
// Whisper
|
||||||
|
private val whisper: Whisper = Whisper()
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var recording: File? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
Log.d(tag, "WKT9 is loading")
|
Log.d(tag, "WKT9 is loading")
|
||||||
@ -145,6 +160,9 @@ class WKT9: InputMethodService() {
|
|||||||
val inputType = attribute?.inputType?.and(InputType.TYPE_MASK_CLASS) ?: 0
|
val inputType = attribute?.inputType?.and(InputType.TYPE_MASK_CLASS) ?: 0
|
||||||
|
|
||||||
cursorPosition = attribute?.initialSelEnd ?: 0
|
cursorPosition = attribute?.initialSelEnd ?: 0
|
||||||
|
sentenceStart =
|
||||||
|
if (cursorPosition == 0) true
|
||||||
|
else isSentenceStart()
|
||||||
|
|
||||||
when (inputType) {
|
when (inputType) {
|
||||||
InputType.TYPE_CLASS_DATETIME,
|
InputType.TYPE_CLASS_DATETIME,
|
||||||
@ -179,15 +197,6 @@ class WKT9: InputMethodService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelComposing() {
|
|
||||||
composing = false
|
|
||||||
|
|
||||||
currentInputConnection.let {
|
|
||||||
it.setComposingText("", 1)
|
|
||||||
it.finishComposingText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearCandidates() {
|
private fun clearCandidates() {
|
||||||
clearCandidateUI()
|
clearCandidateUI()
|
||||||
|
|
||||||
@ -201,8 +210,8 @@ class WKT9: InputMethodService() {
|
|||||||
candidatesView.removeAllViews()
|
candidatesView.removeAllViews()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun commitText(text: CharSequence, start: Int, end: Int, cursorPosition: Int): Boolean {
|
private fun commitText(text: CharSequence, start: Int, end: Int): Boolean {
|
||||||
return (markComposingRegion(start, end) && composeText(text, cursorPosition) && finishComposingText())
|
return (markComposingRegion(start, end) && composeText(text, 1) && finishComposingText())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun composeText(text: CharSequence, cursorPosition: Int = 1): Boolean {
|
private fun composeText(text: CharSequence, cursorPosition: Int = 1): Boolean {
|
||||||
@ -213,12 +222,17 @@ class WKT9: InputMethodService() {
|
|||||||
|
|
||||||
private fun deleteText(beforeCursor: Int, afterCursor: Int) {
|
private fun deleteText(beforeCursor: Int, afterCursor: Int) {
|
||||||
currentInputConnection?.deleteSurroundingText(beforeCursor, afterCursor)
|
currentInputConnection?.deleteSurroundingText(beforeCursor, afterCursor)
|
||||||
|
|
||||||
|
sentenceStart = isSentenceStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: inputType
|
// Todo: inputType
|
||||||
private fun enableInputMode(mode: WKT9InputMode, inputType: Int) {
|
private fun enableInputMode(mode: WKT9InputMode, inputType: Int) {
|
||||||
lastInputMode = mode
|
lastInputMode = mode
|
||||||
|
|
||||||
|
if (inputType.and(InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS) == InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS)
|
||||||
|
Log.d(tag, "InputConnection expects email address")
|
||||||
|
|
||||||
inputMode = when(mode) {
|
inputMode = when(mode) {
|
||||||
WKT9InputMode.ALPHA -> alphaInputMode
|
WKT9InputMode.ALPHA -> alphaInputMode
|
||||||
WKT9InputMode.NUMERIC -> numericInputMode
|
WKT9InputMode.NUMERIC -> numericInputMode
|
||||||
@ -229,22 +243,47 @@ class WKT9: InputMethodService() {
|
|||||||
private fun finishComposingText(): Boolean {
|
private fun finishComposingText(): Boolean {
|
||||||
return if (composing) {
|
return if (composing) {
|
||||||
composing = false
|
composing = false
|
||||||
|
sentenceStart = isSentenceStart()
|
||||||
currentInputConnection?.finishComposingText() ?: false
|
currentInputConnection?.finishComposingText() ?: false
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun goHome() {
|
||||||
|
with(Intent(Intent.ACTION_MAIN)) {
|
||||||
|
this.addCategory(Intent.CATEGORY_HOME)
|
||||||
|
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
startActivity(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleKeyEventResult(res: KeyEventResult): Boolean {
|
private fun handleKeyEventResult(res: KeyEventResult): Boolean {
|
||||||
if (res.finishComposing) finishComposingText()
|
if (res.finishComposing) finishComposingText()
|
||||||
if (res.startComposing) markComposingRegion()
|
if (res.startComposing) markComposingRegion()
|
||||||
if (!res.codeWord.isNullOrEmpty()) onCodeWordUpdate(res.codeWord)
|
if (!res.codeWord.isNullOrEmpty()) onCodeWordUpdate(res.codeWord)
|
||||||
if (!res.candidates.isNullOrEmpty()) onCandidates(res.candidates)
|
if (!res.candidates.isNullOrEmpty()) onCandidates(res.candidates)
|
||||||
if (res.deleteBeforeCursor > 0 || res.deleteAfterCursor > 0) onDelete(res.deleteBeforeCursor, res.deleteAfterCursor)
|
if (res.deleteBeforeCursor > 0 || res.deleteAfterCursor > 0) onDelete(res.deleteBeforeCursor, res.deleteAfterCursor)
|
||||||
|
if (res.goHome) goHome()
|
||||||
if (res.left) onLeft()
|
if (res.left) onLeft()
|
||||||
if (res.right) onRight()
|
if (res.right) onRight()
|
||||||
|
if (res.record) onRecord()
|
||||||
|
if (res.transcribe) onTranscribe()
|
||||||
|
|
||||||
return res.consumed
|
return res.consumed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isSentenceStart(): Boolean {
|
||||||
|
if (cursorPosition == 0) return true
|
||||||
|
|
||||||
|
val textBeforeCursor = currentInputConnection?.getTextBeforeCursor(10, 0) ?: return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
textBeforeCursor.trimEnd().isEmpty() ||
|
||||||
|
listOf('.', '!', '?').contains(textBeforeCursor.trimEnd().last())) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadCandidates(highLight: Int? = null) {
|
private fun loadCandidates(highLight: Int? = null) {
|
||||||
val candidatesView = inputView.findViewById<LinearLayout>(R.id.suggestions)
|
val candidatesView = inputView.findViewById<LinearLayout>(R.id.suggestions)
|
||||||
|
|
||||||
@ -284,7 +323,7 @@ class WKT9: InputMethodService() {
|
|||||||
clearCandidates()
|
clearCandidates()
|
||||||
|
|
||||||
queryJob?.cancel()
|
queryJob?.cancel()
|
||||||
queryJob = scope.launch {
|
queryJob = queryScope.launch {
|
||||||
val hasCandidates = queryT9Candidates(codeWord, 10)
|
val hasCandidates = queryT9Candidates(codeWord, 10)
|
||||||
|
|
||||||
if (!hasCandidates) return@launch
|
if (!hasCandidates) return@launch
|
||||||
@ -311,6 +350,41 @@ class WKT9: InputMethodService() {
|
|||||||
composeText(candidates[candidateIndex])
|
composeText(candidates[candidateIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onRecord() {
|
||||||
|
// The recorder must be busy...
|
||||||
|
if (recorder !== null) return
|
||||||
|
|
||||||
|
clearCandidates()
|
||||||
|
|
||||||
|
recording?.delete()
|
||||||
|
|
||||||
|
// Toast settings
|
||||||
|
val text = "Recording now.\nRelease the button to start transcribing."
|
||||||
|
val duration = Toast.LENGTH_SHORT
|
||||||
|
|
||||||
|
// Instantiate recorder and start recording
|
||||||
|
recorder = MediaRecorder(this).also {
|
||||||
|
recording = File.createTempFile("recording.3gp", null, cacheDir)
|
||||||
|
|
||||||
|
it.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||||
|
it.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
|
||||||
|
it.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
|
||||||
|
it.setOutputFile(recording)
|
||||||
|
|
||||||
|
try {
|
||||||
|
it.prepare()
|
||||||
|
it.start()
|
||||||
|
|
||||||
|
toast?.cancel()
|
||||||
|
toast = Toast.makeText(this, text, duration).apply {
|
||||||
|
this.show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(tag, "Failed to start recording", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onRight() {
|
private fun onRight() {
|
||||||
if (candidates.isEmpty()) return
|
if (candidates.isEmpty()) return
|
||||||
|
|
||||||
@ -323,11 +397,45 @@ class WKT9: InputMethodService() {
|
|||||||
composeText(candidates[candidateIndex])
|
composeText(candidates[candidateIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onTranscribe() {
|
||||||
|
val recorder = this.recorder ?: return
|
||||||
|
|
||||||
|
recorder.stop()
|
||||||
|
recorder.reset()
|
||||||
|
recorder.release()
|
||||||
|
|
||||||
|
this.recorder = null
|
||||||
|
|
||||||
|
val text = "Sending recording to speech-to-text server for transcription."
|
||||||
|
val duration = Toast.LENGTH_SHORT
|
||||||
|
|
||||||
|
toast?.cancel()
|
||||||
|
toast = Toast.makeText(this, text, duration).apply {
|
||||||
|
this.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
ioJob?.cancel()
|
||||||
|
ioJob = ioScope.launch {
|
||||||
|
try {
|
||||||
|
val transcription = whisper.run(recording!!)
|
||||||
|
|
||||||
|
commitText(transcription, cursorPosition, cursorPosition)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.d(tag, "A failure occurred in the communication with the speech-to-text server", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun queryT9Candidates(codeWord: StringBuilder, limit: Int = 10): Boolean {
|
private suspend fun queryT9Candidates(codeWord: StringBuilder, limit: Int = 10): Boolean {
|
||||||
val words = wordDao.findCandidates(codeWord.toString(), limit)
|
val words = wordDao.findCandidates(codeWord.toString(), limit)
|
||||||
|
|
||||||
words.forEach {
|
words.forEach { word ->
|
||||||
candidates.add(it.word)
|
val candidate =
|
||||||
|
if (sentenceStart && inputMode?.status == Status.WORD_CAP) word.word.replaceFirstChar { it.uppercase() }
|
||||||
|
else if (inputMode?.status == Status.WORD_UPPER) word.word.uppercase()
|
||||||
|
else word.word
|
||||||
|
|
||||||
|
candidates.add(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return words.isNotEmpty()
|
return words.isNotEmpty()
|
||||||
|
@ -4,6 +4,9 @@ import net.mezimmah.wkt9.keypad.Key
|
|||||||
import net.mezimmah.wkt9.keypad.KeyEventResult
|
import net.mezimmah.wkt9.keypad.KeyEventResult
|
||||||
|
|
||||||
class AlphaInputMode: InputMode {
|
class AlphaInputMode: InputMode {
|
||||||
|
override var status: Status = Status.ALPHA_CAP
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onKeyDown(key: Key): KeyEventResult {
|
override fun onKeyDown(key: Key): KeyEventResult {
|
||||||
return KeyEventResult(consumed = false)
|
return KeyEventResult(consumed = false)
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,8 @@ import net.mezimmah.wkt9.keypad.Key
|
|||||||
import net.mezimmah.wkt9.keypad.KeyEventResult
|
import net.mezimmah.wkt9.keypad.KeyEventResult
|
||||||
|
|
||||||
interface InputMode {
|
interface InputMode {
|
||||||
|
val status: Status
|
||||||
|
|
||||||
fun onKeyDown(key: Key): KeyEventResult
|
fun onKeyDown(key: Key): KeyEventResult
|
||||||
|
|
||||||
fun onKeyLongDown(key: Key): KeyEventResult
|
fun onKeyLongDown(key: Key): KeyEventResult
|
||||||
|
@ -12,6 +12,9 @@ class NumericInputMode: InputMode {
|
|||||||
private val keyCommandResolver: KeyCommandResolver = KeyCommandResolver.getBasic()
|
private val keyCommandResolver: KeyCommandResolver = KeyCommandResolver.getBasic()
|
||||||
private val codeWord = StringBuilder()
|
private val codeWord = StringBuilder()
|
||||||
|
|
||||||
|
override var status: Status = Status.NUM
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onKeyDown(key: Key): KeyEventResult {
|
override fun onKeyDown(key: Key): KeyEventResult {
|
||||||
return when(keyCommandResolver.getCommand(key)) {
|
return when(keyCommandResolver.getCommand(key)) {
|
||||||
Command.CHARACTER -> buildCodeWord(key)
|
Command.CHARACTER -> buildCodeWord(key)
|
||||||
|
11
app/src/main/java/net/mezimmah/wkt9/inputmode/Status.kt
Normal file
11
app/src/main/java/net/mezimmah/wkt9/inputmode/Status.kt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package net.mezimmah.wkt9.inputmode
|
||||||
|
|
||||||
|
enum class Status(val idx: Int) {
|
||||||
|
WORD(0),
|
||||||
|
WORD_CAP(1),
|
||||||
|
WORD_UPPER(2),
|
||||||
|
ALPHA(3),
|
||||||
|
ALPHA_CAP(4),
|
||||||
|
ALPHA_UPPER(5),
|
||||||
|
NUM(6)
|
||||||
|
}
|
@ -6,7 +6,6 @@ import net.mezimmah.wkt9.keypad.Key
|
|||||||
import net.mezimmah.wkt9.keypad.KeyCommandResolver
|
import net.mezimmah.wkt9.keypad.KeyCommandResolver
|
||||||
import net.mezimmah.wkt9.keypad.KeyEventResult
|
import net.mezimmah.wkt9.keypad.KeyEventResult
|
||||||
import java.lang.StringBuilder
|
import java.lang.StringBuilder
|
||||||
import java.lang.annotation.Native
|
|
||||||
|
|
||||||
class WordInputMode: InputMode {
|
class WordInputMode: InputMode {
|
||||||
private val tag = "WKT9"
|
private val tag = "WKT9"
|
||||||
@ -16,66 +15,54 @@ class WordInputMode: InputMode {
|
|||||||
private var keyIndex = 0
|
private var keyIndex = 0
|
||||||
private var lastKey: Key? = null
|
private var lastKey: Key? = null
|
||||||
|
|
||||||
|
override var status: Status = Status.WORD_CAP
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
Log.d(tag, "Started word input mode.")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onKeyDown(key: Key): KeyEventResult {
|
override fun onKeyDown(key: Key): KeyEventResult {
|
||||||
keyStats(key)
|
keyStats(key)
|
||||||
|
|
||||||
val command = keyCommandResolver.getCommand(key)
|
|
||||||
|
|
||||||
Log.d(tag, "Command: $command")
|
|
||||||
|
|
||||||
return when(keyCommandResolver.getCommand(key)) {
|
return when(keyCommandResolver.getCommand(key)) {
|
||||||
Command.BACK -> KeyEventResult(false)
|
Command.BACK -> KeyEventResult(false)
|
||||||
Command.CHARACTER -> buildCodeWord(key)
|
Command.CHARACTER -> buildCodeWord(key)
|
||||||
// Command.SELECT -> true
|
|
||||||
Command.DELETE -> deleteCharacter()
|
Command.DELETE -> deleteCharacter()
|
||||||
Command.SPACE -> finalizeWordOrSentence()
|
Command.SPACE -> finalizeWordOrSentence()
|
||||||
Command.LEFT -> navigateLeft()
|
Command.LEFT -> navigateLeft()
|
||||||
Command.RIGHT -> navigateRight()
|
Command.RIGHT -> navigateRight()
|
||||||
// Command.CYCLE_CANDIDATES -> cycleCandidates()
|
|
||||||
else -> KeyEventResult()
|
else -> KeyEventResult()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyLongDown(key: Key): KeyEventResult {
|
override fun onKeyLongDown(key: Key): KeyEventResult {
|
||||||
// Log.d(tag, "onKeyLongDown")
|
return when(keyCommandResolver.getCommand(key, true)) {
|
||||||
|
Command.RECORD -> record()
|
||||||
val command = keyCommandResolver.getCommand(key = key, longPress = true)
|
else -> KeyEventResult(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Log.d(tag, "Command: $command")
|
|
||||||
|
|
||||||
return KeyEventResult()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDownRepeatedly(key: Key, repeat: Int): KeyEventResult {
|
override fun onKeyDownRepeatedly(key: Key, repeat: Int): KeyEventResult {
|
||||||
return when(keyCommandResolver.getCommand(key, repeat = repeat)) {
|
return when(keyCommandResolver.getCommand(key, repeat = repeat)) {
|
||||||
|
Command.HOME -> goHome(repeat)
|
||||||
Command.DELETE -> deleteCharacter(repeat)
|
Command.DELETE -> deleteCharacter(repeat)
|
||||||
else -> KeyEventResult()
|
else -> KeyEventResult()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun afterKeyDown(key: Key): KeyEventResult {
|
override fun afterKeyDown(key: Key): KeyEventResult {
|
||||||
// Log.d(tag, "afterKeyDown")
|
return when(keyCommandResolver.getCommand(key, after = true)) {
|
||||||
|
Command.BACK -> goBack()
|
||||||
// return when(keyCommandResolver.getCommand(key, after = true)) {
|
else -> KeyEventResult()
|
||||||
// Command.DELETE -> deleteCharacter(repeat)
|
}
|
||||||
// else -> KeyEventResult()
|
|
||||||
// }
|
|
||||||
|
|
||||||
val command = keyCommandResolver.getCommand(key, after = true)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Log.d(tag, "Command: $command")
|
|
||||||
|
|
||||||
return KeyEventResult(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun afterKeyLongDown(key: Key, keyDownMS: Long): KeyEventResult {
|
override fun afterKeyLongDown(key: Key, keyDownMS: Long): KeyEventResult {
|
||||||
// Log.d(tag, "afterKeyLongDown")
|
return when(keyCommandResolver.getCommand(key, after = true, longPress = true)) {
|
||||||
|
Command.TRANSCRIBE -> transcribe()
|
||||||
return KeyEventResult()
|
else -> KeyEventResult()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCodeWord(key: Key): KeyEventResult {
|
private fun buildCodeWord(key: Key): KeyEventResult {
|
||||||
@ -115,6 +102,27 @@ class WordInputMode: InputMode {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun goBack(): KeyEventResult {
|
||||||
|
reset()
|
||||||
|
|
||||||
|
return KeyEventResult(
|
||||||
|
consumed = false,
|
||||||
|
finishComposing = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun goHome(repeat: Int): KeyEventResult {
|
||||||
|
if (repeat > 1) return KeyEventResult(true)
|
||||||
|
|
||||||
|
reset()
|
||||||
|
|
||||||
|
return KeyEventResult(
|
||||||
|
consumed = true,
|
||||||
|
finishComposing = true,
|
||||||
|
goHome = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun keyStats(key: Key) {
|
private fun keyStats(key: Key) {
|
||||||
when (key != lastKey) {
|
when (key != lastKey) {
|
||||||
true -> {
|
true -> {
|
||||||
@ -137,4 +145,28 @@ class WordInputMode: InputMode {
|
|||||||
private fun navigateRight(): KeyEventResult {
|
private fun navigateRight(): KeyEventResult {
|
||||||
return KeyEventResult(right = true)
|
return KeyEventResult(right = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun record(): KeyEventResult {
|
||||||
|
codeWord.clear()
|
||||||
|
|
||||||
|
return KeyEventResult(
|
||||||
|
consumed = true,
|
||||||
|
finishComposing = true,
|
||||||
|
record = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reset() {
|
||||||
|
codeWord.clear()
|
||||||
|
newKey = true
|
||||||
|
keyIndex = 0
|
||||||
|
lastKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transcribe(): KeyEventResult {
|
||||||
|
return KeyEventResult(
|
||||||
|
consumed = true,
|
||||||
|
transcribe = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
@ -51,8 +51,6 @@ class KeyCommandResolver (
|
|||||||
)),
|
)),
|
||||||
|
|
||||||
onLong = HashMap(mapOf(
|
onLong = HashMap(mapOf(
|
||||||
Key.BACK to Command.HOME,
|
|
||||||
|
|
||||||
Key.N0 to Command.NUMBER,
|
Key.N0 to Command.NUMBER,
|
||||||
Key.N1 to Command.NUMBER,
|
Key.N1 to Command.NUMBER,
|
||||||
Key.N2 to Command.NUMBER,
|
Key.N2 to Command.NUMBER,
|
||||||
@ -78,6 +76,7 @@ class KeyCommandResolver (
|
|||||||
)),
|
)),
|
||||||
|
|
||||||
onRepeat = HashMap(mapOf(
|
onRepeat = HashMap(mapOf(
|
||||||
|
Key.BACK to Command.HOME,
|
||||||
Key.STAR to Command.DELETE,
|
Key.STAR to Command.DELETE,
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
@ -10,6 +10,9 @@ data class KeyEventResult(
|
|||||||
val candidates: List<String>? = null,
|
val candidates: List<String>? = null,
|
||||||
val deleteBeforeCursor: Int = 0,
|
val deleteBeforeCursor: Int = 0,
|
||||||
val deleteAfterCursor: Int = 0,
|
val deleteAfterCursor: Int = 0,
|
||||||
|
val goHome: Boolean = false,
|
||||||
val left: Boolean = false,
|
val left: Boolean = false,
|
||||||
val right: Boolean = false
|
val right: Boolean = false,
|
||||||
|
val record: Boolean = false,
|
||||||
|
val transcribe: Boolean = false
|
||||||
)
|
)
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
package net.mezimmah.wkt9.preferences
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import net.mezimmah.wkt9.R
|
||||||
|
|
||||||
|
class PreferencesActivity: AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContentView(R.layout.preferences_container)
|
||||||
|
supportFragmentManager
|
||||||
|
.beginTransaction()
|
||||||
|
.replace(R.id.preferences_container, PreferencesFragment())
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package net.mezimmah.wkt9.preferences
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.preference.SwitchPreference
|
||||||
|
import net.mezimmah.wkt9.R
|
||||||
|
|
||||||
|
class PreferencesFragment: PreferenceFragmentCompat(),
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
private val tag = "WKT9"
|
||||||
|
private val requestPermissionLauncher = registerForActivityResult(RequestMultiplePermissions()) { isGranted: Map<String, Boolean> ->
|
||||||
|
// If any permission got denied we programmatically disable the option
|
||||||
|
if (isGranted.containsValue(false)) {
|
||||||
|
val key = getString(R.string.preference_setting_speech_to_text_key)
|
||||||
|
|
||||||
|
findPreference<SwitchPreference>(key)?.isChecked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
Log.d(tag, "Loading preferences")
|
||||||
|
|
||||||
|
setPreferencesFromResource(R.xml.preferences, rootKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
|
||||||
|
preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(p0: SharedPreferences?, key: String?) {
|
||||||
|
when (key) {
|
||||||
|
getString(R.string.preference_setting_speech_to_text_key) -> {
|
||||||
|
if (findPreference<SwitchPreference>(key)?.isChecked == true) {
|
||||||
|
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
} else arrayOf(Manifest.permission.RECORD_AUDIO)
|
||||||
|
|
||||||
|
requestPermissionLauncher.launch(permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
app/src/main/java/net/mezimmah/wkt9/voice/Whisper.kt
Normal file
41
app/src/main/java/net/mezimmah/wkt9/voice/Whisper.kt
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package net.mezimmah.wkt9.voice
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class Whisper {
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(2, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(25, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(32, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun run(recording: File): String {
|
||||||
|
val mediaType = "audio/3gpp".toMediaType()
|
||||||
|
|
||||||
|
val requestBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("language", "en")
|
||||||
|
.addFormDataPart("model_size", "tiny.en")
|
||||||
|
.addFormDataPart("files", "recording.3gp", recording.asRequestBody(mediaType))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://voice.mezimmah.net")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||||
|
|
||||||
|
response.body.string().trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
app/src/main/res/layout/preferences_container.xml
Normal file
9
app/src/main/res/layout/preferences_container.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/preferences_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".preferences.PreferencesActivity">
|
||||||
|
</FrameLayout>
|
@ -1,3 +1,15 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">WKT9</string>
|
<string name="app_name">WKT9</string>
|
||||||
|
|
||||||
|
<string name="app_preferences_name">WKT9 Preferences</string>
|
||||||
|
|
||||||
|
<string name="preference_category_speech_to_text_name">Speech to Text</string>
|
||||||
|
|
||||||
|
<string name="preference_setting_speech_to_text_key">speech_to_text</string>
|
||||||
|
<string name="preference_setting_speech_to_text_title">Enable Speech to Text</string>
|
||||||
|
<string name="preference_setting_speech_to_text_summary">For this feature to work net.mezimmah.wkt9.WKT9 needs permission to show notifications and record audio. You will be asked to grant these permissions if you haven\'t already permitted it.</string>
|
||||||
|
|
||||||
|
<string name="preference_setting_whisper_url_key">whisper_url</string>
|
||||||
|
<string name="preference_setting_whisper_url_title">Whisper Server URL</string>
|
||||||
|
<string name="preference_setting_whisper_url_summary">Provide an URL to the Whisper server.</string>
|
||||||
</resources>
|
</resources>
|
19
app/src/main/res/xml/preferences.xml
Normal file
19
app/src/main/res/xml/preferences.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<PreferenceScreen
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<PreferenceCategory
|
||||||
|
app:title="@string/preference_category_speech_to_text_name" />
|
||||||
|
|
||||||
|
<SwitchPreference
|
||||||
|
app:key="@string/preference_setting_speech_to_text_key"
|
||||||
|
app:title="@string/preference_setting_speech_to_text_title"
|
||||||
|
app:summary="@string/preference_setting_speech_to_text_summary" />
|
||||||
|
|
||||||
|
<EditTextPreference
|
||||||
|
app:key="@string/preference_setting_whisper_url_key"
|
||||||
|
app:title="@string/preference_setting_whisper_url_title"
|
||||||
|
app:summary="@string/preference_setting_whisper_url_summary"
|
||||||
|
app:dependency="@string/preference_setting_speech_to_text_key" />
|
||||||
|
|
||||||
|
</PreferenceScreen>
|
Loading…
x
Reference in New Issue
Block a user