Stypox / dicio-android

Dicio assistant app for Android
GNU General Public License v3.0
726 stars 69 forks source link

Can Dicio register for the ACTION_VOICE_COMMAND intent? #226

Closed sudomain closed 1 month ago

sudomain commented 1 month ago

Hello,

Can Dicio respond to the intent ACTION_VOICE_COMMAND so it can be a "voice assistant"?

Dicio currently uses ACTION_ASSIST which is for digital or device assistants. I'm not entirely sure about this terminology because it is different in different apps and my device settings.

I bought a headset recently that claims it can start the "voice assistant" and from what I understand from my research so far, this means "ACTION_VOICE_COMMAND".

JakobDFrank commented 1 month ago

Reposting here for visibility

+1 I think this functionality would increase adoption quite a bit. Home Assistant does this. I'm not an Android developer but maybe this could help someone out:

Setting toggle location:

<PreferenceCategory
        android:title="@string/assist"
        android:key="assist">
        <SwitchPreference
            android:key="assist_voice_command_intent"
            android:icon="@drawable/ic_headphones_settings"
            android:title="@string/open_with_headphone_button"
            android:summary="@string/open_with_headphone_button_summary" 
        />
</PreferenceCategory>

Activity? location:

<activity-alias
            android:name=".assist.VoiceCommandIntentActivity"
            android:targetActivity=".assist.AssistActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.VOICE_COMMAND" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
</activity-alias>

AssistActivity:

@AndroidEntryPoint
class AssistActivity : BaseActivity() {

    private val viewModel: AssistViewModel by viewModels()

    private var contextIsLocked = true

    companion object {
        const val TAG = "AssistActivity"

        private const val EXTRA_SERVER = "server"
        private const val EXTRA_PIPELINE = "pipeline"
        private const val EXTRA_START_LISTENING = "start_listening"
        private const val EXTRA_FROM_FRONTEND = "from_frontend"

        fun newInstance(
            context: Context,
            serverId: Int = -1,
            pipelineId: String? = null,
            startListening: Boolean = true,
            fromFrontend: Boolean = true
        ): Intent {
            return Intent(context, AssistActivity::class.java).apply {
                putExtra(EXTRA_SERVER, serverId)
                putExtra(EXTRA_PIPELINE, pipelineId)
                putExtra(EXTRA_START_LISTENING, startListening)
                putExtra(EXTRA_FROM_FRONTEND, fromFrontend)
            }
        }
    }

    private val requestPermission = registerForActivityResult(
        ActivityResultContracts.RequestPermission(),
        { viewModel.onPermissionResult(it) }
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge(
            navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
        )
        super.onCreate(savedInstanceState)
        updateShowWhenLocked()

        if (savedInstanceState == null) {
            viewModel.onCreate(
                hasPermission = hasRecordingPermission(),
                serverId = if (intent.hasExtra(EXTRA_SERVER)) {
                    intent.getIntExtra(EXTRA_SERVER, ServerManager.SERVER_ID_ACTIVE)
                } else {
                    null
                },
                pipelineId = if (intent.hasExtra(EXTRA_PIPELINE)) {
                    intent.getStringExtra(EXTRA_PIPELINE) ?: AssistViewModelBase.PIPELINE_LAST_USED
                } else {
                    AssistViewModelBase.PIPELINE_LAST_USED
                },
                startListening = if (intent.hasExtra(EXTRA_START_LISTENING)) {
                    intent.getBooleanExtra(EXTRA_START_LISTENING, true)
                } else if (intent.action == Intent.ACTION_VOICE_COMMAND) {
                    // Always start listening if triggered via the voice command (e.g., from a BT headset).
                    true
                } else {
                    null
                }
            )
        }

        val fromFrontend = intent.getBooleanExtra(EXTRA_FROM_FRONTEND, false)

        setContent {
            HomeAssistantAppTheme {
                AssistSheetView(
                    conversation = viewModel.conversation,
                    pipelines = viewModel.pipelines,
                    inputMode = viewModel.inputMode,
                    fromFrontend = fromFrontend,
                    currentPipeline = viewModel.currentPipeline,
                    onSelectPipeline = viewModel::changePipeline,
                    onManagePipelines =
                    if (fromFrontend && viewModel.userCanManagePipelines()) {
                        {
                            startActivity(
                                WebViewActivity.newInstance(
                                    this,
                                    "config/voice-assistants/assistants"
                                ).apply {
                                    flags += Intent.FLAG_ACTIVITY_NEW_TASK // Delivers data in onNewIntent
                                }
                            )
                            finish()
                        }
                    } else {
                        null
                    },
                    onChangeInput = viewModel::onChangeInput,
                    onTextInput = viewModel::onTextInput,
                    onMicrophoneInput = viewModel::onMicrophoneInput,
                    onHide = { finish() }
                )
            }
        }
    }

    override fun onResume() {
        super.onResume()
        viewModel.setPermissionInfo(hasRecordingPermission()) { requestPermission.launch(Manifest.permission.RECORD_AUDIO) }
    }

    override fun onPause() {
        super.onPause()
        viewModel.onPause()
    }

    override fun onDestroy() {
        super.onDestroy()
        viewModel.onDestroy()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        this.intent = intent

        val isLocked = getSystemService<KeyguardManager>()?.isKeyguardLocked ?: false
        viewModel.onNewIntent(intent, contextIsLocked == isLocked)
        updateShowWhenLocked(isLocked)
    }

    /** Set flags to show dialog when (un)locked, and prevent unlocked dialogs from resuming while locked **/
    private fun updateShowWhenLocked(isLocked: Boolean? = null) {
        val locked = isLocked ?: getSystemService<KeyguardManager>()?.isKeyguardLocked ?: false
        contextIsLocked = locked
        if (locked) {
            window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER)
            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
                @Suppress("DEPRECATION")
                window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
            } else {
                setShowWhenLocked(true)
                setTurnScreenOn(true)
            }
        } else {
            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
                @Suppress("DEPRECATION")
                window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
            } else {
                setShowWhenLocked(false)
            }
        }
    }

    private fun hasRecordingPermission() =
        ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
}
Stypox commented 1 month ago

Could you test if #228 works? A testing APK is linked there

sudomain commented 1 month ago

That APK works great! Thank you!

Stypox commented 1 month ago

Thank you for testing! I'm leaving this open until that PR is merged