Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ android {
applicationId = "me.maxistar.voiceinbox"
minSdk = 24
targetSdk = 36
versionCode = 6
versionName = "0.3.0"
versionCode = 7
versionName = "0.4.0"
ndk {
abiFilters += "arm64-v8a"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package me.maxistar.voiceinbox

import android.content.ComponentName
import android.content.pm.PackageManager
import android.view.LayoutInflater
import android.widget.EditText
import android.widget.ImageButton
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class VoiceKeyboardInstrumentedTest {
@Test
fun manifestDeclaresBoundInputMethodService() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val service = context.packageManager.getServiceInfo(
ComponentName(context, VoiceKeyboardInputMethodService::class.java),
PackageManager.GET_META_DATA,
)

assertEquals("android.permission.BIND_INPUT_METHOD", service.permission)
assertTrue(service.metaData?.containsKey("android.view.im") == true)
assertEquals(
PackageManager.PERMISSION_GRANTED,
context.packageManager.checkPermission(android.Manifest.permission.RECORD_AUDIO, context.packageName),
)
}

@Test
fun inputConnectionCommitsAndDeletesText() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val editor = EditText(context)
editor.setText("start")
editor.setSelection(editor.length())
val connection = editor.onCreateInputConnection(android.view.inputmethod.EditorInfo())

connection.commitText(" text", 1)
connection.deleteSurroundingTextInCodePoints(1, 0)

assertEquals("start tex", editor.text.toString())
}
}

@Test
fun inputConnectionDeletesEmojiAsOneCodePoint() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val editor = EditText(context)
editor.setText("start \uD83D\uDE00")
editor.setSelection(editor.length())
val connection = editor.onCreateInputConnection(android.view.inputmethod.EditorInfo())

assertTrue(connection.deleteSurroundingTextInCodePoints(1, 0))

assertEquals("start ", editor.text.toString())
}
}

@Test
fun keyboardLayoutInflatesWithAccessibleIconControls() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val view = LayoutInflater.from(context).inflate(R.layout.input_view_voice_keyboard, null)

assertEquals(
context.getString(R.string.voice_keyboard_return_to_previous),
view.findViewById<ImageButton>(R.id.voiceKeyboardNextKeyboard).contentDescription,
)
assertEquals(
context.getString(R.string.voice_keyboard_space),
view.findViewById<ImageButton>(R.id.voiceKeyboardSpace).contentDescription,
)
assertEquals(
context.getString(R.string.voice_keyboard_enter),
view.findViewById<ImageButton>(R.id.voiceKeyboardEnter).contentDescription,
)
assertEquals(
context.getString(R.string.voice_keyboard_backspace),
view.findViewById<ImageButton>(R.id.voiceKeyboardBackspace).contentDescription,
)
}
}
}
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<application
android:allowBackup="true"
Expand Down Expand Up @@ -53,6 +54,18 @@
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name=".VoiceKeyboardInputMethodService"
android:exported="true"
android:label="@string/voice_keyboard_subtype"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/voice_keyboard_method" />
</service>
</application>

</manifest>
25 changes: 24 additions & 1 deletion app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package me.maxistar.voiceinbox

import me.maxistar.voiceinbox.core.*

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
Expand Down Expand Up @@ -160,6 +162,16 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (uri != null) acceptModelFolder(uri)
}

private val microphonePermissionRequest = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Toast.makeText(
this,
if (granted) R.string.voice_keyboard_permission_granted else R.string.voice_keyboard_permission_denied,
Toast.LENGTH_LONG,
).show()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Expand Down Expand Up @@ -237,6 +249,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
intent.removeExtra(EXTRA_OPEN_MODEL_FOLDER_PICKER)
modelFolderPicker.launch(null)
}
requestMicrophonePermissionIfNeeded(intent)
}

override fun onStart() {
Expand All @@ -251,7 +264,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleShareIntent(intent)
requestMicrophonePermissionIfNeeded(intent)
}

override fun onSaveInstanceState(outState: Bundle) {
Expand Down Expand Up @@ -307,6 +322,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
super.onDestroy()
}

private fun requestMicrophonePermissionIfNeeded(intent: Intent) {
if (!intent.getBooleanExtra(VoiceKeyboardSetup.EXTRA_REQUEST_MICROPHONE_PERMISSION, false)) return
intent.removeExtra(VoiceKeyboardSetup.EXTRA_REQUEST_MICROPHONE_PERMISSION)
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
microphonePermissionRequest.launch(Manifest.permission.RECORD_AUDIO)
}
}

