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" /> + + +