diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ae70889..38ebc66 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -42,6 +42,8 @@ dependencies {
implementation("com.google.android.material:material:1.9.0")
implementation("androidx.room:room-common: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")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7db66de..c0fe91f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,10 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/net/mezimmah/wkt9/WKT9.kt b/app/src/main/java/net/mezimmah/wkt9/WKT9.kt
index 706290a..c093cde 100644
--- a/app/src/main/java/net/mezimmah/wkt9/WKT9.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/WKT9.kt
@@ -1,7 +1,9 @@
package net.mezimmah.wkt9
import android.annotation.SuppressLint
+import android.content.Intent
import android.inputmethodservice.InputMethodService
+import android.media.MediaRecorder
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
@@ -10,6 +12,7 @@ import android.view.ViewConfiguration
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
+import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.AlphaInputMode
import net.mezimmah.wkt9.inputmode.NumericInputMode
+import net.mezimmah.wkt9.inputmode.Status
import net.mezimmah.wkt9.inputmode.WordInputMode
import net.mezimmah.wkt9.inputmode.WKT9InputMode
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.Keypad
import net.mezimmah.wkt9.t9.T9
+import net.mezimmah.wkt9.voice.Whisper
+import okio.IOException
+import java.io.File
import java.lang.StringBuilder
class WKT9: InputMethodService() {
@@ -39,9 +46,10 @@ class WKT9: InputMethodService() {
private lateinit var settingDao: SettingDao
// Coroutines
- private val job = SupervisorJob()
- private val scope = CoroutineScope(Dispatchers.Main + job)
+ private val queryScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var queryJob: Job? = null
+ private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var ioJob: Job? = null
private var cursorPosition = 0
private var longPressTimeout = 700
@@ -62,9 +70,16 @@ class WKT9: InputMethodService() {
private var composing = false
private val candidates: MutableList = mutableListOf()
private var candidateIndex = 0
+ private var sentenceStart = false
// UI
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() {
Log.d(tag, "WKT9 is loading")
@@ -145,6 +160,9 @@ class WKT9: InputMethodService() {
val inputType = attribute?.inputType?.and(InputType.TYPE_MASK_CLASS) ?: 0
cursorPosition = attribute?.initialSelEnd ?: 0
+ sentenceStart =
+ if (cursorPosition == 0) true
+ else isSentenceStart()
when (inputType) {
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() {
clearCandidateUI()
@@ -201,8 +210,8 @@ class WKT9: InputMethodService() {
candidatesView.removeAllViews()
}
- private fun commitText(text: CharSequence, start: Int, end: Int, cursorPosition: Int): Boolean {
- return (markComposingRegion(start, end) && composeText(text, cursorPosition) && finishComposingText())
+ private fun commitText(text: CharSequence, start: Int, end: Int): Boolean {
+ return (markComposingRegion(start, end) && composeText(text, 1) && finishComposingText())
}
private fun composeText(text: CharSequence, cursorPosition: Int = 1): Boolean {
@@ -213,12 +222,17 @@ class WKT9: InputMethodService() {
private fun deleteText(beforeCursor: Int, afterCursor: Int) {
currentInputConnection?.deleteSurroundingText(beforeCursor, afterCursor)
+
+ sentenceStart = isSentenceStart()
}
// Todo: inputType
private fun enableInputMode(mode: WKT9InputMode, inputType: Int) {
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) {
WKT9InputMode.ALPHA -> alphaInputMode
WKT9InputMode.NUMERIC -> numericInputMode
@@ -229,22 +243,47 @@ class WKT9: InputMethodService() {
private fun finishComposingText(): Boolean {
return if (composing) {
composing = false
+ sentenceStart = isSentenceStart()
currentInputConnection?.finishComposingText() ?: 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 {
if (res.finishComposing) finishComposingText()
if (res.startComposing) markComposingRegion()
if (!res.codeWord.isNullOrEmpty()) onCodeWordUpdate(res.codeWord)
if (!res.candidates.isNullOrEmpty()) onCandidates(res.candidates)
if (res.deleteBeforeCursor > 0 || res.deleteAfterCursor > 0) onDelete(res.deleteBeforeCursor, res.deleteAfterCursor)
+ if (res.goHome) goHome()
if (res.left) onLeft()
if (res.right) onRight()
+ if (res.record) onRecord()
+ if (res.transcribe) onTranscribe()
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) {
val candidatesView = inputView.findViewById(R.id.suggestions)
@@ -284,7 +323,7 @@ class WKT9: InputMethodService() {
clearCandidates()
queryJob?.cancel()
- queryJob = scope.launch {
+ queryJob = queryScope.launch {
val hasCandidates = queryT9Candidates(codeWord, 10)
if (!hasCandidates) return@launch
@@ -311,6 +350,41 @@ class WKT9: InputMethodService() {
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() {
if (candidates.isEmpty()) return
@@ -323,11 +397,45 @@ class WKT9: InputMethodService() {
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 {
val words = wordDao.findCandidates(codeWord.toString(), limit)
- words.forEach {
- candidates.add(it.word)
+ words.forEach { 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()
diff --git a/app/src/main/java/net/mezimmah/wkt9/inputmode/AlphaInputMode.kt b/app/src/main/java/net/mezimmah/wkt9/inputmode/AlphaInputMode.kt
index 7c50beb..9e6edb8 100644
--- a/app/src/main/java/net/mezimmah/wkt9/inputmode/AlphaInputMode.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/inputmode/AlphaInputMode.kt
@@ -4,6 +4,9 @@ import net.mezimmah.wkt9.keypad.Key
import net.mezimmah.wkt9.keypad.KeyEventResult
class AlphaInputMode: InputMode {
+ override var status: Status = Status.ALPHA_CAP
+ private set
+
override fun onKeyDown(key: Key): KeyEventResult {
return KeyEventResult(consumed = false)
}
diff --git a/app/src/main/java/net/mezimmah/wkt9/inputmode/InputMode.kt b/app/src/main/java/net/mezimmah/wkt9/inputmode/InputMode.kt
index b24e3a4..b467b6f 100644
--- a/app/src/main/java/net/mezimmah/wkt9/inputmode/InputMode.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/inputmode/InputMode.kt
@@ -4,6 +4,8 @@ import net.mezimmah.wkt9.keypad.Key
import net.mezimmah.wkt9.keypad.KeyEventResult
interface InputMode {
+ val status: Status
+
fun onKeyDown(key: Key): KeyEventResult
fun onKeyLongDown(key: Key): KeyEventResult
diff --git a/app/src/main/java/net/mezimmah/wkt9/inputmode/NumericInputMode.kt b/app/src/main/java/net/mezimmah/wkt9/inputmode/NumericInputMode.kt
index e855931..a9cadb9 100644
--- a/app/src/main/java/net/mezimmah/wkt9/inputmode/NumericInputMode.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/inputmode/NumericInputMode.kt
@@ -12,6 +12,9 @@ class NumericInputMode: InputMode {
private val keyCommandResolver: KeyCommandResolver = KeyCommandResolver.getBasic()
private val codeWord = StringBuilder()
+ override var status: Status = Status.NUM
+ private set
+
override fun onKeyDown(key: Key): KeyEventResult {
return when(keyCommandResolver.getCommand(key)) {
Command.CHARACTER -> buildCodeWord(key)
diff --git a/app/src/main/java/net/mezimmah/wkt9/inputmode/Status.kt b/app/src/main/java/net/mezimmah/wkt9/inputmode/Status.kt
new file mode 100644
index 0000000..7c497f7
--- /dev/null
+++ b/app/src/main/java/net/mezimmah/wkt9/inputmode/Status.kt
@@ -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)
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/mezimmah/wkt9/inputmode/WordInputMode.kt b/app/src/main/java/net/mezimmah/wkt9/inputmode/WordInputMode.kt
index 5e6b909..ce5aa5f 100644
--- a/app/src/main/java/net/mezimmah/wkt9/inputmode/WordInputMode.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/inputmode/WordInputMode.kt
@@ -6,7 +6,6 @@ import net.mezimmah.wkt9.keypad.Key
import net.mezimmah.wkt9.keypad.KeyCommandResolver
import net.mezimmah.wkt9.keypad.KeyEventResult
import java.lang.StringBuilder
-import java.lang.annotation.Native
class WordInputMode: InputMode {
private val tag = "WKT9"
@@ -16,66 +15,54 @@ class WordInputMode: InputMode {
private var keyIndex = 0
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 {
keyStats(key)
- val command = keyCommandResolver.getCommand(key)
-
- Log.d(tag, "Command: $command")
-
return when(keyCommandResolver.getCommand(key)) {
Command.BACK -> KeyEventResult(false)
Command.CHARACTER -> buildCodeWord(key)
-// Command.SELECT -> true
Command.DELETE -> deleteCharacter()
Command.SPACE -> finalizeWordOrSentence()
Command.LEFT -> navigateLeft()
Command.RIGHT -> navigateRight()
-// Command.CYCLE_CANDIDATES -> cycleCandidates()
else -> KeyEventResult()
}
}
override fun onKeyLongDown(key: Key): KeyEventResult {
-// Log.d(tag, "onKeyLongDown")
-
- val command = keyCommandResolver.getCommand(key = key, longPress = true)
-
-
-
- Log.d(tag, "Command: $command")
-
- return KeyEventResult()
+ return when(keyCommandResolver.getCommand(key, true)) {
+ Command.RECORD -> record()
+ else -> KeyEventResult(true)
+ }
}
override fun onKeyDownRepeatedly(key: Key, repeat: Int): KeyEventResult {
return when(keyCommandResolver.getCommand(key, repeat = repeat)) {
+ Command.HOME -> goHome(repeat)
Command.DELETE -> deleteCharacter(repeat)
else -> KeyEventResult()
}
}
override fun afterKeyDown(key: Key): KeyEventResult {
-// Log.d(tag, "afterKeyDown")
-
-// return when(keyCommandResolver.getCommand(key, after = true)) {
-// Command.DELETE -> deleteCharacter(repeat)
-// else -> KeyEventResult()
-// }
-
- val command = keyCommandResolver.getCommand(key, after = true)
-
-
-
- Log.d(tag, "Command: $command")
-
- return KeyEventResult(false)
+ return when(keyCommandResolver.getCommand(key, after = true)) {
+ Command.BACK -> goBack()
+ else -> KeyEventResult()
+ }
}
override fun afterKeyLongDown(key: Key, keyDownMS: Long): KeyEventResult {
-// Log.d(tag, "afterKeyLongDown")
-
- return KeyEventResult()
+ return when(keyCommandResolver.getCommand(key, after = true, longPress = true)) {
+ Command.TRANSCRIBE -> transcribe()
+ else -> 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) {
when (key != lastKey) {
true -> {
@@ -137,4 +145,28 @@ class WordInputMode: InputMode {
private fun navigateRight(): KeyEventResult {
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
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/net/mezimmah/wkt9/keypad/KeyCommandResolver.kt b/app/src/main/java/net/mezimmah/wkt9/keypad/KeyCommandResolver.kt
index 60b6a7f..8470d40 100644
--- a/app/src/main/java/net/mezimmah/wkt9/keypad/KeyCommandResolver.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/keypad/KeyCommandResolver.kt
@@ -51,8 +51,6 @@ class KeyCommandResolver (
)),
onLong = HashMap(mapOf(
- Key.BACK to Command.HOME,
-
Key.N0 to Command.NUMBER,
Key.N1 to Command.NUMBER,
Key.N2 to Command.NUMBER,
@@ -78,6 +76,7 @@ class KeyCommandResolver (
)),
onRepeat = HashMap(mapOf(
+ Key.BACK to Command.HOME,
Key.STAR to Command.DELETE,
))
)
diff --git a/app/src/main/java/net/mezimmah/wkt9/keypad/KeyEventResult.kt b/app/src/main/java/net/mezimmah/wkt9/keypad/KeyEventResult.kt
index ef6bbf0..d3d91db 100644
--- a/app/src/main/java/net/mezimmah/wkt9/keypad/KeyEventResult.kt
+++ b/app/src/main/java/net/mezimmah/wkt9/keypad/KeyEventResult.kt
@@ -10,6 +10,9 @@ data class KeyEventResult(
val candidates: List? = null,
val deleteBeforeCursor: Int = 0,
val deleteAfterCursor: Int = 0,
+ val goHome: Boolean = false,
val left: Boolean = false,
- val right: Boolean = false
+ val right: Boolean = false,
+ val record: Boolean = false,
+ val transcribe: Boolean = false
)
diff --git a/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesActivity.kt b/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesActivity.kt
new file mode 100644
index 0000000..c7c94be
--- /dev/null
+++ b/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesActivity.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesFragment.kt b/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesFragment.kt
new file mode 100644
index 0000000..d733eda
--- /dev/null
+++ b/app/src/main/java/net/mezimmah/wkt9/preferences/PreferencesFragment.kt
@@ -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 ->
+ // 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(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(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)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/mezimmah/wkt9/voice/Whisper.kt b/app/src/main/java/net/mezimmah/wkt9/voice/Whisper.kt
new file mode 100644
index 0000000..e68ec61
--- /dev/null
+++ b/app/src/main/java/net/mezimmah/wkt9/voice/Whisper.kt
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/preferences_container.xml b/app/src/main/res/layout/preferences_container.xml
new file mode 100644
index 0000000..e0f6b1b
--- /dev/null
+++ b/app/src/main/res/layout/preferences_container.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b3607dd..f807f0c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,15 @@
WKT9
+
+ WKT9 Preferences
+
+ Speech to Text
+
+ speech_to_text
+ Enable Speech to Text
+ 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.
+
+ whisper_url
+ Whisper Server URL
+ Provide an URL to the Whisper server.
\ No newline at end of file
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
new file mode 100644
index 0000000..5639099
--- /dev/null
+++ b/app/src/main/res/xml/preferences.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+