diff --git a/apps/mobile/app/build.gradle.kts b/apps/mobile/app/build.gradle.kts index 0b06288..bc5b12a 100644 --- a/apps/mobile/app/build.gradle.kts +++ b/apps/mobile/app/build.gradle.kts @@ -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 { diff --git a/apps/mobile/app/src/main/AndroidManifest.xml b/apps/mobile/app/src/main/AndroidManifest.xml index 1f68166..9d4fc32 100644 --- a/apps/mobile/app/src/main/AndroidManifest.xml +++ b/apps/mobile/app/src/main/AndroidManifest.xml @@ -25,6 +25,8 @@ @@ -52,6 +54,24 @@ + + + + + + + + + + + + + + + + + + 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,11 +54,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { AvtexTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + CompositionLocalProvider( + LocalVoiceInputLauncher provides { launchVoiceInput() }, + LocalRoleNotesRequester provides { requestDefaultNotesRole() } ) { - MainNavigation() + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainNavigation() + } } } } @@ -39,6 +74,41 @@ class MainActivity : ComponentActivity() { 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) { diff --git a/apps/mobile/app/src/main/java/com/example/avtex/ui/ListsScreen.kt b/apps/mobile/app/src/main/java/com/example/avtex/ui/ListsScreen.kt index 4a2c13e..f62f096 100644 --- a/apps/mobile/app/src/main/java/com/example/avtex/ui/ListsScreen.kt +++ b/apps/mobile/app/src/main/java/com/example/avtex/ui/ListsScreen.kt @@ -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,9 +134,32 @@ fun ListsScreen(modifier: Modifier = Modifier) { ) } - // Button to toggle Assistant Tester - OutlinedButton( - onClick = { showAssistantTester = !showAssistantTester }, + 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 }, shape = RoundedCornerShape(12.dp), border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f)), colors = ButtonDefaults.outlinedButtonColors( @@ -153,6 +181,7 @@ fun ListsScreen(modifier: Modifier = Modifier) { color = NeonViolet ) } + } } } } @@ -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, diff --git a/apps/mobile/app/src/main/res/values/strings.xml b/apps/mobile/app/src/main/res/values/strings.xml index 649f2ca..9ebcf78 100644 --- a/apps/mobile/app/src/main/res/values/strings.xml +++ b/apps/mobile/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ - XFlow + Avtex + Голосовая заметка + Быстрая запись заметки голосом в Avtex diff --git a/apps/mobile/app/src/main/res/xml/shortcuts.xml b/apps/mobile/app/src/main/res/xml/shortcuts.xml index ef1ce7e..39bb255 100644 --- a/apps/mobile/app/src/main/res/xml/shortcuts.xml +++ b/apps/mobile/app/src/main/res/xml/shortcuts.xml @@ -1,6 +1,21 @@ + + + + + +