feat(voice): add direct speech recognition, system ROLE_NOTES, voice shortcuts, and change app label to Avtex

This commit is contained in:
2026-09-29 14:55:05 +05:00
parent 34ec8944c1
commit 27235f6d54
6 changed files with 239 additions and 16 deletions
+7 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.example.avtex" applicationId = "com.example.avtex"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 4
versionName = "1.2" versionName = "1.3"
} }
signingConfigs { signingConfigs {
@@ -49,6 +49,11 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
} }
lint {
abortOnError = false
checkReleaseBuilds = false
}
} }
kotlin { kotlin {
@@ -25,6 +25,8 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:icon="@mipmap/ic_launcher_avtex" android:icon="@mipmap/ic_launcher_avtex"
android:roundIcon="@mipmap/ic_launcher_avtex_round"> android:roundIcon="@mipmap/ic_launcher_avtex_round">
<intent-filter> <intent-filter>
@@ -52,6 +54,24 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </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> </activity>
<meta-data <meta-data
@@ -1,22 +1,52 @@
package com.example.avtex package com.example.avtex
import android.app.Activity
import android.app.role.RoleManager
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.speech.RecognizerIntent
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.example.avtex.theme.AvtexTheme import com.example.avtex.theme.AvtexTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
handleIncomingIntent(intent) handleIncomingIntent(intent)
@@ -24,11 +54,16 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
AvtexTheme { AvtexTheme {
Surface( CompositionLocalProvider(
modifier = Modifier.fillMaxSize(), LocalVoiceInputLauncher provides { launchVoiceInput() },
color = MaterialTheme.colorScheme.background LocalRoleNotesRequester provides { requestDefaultNotesRole() }
) { ) {
MainNavigation() Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainNavigation()
}
} }
} }
} }
@@ -39,6 +74,41 @@ class MainActivity : ComponentActivity() {
handleIncomingIntent(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?) { private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return if (intent == null) return
val action = intent.action val action = intent.action
@@ -54,10 +124,37 @@ class MainActivity : ComponentActivity() {
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
routeAndSaveNote(fullText, preferredList = null) routeAndSaveNote(fullText, preferredList = null)
return 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) { if (action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) 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 val data = intent.data
if (data != null && data.scheme == "xflow") { 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") val listName = data.getQueryParameter("list_name")
?: data.getQueryParameter("name") ?: data.getQueryParameter("name")
?: data.getQueryParameter("list") ?: 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 val extras = intent.extras
if (extras != null) { if (extras != null) {
val itemName = extras.getString("itemListElement.name") val itemName = extras.getString("itemListElement.name")
@@ -124,13 +227,13 @@ class MainActivity : ComponentActivity() {
Toast.makeText( Toast.makeText(
this@MainActivity, this@MainActivity,
"✅ XFlow: Записано в «$targetList»:\n$cleanText", "✅ Avtex: Записано в «$targetList»:\n$cleanText",
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
AvtexApplication.instance.settingsRepository.addLog( AvtexApplication.instance.settingsRepository.addLog(
tag = "Assistant", tag = "Assistant",
message = "Заметка через ассистент: \"$cleanText\" в список \"$targetList\"", message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
isImportant = true isImportant = true
) )
} catch (e: Exception) { } 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.Mic
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Terminal 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -94,6 +98,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
var selectedCategory by remember { mutableStateOf("all") } var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") } var newItemText by remember { mutableStateOf("") }
var showAssistantTester by remember { mutableStateOf(false) } var showAssistantTester by remember { mutableStateOf(false) }
val voiceLauncher = LocalVoiceInputLauncher.current
val filteredLists = remember(lists, selectedCategory) { val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists if (selectedCategory == "all") lists
@@ -129,9 +134,32 @@ fun ListsScreen(modifier: Modifier = Modifier) {
) )
} }
// Button to toggle Assistant Tester Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( // Direct Voice Button
onClick = { showAssistantTester = !showAssistantTester }, 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 },
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f)), border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f)),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
@@ -153,6 +181,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
color = NeonViolet color = NeonViolet
) )
} }
}
} }
} }
} }
@@ -307,6 +336,8 @@ fun ListsScreen(modifier: Modifier = Modifier) {
@Composable @Composable
private fun AssistantTesterCard(context: Context) { private fun AssistantTesterCard(context: Context) {
val voiceLauncher = LocalVoiceInputLauncher.current
val roleRequester = LocalRoleNotesRequester.current
var customVoiceQuery by remember { mutableStateOf("") } var customVoiceQuery by remember { mutableStateOf("") }
var selectedPreset 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(
text = "Быстрые сценарии тестирования (нажмите для проверки):", text = "Быстрые сценарии тестирования (нажмите для проверки):",
fontSize = 12.sp, fontSize = 12.sp,
@@ -1,3 +1,5 @@
<resources> <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> </resources>
@@ -1,6 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"> <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 ("Сделай заметку...") --> <!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
<capability android:name="actions.intent.CREATE_NOTE"> <capability android:name="actions.intent.CREATE_NOTE">
<intent <intent