feat(voice): add direct speech recognition, system ROLE_NOTES, voice shortcuts, and change app label to Avtex
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.example.avtex"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.2"
|
||||
versionCode = 4
|
||||
versionName = "1.3"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -49,6 +49,11 @@ android {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = false
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:icon="@mipmap/ic_launcher_avtex"
|
||||
android:roundIcon="@mipmap/ic_launcher_avtex_round">
|
||||
<intent-filter>
|
||||
@@ -52,6 +54,24 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Google Voice Actions Note Creation -->
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Android Text Selection Process Action (выделение текста в любом месте -> отправить в Avtex) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
package com.example.avtex
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.role.RoleManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.avtex.theme.AvtexTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
|
||||
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val speechRecognizerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
val spokenText = matches?.firstOrNull()
|
||||
if (!spokenText.isNullOrBlank()) {
|
||||
routeAndSaveNote(spokenText, preferredList = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val roleRequestLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
Toast.makeText(this, "Avtex назначен приложением заметок по умолчанию!", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIncomingIntent(intent)
|
||||
@@ -24,6 +54,10 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AvtexTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalVoiceInputLauncher provides { launchVoiceInput() },
|
||||
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
@@ -33,12 +67,48 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
fun launchVoiceInput() {
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ru-RU")
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Скажите заметку, задачу или фильм...")
|
||||
}
|
||||
try {
|
||||
speechRecognizerLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Speech recognizer not available", e)
|
||||
Toast.makeText(this, "Голосовой ввод недоступен на этом устройстве", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestDefaultNotesRole() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
try {
|
||||
val roleManager = getSystemService(RoleManager::class.java)
|
||||
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_NOTES)) {
|
||||
val roleIntent = roleManager.createRequestRoleIntent(RoleManager.ROLE_NOTES)
|
||||
roleRequestLauncher.launch(roleIntent)
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to request ROLE_NOTES", e)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
|
||||
startActivity(settingsIntent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Откройте: Настройки -> Приложения -> Приложения по умолчанию", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
val action = intent.action
|
||||
@@ -54,10 +124,37 @@ class MainActivity : ComponentActivity() {
|
||||
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
|
||||
routeAndSaveNote(fullText, preferredList = null)
|
||||
return
|
||||
} else {
|
||||
launchVoiceInput()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Android Share Sheet Action (android.intent.action.SEND with text/plain)
|
||||
// 2. Google Voice Actions (com.google.android.gms.actions.CREATE_NOTE)
|
||||
if (action == "com.google.android.gms.actions.CREATE_NOTE") {
|
||||
val text = intent.getStringExtra("com.google.android.gms.actions.extra.TEXT")
|
||||
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val name = intent.getStringExtra("com.google.android.gms.actions.extra.NAME")
|
||||
if (!text.isNullOrBlank()) {
|
||||
val fullText = if (!name.isNullOrBlank() && name != text) "$name: $text" else text
|
||||
routeAndSaveNote(fullText, preferredList = null)
|
||||
return
|
||||
} else {
|
||||
launchVoiceInput()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Android Text Selection (ACTION_PROCESS_TEXT)
|
||||
if (action == Intent.ACTION_PROCESS_TEXT) {
|
||||
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||
if (!text.isNullOrBlank()) {
|
||||
routeAndSaveNote(text, preferredList = null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Android Share Sheet Action (android.intent.action.SEND with text/plain)
|
||||
if (action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
@@ -68,10 +165,16 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deep Link Action (xflow://...)
|
||||
// 5. Deep Link Action (xflow://...)
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == "xflow") {
|
||||
val path = data.path ?: data.host ?: ""
|
||||
val host = data.host ?: ""
|
||||
val path = data.path ?: ""
|
||||
if (host == "voice" || path.contains("record")) {
|
||||
launchVoiceInput()
|
||||
return
|
||||
}
|
||||
|
||||
val listName = data.getQueryParameter("list_name")
|
||||
?: data.getQueryParameter("name")
|
||||
?: data.getQueryParameter("list")
|
||||
@@ -88,7 +191,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Google Assistant / Gemini Built-in Intent Extras
|
||||
// 6. Google Assistant / Gemini Built-in Intent Extras
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
val itemName = extras.getString("itemListElement.name")
|
||||
@@ -124,13 +227,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"✅ XFlow: Записано в «$targetList»:\n$cleanText",
|
||||
"✅ Avtex: Записано в «$targetList»:\n$cleanText",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "Assistant",
|
||||
message = "Заметка через ассистент: \"$cleanText\" в список \"$targetList\"",
|
||||
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
|
||||
isImportant = true
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -35,6 +35,10 @@ import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import com.example.avtex.LocalVoiceInputLauncher
|
||||
import com.example.avtex.LocalRoleNotesRequester
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -94,6 +98,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
var selectedCategory by remember { mutableStateOf("all") }
|
||||
var newItemText by remember { mutableStateOf("") }
|
||||
var showAssistantTester by remember { mutableStateOf(false) }
|
||||
val voiceLauncher = LocalVoiceInputLauncher.current
|
||||
|
||||
val filteredLists = remember(lists, selectedCategory) {
|
||||
if (selectedCategory == "all") lists
|
||||
@@ -129,6 +134,29 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Direct Voice Button
|
||||
Button(
|
||||
onClick = { voiceLauncher?.invoke() },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Голос",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Button to toggle Assistant Tester
|
||||
OutlinedButton(
|
||||
onClick = { showAssistantTester = !showAssistantTester },
|
||||
@@ -156,6 +184,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Interactive Assistant Simulator Card
|
||||
item {
|
||||
@@ -307,6 +336,8 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun AssistantTesterCard(context: Context) {
|
||||
val voiceLauncher = LocalVoiceInputLauncher.current
|
||||
val roleRequester = LocalRoleNotesRequester.current
|
||||
var customVoiceQuery by remember { mutableStateOf("") }
|
||||
var selectedPreset by remember { mutableStateOf("") }
|
||||
|
||||
@@ -371,6 +402,53 @@ private fun AssistantTesterCard(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Voice Action & System Role
|
||||
Button(
|
||||
onClick = { voiceLauncher?.invoke() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||
) {
|
||||
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("🎤 Сказать голосом прямо сейчас", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { roleRequester?.invoke() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("⚙️ Сделать Avtex заметками по умолчанию", fontSize = 12.sp, color = NeonViolet)
|
||||
}
|
||||
|
||||
// Explanatory note about Google Keep
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Info, contentDescription = null, tint = SolarAmber, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Почему Ассистент отправляет в Google Keep?", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = SolarAmber)
|
||||
}
|
||||
Text(
|
||||
text = "Google зафиксировал Keep как облачного провайдера заметок. Чтобы переключить:\n• Откройте: Настройки -> Google -> Google Ассистент -> «Заметки и списки» -> выберите «Не синхронизировать».\n• Либо произносите явно: «Окей Google, открой Avtex и запиши...».\n• Либо нажмите кнопку «Сказать голосом» выше или зажмите иконку Avtex на рабочем столе!",
|
||||
fontSize = 10.sp,
|
||||
color = TextSecondary,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Быстрые сценарии тестирования (нажмите для проверки):",
|
||||
fontSize = 12.sp,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">XFlow</string>
|
||||
<string name="app_name">Avtex</string>
|
||||
<string name="shortcut_voice_note">Голосовая заметка</string>
|
||||
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка -->
|
||||
<shortcut
|
||||
android:shortcutId="voice_note"
|
||||
android:enabled="true"
|
||||
android:icon="@mipmap/ic_launcher_avtex"
|
||||
android:shortcutShortLabel="@string/shortcut_voice_note"
|
||||
android:shortcutLongLabel="@string/shortcut_voice_note_desc">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://voice/record" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
<!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
|
||||
<capability android:name="actions.intent.CREATE_NOTE">
|
||||
<intent
|
||||
|
||||
Reference in New Issue
Block a user