diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6355382b5..1ef2b5ce0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,7 +9,9 @@
+
+
-
-
-
-
-
-
-
-
@@ -119,5 +111,33 @@
android:foregroundServiceType="microphone"
android:icon="@mipmap/ic_launcher"
android:label="@string/wake_service_label" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantLauncherActivity.kt b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantLauncherActivity.kt
new file mode 100644
index 000000000..28811cc42
--- /dev/null
+++ b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantLauncherActivity.kt
@@ -0,0 +1,17 @@
+package org.stypox.dicio.io.assistant
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+
+/**
+ * A transparent activity that immediately launches the assistant overlay service
+ * and finishes itself. This is used to handle ASSIST and VOICE_COMMAND intents.
+ */
+class AssistantLauncherActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ AssistantOverlayService.start(this)
+ finish()
+ }
+}
diff --git a/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlay.kt b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlay.kt
new file mode 100644
index 000000000..a0d7c855d
--- /dev/null
+++ b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlay.kt
@@ -0,0 +1,256 @@
+package org.stypox.dicio.io.assistant
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.dicio.skill.context.SkillContext
+import org.stypox.dicio.io.input.SttState
+import org.stypox.dicio.ui.home.InteractionLog
+import org.stypox.dicio.ui.home.SttFab
+
+@Composable
+fun AssistantOverlay(
+ skillContext: SkillContext,
+ interactionLog: InteractionLog,
+ sttState: SttState?,
+ onSttClick: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ // Use a simple MaterialTheme without the Activity-dependent SideEffect
+ val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
+
+ MaterialTheme(colorScheme = colorScheme) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(24.dp),
+ shadowElevation = 8.dp,
+ tonalElevation = 2.dp,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(16.dp)
+ ) {
+ // Header with close button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = androidx.compose.ui.platform.LocalContext.current.getString(org.stypox.dicio.R.string.app_name),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Dismiss",
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Compact interaction list
+ CompactInteractionList(
+ skillContext = skillContext,
+ interactionLog = interactionLog,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Microphone button
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (sttState != null) {
+ SttFab(
+ state = sttState,
+ onClick = onSttClick,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompactInteractionList(
+ skillContext: SkillContext,
+ interactionLog: InteractionLog,
+ modifier: Modifier = Modifier,
+) {
+ val listState = rememberLazyListState()
+ val interactions = interactionLog.interactions
+ val pendingQuestion = interactionLog.pendingQuestion
+
+ // Continuously scroll to bottom while there's a pending question
+ LaunchedEffect(pendingQuestion, interactions) {
+ if (pendingQuestion != null) {
+ // Keep scrolling while the question is pending
+ while (isActive && pendingQuestion != null) {
+ val itemCount = listState.layoutInfo.totalItemsCount
+ if (itemCount > 0) {
+ listState.scrollToItem(itemCount - 1)
+ }
+ delay(150)
+ }
+ } else {
+ // Scroll once when new answer is added
+ val itemCount = listState.layoutInfo.totalItemsCount
+ if (itemCount > 0) {
+ delay(100) // Small delay to let content render
+ listState.animateScrollToItem(itemCount - 1)
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+ .padding(8.dp),
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ interactions.forEach { interaction ->
+ items(interaction.questionsAnswers) { qa ->
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (qa.question != null) {
+ CompactQuestionBubble(text = qa.question)
+ }
+
+ CompactAnswerBubble {
+ qa.answer.GraphicalOutput(ctx = skillContext)
+ }
+ }
+ }
+ }
+
+ if (pendingQuestion != null) {
+ item {
+ CompactQuestionBubble(
+ text = pendingQuestion.userInput,
+ isPending = true
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+}
+
+@Composable
+private fun CompactQuestionBubble(
+ text: String,
+ isPending: Boolean = false,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ ),
+ modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = if (isPending) FontWeight.Normal else FontWeight.Medium,
+ fontStyle = if (isPending) FontStyle.Italic else FontStyle.Normal
+ ),
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompactAnswerBubble(
+ content: @Composable () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
+ ) {
+ Box(
+ modifier = Modifier.padding(8.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ content()
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlayService.kt b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlayService.kt
new file mode 100644
index 000000000..5571a7041
--- /dev/null
+++ b/app/src/main/kotlin/org/stypox/dicio/io/assistant/AssistantOverlayService.kt
@@ -0,0 +1,407 @@
+package org.stypox.dicio.io.assistant
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.PixelFormat
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.PowerManager
+import android.provider.Settings
+import android.util.Log
+import android.view.Gravity
+import android.view.WindowManager
+import android.widget.Toast
+import androidx.activity.result.ActivityResultRegistry
+import androidx.activity.result.ActivityResultRegistryOwner
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.app.ActivityOptionsCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat.getSystemService
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import dagger.hilt.android.AndroidEntryPoint
+import org.stypox.dicio.R
+import org.stypox.dicio.di.SkillContextInternal
+import org.stypox.dicio.di.SttInputDeviceWrapper
+import org.stypox.dicio.eval.SkillEvaluator
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class AssistantOverlayService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner, ActivityResultRegistryOwner {
+
+ @Inject
+ lateinit var skillEvaluator: SkillEvaluator
+ @Inject
+ lateinit var sttInputDevice: SttInputDeviceWrapper
+ @Inject
+ lateinit var skillContext: SkillContextInternal
+
+ private lateinit var windowManager: WindowManager
+ private var overlayView: ComposeView? = null
+ private lateinit var powerManager: PowerManager
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val store = ViewModelStore()
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+
+ // Screen state monitoring
+ private val handler = Handler(Looper.getMainLooper())
+ private var screenOffTimeoutRunnable: Runnable? = null
+ private var screenReceiver: BroadcastReceiver? = null
+
+ companion object {
+ private val TAG = AssistantOverlayService::class.simpleName
+ private const val NOTIFICATION_CHANNEL_ID = "org.stypox.dicio.io.assistant.OVERLAY"
+ private const val NOTIFICATION_ID = 87654321
+ private const val ACTION_SHOW_OVERLAY = "org.stypox.dicio.io.assistant.SHOW_OVERLAY"
+ private const val ACTION_HIDE_OVERLAY = "org.stypox.dicio.io.assistant.HIDE_OVERLAY"
+ private const val SCREEN_OFF_TIMEOUT_MS = 60_000L // 1 minute
+
+ fun start(context: Context) {
+ val intent = Intent(context, AssistantOverlayService::class.java)
+ intent.action = ACTION_SHOW_OVERLAY
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, AssistantOverlayService::class.java)
+ intent.action = ACTION_HIDE_OVERLAY
+ context.startService(intent)
+ }
+ }
+
+ private val customActivityResultRegistry = object : ActivityResultRegistry() {
+ override fun onLaunch(
+ requestCode: Int,
+ contract: ActivityResultContract,
+ input: I,
+ options: ActivityOptionsCompat?
+ ) {
+ val intent = contract.createIntent(this@AssistantOverlayService, input)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ }
+ }
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ override val viewModelStore: ViewModelStore
+ get() = store
+
+ override val savedStateRegistry: SavedStateRegistry
+ get() = savedStateRegistryController.savedStateRegistry
+
+ override val activityResultRegistry: ActivityResultRegistry
+ get() = customActivityResultRegistry
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ savedStateRegistryController.performRestore(null)
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
+
+ windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+
+ createForegroundNotification()
+ registerScreenStateReceiver()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_SHOW_OVERLAY -> {
+ if (checkOverlayPermission()) {
+ showOverlay()
+ } else {
+ Toast.makeText(this, R.string.overlay_permission_required, Toast.LENGTH_LONG).show()
+ requestOverlayPermission()
+ stopSelf()
+ }
+ }
+ ACTION_HIDE_OVERLAY -> {
+ hideOverlay()
+ stopSelf()
+ }
+ else -> {
+ if (checkOverlayPermission()) {
+ showOverlay()
+ } else {
+ Toast.makeText(this, R.string.overlay_permission_required, Toast.LENGTH_LONG).show()
+ requestOverlayPermission()
+ stopSelf()
+ }
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ hideOverlay()
+ unregisterScreenStateReceiver()
+ cancelScreenOffTimeout()
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ super.onDestroy()
+ }
+
+ private fun checkOverlayPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Settings.canDrawOverlays(this)
+ } else {
+ true
+ }
+ }
+
+ private fun requestOverlayPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:$packageName")
+ )
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ startActivity(intent)
+ }
+ }
+
+ private fun showOverlay() {
+ if (overlayView != null) {
+ return
+ }
+
+ lifecycleRegistry.currentState = Lifecycle.State.STARTED
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+
+ val params = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ } else {
+ @Suppress("DEPRECATION")
+ WindowManager.LayoutParams.TYPE_PHONE
+ },
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
+ PixelFormat.TRANSLUCENT
+ )
+
+ params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+ params.y = 100
+
+ overlayView = ComposeView(this).apply {
+ setViewTreeLifecycleOwner(this@AssistantOverlayService)
+ setViewTreeViewModelStoreOwner(this@AssistantOverlayService)
+ setViewTreeSavedStateRegistryOwner(this@AssistantOverlayService)
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+
+ setContent {
+ CompositionLocalProvider(
+ androidx.activity.compose.LocalActivityResultRegistryOwner provides this@AssistantOverlayService
+ ) {
+ AssistantOverlayContent(
+ onDismiss = {
+ hideOverlay()
+ stopSelf()
+ }
+ )
+ }
+ }
+ }
+
+ try {
+ windowManager.addView(overlayView, params)
+
+ // Start listening immediately
+ sttInputDevice.tryLoad(skillEvaluator::processInputEvent)
+ checkScreenStateAndScheduleTimeout()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to add overlay view", e)
+ Toast.makeText(this, "Failed to show assistant overlay", Toast.LENGTH_SHORT).show()
+ stopSelf()
+ }
+ }
+
+ private fun hideOverlay() {
+ overlayView?.let {
+ try {
+ windowManager.removeView(it)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to remove overlay view", e)
+ }
+ overlayView = null
+ }
+
+ // Stop listening when overlay is hidden
+ sttInputDevice.stopListening()
+ cancelScreenOffTimeout()
+ }
+
+ private fun registerScreenStateReceiver() {
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
+ }
+
+ screenReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ Log.d(TAG, "Screen turned off, scheduling timeout")
+ scheduleScreenOffTimeout()
+ }
+ Intent.ACTION_SCREEN_ON -> {
+ Log.d(TAG, "Screen turned on, canceling timeout")
+ cancelScreenOffTimeout()
+ }
+ }
+ }
+ }
+
+ registerReceiver(screenReceiver, filter)
+ }
+
+ private fun unregisterScreenStateReceiver() {
+ screenReceiver?.let {
+ try {
+ unregisterReceiver(it)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to unregister screen receiver", e)
+ }
+ screenReceiver = null
+ }
+ }
+
+ private fun checkScreenStateAndScheduleTimeout() {
+ if (!isScreenOn()) {
+ Log.d(TAG, "Overlay shown with screen off, scheduling timeout")
+ scheduleScreenOffTimeout()
+ }
+ }
+
+ private fun isScreenOn(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
+ powerManager.isInteractive
+ } else {
+ @Suppress("DEPRECATION")
+ powerManager.isScreenOn
+ }
+ }
+
+ private fun scheduleScreenOffTimeout() {
+ cancelScreenOffTimeout()
+
+ // Only schedule if overlay is visible
+ if (overlayView == null) {
+ return
+ }
+
+ screenOffTimeoutRunnable = Runnable {
+ Log.d(TAG, "Screen off timeout reached, dismissing overlay")
+ hideOverlay()
+ stopSelf()
+ }
+
+ handler.postDelayed(screenOffTimeoutRunnable!!, SCREEN_OFF_TIMEOUT_MS)
+ Log.d(TAG, "Scheduled screen off timeout for ${SCREEN_OFF_TIMEOUT_MS}ms")
+ }
+
+ private fun cancelScreenOffTimeout() {
+ screenOffTimeoutRunnable?.let {
+ handler.removeCallbacks(it)
+ screenOffTimeoutRunnable = null
+ Log.d(TAG, "Canceled screen off timeout")
+ }
+ }
+
+ @Composable
+ private fun AssistantOverlayContent(onDismiss: () -> Unit) {
+ val interactionLog = skillEvaluator.state.collectAsState()
+ val sttState = sttInputDevice.uiState.collectAsState()
+
+ AssistantOverlay(
+ skillContext = skillContext,
+ interactionLog = interactionLog.value,
+ sttState = sttState.value,
+ onSttClick = {
+ sttInputDevice.onClick(skillEvaluator::processInputEvent)
+ },
+ onDismiss = onDismiss
+ )
+ }
+
+ private fun createForegroundNotification() {
+ val notificationManager = getSystemService(this, NotificationManager::class.java)!!
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ getString(R.string.assistant_overlay_notification_channel),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ channel.description = getString(R.string.assistant_overlay_notification_description)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val dismissIntent = Intent(this, AssistantOverlayService::class.java).apply {
+ action = ACTION_HIDE_OVERLAY
+ }
+ val dismissPendingIntent = PendingIntent.getService(
+ this,
+ 0,
+ dismissIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_hearing_white)
+ .setContentTitle(getString(R.string.assistant_overlay_active))
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .addAction(
+ NotificationCompat.Action(
+ R.drawable.ic_stop_circle_white,
+ getString(R.string.dismiss),
+ dismissPendingIntent
+ )
+ )
+ .build()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/stypox/dicio/io/wake/WakeService.kt b/app/src/main/kotlin/org/stypox/dicio/io/wake/WakeService.kt
index 3942951a1..6ad00e091 100644
--- a/app/src/main/kotlin/org/stypox/dicio/io/wake/WakeService.kt
+++ b/app/src/main/kotlin/org/stypox/dicio/io/wake/WakeService.kt
@@ -35,6 +35,7 @@ import org.stypox.dicio.R
import org.stypox.dicio.di.SttInputDeviceWrapper
import org.stypox.dicio.di.WakeDeviceWrapper
import org.stypox.dicio.eval.SkillEvaluator
+import org.stypox.dicio.io.assistant.AssistantOverlayService
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
@@ -224,56 +225,16 @@ class WakeService : Service() {
private fun onWakeWordDetected() {
Log.d(TAG, "Wake word detected")
- val intent = Intent(this, MainActivity::class.java)
- intent.setAction(ACTION_WAKE_WORD)
- intent.setFlags(FLAG_ACTIVITY_NEW_TASK)
-
// Start listening and pass STT events to the skill evaluator.
- // Note that this works even if the MainActivity is opened later!
+ // Note that this works even if the overlay/MainActivity is opened later!
sttInputDevice.tryLoad(skillEvaluator::processInputEvent)
// Unload the STT after a while because it would be using RAM uselessly
handler.removeCallbacks(releaseSttResourcesRunnable)
handler.postDelayed(releaseSttResourcesRunnable, RELEASE_STT_RESOURCES_MILLIS)
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || MainActivity.isInForeground > 0) {
- // start the activity directly on versions prior to Android 10,
- // or if the MainActivity is already running in the foreground
- startActivity(intent)
-
- } else {
- // Android 10+ does not allow starting activities from the background,
- // so show a full-screen notification instead, which does actually result in starting
- // the activity from the background if the phone is off and Do Not Disturb is not active
- // Maybe we could also use the "Display over other apps" permission?
-
- val channel = NotificationChannel(
- TRIGGERED_NOTIFICATION_CHANNEL_ID,
- getString(R.string.wake_service_triggered_notification),
- NotificationManager.IMPORTANCE_HIGH
- )
- channel.description = getString(R.string.wake_service_triggered_notification_summary)
- notificationManager.createNotificationChannel(channel)
-
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- val notification = NotificationCompat.Builder(this, TRIGGERED_NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_hearing_white)
- .setContentTitle(getString(R.string.wake_service_triggered_notification))
- .setStyle(NotificationCompat.BigTextStyle().bigText(
- getString(R.string.wake_service_triggered_notification_summary)))
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setFullScreenIntent(pendingIntent, true)
- .build()
-
- notificationManager.cancel(TRIGGERED_NOTIFICATION_ID)
- notificationManager.notify(TRIGGERED_NOTIFICATION_ID, notification)
- }
+ // Launch the assistant overlay service
+ AssistantOverlayService.start(this)
}
companion object {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8230af59d..fbe0470ba 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -128,6 +128,8 @@
Say something…
I could not understand, try again
Speech to text popup
+ Assistant overlay
+ Digital assistant overlay
Search
Search how to feed a cat
Weather
@@ -203,6 +205,12 @@
Dicio wake word triggered, tap to open
In Android 11+ Dicio can\'t start the service without a manual interaction from the user.
In Android 10+ Dicio can\'t start when a wake word is detected without showing a notification.
+ Dicio assistant overlay
+ Assistant Overlay
+ Notification for the assistant overlay service
+ Assistant overlay is active
+ Overlay permission is required to show the assistant
+ Dismiss
Setup wake word
Dicio will wake up automatically whenever you say \"Hey Dicio\".
This works by starting a service that always listens in the background.
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
index 9a9384c96..e76131f94 100644
--- a/app/src/main/res/xml/shortcuts.xml
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -10,4 +10,15 @@
android:targetPackage="org.stypox.dicio"
android:targetClass="org.stypox.dicio.io.input.stt_popup.SttPopupActivity" />
+
+
+