Merge pull request #159 from cogwheel0/android-assistant-feature-implementation
android-assistant-feature-implementation
This commit is contained in:
@@ -32,6 +32,18 @@
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:usesCleartextTraffic="true">
|
||||
<service
|
||||
android:name=".ConduitVoiceInteractionSessionService"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||
android:exported="true">
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_service" />
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package app.cogwheel.conduit
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.os.Bundle
|
||||
import android.app.assist.AssistStructure
|
||||
import android.app.assist.AssistContent
|
||||
import android.graphics.Bitmap
|
||||
|
||||
class ConduitVoiceInteractionSession(context: Context) : VoiceInteractionSession(context) {
|
||||
|
||||
private var capturedContext: String? = null
|
||||
private var capturedScreenshot: Bitmap? = null
|
||||
|
||||
override fun onCreateContentView(): android.view.View {
|
||||
val view = layoutInflater.inflate(app.cogwheel.conduit.R.layout.assistant_overlay, null)
|
||||
|
||||
// Summarize page button - sends screen context
|
||||
val summarizeButton = view.findViewById<android.view.View>(app.cogwheel.conduit.R.id.btn_summarize)
|
||||
summarizeButton?.setOnClickListener {
|
||||
launchAppWithContext(includeScreenshot = false)
|
||||
}
|
||||
|
||||
// Ask about page button - sends screenshot
|
||||
val askAboutButton = view.findViewById<android.view.View>(app.cogwheel.conduit.R.id.btn_ask_about)
|
||||
askAboutButton?.setOnClickListener {
|
||||
launchAppWithScreenshot()
|
||||
}
|
||||
|
||||
// Input area (opens text input)
|
||||
val inputArea = view.findViewById<android.view.View>(app.cogwheel.conduit.R.id.input_area)
|
||||
inputArea?.setOnClickListener {
|
||||
launchApp()
|
||||
}
|
||||
|
||||
// Voice button - opens voice call directly
|
||||
val voiceButton = view.findViewById<android.view.View>(app.cogwheel.conduit.R.id.btn_voice)
|
||||
voiceButton?.setOnClickListener {
|
||||
launchAppForVoiceCall()
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onHandleAssist(
|
||||
data: Bundle?,
|
||||
structure: AssistStructure?,
|
||||
content: AssistContent?
|
||||
) {
|
||||
super.onHandleAssist(data, structure, content)
|
||||
|
||||
android.util.Log.d("ConduitVoiceSession", "onHandleAssist called")
|
||||
|
||||
// Capture screen context
|
||||
val screenContext = StringBuilder()
|
||||
structure?.let {
|
||||
val nodes = it.windowNodeCount
|
||||
for (i in 0 until nodes) {
|
||||
val windowNode = it.getWindowNodeAt(i)
|
||||
traverseNode(windowNode.rootViewNode, screenContext)
|
||||
}
|
||||
}
|
||||
capturedContext = screenContext.toString()
|
||||
|
||||
// Capture screenshot from assist data
|
||||
data?.let {
|
||||
try {
|
||||
capturedScreenshot = it.getParcelable("screenshot")
|
||||
if (capturedScreenshot == null) {
|
||||
// Try alternative key
|
||||
capturedScreenshot = it.getParcelable("android.intent.extra.ASSIST_SCREENSHOT")
|
||||
}
|
||||
android.util.Log.d("ConduitVoiceSession", "Screenshot captured: ${capturedScreenshot != null}")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to get screenshot from bundle", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHandleScreenshot(screenshot: Bitmap?) {
|
||||
super.onHandleScreenshot(screenshot)
|
||||
capturedScreenshot = screenshot
|
||||
android.util.Log.d("ConduitVoiceSession", "Screenshot received via onHandleScreenshot: ${screenshot != null}")
|
||||
}
|
||||
|
||||
private fun launchApp() {
|
||||
try {
|
||||
android.util.Log.d("ConduitVoiceSession", "Attempting to launch app")
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
|
||||
context.startActivity(intent)
|
||||
android.util.Log.d("ConduitVoiceSession", "App launch requested")
|
||||
finish() // Close the overlay
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to launch app", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchAppWithContext(includeScreenshot: Boolean) {
|
||||
try {
|
||||
android.util.Log.d("ConduitVoiceSession", "Attempting to launch app with context")
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
|
||||
if (capturedContext != null) {
|
||||
intent.putExtra("screen_context", capturedContext)
|
||||
android.util.Log.d("ConduitVoiceSession", "Context attached: ${capturedContext?.take(50)}...")
|
||||
} else {
|
||||
android.util.Log.d("ConduitVoiceSession", "No context captured")
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
android.util.Log.d("ConduitVoiceSession", "App launch requested")
|
||||
finish() // Close the overlay
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to launch app", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchAppWithScreenshot() {
|
||||
try {
|
||||
android.util.Log.d("ConduitVoiceSession", "Attempting to launch app with screenshot")
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
|
||||
// Save screenshot to cache and pass URI
|
||||
capturedScreenshot?.let { bitmap ->
|
||||
try {
|
||||
val file = java.io.File(context.cacheDir, "assistant_screenshot_${System.currentTimeMillis()}.png")
|
||||
val outputStream = java.io.FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
intent.putExtra("screenshot_path", file.absolutePath)
|
||||
android.util.Log.d("ConduitVoiceSession", "Screenshot saved to: ${file.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to save screenshot", e)
|
||||
}
|
||||
} ?: run {
|
||||
android.util.Log.d("ConduitVoiceSession", "No screenshot captured")
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
android.util.Log.d("ConduitVoiceSession", "App launch requested with screenshot")
|
||||
finish() // Close the overlay
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to launch app with screenshot", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchAppForVoiceCall() {
|
||||
try {
|
||||
android.util.Log.d("ConduitVoiceSession", "Attempting to launch app for voice call")
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
|
||||
intent.putExtra("start_voice_call", true)
|
||||
android.util.Log.d("ConduitVoiceSession", "Voice call flag attached")
|
||||
|
||||
context.startActivity(intent)
|
||||
android.util.Log.d("ConduitVoiceSession", "App launch requested for voice call")
|
||||
finish() // Close the overlay
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ConduitVoiceSession", "Failed to launch app for voice call", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun traverseNode(node: AssistStructure.ViewNode?, builder: StringBuilder) {
|
||||
if (node == null) return
|
||||
|
||||
if (node.text != null) {
|
||||
builder.append(node.text).append("\n")
|
||||
}
|
||||
|
||||
// Also check content description for accessibility text
|
||||
if (node.contentDescription != null) {
|
||||
builder.append(node.contentDescription).append("\n")
|
||||
}
|
||||
|
||||
for (i in 0 until node.childCount) {
|
||||
traverseNode(node.getChildAt(i), builder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package app.cogwheel.conduit
|
||||
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
import android.os.Bundle
|
||||
|
||||
class ConduitVoiceInteractionSessionService : VoiceInteractionSessionService() {
|
||||
override fun onNewSession(args: Bundle?): VoiceInteractionSession {
|
||||
return ConduitVoiceInteractionSession(this)
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,52 @@ class MainActivity : FlutterActivity() {
|
||||
windowInsetsController.isAppearanceLightNavigationBars = false
|
||||
}
|
||||
|
||||
private val CHANNEL = "app.cogwheel.conduit/assistant"
|
||||
private var methodChannel: io.flutter.plugin.common.MethodChannel? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// Initialize background streaming handler
|
||||
backgroundStreamingHandler = BackgroundStreamingHandler(this)
|
||||
backgroundStreamingHandler.setup(flutterEngine)
|
||||
|
||||
methodChannel = io.flutter.plugin.common.MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
|
||||
// Check if started with context
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: android.content.Intent) {
|
||||
android.util.Log.d("MainActivity", "handleIntent called")
|
||||
android.util.Log.d("MainActivity", "Intent extras: ${intent.extras?.keySet()}")
|
||||
|
||||
val screenContext = intent.getStringExtra("screen_context")
|
||||
val screenshotPath = intent.getStringExtra("screenshot_path")
|
||||
val startVoiceCall = intent.getBooleanExtra("start_voice_call", false)
|
||||
|
||||
android.util.Log.d("MainActivity", "screenContext: $screenContext")
|
||||
android.util.Log.d("MainActivity", "screenshotPath: $screenshotPath")
|
||||
android.util.Log.d("MainActivity", "startVoiceCall: $startVoiceCall")
|
||||
android.util.Log.d("MainActivity", "methodChannel: $methodChannel")
|
||||
|
||||
if (startVoiceCall) {
|
||||
android.util.Log.d("MainActivity", "Invoking startVoiceCall")
|
||||
methodChannel?.invokeMethod("startVoiceCall", null)
|
||||
} else if (screenContext != null) {
|
||||
android.util.Log.d("MainActivity", "Invoking analyzeScreen")
|
||||
methodChannel?.invokeMethod("analyzeScreen", screenContext)
|
||||
} else if (screenshotPath != null) {
|
||||
android.util.Log.d("MainActivity", "Invoking analyzeScreenshot with path: $screenshotPath")
|
||||
methodChannel?.invokeMethod("analyzeScreenshot", screenshotPath)
|
||||
} else {
|
||||
android.util.Log.d("MainActivity", "No screen context or screenshot path found")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1F1F1F" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
10
android/app/src/main/res/drawable/ic_add.xml
Normal file
10
android/app/src/main/res/drawable/ic_add.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_ask.xml
Normal file
10
android/app/src/main/res/drawable/ic_ask.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2V19zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2H8c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_share.xml
Normal file
10
android/app/src/main/res/drawable/ic_share.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
|
||||
</vector>
|
||||
13
android/app/src/main/res/drawable/ic_sparkle.xml
Normal file
13
android/app/src/main/res/drawable/ic_sparkle.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2l1.5,5.5L19,9l-5.5,1.5L12,16l-1.5,-5.5L5,9l5.5,-1.5L12,2z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M19,15l0.94,2.06L22,18l-2.06,0.94L19,21l-0.94,-2.06L16,18l2.06,-0.94L19,15z"/>
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_summarize.xml
Normal file
10
android/app/src/main/res/drawable/ic_summarize.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8V18zM16,14H8v-2h8V14zM13,9V3.5L18.5,9H13z"/>
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_voice.xml
Normal file
10
android/app/src/main/res/drawable/ic_voice.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
|
||||
</vector>
|
||||
6
android/app/src/main/res/drawable/input_bar_bg.xml
Normal file
6
android/app/src/main/res/drawable/input_bar_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2D2D2D" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/rounded_button_bg.xml
Normal file
6
android/app/src/main/res/drawable/rounded_button_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2196F3" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/rounded_overlay_bg.xml
Normal file
6
android/app/src/main/res/drawable/rounded_overlay_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
5
android/app/src/main/res/drawable/voice_button_bg.xml
Normal file
5
android/app/src/main/res/drawable/voice_button_bg.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#3D3D3D" />
|
||||
</shape>
|
||||
128
android/app/src/main/res/layout/assistant_overlay.xml
Normal file
128
android/app/src/main/res/layout/assistant_overlay.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#99000000">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp"
|
||||
android:layout_margin="16dp">
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="start"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<!-- Summarize page button -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btn_summarize"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/dark_rounded_button_bg"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@drawable/ic_summarize"
|
||||
android:layout_marginEnd="12dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Summarize page"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Ask about page button -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btn_ask_about"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/dark_rounded_button_bg"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@drawable/ic_ask"
|
||||
android:layout_marginEnd="12dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Ask about page"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp"/>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bottom Input Bar -->
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="@drawable/input_bar_bg"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="8dp">
|
||||
|
||||
<!-- Ask Conduit text -->
|
||||
<TextView
|
||||
android:id="@+id/input_area"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_toStartOf="@id/btn_voice"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="Ask Conduit"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp"
|
||||
android:alpha="0.6"
|
||||
android:clickable="true"
|
||||
android:focusable="true"/>
|
||||
|
||||
<!-- Voice button -->
|
||||
<FrameLayout
|
||||
android:id="@+id/btn_voice"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/voice_button_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/ic_voice"/>
|
||||
</FrameLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sessionService="app.cogwheel.conduit.ConduitVoiceInteractionSessionService"
|
||||
android:recognitionService="android.speech.RecognitionService"
|
||||
android:settingsActivity="app.cogwheel.conduit.MainActivity"
|
||||
android:supportsAssist="true"
|
||||
android:supportsLocalInteraction="true" />
|
||||
178
lib/core/utils/android_assistant_handler.dart
Normal file
178
lib/core/utils/android_assistant_handler.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../features/chat/providers/chat_providers.dart';
|
||||
import '../../features/chat/services/file_attachment_service.dart';
|
||||
import '../../features/chat/views/voice_call_page.dart';
|
||||
import '../services/navigation_service.dart';
|
||||
import '../../shared/services/tasks/task_queue.dart';
|
||||
import '../providers/app_providers.dart';
|
||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
final androidAssistantProvider = Provider(
|
||||
(ref) => AndroidAssistantHandler(ref),
|
||||
);
|
||||
|
||||
final screenContextProvider = NotifierProvider<ScreenContextNotifier, String?>(
|
||||
ScreenContextNotifier.new,
|
||||
);
|
||||
|
||||
class ScreenContextNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void setContext(String? context) {
|
||||
state = context;
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidAssistantHandler {
|
||||
static const platform = MethodChannel('app.cogwheel.conduit/assistant');
|
||||
final Ref _ref;
|
||||
|
||||
AndroidAssistantHandler(this._ref) {
|
||||
platform.setMethodCallHandler(_handleMethodCall);
|
||||
}
|
||||
|
||||
Future<void> _handleMethodCall(MethodCall call) async {
|
||||
if (call.method == 'analyzeScreen') {
|
||||
final String context = call.arguments as String;
|
||||
await _processScreenContext(context);
|
||||
} else if (call.method == 'analyzeScreenshot') {
|
||||
final String screenshotPath = call.arguments as String;
|
||||
await _processScreenshot(screenshotPath);
|
||||
} else if (call.method == 'startVoiceCall') {
|
||||
await _startVoiceCall();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processScreenContext(String context) async {
|
||||
try {
|
||||
DebugLogger.log('Processing screen context', scope: 'assistant');
|
||||
|
||||
// Wait for app to be ready (authenticated and model available)
|
||||
final navState = _ref.read(authNavigationStateProvider);
|
||||
final model = _ref.read(selectedModelProvider);
|
||||
|
||||
if (navState != AuthNavigationState.authenticated || model == null) {
|
||||
DebugLogger.log('App not ready for screen context processing', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to chat if not already there
|
||||
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
|
||||
if (!isOnChatRoute) {
|
||||
// Navigation will happen via auth state
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the screen context
|
||||
_ref.read(screenContextProvider.notifier).setContext(context);
|
||||
DebugLogger.log('Screen context set successfully', scope: 'assistant');
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to process screen context: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processScreenshot(String screenshotPath) async {
|
||||
try {
|
||||
DebugLogger.log('Processing screenshot: $screenshotPath', scope: 'assistant');
|
||||
|
||||
// Wait for app to be ready (authenticated and model available)
|
||||
final navState = _ref.read(authNavigationStateProvider);
|
||||
final model = _ref.read(selectedModelProvider);
|
||||
|
||||
if (navState != AuthNavigationState.authenticated || model == null) {
|
||||
DebugLogger.log('App not ready for screenshot processing', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to chat if not already there
|
||||
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
|
||||
if (!isOnChatRoute) {
|
||||
// Navigation will happen via auth state
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a fresh chat context
|
||||
startNewChat(_ref);
|
||||
|
||||
// Add screenshot as attachment
|
||||
final file = File(screenshotPath);
|
||||
if (!await file.exists()) {
|
||||
DebugLogger.log('Screenshot file not found: $screenshotPath', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
final svc = _ref.read(fileAttachmentServiceProvider);
|
||||
if (svc != null) {
|
||||
final attachment = LocalAttachment(
|
||||
file: file,
|
||||
displayName: path.basename(screenshotPath),
|
||||
);
|
||||
|
||||
_ref.read(attachedFilesProvider.notifier).addFiles([attachment]);
|
||||
|
||||
// Enqueue upload via task queue
|
||||
final activeConv = _ref.read(activeConversationProvider);
|
||||
try {
|
||||
await _ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: attachment.file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: await attachment.file.length(),
|
||||
);
|
||||
DebugLogger.log('Screenshot uploaded successfully', scope: 'assistant');
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to upload screenshot: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to process screenshot: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startVoiceCall() async {
|
||||
try {
|
||||
DebugLogger.log('Starting voice call from assistant', scope: 'assistant');
|
||||
|
||||
// Wait for app to be ready (authenticated and model available)
|
||||
final navState = _ref.read(authNavigationStateProvider);
|
||||
final model = _ref.read(selectedModelProvider);
|
||||
|
||||
if (navState != AuthNavigationState.authenticated || model == null) {
|
||||
DebugLogger.log('App not ready for voice call', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to chat if not already there
|
||||
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
|
||||
if (!isOnChatRoute) {
|
||||
// Navigation will happen via auth state
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current BuildContext from the navigation service
|
||||
final context = NavigationService.navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
DebugLogger.log('No context available for voice call navigation', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to voice call page
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const VoiceCallPage(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
DebugLogger.log('Voice call page launched', scope: 'assistant');
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to start voice call: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
import '../../../core/utils/model_icon_utils.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/android_assistant_handler.dart';
|
||||
|
||||
import '../widgets/modern_chat_input.dart';
|
||||
import '../widgets/user_message_bubble.dart';
|
||||
@@ -288,6 +289,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Initialize chat page components
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Initialize Android Assistant Handler
|
||||
ref.read(androidAssistantProvider);
|
||||
|
||||
// First, ensure a model is selected
|
||||
await _checkAndAutoSelectModel();
|
||||
if (!mounted) return;
|
||||
@@ -301,6 +306,24 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Listen for screen context from Android Assistant
|
||||
final screenContext = ref.watch(screenContextProvider);
|
||||
if (screenContext != null && screenContext.isNotEmpty) {
|
||||
// Clear the context so we don't process it again
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(screenContextProvider.notifier).setContext(null);
|
||||
final currentModel = ref.read(selectedModelProvider);
|
||||
_handleMessageSend(
|
||||
"Here is the content of my screen:\n\n$screenContext\n\nCan you summarize this?",
|
||||
currentModel,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -961,10 +961,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1606,26 +1606,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.2"
|
||||
version: "1.26.3"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.11"
|
||||
version: "0.6.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user