private fun bindViews() {
importAudio = findViewById(R.id.importAudio)
newTab = findViewById(R.id.newTab)
Expand Down Expand Up @@ -1009,7 +1032,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
SpeechModelImportPermission.releaseOwnedIfUnused(this)
if (shouldHandleModelInstallSuccess(info.id.toString())) {
modelReadiness.invalidate()
SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
SpeechModelWarmup.invalidate()
}
refreshModel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class SpeechModelDownloadWorker(
val installedDirectory = repository.activate().getOrElse {
return failure(it.message ?: "Failed to activate speech model")
}
SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
SpeechModelWarmup.invalidate()
return Result.success(workDataOf(KEY_MODEL_PATH to installedDirectory.absolutePath))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class SpeechModelImportWorker(
).import(treeUri.toString()) { progress -> publishProgress(progress, repository) }.getOrElse {
return failure(it.message ?: "Could not import speech model")
}
SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
SpeechModelWarmup.invalidate()
Result.success(
workDataOf(SpeechModelInstallationWork.KEY_MODEL_PATH to installed.absolutePath),
)
Expand Down
103 changes: 103 additions & 0 deletions app/src/main/java/me/maxistar/voiceinbox/SpeechModelWarmup.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package me.maxistar.voiceinbox

import java.io.File
import java.util.concurrent.Callable
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.FutureTask

internal sealed interface SpeechModelWarmupState {
data object Idle : SpeechModelWarmupState
data class Preparing(val installation: String) : SpeechModelWarmupState
data class Ready(val installation: String) : SpeechModelWarmupState
data class Failed(val installation: String, val message: String) : SpeechModelWarmupState
}

internal class SpeechModelWarmupCoordinator(
private val executor: Executor = Executors.newSingleThreadExecutor(),
private val prepareModel: (SpeechModelRepository) -> Result<File>,
) {
private data class Attempt(
val installation: String,
val future: Future<Result<File>>,
)

private val lock = Any()
private var attempt: Attempt? = null
private var currentState: SpeechModelWarmupState = SpeechModelWarmupState.Idle

fun state(): SpeechModelWarmupState = synchronized(lock) { currentState }

fun warmUp(repository: SpeechModelRepository) {
prepare(repository, retryFailed = false)
}

fun prepare(
repository: SpeechModelRepository,
retryFailed: Boolean,
): Future<Result<File>> {
val installation = installationIdentity(repository)
synchronized(lock) {
attempt?.takeIf { it.installation == installation }?.let { active ->
if (currentState !is SpeechModelWarmupState.Failed || !retryFailed) {
return active.future
}
}

lateinit var task: FutureTask<Result<File>>
task = FutureTask(Callable {
val result = prepareModel(repository)
synchronized(lock) {
if (attempt?.future === task) {
currentState = result.fold(
onSuccess = { SpeechModelWarmupState.Ready(installation) },
onFailure = { error ->
SpeechModelWarmupState.Failed(
installation,
error.message ?: "Speech model preparation failed",
)
},
)
}
}
result
})
attempt = Attempt(installation, task)
currentState = SpeechModelWarmupState.Preparing(installation)
executor.execute(task)
return task
}
}

fun invalidate() {
synchronized(lock) {
attempt = null
currentState = SpeechModelWarmupState.Idle
}
}

private fun installationIdentity(repository: SpeechModelRepository): String =
"${repository.descriptor.backend}:${repository.descriptor.catalogId}:" +
"${repository.manifest.version}:${repository.installedDirectory.canonicalPath}"
}

internal object SpeechModelWarmup {
private val coordinator = SpeechModelWarmupCoordinator { repository ->
SpeechModelPreparation.prepare(repository, NativeTranscriptionBridge::initialize)
}

fun warmUp(repository: SpeechModelRepository) {
coordinator.warmUp(repository)
}

fun prepare(
repository: SpeechModelRepository,
retryFailed: Boolean,
): Future<Result<File>> = coordinator.prepare(repository, retryFailed)

fun invalidate() {
SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
coordinator.invalidate()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class TranscriptionWorker(
applicationContext.noBackupFilesDir.resolve("models"),
)
publish("Preparing speech model", null, null, 0, 0, null, null)
SpeechModelPreparation.prepare(modelRepository, NativeTranscriptionBridge::initialize)
SpeechModelWarmup.prepare(modelRepository, retryFailed = true).get()
.getOrElse { return@withContext failure(it.message ?: "Speech model preparation failed") }

val batch = BatchTranscriptionUseCase(
Expand Down
Loading
Loading