mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
Merge cd5c60e2a53d5d187ea5fecd845f7099bb11c1b1 into bcb694384de8462302448cab6a3dfb1853ba5d5e
This commit is contained in:
commit
c474ed63ad
@ -113,6 +113,7 @@ dependencies {
|
||||
implementation(libs.imagecropper)
|
||||
implementation(libs.flexbox)
|
||||
implementation(libs.dependency)
|
||||
implementation(libs.soundwavelib)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.splitties.bitflags)
|
||||
implementation(libs.splitties.dimensions)
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- remove intent queries merged from library manifest (namely Android-Image-Cropper) -->
|
||||
<queries tools:node="removeAll" />
|
||||
|
||||
@ -24,6 +26,9 @@
|
||||
<intent>
|
||||
<action android:name="android.view.InputMethod" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<permission
|
||||
|
||||
@ -921,7 +921,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
|
||||
// those events need to be filtered.
|
||||
// because of https://android.googlesource.com/platform/frameworks/base.git/+/refs/tags/android-11.0.0_r45/core/java/android/view/inputmethod/BaseInputConnection.java#851
|
||||
// it's not possible to set cursor inside composing text
|
||||
private fun updateComposingText(text: FormattedText) {
|
||||
fun updateComposingText(text: FormattedText) {
|
||||
val ic = currentInputConnection ?: return
|
||||
val lastSelection = selection.latest
|
||||
ic.beginBatchEdit()
|
||||
|
||||
@ -40,6 +40,7 @@ import org.fcitx.fcitx5.android.input.picker.emoticonPicker
|
||||
import org.fcitx.fcitx5.android.input.picker.symbolPicker
|
||||
import org.fcitx.fcitx5.android.input.popup.PopupComponent
|
||||
import org.fcitx.fcitx5.android.input.preedit.PreeditComponent
|
||||
import org.fcitx.fcitx5.android.input.voice.VoiceInputComponent
|
||||
import org.fcitx.fcitx5.android.input.wm.InputWindowManager
|
||||
import org.fcitx.fcitx5.android.utils.unset
|
||||
import org.fcitx.fcitx5.android.utils.windowManager
|
||||
@ -110,6 +111,7 @@ class InputView(
|
||||
private val symbolPicker = symbolPicker()
|
||||
private val emojiPicker = emojiPicker()
|
||||
private val emoticonPicker = emoticonPicker()
|
||||
private val voiceInput = VoiceInputComponent()
|
||||
|
||||
private fun setupScope() {
|
||||
scope += this@InputView.wrapToUniqueComponent()
|
||||
@ -127,6 +129,7 @@ class InputView(
|
||||
scope += windowManager
|
||||
scope += kawaiiBar
|
||||
scope += horizontalCandidate
|
||||
scope += voiceInput
|
||||
broadcaster.onScopeSetupFinished(scope)
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InlineSuggestion
|
||||
import android.view.inputmethod.InlineSuggestionsResponse
|
||||
import android.view.inputmethod.InputMethodSubtype
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ViewAnimator
|
||||
import android.widget.inline.InlineContentView
|
||||
@ -64,10 +63,10 @@ import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
|
||||
import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow
|
||||
import org.fcitx.fcitx5.android.input.popup.PopupComponent
|
||||
import org.fcitx.fcitx5.android.input.status.StatusAreaWindow
|
||||
import org.fcitx.fcitx5.android.input.voice.VoiceInputComponent
|
||||
import org.fcitx.fcitx5.android.input.wm.InputWindow
|
||||
import org.fcitx.fcitx5.android.input.wm.InputWindowManager
|
||||
import org.fcitx.fcitx5.android.utils.AppUtil
|
||||
import org.fcitx.fcitx5.android.utils.InputMethodUtil
|
||||
import org.mechdancer.dependency.DynamicScope
|
||||
import org.mechdancer.dependency.manager.must
|
||||
import splitties.bitflags.hasFlag
|
||||
@ -94,6 +93,7 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
private val horizontalCandidate: HorizontalCandidateComponent by manager.must()
|
||||
private val commonKeyActionListener: CommonKeyActionListener by manager.must()
|
||||
private val popup: PopupComponent by manager.must()
|
||||
private val voiceInput: VoiceInputComponent by manager.must()
|
||||
|
||||
private val prefs = AppPrefs.getInstance()
|
||||
|
||||
@ -103,8 +103,6 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
private val expandedCandidateStyle by prefs.keyboard.expandedCandidateStyle
|
||||
private val expandToolbarByDefault by prefs.keyboard.expandToolbarByDefault
|
||||
private val toolbarNumRowOnPassword by prefs.keyboard.toolbarNumRowOnPassword
|
||||
private val showVoiceInputButton by prefs.keyboard.showVoiceInputButton
|
||||
private val preferredVoiceInput by prefs.keyboard.preferredVoiceInput
|
||||
|
||||
private var clipboardTimeoutJob: Job? = null
|
||||
|
||||
@ -254,13 +252,6 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
} else false
|
||||
}
|
||||
|
||||
private var voiceInputSubtype: Pair<String, InputMethodSubtype>? = null
|
||||
|
||||
private val switchToVoiceInputCallback = View.OnClickListener {
|
||||
val (id, subtype) = voiceInputSubtype ?: return@OnClickListener
|
||||
InputMethodUtil.switchInputMethod(service, id, subtype)
|
||||
}
|
||||
|
||||
private val idleUi: IdleUi by lazy {
|
||||
IdleUi(context, theme, popup, commonKeyActionListener).apply {
|
||||
menuButton.setOnClickListener {
|
||||
@ -429,6 +420,7 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
ClipboardManager.addOnUpdateListener(onClipboardUpdateListener)
|
||||
clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener)
|
||||
clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener)
|
||||
voiceInput.addAudioVolumeListener(idleUi.audioVolumeListener)
|
||||
}
|
||||
|
||||
override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) {
|
||||
@ -441,12 +433,10 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
idleUi.inlineSuggestionsBar.clear()
|
||||
}
|
||||
voiceInputSubtype = InputMethodUtil.findVoiceSubtype(preferredVoiceInput)
|
||||
val shouldShowVoiceInput =
|
||||
showVoiceInputButton && voiceInputSubtype != null && !capFlags.has(CapabilityFlag.Password)
|
||||
val shouldShowVoiceInput = voiceInput.shouldShowVoiceInput(capFlags)
|
||||
idleUi.setHideKeyboardIsVoiceInput(
|
||||
shouldShowVoiceInput,
|
||||
if (shouldShowVoiceInput) switchToVoiceInputCallback else hideKeyboardCallback
|
||||
if (shouldShowVoiceInput) voiceInput.voiceInputCallback else hideKeyboardCallback
|
||||
)
|
||||
evalIdleUiState()
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import android.content.Context
|
||||
import android.transition.Slide
|
||||
import android.transition.TransitionManager
|
||||
import android.transition.TransitionSet
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.Gravity
|
||||
import android.view.animation.AlphaAnimation
|
||||
@ -15,6 +16,7 @@ import android.view.animation.AnimationSet
|
||||
import android.view.animation.TranslateAnimation
|
||||
import android.widget.Space
|
||||
import android.widget.ViewAnimator
|
||||
import com.soundwave.lib.SoundWaveView
|
||||
import org.fcitx.fcitx5.android.R
|
||||
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
|
||||
import org.fcitx.fcitx5.android.data.theme.Theme
|
||||
@ -25,6 +27,7 @@ import org.fcitx.fcitx5.android.input.bar.ui.idle.InlineSuggestionsUi
|
||||
import org.fcitx.fcitx5.android.input.bar.ui.idle.NumberRow
|
||||
import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener
|
||||
import org.fcitx.fcitx5.android.input.popup.PopupComponent
|
||||
import org.fcitx.fcitx5.android.input.voice.VoiceInputComponent
|
||||
import splitties.dimensions.dp
|
||||
import splitties.views.dsl.constraintlayout.after
|
||||
import splitties.views.dsl.constraintlayout.before
|
||||
@ -39,8 +42,10 @@ import splitties.views.dsl.core.add
|
||||
import splitties.views.dsl.core.lParams
|
||||
import splitties.views.dsl.core.frameLayout
|
||||
import splitties.views.dsl.core.matchParent
|
||||
import splitties.views.dsl.core.view
|
||||
import splitties.views.imageResource
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class IdleUi(
|
||||
override val ctx: Context,
|
||||
@ -77,7 +82,30 @@ class IdleUi(
|
||||
|
||||
val hideKeyboardButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_drop_down_24, theme)
|
||||
|
||||
val emptyBar = Space(ctx)
|
||||
// val emptyBar = Space(ctx)
|
||||
|
||||
// TODO: dedicated UI for active voice input
|
||||
val emptyBar = SoundWaveView(ctx).apply {
|
||||
enableIdle(true)
|
||||
setColor(theme.altKeyTextColor)
|
||||
// volumeCount = 20
|
||||
// volumeIdleCount = 8
|
||||
maxVolume = 12
|
||||
minVolume = 2
|
||||
minVolumeBarHeight = dp(3)
|
||||
maxVolumeBarHeight = dp(KawaiiBarComponent.HEIGHT - 6)
|
||||
// volumeBarMargin = dp(4)
|
||||
// volumeBarHalfWidth = dp(2)
|
||||
maxIdleHeight = maxVolumeBarHeight / 2
|
||||
}
|
||||
|
||||
val audioVolumeListener = VoiceInputComponent.AudioVolumeListener { listening, dB ->
|
||||
if (listening) {
|
||||
emptyBar.handleVolume(dB.roundToInt())
|
||||
} else {
|
||||
emptyBar.stopDance()
|
||||
}
|
||||
}
|
||||
|
||||
val buttonsUi = ButtonsBarUi(ctx, theme)
|
||||
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
|
||||
package org.fcitx.fcitx5.android.input.voice
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodSubtype
|
||||
import org.fcitx.fcitx5.android.core.CapabilityFlag
|
||||
import org.fcitx.fcitx5.android.core.CapabilityFlags
|
||||
import org.fcitx.fcitx5.android.core.FormattedText
|
||||
import org.fcitx.fcitx5.android.core.InputMethodEntry
|
||||
import org.fcitx.fcitx5.android.core.TextFormatFlag
|
||||
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
|
||||
import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver
|
||||
import org.fcitx.fcitx5.android.input.dependency.context
|
||||
import org.fcitx.fcitx5.android.input.dependency.fcitx
|
||||
import org.fcitx.fcitx5.android.input.dependency.inputMethodService
|
||||
import org.fcitx.fcitx5.android.utils.InputMethodUtil
|
||||
import org.fcitx.fcitx5.android.utils.WeakHashSet
|
||||
import org.fcitx.fcitx5.android.utils.toast
|
||||
import org.mechdancer.dependency.Dependent
|
||||
import org.mechdancer.dependency.UniqueComponent
|
||||
import org.mechdancer.dependency.manager.ManagedHandler
|
||||
import org.mechdancer.dependency.manager.managedHandler
|
||||
import timber.log.Timber
|
||||
|
||||
class VoiceInputComponent : UniqueComponent<VoiceInputComponent>(), Dependent,
|
||||
ManagedHandler by managedHandler(), InputBroadcastReceiver {
|
||||
|
||||
val context by manager.context()
|
||||
val service by manager.inputMethodService()
|
||||
val fcitx by manager.fcitx()
|
||||
|
||||
private val prefs = AppPrefs.getInstance()
|
||||
|
||||
private val showVoiceInputButton by prefs.keyboard.showVoiceInputButton
|
||||
private val preferredVoiceInput by prefs.keyboard.preferredVoiceInput
|
||||
|
||||
private var voiceInputSubtype: Pair<String, InputMethodSubtype>? = null
|
||||
|
||||
fun shouldShowVoiceInput(capFlags: CapabilityFlags): Boolean {
|
||||
voiceInputSubtype = InputMethodUtil.findVoiceSubtype(preferredVoiceInput)
|
||||
return showVoiceInputButton && voiceInputSubtype != null && !capFlags.has(CapabilityFlag.Password)
|
||||
}
|
||||
|
||||
// TODO: switch between "other voice input method" and "SpeechRecognizer"
|
||||
val voiceInputCallback = View.OnClickListener {
|
||||
val preferredIdx = InputMethodUtil.listVoiceInputMethods().indexOfFirst { (imi, subType) ->
|
||||
imi.id == preferredVoiceInput
|
||||
}
|
||||
if (preferredIdx < 0) {
|
||||
startListening()
|
||||
return@OnClickListener
|
||||
}
|
||||
val (id, subtype) = voiceInputSubtype ?: return@OnClickListener
|
||||
InputMethodUtil.switchInputMethod(service, id, subtype)
|
||||
}
|
||||
|
||||
private var languageCode = ""
|
||||
|
||||
override fun onImeUpdate(ime: InputMethodEntry) {
|
||||
languageCode = ime.languageCode.replace("_", "-")
|
||||
}
|
||||
|
||||
fun Bundle.results() = getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
|
||||
fun Bundle.dumpResults() = buildString {
|
||||
val results = getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
val scores = getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES)
|
||||
results?.forEachIndexed { index, string ->
|
||||
append(string)
|
||||
append("[score=${scores?.get(index)}]")
|
||||
if (index > 0) append(", ")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: destroy recognizer onFinishInput
|
||||
private val speechRecognizer: SpeechRecognizer by lazy {
|
||||
SpeechRecognizer.createSpeechRecognizer(context).apply {
|
||||
setRecognitionListener(recognitionListener)
|
||||
}
|
||||
}
|
||||
|
||||
private var startedListening = false
|
||||
|
||||
private fun buildUnderlineText(str: String): FormattedText {
|
||||
return FormattedText(arrayOf(str), intArrayOf(TextFormatFlag.Underline.flag), -1)
|
||||
}
|
||||
|
||||
fun interface AudioVolumeListener {
|
||||
fun onAudioVolumeChange(listening: Boolean, dB: Float)
|
||||
}
|
||||
|
||||
private val audioVolumeListeners = WeakHashSet<AudioVolumeListener>()
|
||||
|
||||
fun addAudioVolumeListener(listener: AudioVolumeListener) {
|
||||
audioVolumeListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeAudioVolumeListener(listener: AudioVolumeListener) {
|
||||
audioVolumeListeners.remove(listener)
|
||||
}
|
||||
|
||||
// TODO: interrupt voice input on keyboard input
|
||||
private val recognitionListener by lazy {
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle) {
|
||||
startedListening = true
|
||||
audioVolumeListeners.forEach { it.onAudioVolumeChange(true, 0f) }
|
||||
Timber.d("onReadyForSpeech, $params")
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {
|
||||
Timber.d("onBeginningOfSpeech")
|
||||
}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
Timber.d("onRmsChanged: rmsdB=$rmsdB")
|
||||
audioVolumeListeners.forEach { it.onAudioVolumeChange(true, rmsdB) }
|
||||
}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray) {
|
||||
/* This would never be called */
|
||||
}
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
Timber.d("onEndOfSpeech")
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
startedListening = false
|
||||
audioVolumeListeners.forEach { it.onAudioVolumeChange(false, 0f) }
|
||||
Timber.d("onError: $error")
|
||||
context.toast("onError: $error")
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> {
|
||||
// TODO: launch activity to request permission
|
||||
}
|
||||
SpeechRecognizer.ERROR_CLIENT -> {
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle) {
|
||||
Timber.d("onPartialResults: ${partialResults.dumpResults()}")
|
||||
val strings = partialResults.results() ?: return
|
||||
// TODO: don't call IMS directly
|
||||
service.updateComposingText(buildUnderlineText(strings[0]))
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle) {
|
||||
startedListening = false
|
||||
audioVolumeListeners.forEach { it.onAudioVolumeChange(false, 0f) }
|
||||
Timber.d("onResults: ${results.dumpResults()}")
|
||||
val strings = results.results() ?: return
|
||||
service.commitText(strings[0])
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle) {
|
||||
/* unused */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startListening() {
|
||||
if (startedListening) {
|
||||
startedListening = false
|
||||
speechRecognizer.stopListening()
|
||||
return
|
||||
}
|
||||
|
||||
speechRecognizer.startListening(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
// required
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
// optional
|
||||
if (languageCode.isNotBlank()) {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageCode)
|
||||
}
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,7 @@ arrow-functions = { module = "io.arrow-kt:arrow-functions", version.ref = "arrow
|
||||
imagecropper = { module = "com.vanniktech:android-image-cropper", version = "4.7.0" }
|
||||
flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" }
|
||||
dependency = { module = "org.mechdancer:dependency", version = "0.1.2" }
|
||||
soundwavelib = { module = "io.github.ultimatehandsomeboy666:soundwavelib", version = "1.0.1" }
|
||||
timber = { module = "com.jakewharton.timber:timber", version = "5.0.1" }
|
||||
splitties-bitflags = { module = "com.louiscad.splitties:splitties-bitflags", version.ref = "splitties" }
|
||||
splitties-dimensions = { module = "com.louiscad.splitties:splitties-dimensions", version.ref = "splitties" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user