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:allowBackup="false"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
android:usesCleartextTraffic="true">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
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
|
windowInsetsController.isAppearanceLightNavigationBars = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val CHANNEL = "app.cogwheel.conduit/assistant"
|
||||||
|
private var methodChannel: io.flutter.plugin.common.MethodChannel? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
// Initialize background streaming handler
|
// Initialize background streaming handler
|
||||||
backgroundStreamingHandler = BackgroundStreamingHandler(this)
|
backgroundStreamingHandler = BackgroundStreamingHandler(this)
|
||||||
backgroundStreamingHandler.setup(flutterEngine)
|
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() {
|
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/user_display_name.dart';
|
||||||
import '../../../core/utils/model_icon_utils.dart';
|
import '../../../core/utils/model_icon_utils.dart';
|
||||||
import '../../auth/providers/unified_auth_providers.dart';
|
import '../../auth/providers/unified_auth_providers.dart';
|
||||||
|
import '../../../core/utils/android_assistant_handler.dart';
|
||||||
|
|
||||||
import '../widgets/modern_chat_input.dart';
|
import '../widgets/modern_chat_input.dart';
|
||||||
import '../widgets/user_message_bubble.dart';
|
import '../widgets/user_message_bubble.dart';
|
||||||
@@ -288,6 +289,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
// Initialize chat page components
|
// Initialize chat page components
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Initialize Android Assistant Handler
|
||||||
|
ref.read(androidAssistantProvider);
|
||||||
|
|
||||||
// First, ensure a model is selected
|
// First, ensure a model is selected
|
||||||
await _checkAndAutoSelectModel();
|
await _checkAndAutoSelectModel();
|
||||||
if (!mounted) return;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -961,10 +961,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1606,26 +1606,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.2"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.11"
|
version: "0.6.12"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user