다음은 외부 배달어플앱의 주문을 음성으로 자동화하기위한 앱의 소스코드입니다. 접근성서비스를...

생성일: 2025년 5월 11일

답변에 사용된 모델 O1 Pro by Chat01

질문

다음은 외부 배달어플앱의 주문을 음성으로 자동화하기위한 앱의 소스코드입니다. 접근성서비스를 기반으로 만들었는데, 배달앱에서 클릭이 될때도있고, 안될때도있고 ui노드트리가 추출될때도있고 새로고침빈도가잦아서 ui노드의 구조파악도어렵습니다. 현재 코드베이스를 처음부터 끝까지 차근차근 읽고 분석해서 근본적인 정확한 원인들을 파악하세요:

markdown
This file is a merged representation of a subset of the codebase, containing files not matching ignore patterns, combined into a single document by Repomix. The content has been processed where comments have been removed, content has been compressed (code blocks are separated by ⋮---- delimiter), security check has been disabled. # File Summary ## Purpose This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. ## File Format The content is organized as follows: 1. This summary section 2. Repository information 3. Directory structure 4. Multiple file entries, each consisting of: a. A header with the file path (## File: path/to/file) b. The full contents of the file in a code block ## Usage Guidelines - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. ## Notes - Some files may have been excluded based on .gitignore rules and Repomix's configuration - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files - Files matching these patterns are excluded: *.md - Files matching patterns in .gitignore are excluded - Files matching default ignore patterns are excluded - Code comments have been removed from supported file types - Content has been compressed - code blocks are separated by ⋮---- delimiter - Security check has been disabled - content may contain sensitive information - Files are sorted by Git change count (files with more changes are at the bottom) ## Additional Info # Directory Structure

accessibility/
AccessibilityUtils.kt
AutoEatsAccessibilityService.kt
CommandExecutor.kt
NodeHandler.kt
config/
AppConfig.kt
gpt/
GptService.kt
service/
VoiceCommandService.kt
speech/
SpeechRecognizer.kt
ui/
theme/
Color.kt
Theme.kt
Type.kt
AutoEatsApplication.kt
MainActivity.kt

text
# Files ## File: accessibility/AccessibilityUtils.kt ```kotlin package com.metainai.autoeats.accessibility import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription import android.graphics.Path import android.os.Bundle import android.os.Handler import android.os.Looper import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.util.Log import android.view.accessibility.AccessibilityEvent import com.metainai.autoeats.AutoEatsApplication import com.metainai.autoeats.service.VoiceCommandService import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.coroutines.resume object AccessibilityUtils { private const val TAG = "AccessibilityUtils" private var isSpeaking = false private var utteranceCompletionCallbacks = mutableMapOf<String, () -> Unit>() private val mainHandler = Handler(Looper.getMainLooper()) private const val TTS_COOLDOWN_DELAY = 300L private var isOrderCompleted = false private var activeTtsCount = 0 fun setOrderCompleted(completed: Boolean) { isOrderCompleted = completed Log.d(TAG, "주문 완료 플래그 설정: $completed") if (completed) { try { val voiceService = findVoiceCommandService() voiceService?.let { Log.d(TAG, "주문 완료로 인해 음성 서비스 중지 요청") it.stopVoiceRecognition() } } catch (e: Exception) { Log.e(TAG, "음성 서비스 중지 요청 중 오류 발생", e) } } } fun isTtsSpeaking(): Boolean { return isSpeaking } fun performTap(service: AccessibilityService, x: Float, y: Float): Boolean { val path = Path() path.moveTo(x, y) val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 50)) .build() val latch = CountDownLatch(1) var result = false service.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription) { result = true latch.countDown() } override fun onCancelled(gestureDescription: GestureDescription) { result = false latch.countDown() } }, null) try { latch.await(1, TimeUnit.SECONDS) } catch (e: InterruptedException) { result = false } return result } fun speakFeedback(service: AccessibilityService, text: String, onComplete: (() -> Unit)? = null) { val tts = AutoEatsApplication.instance.textToSpeech val voiceService = findVoiceCommandService() mainHandler.removeCallbacksAndMessages(null) voiceService?.let { if (it.isListening()) { Log.d(TAG, "speakFeedback: 음성 인식 일시 중지") it.stopVoiceRecognition() } } isSpeaking = true activeTtsCount++ Log.d(TAG, "speakFeedback: 활성 TTS 카운터 증가: $activeTtsCount") val utteranceId = System.currentTimeMillis().toString() if (onComplete != null) { utteranceCompletionCallbacks[utteranceId] = onComplete } tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { Log.d(TAG, "TTS 발화 시작: $utteranceId") } override fun onDone(utteranceId: String?) { Log.d(TAG, "TTS 발화 완료: $utteranceId - ${TTS_COOLDOWN_DELAY}ms 대기 시작") mainHandler.postDelayed({ activeTtsCount-- Log.d(TAG, "TTS 발화 완료 후 활성 TTS 카운터 감소: $activeTtsCount") if (activeTtsCount == 0) { isSpeaking = false utteranceId?.let { id -> utteranceCompletionCallbacks[id]?.invoke() utteranceCompletionCallbacks.remove(id) } if (!isOrderCompleted) { mainHandler.postDelayed({ if (!isTtsSpeaking() && !isOrderCompleted) { Log.d(TAG, "TTS 발화 완료 후 ${TTS_COOLDOWN_DELAY}ms 대기 완료, 음성 인식 재개") voiceService?.let { try { if (!it.isListening()) { Log.d(TAG, "TTS 발화 완료 후 ${TTS_COOLDOWN_DELAY}ms 대기 완료, 음성 인식 재개") it.startVoiceRecognition() } else { Log.d(TAG, "이미 음성 인식 중이므로 재시작하지 않습니다") } } catch (e: Exception) { Log.e(TAG, "음성 인식 재시작 중 오류 발생", e) } } } else { if (isOrderCompleted) { Log.d(TAG, "주문 완료 상태이므로 음성 인식을 재개하지 않음") } else { Log.d(TAG, "TTS가 여전히 활성 상태이므로 음성 인식을 재시작하지 않습니다.") } } }, TTS_COOLDOWN_DELAY) } else { Log.d(TAG, "주문 완료 상태이므로 음성 인식을 재개하지 않음") } } else { Log.d(TAG, "다른 TTS 발화가 아직 활성 상태이므로 음성 인식을 재개하지 않음") } }, TTS_COOLDOWN_DELAY) } override fun onError(utteranceId: String?) { Log.e(TAG, "TTS 발화 오류: $utteranceId") mainHandler.postDelayed({ activeTtsCount-- Log.d(TAG, "TTS 발화 오류 후 활성 TTS 카운터 감소: $activeTtsCount") if (activeTtsCount == 0) { isSpeaking = false utteranceId?.let { id -> utteranceCompletionCallbacks[id]?.invoke() utteranceCompletionCallbacks.remove(id) } if (!isOrderCompleted) { mainHandler.postDelayed({ if (!isTtsSpeaking() && !isOrderCompleted) { voiceService?.let { try { if (!it.isListening()) { Log.d(TAG, "TTS 오류 후 음성 인식 재개") it.startVoiceRecognition() } else { Log.d(TAG, "이미 음성 인식 중이므로 재시작하지 않습니다") } } catch (e: Exception) { Log.e(TAG, "TTS 오류 후 음성 인식 재개 중 오류", e) } } } else { if (isOrderCompleted) { Log.d(TAG, "주문 완료 상태이므로 오류 후에도 음성 인식을 재개하지 않음") } else { Log.d(TAG, "TTS가 여전히 활성 상태이므로 음성 인식을 재시작하지 않습니다.") } } }, TTS_COOLDOWN_DELAY) } else { Log.d(TAG, "주문 완료 상태이므로 오류 후에도 음성 인식을 재개하지 않음") } } else { Log.d(TAG, "다른 TTS 발화가 아직 활성 상태이므로 음성 인식을 재개하지 않음") } }, TTS_COOLDOWN_DELAY) } }) val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.0f) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { putBoolean(TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS, true) } } Log.d(TAG, "speakFeedback: TTS 발화 요청 - '$text'") tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, utteranceId) } suspend fun speakFeedbackAndWait(service: AccessibilityService, text: String): Boolean = suspendCancellableCoroutine { continuation -> Log.d(TAG, "speakFeedbackAndWait: 코루틴으로 TTS 완료 대기 시작 - '$text'") speakFeedback(service, text) { if (continuation.isActive) { Log.d(TAG, "speakFeedbackAndWait: TTS 완료 감지, 코루틴 재개") continuation.resume(true) } } continuation.invokeOnCancellation { Log.d(TAG, "speakFeedbackAndWait: 코루틴 취소됨") } } fun resetTtsState() { Log.d(TAG, "resetTtsState: TTS 상태 초기화") isSpeaking = false isOrderCompleted = false activeTtsCount = 0 utteranceCompletionCallbacks.clear() mainHandler.removeCallbacksAndMessages(null) try { val tts = AutoEatsApplication.instance.textToSpeech tts.stop() } catch (e: Exception) { Log.e(TAG, "resetTtsState: TTS 중지 중 오류 발생", e) } } private fun findVoiceCommandService(): VoiceCommandService? { return try { val application = com.metainai.autoeats.AutoEatsApplication.instance val voiceService = application.voiceCommandService if (voiceService == null) { Log.d(TAG, "findVoiceCommandService: VoiceCommandService 인스턴스가 아직 등록되지 않았습니다.") } else { Log.d(TAG, "findVoiceCommandService: VoiceCommandService 인스턴스를 찾았습니다.") } voiceService } catch (e: Exception) { Log.e(TAG, "findVoiceCommandService: VoiceCommandService 인스턴스를 찾을 수 없습니다", e) null } } fun getEventTypeString(eventType: Int): String { return when (eventType) { AccessibilityEvent.TYPE_VIEW_CLICKED -> "VIEW_CLICKED" AccessibilityEvent.TYPE_VIEW_LONG_CLICKED -> "VIEW_LONG_CLICKED" AccessibilityEvent.TYPE_VIEW_SELECTED -> "VIEW_SELECTED" AccessibilityEvent.TYPE_VIEW_FOCUSED -> "VIEW_FOCUSED" AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> "VIEW_TEXT_CHANGED" AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> "WINDOW_STATE_CHANGED" AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> "NOTIFICATION_STATE_CHANGED" AccessibilityEvent.TYPE_VIEW_HOVER_ENTER -> "VIEW_HOVER_ENTER" AccessibilityEvent.TYPE_VIEW_HOVER_EXIT -> "VIEW_HOVER_EXIT" AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START -> "TOUCH_EXPLORATION_GESTURE_START" AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END -> "TOUCH_EXPLORATION_GESTURE_END" AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> "WINDOW_CONTENT_CHANGED" AccessibilityEvent.TYPE_VIEW_SCROLLED -> "VIEW_SCROLLED" AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED -> "VIEW_TEXT_SELECTION_CHANGED" AccessibilityEvent.TYPE_ANNOUNCEMENT -> "ANNOUNCEMENT" AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED -> "VIEW_ACCESSIBILITY_FOCUSED" AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED -> "VIEW_ACCESSIBILITY_FOCUS_CLEARED" AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY -> "VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY" else -> "TYPE_UNKNOWN: $eventType" } } fun isAppInstalled(service: AccessibilityService, packageName: String): Boolean { return try { service.packageManager.getPackageInfo(packageName, 0) true } catch (e: Exception) { false } } fun performSwipe( service: AccessibilityService, startX: Float, startY: Float, endX: Float, endY: Float, duration: Long = 300 ): Boolean { val path = Path() path.moveTo(startX, startY) path.lineTo(endX, endY) val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, duration)) .build() val latch = CountDownLatch(1) var result = false service.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription) { result = true latch.countDown() } override fun onCancelled(gestureDescription: GestureDescription) { result = false latch.countDown() } }, null) try { latch.await(duration + 500, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { result = false } return result } fun swipeUp(service: AccessibilityService): Boolean { return performSwipe( service, 450f, 500f, 450f, 200f ) } fun swipeDown(service: AccessibilityService): Boolean { return performSwipe( service, 450f, 200f, 450f, 500f ) } fun swipeLeft(service: AccessibilityService): Boolean { return performSwipe( service, 600f, 300f, 200f, 300f ) } fun swipeRight(service: AccessibilityService): Boolean { return performSwipe( service, 200f, 300f, 600f, 300f ) } }

File: accessibility/AutoEatsAccessibilityService.kt

kotlin
package com.metainai.autoeats.accessibility import android.accessibilityservice.AccessibilityService import android.content.ComponentName import android.content.Intent import android.os.Handler import android.provider.Settings import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import com.metainai.autoeats.config.AppConfig import com.metainai.autoeats.gpt.CommandIntent import com.metainai.autoeats.gpt.CommandResult import com.metainai.autoeats.service.VoiceCommandService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch class AutoEatsAccessibilityService : AccessibilityService() { val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) lateinit var appConfig: AppConfig private val TAG = "AutoEatsAccessibility" private val handler = Handler() private var isLotteEatsRunning = false private val importantEventTypes = setOf( AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_VIEW_CLICKED, AccessibilityEvent.TYPE_VIEW_FOCUSED ) private val ignoredEventTypes = setOf( AccessibilityEvent.TYPE_VIEW_SCROLLED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START, AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END, AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY ) private var lastEventProcessTime = 0L private val EVENT_THROTTLE_MS = 500L override fun onCreate() { super.onCreate() appConfig = AppConfig.getInstance(applicationContext) registerWithVoiceCommandService() } override fun onServiceConnected() { super.onServiceConnected() registerWithVoiceCommandService() } override fun onAccessibilityEvent(event: AccessibilityEvent?) { event ?: return val lotteEatsPackage = appConfig.lotteEatsPackageName if (event.packageName != lotteEatsPackage) { if (isLotteEatsRunning) { isLotteEatsRunning = false Log.d(TAG, "롯데잇츠 앱이 종료되었습니다.") } return } if (ignoredEventTypes.contains(event.eventType)) { return } val currentTime = System.currentTimeMillis() if (currentTime - lastEventProcessTime < EVENT_THROTTLE_MS) { return } lastEventProcessTime = currentTime val isImportantEvent = importantEventTypes.contains(event.eventType) if (isImportantEvent) { val eventTypeStr = AccessibilityUtils.getEventTypeString(event.eventType) Log.d(TAG, "중요 접근성 이벤트 발생: $eventTypeStr") } if (!isLotteEatsRunning) { isLotteEatsRunning = true Log.d(TAG, "롯데잇츠 앱이 실행되었습니다.") } } override fun onInterrupt() { } override fun onDestroy() { super.onDestroy() serviceScope.cancel() } private fun registerWithVoiceCommandService() { val intent = Intent(this, VoiceCommandService::class.java) bindService(intent, VoiceCommandServiceConnection(this), BIND_AUTO_CREATE) } suspend fun executeCommand(command: CommandResult) { when (command.intent) { CommandIntent.COMPLETE_ORDER -> { CommandExecutor.executeBurgerOrder( this@AutoEatsAccessibilityService, burger = command.parameters["burger"] ?: "", setOrSingle = command.parameters["setOrSingle"] ?: "", quantity = command.parameters["quantity"]?.toIntOrNull() ?: 1 ) } CommandIntent.UNKNOWN -> { AccessibilityUtils.speakFeedback(this@AutoEatsAccessibilityService, "명령을 이해하지 못했습니다. 다시 말씀해주세요.") } else -> { AccessibilityUtils.speakFeedback(this@AutoEatsAccessibilityService, "명령을 처리할 수 없습니다.") } } } companion object { fun isServiceEnabled(context: android.content.Context): Boolean { val enabledServices = Settings.Secure.getString( context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES ) ?: return false val expectedServiceName = "${context.packageName}/${AutoEatsAccessibilityService::class.java.canonicalName}" return enabledServices.contains(expectedServiceName) } fun openAccessibilitySettings(context: android.content.Context) { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) } } class VoiceCommandServiceConnection(private val accessibilityService: AutoEatsAccessibilityService) : android.content.ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: android.os.IBinder?) { if (service is VoiceCommandService.LocalBinder) { val voiceService = service.getService() voiceService.registerAccessibilityService(accessibilityService) android.util.Log.d("AutoEatsAccService", "VoiceCommandService에 성공적으로 등록되었습니다.") } } override fun onServiceDisconnected(name: ComponentName?) { android.util.Log.d("AutoEatsAccService", "VoiceCommandService와 연결이 끊어졌습니다.") } } }

File: accessibility/CommandExecutor.kt

kotlin
package com.metainai.autoeats.accessibility import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription import android.content.ComponentName import android.content.Context import android.content.Intent import android.graphics.Path import android.graphics.Rect import android.util.Log import android.view.accessibility.AccessibilityNodeInfo import kotlinx.coroutines.delay import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit object CommandExecutor { private const val TAG = "CommandExecutor" private const val TTS_VERIFICATION_DELAY = 500L private var orderTtsDone = false private var pendingOrderDetails: Triple<String, String, Int>? = null private var appLaunchInProgress = false private fun findNodeRecursive( node: AccessibilityNodeInfo?, predicate: (AccessibilityNodeInfo) -> Boolean ): AccessibilityNodeInfo? { node ?: return null if (predicate(node)) return AccessibilityNodeInfo.obtain(node) for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue val result = findNodeRecursive(child, predicate) child.recycle() if (result != null) return result } return null } private fun isNodeVisible(node: AccessibilityNodeInfo, service: AutoEatsAccessibilityService): Boolean { val rect = Rect().apply { node.getBoundsInScreen(this) } val screenHeight = service.resources.displayMetrics.heightPixels return rect.bottom > 0 && rect.top < screenHeight } private fun printNodeHierarchy( node: AccessibilityNodeInfo?, indent: String = "", currentDepth: Int = 0, maxDepth: Int = 3, visited: MutableSet<Int> = mutableSetOf() ) { if (node == null || currentDepth > maxDepth) return val nodeId = System.identityHashCode(node) if (visited.contains(nodeId)) return visited.add(nodeId) val bounds = Rect().apply { node.getBoundsInScreen(this) } // 주요 노드 판단: 클릭 가능하거나, 최상위이거나, 텍스트/설명이 있으면 출력. val shouldPrint = node.isClickable || currentDepth == 0 || (!node.text.isNullOrBlank()) || (!node.contentDescription.isNullOrBlank()) if (shouldPrint) { Log.d(TAG, "$indent- [${node.className}] text: ${node.text}, desc: ${node.contentDescription}, bounds: $bounds, clickable: ${node.isClickable}") } for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue printNodeHierarchy(child, "$indent ", currentDepth + 1, maxDepth, visited) child.recycle() } } /** * 현재 노드의 주변 정보를 출력한다. * 부모 노드(및 그 자식들)와 현재 노드(및 그 자식들)를 최대 깊이 2까지만 로그로 출력하여 * 노드 파싱 과정에서 주요 노드들의 위치와 관계를 시각적으로 확인할 수 있도록 한다. */ private fun logSurroundingNodes(node: AccessibilityNodeInfo?) { if (node == null) return Log.d(TAG, "----- 노드 계층 구조 출력 시작 -----") // 부모 노드 및 형제들(부모의 자식들) 출력 val parent = node.parent if (parent != null) { Log.d(TAG, "부모 노드 및 자식들:") printNodeHierarchy(parent, " ", 0, 2) parent.recycle() } else { Log.d(TAG, "부모 노드 없음.") } // 현재 노드와 자식들 출력 Log.d(TAG, "현재 노드 및 자식들:") printNodeHierarchy(node, " ", 0, 2) Log.d(TAG, "----- 노드 계층 구조 출력 종료 -----") } // 제스처를 디스패치하고 결과를 기다림. private fun dispatchGestureAndWait(service: AutoEatsAccessibilityService, gesture: GestureDescription): Boolean { var result = false val latch = CountDownLatch(1) service.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription?) { result = true latch.countDown() } override fun onCancelled(gestureDescription: GestureDescription?) { latch.countDown() } }, null) latch.await(1, TimeUnit.SECONDS) return result } // 주어진 조건에 맞는 노드를 timeoutSeconds(초) 동안 기다림. private fun waitForNode( service: AutoEatsAccessibilityService, predicate: (AccessibilityNodeInfo) -> Boolean, timeoutSeconds: Int ): AccessibilityNodeInfo? { repeat(timeoutSeconds) { attempt -> service.rootInActiveWindow?.let { root -> findNodeRecursive(root, predicate)?.let { return it } } Log.d(TAG, "Waiting for node... attempt ${attempt + 1}") Thread.sleep(1000) } return null } // TTS 안내를 실행하고 TTS가 종료될 때까지 대기. private suspend fun performTTSFeedbackAndWait( service: AutoEatsAccessibilityService, message: String, delayMultiplier: Long = 1 ): Boolean { Log.d(TAG, "TTS start: $message") val ttsDone = AccessibilityUtils.speakFeedbackAndWait(service, message) delay(TTS_VERIFICATION_DELAY * delayMultiplier) return ttsDone && !AccessibilityUtils.isTtsSpeaking() } private fun stopVoiceRecognitionIfRunning() { try { com.metainai.autoeats.AutoEatsApplication.instance.voiceCommandService?.takeIf { it.isListening() } ?.apply { Log.d(TAG, "Stopping voice recognition") stopVoiceRecognition() } } catch (e: Exception) { Log.e(TAG, "Error stopping voice recognition", e) } } private fun tapAt(service: AutoEatsAccessibilityService, x: Float, y: Float, feedback: String) { AccessibilityUtils.performTap(service, x, y) Log.d(TAG, "Tap at: ($x, $y)") AccessibilityUtils.speakFeedback(service, feedback) } private fun dumpNodeInfo(node: AccessibilityNodeInfo?, tag: String) { node ?: return val bounds = Rect().apply { node.getBoundsInScreen(this) } Log.d(TAG, "$tag - Class: ${node.className}, Text: ${node.text}, Desc: ${node.contentDescription}, " + "Bounds: $bounds, Clickable: ${node.isClickable}, Children: ${node.childCount}") } private fun swipeUp(service: AutoEatsAccessibilityService, duration: Long = 300): Boolean { return try { val metrics = service.resources.displayMetrics val startX = (metrics.widthPixels / 2).toFloat() val startY = metrics.heightPixels * 0.8f val endY = metrics.heightPixels * 0.3f val path = Path().apply { moveTo(startX, startY) lineTo(startX, endY) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, duration)) .build() val result = dispatchGestureAndWait(service, gesture) Log.d(TAG, "Swipe up result: $result") result } catch (e: Exception) { Log.e(TAG, "Swipe up error", e) false } } private fun swipeUpInWebView(service: AutoEatsAccessibilityService, rootNode: AccessibilityNodeInfo): Boolean { val webViewNode = findNodeRecursive(rootNode) { it.className?.toString()?.contains("android.webkit.WebView") == true } return if (webViewNode != null) { val rect = Rect().apply { webViewNode.getBoundsInScreen(this) } webViewNode.recycle() val startX = rect.exactCenterX() val startY = rect.bottom - (rect.height() * 0.2f) val endY = rect.top + (rect.height() * 0.2f) val path = Path().apply { moveTo(startX, startY) lineTo(startX, endY) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 300)) .build() val result = dispatchGestureAndWait(service, gesture) Log.d(TAG, "Swipe up in WebView result: $result") result } else { Log.d(TAG, "WebView not found, fallback to swipe up") swipeUp(service) } } private fun scrollForward(service: AutoEatsAccessibilityService, rootNode: AccessibilityNodeInfo): Boolean { val webViewNode = findNodeRecursive(rootNode) { it.className?.toString()?.contains("android.webkit.WebView") == true } if (webViewNode != null) { webViewNode.recycle() Log.d(TAG, "WebView detected, using swipe up in WebView") val result = swipeUpInWebView(service, rootNode) Thread.sleep(1000) return result } val scrollableNode = findNodeRecursive(rootNode) { it.isScrollable } if (scrollableNode != null) { val result = scrollableNode.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) scrollableNode.recycle() if (result) { Log.d(TAG, "Scrolled via ACTION_SCROLL_FORWARD") Thread.sleep(1000) return true } } Log.d(TAG, "Fallback to swipe up for scrolling") val result = swipeUp(service) Thread.sleep(1000) return result } private fun simulateTapGesture(service: AutoEatsAccessibilityService, x: Float, y: Float, feedback: String) { Log.d(TAG, "Simulating tap at ($x, $y)") val path = Path().apply { moveTo(x, y) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 100)) .build() if (dispatchGestureAndWait(service, gesture)) { AccessibilityUtils.speakFeedback(service, feedback) } else { Log.e(TAG, "Tap gesture failed at ($x, $y)") } } private fun clickPickupNode(service: AutoEatsAccessibilityService) { Log.d(TAG, "Clicking pickup node") val pickupNode = waitForNode(service, { it.className?.toString() == "android.view.View" && it.contentDescription?.toString() == "픽업" }, timeoutSeconds = 20) if (pickupNode != null) { dumpNodeInfo(pickupNode, "Pickup node found") logSurroundingNodes(pickupNode) if (!pickupNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { val rect = Rect().apply { pickupNode.getBoundsInScreen(this) } tapAt(service, rect.centerX().toFloat(), rect.centerY().toFloat(), "픽업 선택합니다.") } else { AccessibilityUtils.speakFeedback(service, "픽업 선택합니다.") } pickupNode.recycle() } else { Log.e(TAG, "Pickup node not found, using fallback tap") tapAt(service, 450f, 300f, "픽업 선택합니다. 노드를 찾지 못했습니다.") } } private fun clickOnStore(service: AutoEatsAccessibilityService) { Log.d(TAG, "Clicking on store") clickPickupNode(service) var attempts = 0 var clicked = false while (attempts < 5) { service.rootInActiveWindow?.let { root -> val storeButton = findNodeRecursive(root) { it.contentDescription?.toString() == "매장 바로가기" } if (storeButton == null) { Log.d(TAG, "'매장 바로가기' not visible, ending loop") } else { dumpNodeInfo(storeButton, "Store button found") logSurroundingNodes(storeButton) if (!storeButton.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { val rect = Rect().apply { storeButton.getBoundsInScreen(this) } tapAt(service, rect.centerX().toFloat(), rect.centerY().toFloat(), "매장을 선택합니다.") } else { AccessibilityUtils.speakFeedback(service, "매장을 선택합니다.") } storeButton.recycle() clicked = true } } Thread.sleep(1000) attempts++ } if (!clicked) { Log.e(TAG, "Max attempts reached in clickOnStore, using fallback tap") tapAt(service, 450f, 300f, "매장을 선택합니다. 노드를 찾지 못했습니다.") } } private fun selectBurgerCategory(service: AutoEatsAccessibilityService) { Log.d(TAG, "Selecting burger category") val burgerNode = waitForNode(service, { it.className == "android.view.View" && it.contentDescription?.toString() == "버거" && it.isVisibleToUser }, timeoutSeconds = 10) if (burgerNode != null) { dumpNodeInfo(burgerNode, "Burger category node found") logSurroundingNodes(burgerNode) if (!burgerNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { val rect = Rect().apply { burgerNode.getBoundsInScreen(this) } tapAt(service, rect.centerX().toFloat(), rect.centerY().toFloat(), "버거 선택합니다.") } else { AccessibilityUtils.speakFeedback(service, "버거 카테고리를 선택했습니다.") } burgerNode.recycle() } else { Log.e(TAG, "Burger category node not found") AccessibilityUtils.speakFeedback(service, "버거 카테고리를 찾을 수 없습니다.") } } private fun findAndClickBurger(service: AutoEatsAccessibilityService, burger: String) { Log.d(TAG, "Clicking burger menu: $burger using fixed coordinates") val metrics = service.resources.displayMetrics val x = metrics.widthPixels * 0.25f val y = metrics.heightPixels * 0.9f simulateTapGesture(service, x, y, "$burger 메뉴를 선택합니다.") } private fun clickCartButton(service: AutoEatsAccessibilityService) { Log.d(TAG, "Clicking cart button") var success = false for (i in 0 until 2) { service.rootInActiveWindow?.let { root -> val cartButton = findNodeRecursive(root) { it.className?.toString() == "android.widget.Button" && (it.text?.contains("장바구니") == true) } if (cartButton == null) { Log.d(TAG, "'장바구니' button not visible") } else { dumpNodeInfo(cartButton, "Cart button found") logSurroundingNodes(cartButton) val rect = Rect().apply { cartButton.getBoundsInScreen(this) } simulateTapGesture(service, rect.centerX().toFloat(), rect.centerY().toFloat(), "장바구니 버튼 클릭합니다.") cartButton.recycle() success = true } } Thread.sleep(1500) } if (!success) { Log.e(TAG, "Cart button: maximum attempts reached") } } private fun clickCartView(service: AutoEatsAccessibilityService) { Log.d(TAG, "Clicking cart view") val cartView = waitForNode(service, { it.className?.toString() == "android.view.View" && (it.contentDescription?.contains("장바구니") == true) }, timeoutSeconds = 10) if (cartView != null) { dumpNodeInfo(cartView, "Cart view found") logSurroundingNodes(cartView) if (!cartView.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { val rect = Rect().apply { cartView.getBoundsInScreen(this) } tapAt(service, rect.centerX().toFloat(), rect.centerY().toFloat(), "장바구니 뷰 클릭합니다.") } else { AccessibilityUtils.speakFeedback(service, "장바구니 뷰 클릭합니다.") } cartView.recycle() } else { Log.e(TAG, "Cart view not found, using fallback tap") tapAt(service, 450f, 550f, "장바구니 뷰 클릭합니다.") } } suspend fun executeBurgerOrder( service: AutoEatsAccessibilityService, burger: String, setOrSingle: String, quantity: Int ) { Log.d(TAG, "Executing burger order: burger=$burger, 옵션=$setOrSingle, 수량=$quantity") if (appLaunchInProgress) { Log.d(TAG, "App launch already in progress.") return } AccessibilityUtils.setOrderCompleted(false) pendingOrderDetails = Triple(burger, setOrSingle, quantity) orderTtsDone = false appLaunchInProgress = true try { val orderMsg = "$burger $setOrSingle $quantity 개 주문을 시작합니다. 잠시만 기다려주세요." if (performTTSFeedbackAndWait(service, orderMsg, 2)) { orderTtsDone = true stopVoiceRecognitionIfRunning() if (!launchLotteEatsApp(service, burger, setOrSingle, quantity)) { Log.e(TAG, "App launch failed") AccessibilityUtils.speakFeedback(service, "롯데잇츠 앱 실행에 실패했습니다. 다시 시도해주세요.") return } delay(3000) clickOnStore(service) delay(1000) selectBurgerCategory(service) delay(2000) findAndClickBurger(service, burger) delay(4000) clickCartButton(service) delay(1000) clickCartView(service) Log.d(TAG, "Order completed") AccessibilityUtils.setOrderCompleted(true) val completionMessage = "$burger $setOrSingle $quantity 개 주문이 완료되었습니다." performTTSFeedbackAndWait(service, completionMessage, 2) delay(2000) } else { Log.e(TAG, "TTS did not complete properly") AccessibilityUtils.resetTtsState() AccessibilityUtils.speakFeedback(service, "음성 안내 중 오류가 발생했습니다. 다시 시도해주세요.") } } catch (e: Exception) { Log.e(TAG, "Error during burger order", e) AccessibilityUtils.speakFeedback(service, "주문 과정에서 오류가 발생했습니다. 다시 시도해주세요.") } finally { appLaunchInProgress = false } } private fun launchLotteEatsApp( context: Context, burger: String? = null, setOrSingle: String? = null, quantity: Int = 1 ): Boolean { Log.d(TAG, "Launching LotteEatsApp: burger=$burger, 옵션=$setOrSingle, 수량=$quantity") if (context is AccessibilityService && (!orderTtsDone || AccessibilityUtils.isTtsSpeaking())) { Log.d(TAG, "TTS not completed, delaying app launch") return false } val packageName = "kr.co.angelinus.gift.m" val activityName = "kr.co.angelinus.gift.m.MainActivity" return try { val intent = Intent().apply { component = ComponentName(packageName, activityName) action = Intent.ACTION_MAIN addCategory(Intent.CATEGORY_LAUNCHER) flags = Intent.FLAG_ACTIVITY_NEW_TASK burger?.let { putExtra("burger", it) } setOrSingle?.let { putExtra("option", it) } putExtra("quantity", quantity) } context.startActivity(intent) sendOrderBroadcast(context, burger, setOrSingle, quantity) Log.d(TAG, "App launched successfully") true } catch (e: Exception) { Log.e(TAG, "App launch failed", e) try { context.packageManager.getLaunchIntentForPackage(packageName)?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(this) Log.d(TAG, "App launched via PackageManager") return true } } catch (e2: Exception) { Log.e(TAG, "PackageManager launch failed", e2) } false } } private fun sendOrderBroadcast( context: Context, burger: String?, setOrSingle: String?, quantity: Int ) { try { Intent("com.metainai.autoeats.ORDER_ACTION").apply { putExtra("burger", burger ?: "") putExtra("option", setOrSingle ?: "단품") putExtra("quantity", quantity) putExtra("timestamp", System.currentTimeMillis()) }.also { context.sendBroadcast(it) } Log.d(TAG, "Order broadcast sent") } catch (e: Exception) { Log.e(TAG, "Order broadcast failed", e) } } }

File: accessibility/NodeHandler.kt

kotlin
package com.metainai.autoeats.accessibility import android.util.Log import android.view.accessibility.AccessibilityNodeInfo object NodeHandler { fun logNodeHierarchy(node: AccessibilityNodeInfo, tag: String, depth: Int = 0) { try { val indent = " ".repeat(depth) val nodeText = node.text?.toString() ?: "null" val nodeClass = node.className?.toString() ?: "null" val nodeId = node.viewIdResourceName ?: "null" Log.d(tag, "$indent 노드: 텍스트='$nodeText', 클래스='$nodeClass', ID='$nodeId'") for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue logNodeHierarchy(child, tag, depth + 1) child.recycle() } } catch (e: Exception) { Log.e(tag, "노드 계층 구조 로깅 중 오류 발생", e) } } fun findAllNodes(rootNode: AccessibilityNodeInfo, predicate: (AccessibilityNodeInfo) -> Boolean): List<AccessibilityNodeInfo> { val result = mutableListOf<AccessibilityNodeInfo>() findAllNodesRecursive(rootNode, predicate, result) return result } private fun findAllNodesRecursive( node: AccessibilityNodeInfo, predicate: (AccessibilityNodeInfo) -> Boolean, result: MutableList<AccessibilityNodeInfo> ) { try { if (predicate(node)) { result.add(AccessibilityNodeInfo.obtain(node)) } for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue findAllNodesRecursive(child, predicate, result) child.recycle() } } catch (e: Exception) { Log.e("NodeHandler", "노드 검색 중 오류 발생", e) } } fun findAllNodesByText(rootNode: AccessibilityNodeInfo, text: String): List<AccessibilityNodeInfo> { return findAllNodes(rootNode) { node -> node.text?.contains(text, ignoreCase = true) == true } } fun findAllNodesByClassName(rootNode: AccessibilityNodeInfo, className: String): List<AccessibilityNodeInfo> { return findAllNodes(rootNode) { node -> node.className?.contains(className, ignoreCase = true) == true } } fun findAllClickableNodes(rootNode: AccessibilityNodeInfo): List<AccessibilityNodeInfo> { return findAllNodes(rootNode) { node -> node.isClickable } } fun logNodeProperties(node: AccessibilityNodeInfo, tag: String) { try { Log.d(tag, "노드 속성:") Log.d(tag, "- 텍스트: ${node.text}") Log.d(tag, "- 클래스: ${node.className}") Log.d(tag, "- ID: ${node.viewIdResourceName}") Log.d(tag, "- 클릭 가능: ${node.isClickable}") Log.d(tag, "- 포커스 가능: ${node.isFocusable}") Log.d(tag, "- 체크 가능: ${node.isCheckable}") Log.d(tag, "- 체크됨: ${node.isChecked}") Log.d(tag, "- 활성화됨: ${node.isEnabled}") Log.d(tag, "- 패스워드: ${node.isPassword}") Log.d(tag, "- 스크롤 가능: ${node.isScrollable}") Log.d(tag, "- 선택됨: ${node.isSelected}") Log.d(tag, "- 자식 수: ${node.childCount}") } catch (e: Exception) { Log.e(tag, "노드 속성 로깅 중 오류 발생", e) } } }

File: config/AppConfig.kt

kotlin
package com.metainai.autoeats.config import android.content.Context import android.content.SharedPreferences import com.metainai.autoeats.BuildConfig class AppConfig private constructor(context: Context) { companion object { private const val PREFS_NAME = "autoeats_config" private const val KEY_RTZR_CLIENT_ID = "rtzr_client_id" private const val KEY_RTZR_CLIENT_SECRET = "rtzr_client_secret" private const val KEY_OPENAI_API_KEY = "openai_api_key" private const val KEY_LOTTE_EATS_PACKAGE = "lotte_eats_package" private const val DEFAULT_RTZR_CLIENT_ID = "1Yi7ukOZcpxzk_t5ZsdP" private const val DEFAULT_RTZR_CLIENT_SECRET = "jecutzKh2WjOzSFZApDgN_7Uzqjz6VPOZYG8O5_I" private const val DEFAULT_OPENAI_API_KEY = "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" const val DEFAULT_LOTTE_EATS_PACKAGE = "kr.co.angelinus.gift.m" @Volatile private var instance: AppConfig? = null fun getInstance(context: Context): AppConfig { return instance ?: synchronized(this) { instance ?: AppConfig(context.applicationContext).also { instance = it } } } } private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val rtzrClientId: String get() { return prefs.getString(KEY_RTZR_CLIENT_ID, null) ?: BuildConfig.RTZR_CLIENT_ID.takeIf { it.isNotBlank() && it != "null" } ?: DEFAULT_RTZR_CLIENT_ID } val rtzrClientSecret: String get() { return prefs.getString(KEY_RTZR_CLIENT_SECRET, null) ?: BuildConfig.RTZR_CLIENT_SECRET.takeIf { it.isNotBlank() && it != "null" } ?: DEFAULT_RTZR_CLIENT_SECRET } val openaiApiKey: String get() { return prefs.getString(KEY_OPENAI_API_KEY, null) ?: BuildConfig.OPENAI_API_KEY.takeIf { it.isNotBlank() && it != "null" } ?: DEFAULT_OPENAI_API_KEY } val lotteEatsPackageName: String get() { return prefs.getString(KEY_LOTTE_EATS_PACKAGE, null) ?: DEFAULT_LOTTE_EATS_PACKAGE } fun setRtzrClientId(clientId: String) { prefs.edit().putString(KEY_RTZR_CLIENT_ID, clientId).apply() } fun setRtzrClientSecret(clientSecret: String) { prefs.edit().putString(KEY_RTZR_CLIENT_SECRET, clientSecret).apply() } fun setOpenaiApiKey(apiKey: String) { prefs.edit().putString(KEY_OPENAI_API_KEY, apiKey).apply() } fun setLotteEatsPackageName(packageName: String) { prefs.edit().putString(KEY_LOTTE_EATS_PACKAGE, packageName).apply() } fun resetToDefaults() { prefs.edit().clear().apply() } }

File: gpt/GptService.kt

kotlin
package com.metainai.autoeats.gpt import android.util.Log import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatRole import com.aallam.openai.api.chat.ToolCall import com.aallam.openai.api.chat.ToolChoice import com.aallam.openai.api.chat.chatCompletionRequest import com.aallam.openai.api.model.ModelId import com.aallam.openai.client.OpenAI import com.aallam.openai.client.OpenAIConfig import com.metainai.autoeats.AutoEatsApplication import com.metainai.autoeats.config.AppConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject data class CommandResult( val intent: CommandIntent, val parameters: Map<String, String> ) data class BurgerOrderState( var burger: String? = null, var setOrSingle: String? = null, var quantity: Int = 1, var isComplete: Boolean = false ) { fun isValid(): Boolean { return !burger.isNullOrEmpty() && !setOrSingle.isNullOrEmpty() } } enum class CommandIntent { OPEN_CATEGORY, SELECT_MENU_ITEM, ADD_TO_CART, ADJUST_QUANTITY, CHECKOUT, NAVIGATE_BACK, CONFIRM, CANCEL, ORDER_BURGER, BURGER_SELECTION, SET_OR_SINGLE, QUANTITY_SELECTION, COMPLETE_ORDER, MENU_INFO_REQUEST, HELP_REQUEST, UNKNOWN } class GptService { companion object { private const val TAG = "GptService" val AVAILABLE_BURGERS = listOf( "나폴리맛피아 모짜렐라버거(토마토바질)", ) val SET_OR_SINGLE_OPTIONS = listOf("세트", "단품") private const val TEST_BURGER = "나폴리맛피아 모짜렐라버거(토마토바질)" private const val TEST_SET_OR_SINGLE = "단품" private const val TEST_QUANTITY = 1 } private var currentOrderState = BurgerOrderState() private val appConfig = AppConfig.getInstance(AutoEatsApplication.appContext) private val openAI by lazy { val config = OpenAIConfig( token = appConfig.openaiApiKey, ) OpenAI(config) } fun resetOrderState() { currentOrderState = BurgerOrderState() } fun getCurrentOrderState(): BurgerOrderState { return currentOrderState } private fun preprocessVoiceText(voiceText: String): String { var processed = voiceText processed = processed.replace("한계로", "한 개로") processed = processed.replace("한개로", "한 개로") processed = processed.replace("1계로", "1개로") processed = processed.replace("일개로", "1개로") processed = processed.replace("이개로", "2개로") processed = processed.replace("삼개로", "3개로") processed = processed.replace("리아 불고기 베이크", "리아 불고기 베이컨") processed = processed.replace("리아불고기베이크", "리아 불고기 베이컨") processed = processed.replace("리아불고기베이컨", "리아 불고기 베이컨") processed = processed.replace("세트메뉴", "세트") processed = processed.replace("단품메뉴", "단품") Log.d("GptService", "전처리 전: $voiceText, 전처리 후: $processed") return processed } suspend fun processVoiceCommand(voiceText: String): CommandResult = withContext(Dispatchers.IO) { if (AutoEatsApplication.instance.isTestMode) { val testParameters = mapOf( "burger" to TEST_BURGER, "setOrSingle" to TEST_SET_OR_SINGLE, "quantity" to TEST_QUANTITY.toString() ) currentOrderState.apply { burger = TEST_BURGER setOrSingle = TEST_SET_OR_SINGLE quantity = TEST_QUANTITY isComplete = true } return@withContext CommandResult( intent = CommandIntent.COMPLETE_ORDER, parameters = testParameters ) } val preprocessedText = preprocessVoiceText(voiceText) val systemPrompt = """ 당신은 롯데잇츠 앱에서 버거 주문을 도와주는 AI 비서입니다. 사용자의 음성 명령을 해석하고 다음 카테고리로 분류해주세요: # 버거 주문 관련 명령 1. ORDER_BURGER: 버거 주문 시작 (예: "버거 주문할래", "햄버거 먹고 싶어") 2. BURGER_SELECTION: 버거 선택 (예: "나폴리맛피아 모짜렐라버거(토마토바질) 먹을래", "나폴리맛피아 모짜렐라버거(토마토바질) 주문해줘") 3. SET_OR_SINGLE: 세트/단품 선택 (예: "세트로 할게", "단품으로 주문할래") # 제한된 버거 메뉴 리스트 (이 메뉴만 주문 가능) - 나폴리맛피아 모짜렐라버거(토마토바질) # 세트/단품 옵션 - 세트 - 단품 # 수량 관련 표현 "한 개", "1개", "한개", "일개" 등은 모두 quantity=1로 처리해주세요. "두 개", "2개", "이개" 등은 모두 quantity=2로 처리해주세요. "세 개", "3개", "삼개" 등은 모두 quantity=3으로 처리해주세요. # 응답 형식 응답은 JSON 형태로 제공해주세요: {"intent": "의도_카테고리", "parameters": {"param1": "value1", "param2": "value2"}} # 파라미터 설명 - burger: 선택한 버거 이름 (제한된 메뉴 리스트에서만 선택) - setOrSingle: "세트" 또는 "단품" - quantity: 주문 수량 (숫자) - category: 열려는 카테고리 이름 - item: 선택하려는 메뉴 아이템 이름 사용자의 의도를 정확히 파악하고, 제한된 메뉴 리스트에서만 선택하도록 해주세요. 음성 인식 오류를 고려하여 유사한 발음의 단어도 적절히 해석해주세요. """.trimIndent() val chatMessages = listOf( ChatMessage( role = ChatRole.System, content = systemPrompt ), ChatMessage( role = ChatRole.User, content = preprocessedText ) ) try { val chatCompletionRequest = chatCompletionRequest { model = ModelId("gpt-4o-mini") messages = chatMessages tools { function( name = "command_parser", description = "Parse a voice command to control the app" ) { put("type", JsonPrimitive("object")) putJsonObject("properties") { putJsonObject("intent") { put("type", JsonPrimitive("string")) putJsonArray("enum") { CommandIntent.values().forEach { add(JsonPrimitive(it.name)) } } put("description", JsonPrimitive("The intent of the voice command")) } putJsonObject("parameters") { put("type", JsonPrimitive("object")) put("description", JsonPrimitive("Any parameters extracted from the voice command")) } } putJsonArray("required") { add(JsonPrimitive("intent")) } } } toolChoice = ToolChoice.Auto temperature = 0.2 } val response = openAI.chatCompletion(chatCompletionRequest) val message = response.choices.firstOrNull()?.message if (message != null && !message.toolCalls.isNullOrEmpty()) { val toolCall = message.toolCalls?.firstOrNull() as? ToolCall.Function if (toolCall != null) { val args = Json.parseToJsonElement(toolCall.function.arguments).jsonObject val intentStr = args["intent"]?.jsonPrimitive?.contentOrNull ?: "UNKNOWN" val intent = try { CommandIntent.valueOf(intentStr) } catch (e: IllegalArgumentException) { CommandIntent.UNKNOWN } val parameters = mutableMapOf<String, String>() val paramsObj = args["parameters"]?.jsonObject paramsObj?.entries?.forEach { (key, value) -> if (value is JsonPrimitive) { parameters[key] = value.contentOrNull ?: "" } } // 버거 주문 상태 업데이트 updateOrderState(intent, parameters) return@withContext CommandResult( intent = intent, parameters = parameters ) } } // If we couldn't parse the response properly, handle as plain text val content = message?.content ?: "" // Attempt to extract JSON from plain text content val jsonRegex = "\\{[\\s\\S]*\\}".toRegex() val jsonMatch = jsonRegex.find(content) val jsonStr = jsonMatch?.value ?: "{}" try { val jsonElement = Json.parseToJsonElement(jsonStr) val jsonObject = jsonElement.jsonObject // Parse intent val intentStr = jsonObject["intent"]?.jsonPrimitive?.contentOrNull ?: "UNKNOWN" val intent = try { CommandIntent.valueOf(intentStr) } catch (e: IllegalArgumentException) { CommandIntent.UNKNOWN } val parameters = mutableMapOf<String, String>() val paramsObj = jsonObject["parameters"]?.jsonObject paramsObj?.entries?.forEach { (key, value) -> if (value is JsonPrimitive) { parameters[key] = value.contentOrNull ?: "" } } // 버거 주문 상태 업데이트 updateOrderState(intent, parameters) CommandResult( intent = intent, parameters = parameters ) } catch (e: Exception) { Log.e(TAG, "JSON 파싱 오류", e) CommandResult(intent = CommandIntent.UNKNOWN, parameters = emptyMap()) } } catch (e: Exception) { Log.e(TAG, "GPT 처리 오류", e) CommandResult(intent = CommandIntent.UNKNOWN, parameters = emptyMap()) } } /** * 버거 주문 상태 업데이트 */ private fun updateOrderState(intent: CommandIntent, parameters: Map<String, String>) { when (intent) { CommandIntent.ORDER_BURGER -> { // 주문 시작 - 상태 초기화 resetOrderState() } CommandIntent.BURGER_SELECTION -> { // 버거 선택 val burger = parameters["burger"] if (!burger.isNullOrEmpty() && AVAILABLE_BURGERS.any { it.equals(burger, ignoreCase = true) }) { currentOrderState.burger = burger } } CommandIntent.SET_OR_SINGLE -> { val setOrSingle = parameters["setOrSingle"] if (!setOrSingle.isNullOrEmpty() && SET_OR_SINGLE_OPTIONS.any { it.equals(setOrSingle, ignoreCase = true) }) { currentOrderState.setOrSingle = setOrSingle } } else -> { } } Log.d(TAG, "주문 상태 업데이트: $currentOrderState") } fun getNextRequiredInfoMessage(): String? { return when { currentOrderState.burger == null -> "어떤 버거를 드시겠어요? 저희는 나폴리맛피아 모짜렐라버거(토마토바질), 나폴리맛피아 모짜렐라버거(발사믹바질)를 제공합니다." currentOrderState.setOrSingle == null -> "세트로 주문하시겠어요, 아니면 단품으로 주문하시겠어요?" !currentOrderState.isComplete -> "주문을 완료하시겠어요? '주문 완료'라고 말씀해주세요." else -> null } } fun getOrderSummary(): String { val burger = currentOrderState.burger ?: "선택 안됨" val setOrSingle = currentOrderState.setOrSingle ?: "선택 안됨" val quantity = currentOrderState.quantity return "현재 주문 정보: $burger, $setOrSingle, ${quantity}개" } fun setFixedQuantity(quantity: Int = 1) { currentOrderState.quantity = quantity Log.d(TAG, "setFixedQuantity: 수량을 $quantity 개로 고정 설정") } }

File: service/VoiceCommandService.kt

kotlin
package com.metainai.autoeats.service import RTZRSpeechRecognizer import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Binder import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper import android.provider.Settings import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.speech.tts.Voice import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.metainai.autoeats.MainActivity import com.metainai.autoeats.R import com.metainai.autoeats.accessibility.AccessibilityUtils import com.metainai.autoeats.accessibility.AutoEatsAccessibilityService import com.metainai.autoeats.config.AppConfig import com.metainai.autoeats.gpt.BurgerOrderState import com.metainai.autoeats.gpt.CommandIntent import com.metainai.autoeats.gpt.CommandResult import com.metainai.autoeats.gpt.GptService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.lang.ref.WeakReference interface VoiceCommandCallback { fun onRecognizedSpeech(text: String) fun onAiResponse(text: String) } class VoiceCommandService : Service(), TextToSpeech.OnInitListener { enum class ServiceState { IDLE, LISTENING, PROCESSING, SPEAKING, GREETING } private val TAG = "VoiceCommandService" private val NOTIFICATION_ID = 1001 private val CHANNEL_ID = "AutoEatsVoiceChannel" private val binder = LocalBinder() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private lateinit var speechRecognizer: RTZRSpeechRecognizer private lateinit var gptService: GptService private lateinit var appConfig: AppConfig private lateinit var tts: TextToSpeech private var accessibilityService: WeakReference<AutoEatsAccessibilityService>? = null private var lastConnectionAttemptTime = 0L private val MAX_RECONNECTION_ATTEMPTS = 3 private var reconnectionAttempts = 0 private var pendingAccessibilityCheck = false private val CONNECTION_ATTEMPT_INTERVAL = 5000L private var callback: VoiceCommandCallback? = null private var currentState = ServiceState.IDLE private var recognitionJob: Job? = null private var isServiceReady = false private var pendingStart = false private var lastStatusUpdateTime = 0L private val STATUS_UPDATE_INTERVAL = 5000 private var isTtsActive = false private var isGreetingMode = false private var isOrderProcessing = false inner class LocalBinder : Binder() { fun getService(): VoiceCommandService = this@VoiceCommandService } private val ttsListener = object : TextToSpeech.OnUtteranceCompletedListener { override fun onUtteranceCompleted(utteranceId: String?) { Log.d(TAG, "TTS 발화 완료: $utteranceId") if (utteranceId == "START_LISTENING_AFTER_SPEECH") { serviceScope.launch { delay(300) currentState = ServiceState.IDLE startVoiceRecognition() } } } } fun isListening(): Boolean { return currentState == ServiceState.LISTENING } override fun onCreate() { super.onCreate() Log.d(TAG, "onCreate: VoiceCommandService 생성") val application = applicationContext as? com.metainai.autoeats.AutoEatsApplication if (application != null) { Log.d(TAG, "onCreate: VoiceCommandService 인스턴스를 Application에 등록") application.voiceCommandService = this } createNotificationChannel() if (!checkRequiredPermissions()) { Log.e(TAG, "onCreate: 필수 권한(RECORD_AUDIO)이 없습니다.") Toast.makeText(this, "음성 인식을 위한 마이크 권한이 필요합니다.", Toast.LENGTH_LONG).show() val permissionIntent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra("REQUEST_PERMISSIONS", true) } startActivity(permissionIntent) } else { startForeground(NOTIFICATION_ID, createEnhancedNotification()) } appConfig = AppConfig.getInstance(this) tts = TextToSpeech(applicationContext, this) gptService = GptService() speechRecognizer = RTZRSpeechRecognizer( context = this, resultCallback = { text -> processRecognizedSpeech(text) }, errorCallback = { error -> handleSpeechError(error) } ) isServiceReady = true if (pendingStart) { startVoiceRecognition() pendingStart = false } } fun setCallback(callback: VoiceCommandCallback) { this.callback = callback } private fun findBestVoice(locale: java.util.Locale) { try { if (!::tts.isInitialized) { Log.e(TAG, "findBestVoice: TTS가 초기화되지 않았습니다.") return } val availableVoices = tts.voices if (availableVoices.isNullOrEmpty()) { Log.d(TAG, "findBestVoice: 사용 가능한 음성이 없습니다.") return } val matchingVoices = availableVoices.filter { voice -> voice.locale.language == locale.language } if (matchingVoices.isEmpty()) { Log.d(TAG, "findBestVoice: 해당 언어($locale)에 맞는 음성이 없습니다.") return } val priorityKeywords = listOf("premium", "high", "enhanced", "network", "full") val bestVoice = matchingVoices.firstOrNull { voice -> priorityKeywords.any { keyword -> voice.name.contains(keyword, ignoreCase = true) } } ?: matchingVoices.firstOrNull { it.quality > Voice.QUALITY_NORMAL } ?: matchingVoices.first() bestVoice?.let { tts.voice = it Log.d(TAG, "findBestVoice: 최적의 음성 선택됨 - ${it.name}, 품질: ${it.quality}") } Log.d(TAG, "findBestVoice: 사용 가능한 음성 목록:") matchingVoices.forEachIndexed { index, voice -> Log.d(TAG, "$index: 이름=${voice.name}, 품질=${voice.quality}, 특성=${voice.features}") } } catch (e: Exception) { Log.e(TAG, "findBestVoice: 최적의 음성 찾기 중 오류 발생", e) } } private fun checkRequiredPermissions(): Boolean { return ContextCompat.checkSelfPermission( this, android.Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand: 액션=${intent?.action}, 플래그=$flags, 시작ID=$startId") if (intent?.action == "STOP_SERVICE") { Log.d(TAG, "onStartCommand: 서비스 중지 요청 수신") stopVoiceRecognition() try { if (::tts.isInitialized) { tts.stop() } currentState = ServiceState.IDLE serviceScope.cancel() Log.d(TAG, "onStartCommand: 코루틴 스코프가 취소되었습니다.") speechRecognizer.destroy() Log.d(TAG, "onStartCommand: 음성 인식기가 해제되었습니다.") if (::tts.isInitialized) { tts.shutdown() Log.d(TAG, "onStartCommand: TTS가 종료되었습니다.") } val application = applicationContext as? com.metainai.autoeats.AutoEatsApplication if (application != null) { Log.d(TAG, "onStartCommand: VoiceCommandService 인스턴스를 Application에서 제거") application.voiceCommandService = null } } catch (e: Exception) { Log.e(TAG, "서비스 종료 중 오류 발생", e) } finally { stopForeground(true) stopSelf() Log.d(TAG, "onStartCommand: 서비스 종료 완료") } return START_NOT_STICKY } if (checkRequiredPermissions()) { startForeground(NOTIFICATION_ID, createEnhancedNotification()) Log.d(TAG, "onStartCommand: 포그라운드 서비스로 시작되었습니다.") } else { Log.e(TAG, "onStartCommand: 필수 권한(RECORD_AUDIO)이 없어 포그라운드 서비스를 시작할 수 없습니다.") stopSelf() return START_NOT_STICKY } if (!checkRequiredPermissions()) { Log.e(TAG, "필수 권한(RECORD_AUDIO)이 없습니다.") Toast.makeText(this, "음성 인식을 위한 마이크 권한이 필요합니다.", Toast.LENGTH_LONG).show() val permissionIntent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra("REQUEST_PERMISSIONS", true) } startActivity(permissionIntent) return START_STICKY } if (accessibilityService?.get() == null) { if (!AutoEatsAccessibilityService.isServiceEnabled(this)) { Toast.makeText(this, "접근성 서비스가 활성화되어 있지 않습니다.", Toast.LENGTH_LONG).show() AutoEatsAccessibilityService.openAccessibilitySettings(this) } } return START_STICKY } override fun onBind(intent: Intent): IBinder { return binder } override fun onDestroy() { try { Log.d(TAG, "onDestroy: 서비스를 종료합니다.") if (isListening()) { stopVoiceRecognition() } if (::tts.isInitialized) { tts.stop() } currentState = ServiceState.IDLE try { serviceScope.cancel() Log.d(TAG, "onDestroy: 코루틴 스코프가 취소되었습니다.") } catch (e: Exception) { Log.e(TAG, "코루틴 스코프 취소 중 오류 발생", e) } try { speechRecognizer.destroy() Log.d(TAG, "onDestroy: 음성 인식기가 해제되었습니다.") } catch (e: Exception) { Log.e(TAG, "음성 인식기 해제 중 오류 발생", e) } if (::tts.isInitialized) { try { tts.shutdown() Log.d(TAG, "onDestroy: TTS가 종료되었습니다.") } catch (e: Exception) { Log.e(TAG, "TTS 종료 중 오류 발생", e) } } val application = applicationContext as? com.metainai.autoeats.AutoEatsApplication if (application != null) { Log.d(TAG, "onDestroy: VoiceCommandService 인스턴스를 Application에서 제거") application.voiceCommandService = null } stopForeground(true) stopSelf() Log.d(TAG, "onDestroy: 서비스 종료 완료") } catch (e: Exception) { Log.e(TAG, "서비스 종료 중 오류 발생", e) } finally { super.onDestroy() } } fun registerAccessibilityService(service: AutoEatsAccessibilityService) { try { Log.d(TAG, "registerAccessibilityService: 접근성 서비스 등록") if (service != null) { accessibilityService = WeakReference(service) reconnectionAttempts = 0 lastConnectionAttemptTime = System.currentTimeMillis() pendingAccessibilityCheck = false Log.d(TAG, "registerAccessibilityService: 접근성 서비스가 성공적으로 등록되었습니다.") } else { Log.e(TAG, "registerAccessibilityService: 등록할 접근성 서비스가 null입니다.") } } catch (e: Exception) { Log.e(TAG, "registerAccessibilityService: 접근성 서비스 등록 중 오류 발생", e) } } fun unregisterAccessibilityService() { try { Log.d(TAG, "unregisterAccessibilityService: 접근성 서비스 등록 해제") accessibilityService = null reconnectionAttempts = 0 pendingAccessibilityCheck = false Log.d(TAG, "unregisterAccessibilityService: 접근성 서비스가 성공적으로 등록 해제되었습니다.") } catch (e: Exception) { Log.e(TAG, "unregisterAccessibilityService: 접근성 서비스 등록 해제 중 오류 발생", e) } } private fun ensureAccessibilityServiceConnected(): Boolean { if (accessibilityService?.get() != null) { return true } val currentTime = System.currentTimeMillis() if (AutoEatsAccessibilityService.isServiceEnabled(this)) { if (reconnectionAttempts >= MAX_RECONNECTION_ATTEMPTS) { Log.e(TAG, "ensureAccessibilityServiceConnected: 최대 재연결 시도 횟수($MAX_RECONNECTION_ATTEMPTS)를 초과하였습니다.") reconnectionAttempts = 0 return false } if (!pendingAccessibilityCheck && (currentTime - lastConnectionAttemptTime) > CONNECTION_ATTEMPT_INTERVAL) { Log.d(TAG, "ensureAccessibilityServiceConnected: 접근성 서비스 연결 시도 (#${reconnectionAttempts + 1})") lastConnectionAttemptTime = currentTime reconnectionAttempts++ pendingAccessibilityCheck = true serviceScope.launch { delay(2000) pendingAccessibilityCheck = false if (accessibilityService?.get() == null) { Log.d(TAG, "ensureAccessibilityServiceConnected: 연결 시도 후에도 접근성 서비스가 연결되지 않았습니다.") if (reconnectionAttempts >= MAX_RECONNECTION_ATTEMPTS) { speak("접근성 서비스가 연결되지 않았습니다. 앱을 재시작해주세요.") openAccessibilitySettings() } } else { Log.d(TAG, "ensureAccessibilityServiceConnected: 접근성 서비스 연결 성공!") reconnectionAttempts = 0 } } } return true } else { Log.d(TAG, "ensureAccessibilityServiceConnected: 접근성 서비스가 활성화되어 있지 않습니다.") speak("접근성 서비스가 활성화되어 있지 않습니다. 설정에서 활성화해주세요.") openAccessibilitySettings() return false } } private fun openAccessibilitySettings() { try { AutoEatsAccessibilityService.openAccessibilitySettings(this) } catch (e: Exception) { Log.e(TAG, "접근성 설정 화면을 여는 중 오류 발생", e) try { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } catch (e: Exception) { Log.e(TAG, "대체 방법으로도 접근성 설정 화면을 열지 못했습니다", e) } } } fun startVoiceRecognition() { try { Log.d(TAG, "startVoiceRecognition: 음성 인식 시작 시도") if (isOrderProcessing) { Log.d(TAG, "startVoiceRecognition: 주문 처리 중이므로 음성 인식을 시작하지 않습니다.") return } if (AccessibilityUtils.isTtsSpeaking()) { Log.d(TAG, "startVoiceRecognition: TTS가 활성 상태입니다. 음성 인식을 시작하지 않습니다.") return } if (isTtsActive) { Log.d(TAG, "startVoiceRecognition: TTS가 활성 상태입니다. 음성 인식을 시작하지 않습니다.") return } if (!isServiceReady) { Log.d(TAG, "startVoiceRecognition: 서비스가 준비되지 않았습니다. 대기 상태로 전환합니다.") pendingStart = true return } if (currentState != ServiceState.IDLE) { Log.d(TAG, "startVoiceRecognition: 이미 음성 인식 중이거나 TTS 발화 중입니다. 상태: $currentState") if (recognitionJob?.isActive == true) { Log.d(TAG, "startVoiceRecognition: 이미 음성 인식 작업이 실행 중입니다.") return } if (currentState == ServiceState.SPEAKING || currentState == ServiceState.GREETING) { Log.d(TAG, "startVoiceRecognition: TTS 발화 중입니다. 발화 완료 후 자동으로 음성 인식이 시작됩니다.") return } if (currentState == ServiceState.PROCESSING) { Log.d(TAG, "startVoiceRecognition: 명령 처리 중입니다. 처리 완료 후 자동으로 음성 인식이 시작됩니다.") return } return } if (!ensureAccessibilityServiceConnected()) { return } Log.d(TAG, "startVoiceRecognition: 음성 인식을 시작합니다.") currentState = ServiceState.LISTENING recognitionJob?.cancel() recognitionJob = serviceScope.launch { speechRecognizer.startListening() } } catch (e: Exception) { Log.e(TAG, "음성 인식 시작 중 오류 발생", e) currentState = ServiceState.IDLE } } private val utteranceListener = object : android.speech.tts.UtteranceProgressListener() { override fun onStart(utteranceId: String?) { isTtsActive = true stopVoiceRecognition() } override fun onDone(utteranceId: String?) { isTtsActive = false Handler(Looper.getMainLooper()).post { if (!isListening()) { startVoiceRecognition() } } } override fun onError(utteranceId: String?) { isTtsActive = false } } fun stopVoiceRecognition() { try { Log.d(TAG, "stopVoiceRecognition: 음성 인식 중지 시도") if (currentState == ServiceState.IDLE) { Log.d(TAG, "stopVoiceRecognition: 이미 음성 인식이 중지되어 있습니다.") return } recognitionJob?.cancel() recognitionJob = null currentState = ServiceState.IDLE speechRecognizer.stopListening() Log.d(TAG, "stopVoiceRecognition: 음성 인식이 중지되었습니다.") } catch (e: Exception) { Log.e(TAG, "음성 인식 중지 중 오류 발생", e) currentState = ServiceState.IDLE } } private fun processRecognizedSpeech(text: String) { if (isOrderProcessing) { Log.d(TAG, "processRecognizedSpeech: 주문 처리 중이므로 인식된 음성을 무시합니다: '$text'") return } if (currentState == ServiceState.SPEAKING || currentState == ServiceState.GREETING) { Log.d(TAG, "processRecognizedSpeech: TTS 발화 중이므로 인식 결과를 무시합니다. 텍스트: '$text'") return } Log.d(TAG, "processRecognizedSpeech: 인식된 음성: '$text'") currentState = ServiceState.PROCESSING callback?.onRecognizedSpeech(text) if (text.isEmpty()) { Log.d(TAG, "processRecognizedSpeech: 빈 텍스트가 인식되었습니다.") speak("음성을 인식하지 못했습니다. 다시 시도해주세요.") restartListening() return } if (text.length < 3) { Log.d(TAG, "processRecognizedSpeech: 너무 짧은 텍스트가 인식되었습니다: '$text'") speak("말씀을 잘 이해하지 못했습니다. 다시 말씀해주세요.") restartListening() return } serviceScope.launch { try { Log.d(TAG, "processRecognizedSpeech: GPT로 명령 처리 시작") val commandResult = gptService.processVoiceCommand(text) Log.d( TAG, "processRecognizedSpeech: 해석된 명령: ${commandResult.intent}, 파라미터: ${commandResult.parameters}" ) if (commandResult.intent == CommandIntent.UNKNOWN) { Log.d(TAG, "processRecognizedSpeech: 알 수 없는 명령입니다.") speak("명령을 이해하지 못했습니다: $text") restartListening() return@launch } when (commandResult.intent) { CommandIntent.ORDER_BURGER, CommandIntent.BURGER_SELECTION, CommandIntent.SET_OR_SINGLE, CommandIntent.QUANTITY_SELECTION -> { handleBurgerOrderCommand(commandResult) } else -> { handleGeneralCommand(commandResult) } } Log.d(TAG, "processRecognizedSpeech: 명령 처리 완료, 다음 명령 대기") restartListening() } catch (e: Exception) { Log.e(TAG, "processRecognizedSpeech: 명령 처리 중 오류 발생", e) speak("명령 처리 중 오류가 발생했습니다.") restartListening() } } } private fun handleBurgerOrderCommand(commandResult: CommandResult) { Log.d(TAG, "handleBurgerOrderCommand: 버거 주문 명령 처리 - ${commandResult.intent}") when (commandResult.intent) { CommandIntent.ORDER_BURGER -> { speak("버거 주문을 시작합니다. 어떤 버거를 드시겠어요? 저희는 리아 불고기 베이컨, 데리버거, 클래식치즈버거, 한우불고기 버거, 나폴리맛피아 모짜렐라버거(토마토바질), 나폴리맛피아 모짜렐라버거(발사믹바질)를 제공합니다.") } CommandIntent.BURGER_SELECTION -> { val burger = commandResult.parameters["burger"] if (!burger.isNullOrEmpty()) { speak("$burger 선택하셨습니다. 세트로 주문하시겠어요, 아니면 단품으로 주문하시겠어요?") } else { speak("어떤 버거를 선택하셨는지 이해하지 못했습니다. 다시 말씀해주세요.") } } CommandIntent.SET_OR_SINGLE -> { val setOrSingle = commandResult.parameters["setOrSingle"] if (!setOrSingle.isNullOrEmpty()) { gptService.setFixedQuantity(1) val summary = gptService.getOrderSummary() speak("$setOrSingle 선택하셨습니다. $summary 주문을 진행합니다.") val orderState = gptService.getCurrentOrderState() serviceScope.launch { executeOrderInLotteEats(orderState) } } else { speak("세트인지 단품인지 이해하지 못했습니다. 다시 말씀해주세요.") } } CommandIntent.QUANTITY_SELECTION -> { Log.d(TAG, "handleBurgerOrderCommand: QUANTITY_SELECTION 명령 무시 (수량 항상 1개로 고정)") } else -> { val nextInfoMessage = gptService.getNextRequiredInfoMessage() if (nextInfoMessage != null) { speak(nextInfoMessage) } } } } private suspend fun executeOrderInLotteEats(orderState: BurgerOrderState) { if (!ensureAccessibilityServiceConnected()) { Log.e(TAG, "executeOrderInLotteEats: 접근성 서비스 연결 실패") speak("접근성 서비스에 연결할 수 없어 주문을 진행할 수 없습니다. 앱을 재시작해주세요.") return } val service = accessibilityService?.get() if (service != null) { Log.d(TAG, "executeOrderInLotteEats: 접근성 서비스로 주문 명령 전달") isOrderProcessing = true stopVoiceRecognition() val parameters = mutableMapOf<String, String>() parameters["burger"] = orderState.burger ?: "" parameters["setOrSingle"] = orderState.setOrSingle ?: "" parameters["quantity"] = "1" val commandResult = CommandResult( intent = CommandIntent.COMPLETE_ORDER, parameters = parameters ) try { service.executeCommand(commandResult) gptService.resetOrderState() Log.d(TAG, "executeOrderInLotteEats: 주문 명령 실행 완료, TTS 완료 대기") delay(1000) while (AccessibilityUtils.isTtsSpeaking()) { Log.d(TAG, "executeOrderInLotteEats: TTS 발화가 진행 중입니다. 대기 중...") delay(500) } Log.d(TAG, "executeOrderInLotteEats: 모든 TTS 및 접근성 작업이 완료되었습니다.") delay(2000) Log.d(TAG, "executeOrderInLotteEats: 주문 및 TTS가 완료되어 서비스 종료 준비") stopSelf() val closeIntent = Intent("com.metainai.autoeats.CLOSE_APP") sendBroadcast(closeIntent) } catch (e: Exception) { Log.e(TAG, "executeOrderInLotteEats: 명령 실행 중 오류 발생", e) AccessibilityUtils.speakFeedback(service, "주문 실행 중 오류가 발생했습니다. 다시 시도해주세요.") } finally { isOrderProcessing = false } } else { Log.e(TAG, "executeOrderInLotteEats: 접근성 서비스 참조가 null입니다 (예상치 못한 상황)") speak("접근성 서비스에 연결할 수 없어 주문을 진행할 수 없습니다. 앱을 재시작해주세요.") isOrderProcessing = false } } private fun handleGeneralCommand(commandResult: CommandResult) { if (!ensureAccessibilityServiceConnected()) { return } val service = accessibilityService?.get() if (service != null) { Log.d(TAG, "handleGeneralCommand: 접근성 서비스로 명령 전달") serviceScope.launch { service.executeCommand(commandResult) } } else { Log.e(TAG, "handleGeneralCommand: 접근성 서비스 참조가 null입니다 (예상치 못한 상황)") speak("접근성 서비스에 연결할 수 없어 명령을 실행할 수 없습니다. 앱을 재시작해주세요.") } } private fun handleSpeechError(error: Int) { try { val errorCode = when (error) { android.speech.SpeechRecognizer.ERROR_AUDIO -> "ERROR_AUDIO" android.speech.SpeechRecognizer.ERROR_CLIENT -> "ERROR_CLIENT" android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "ERROR_INSUFFICIENT_PERMISSIONS" android.speech.SpeechRecognizer.ERROR_NETWORK -> "ERROR_NETWORK" android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "ERROR_NO_MATCH" android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "ERROR_RECOGNIZER_BUSY" android.speech.SpeechRecognizer.ERROR_SERVER -> "ERROR_SERVER" android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "ERROR_SPEECH_TIMEOUT" RTZRSpeechRecognizer.ERROR_POLLING_FAILED -> "ERROR_POLLING_FAILED" RTZRSpeechRecognizer.ERROR_TRANSCRIBE_FAILED -> "ERROR_TRANSCRIBE_FAILED" RTZRSpeechRecognizer.ERROR_FILE_NOT_FOUND -> "ERROR_FILE_NOT_FOUND" else -> "UNKNOWN_ERROR" } val errorMessage = when (error) { android.speech.SpeechRecognizer.ERROR_AUDIO -> "음성 인식 중 오류가 발생했습니다. 다시 시도해주세요." android.speech.SpeechRecognizer.ERROR_CLIENT -> "앱 내부 오류가 발생했습니다. 다시 시도해주세요." android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "음성 인식을 위한 권한이 필요합니다." android.speech.SpeechRecognizer.ERROR_NETWORK -> "네트워크 연결을 확인해주세요." android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "네트워크 응답이 늦어지고 있습니다. 다시 시도해주세요." android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "말씀을 알아듣지 못했습니다. 다시 말씀해주세요." android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "음성 인식 서비스가 사용 중입니다. 잠시 후 다시 시도해주세요." android.speech.SpeechRecognizer.ERROR_SERVER -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "말씀이 없어 음성 인식이 종료되었습니다. 다시 시도해주세요." RTZRSpeechRecognizer.ERROR_POLLING_FAILED -> "음성 인식 결과를 가져오는 중 오류가 발생했습니다. 다시 시도해주세요." RTZRSpeechRecognizer.ERROR_TRANSCRIBE_FAILED -> "음성 변환 중 오류가 발생했습니다. 다시 시도해주세요." RTZRSpeechRecognizer.ERROR_FILE_NOT_FOUND -> "음성 파일을 찾을 수 없습니다. 다시 시도해주세요." else -> "알 수 없는 오류가 발생했습니다. 다시 시도해주세요." } Log.e(TAG, "handleSpeechError: 음성 인식 오류 발생 - $errorCode: $errorMessage") if (error == android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT || error == android.speech.SpeechRecognizer.ERROR_NO_MATCH ) { Log.d(TAG, "handleSpeechError: 음성 인식 시간 초과 또는 매치 없음, 자동으로 다시 시작") currentState = ServiceState.IDLE restartListening() return } speak(errorMessage) currentState = ServiceState.IDLE restartListening() } catch (e: Exception) { Log.e(TAG, "handleSpeechError: 오류 처리 중 예외 발생", e) currentState = ServiceState.IDLE restartListening() } } private fun restartListening() { try { Log.d(TAG, "restartListening: 음성 인식 재시작 예약") if (currentState != ServiceState.IDLE) { Log.d(TAG, "restartListening: 현재 상태가 IDLE가 아니므로 재시작하지 않습니다. 현재 상태: $currentState") return } if (isOrderProcessing) { Log.d(TAG, "restartListening: 주문 처리 중이므로 음성 인식을 재시작하지 않습니다.") return } serviceScope.launch { try { delay(500) Log.d(TAG, "restartListening: 지연 후 음성 인식 재시작 시도") if (currentState == ServiceState.IDLE) { Log.d(TAG, "restartListening: 음성 인식 시작") startVoiceRecognition() } else { Log.d( TAG, "restartListening: 지연 후 상태가 IDLE가 아니므로 재시작하지 않습니다. 현재 상태: $currentState" ) } } catch (e: Exception) { Log.e(TAG, "restartListening: 코루틴 내부에서 오류 발생", e) } } } catch (e: Exception) { Log.e(TAG, "restartListening: 음성 인식 재시작 예약 중 오류 발생", e) } } fun speak(text: String) { try { Log.d(TAG, "speak: 고급 TTS 발화 - '$text'") if (currentState == ServiceState.SPEAKING) { Log.d(TAG, "speak: 이미 다른 TTS가 발화 중입니다. 발화 취소") tts.stop() serviceScope.launch { delay(200) performSpeak(text) } return } callback?.onAiResponse(text) if (currentState == ServiceState.LISTENING) { Log.d(TAG, "speak: TTS 발화 전 음성 인식 중지") stopVoiceRecognition() } currentState = ServiceState.SPEAKING performSpeak(text) } catch (e: Exception) { Log.e(TAG, "speak: TTS 발화 중 오류 발생", e) currentState = ServiceState.IDLE serviceScope.launch { delay(500) restartListening() } } } private fun performSpeak(text: String) { if (::tts.isInitialized) { isTtsActive = true val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.2f) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { putBoolean( TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS, true ) } } val utteranceId = "speak_" + System.currentTimeMillis().toString() tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, utteranceId) Log.d(TAG, "performSpeak: TTS 발화 시작 - utteranceId: $utteranceId") } else { Log.e(TAG, "performSpeak: TTS가 초기화되지 않았습니다.") currentState = ServiceState.IDLE isTtsActive = false serviceScope.launch { delay(500) restartListening() } } } fun speakGreeting(text: String) { try { Log.d(TAG, "speakGreeting: 인사말 TTS 발화 - '$text'") Log.d(TAG, "speakGreeting: 현재 상태 - $currentState") isGreetingMode = true isTtsActive = true stopVoiceRecognition() if (::tts.isInitialized) { tts.stop() } currentState = ServiceState.GREETING callback?.onAiResponse(text) if (::tts.isInitialized) { val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.0f) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { putBoolean(TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS, true) } } val utteranceId = "greeting_" + System.currentTimeMillis().toString() val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, utteranceId) if (result == TextToSpeech.SUCCESS) { Log.d(TAG, "speakGreeting: 인사말 TTS 발화 시작 성공") } else { Log.e(TAG, "speakGreeting: 인사말 TTS 발화 시작 실패") currentState = ServiceState.IDLE isTtsActive = false isGreetingMode = false } } else { Log.e(TAG, "speakGreeting: TTS가 초기화되지 않았습니다.") currentState = ServiceState.IDLE isTtsActive = false isGreetingMode = false } } catch (e: Exception) { Log.e(TAG, "speakGreeting: TTS 발화 중 오류 발생", e) currentState = ServiceState.IDLE isTtsActive = false isGreetingMode = false } } fun speakWithoutListening(text: String) { try { Log.d(TAG, "speakWithoutListening: 고급 TTS 발화 (인식 재시작 없음) - '$text'") if (currentState == ServiceState.SPEAKING) { Log.d(TAG, "speakWithoutListening: 이미 다른 TTS가 발화 중입니다. 발화 취소") tts.stop() serviceScope.launch { delay(200) performSpeakWithoutListening(text) } return } callback?.onAiResponse(text) if (currentState == ServiceState.LISTENING) { Log.d(TAG, "speakWithoutListening: TTS 발화 전 음성 인식 중지") stopVoiceRecognition() } currentState = ServiceState.SPEAKING performSpeakWithoutListening(text) } catch (e: Exception) { Log.e(TAG, "speakWithoutListening: TTS 발화 중 오류 발생", e) currentState = ServiceState.IDLE } } private fun performSpeakWithoutListening(text: String) { if (::tts.isInitialized) { isTtsActive = true val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.0f) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { putBoolean( TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS, true ) } } val utteranceId = "speak_no_listen_" + System.currentTimeMillis().toString() tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, utteranceId) Log.d(TAG, "performSpeakWithoutListening: TTS 발화 시작 - utteranceId: $utteranceId") } else { Log.e(TAG, "performSpeakWithoutListening: TTS가 초기화되지 않았습니다.") currentState = ServiceState.IDLE isTtsActive = false } } private fun createNotificationChannel() { try { Log.d(TAG, "createNotificationChannel: 알림 채널 생성 시작") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val name = "음성 명령 서비스" val description = "롯데잇츠 음성 명령을 처리하는 서비스입니다." val importance = NotificationManager.IMPORTANCE_LOW val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { this.description = description enableVibration(false) setSound(null, null) } val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) Log.d(TAG, "createNotificationChannel: 알림 채널이 성공적으로 생성되었습니다.") } else { Log.d(TAG, "createNotificationChannel: Android O 미만 버전에서는 알림 채널이 필요하지 않습니다.") } } catch (e: Exception) { Log.e(TAG, "createNotificationChannel: 알림 채널 생성 중 오류 발생", e) } } private fun createNotification(): Notification { try { Log.d(TAG, "createNotification: 기본 알림 생성") val pendingIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("AutoEats 음성 서비스") .setContentText("롯데잇츠 음성 명령 서비스가 실행 중입니다.") .setSmallIcon(R.mipmap.ic_launcher) .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .build() } catch (e: Exception) { Log.e(TAG, "createNotification: 알림 생성 중 오류 발생", e) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("AutoEats") .setContentText("서비스 실행 중") .setSmallIcon(R.mipmap.ic_launcher) .build() } } private fun createEnhancedNotification(): Notification { try { Log.d(TAG, "createEnhancedNotification: 향상된 알림 생성") val pendingIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) val stopIntent = Intent(this, VoiceCommandService::class.java).apply { action = "STOP_SERVICE" } val stopPendingIntent = PendingIntent.getService( this, 1, stopIntent, PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("AutoEats 음성 서비스") .setContentText("롯데잇츠 음성 명령 서비스가 실행 중입니다.") .setSmallIcon(R.mipmap.ic_launcher) .setContentIntent(pendingIntent) .addAction(android.R.drawable.ic_media_pause, "서비스 중지", stopPendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .build() } catch (e: Exception) { Log.e(TAG, "createEnhancedNotification: 향상된 알림 생성 중 오류 발생", e) return createNotification() } } fun checkServiceStatus(): Boolean { try { Log.d(TAG, "checkServiceStatus: 서비스 상태 확인") updateStatusLog() val micPermissionGranted = ContextCompat.checkSelfPermission( this, android.Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED val notificationPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( this, android.Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED } else { true } val accessibilityEnabled = isAccessibilityServiceEnabled() val allRequirementsMet = micPermissionGranted && notificationPermissionGranted && accessibilityEnabled return allRequirementsMet } catch (e: Exception) { Log.e(TAG, "checkServiceStatus: 서비스 상태 확인 중 오류 발생", e) return false } } fun updateServiceStatus(callback: ((Boolean, Boolean, Boolean) -> Unit)?) { val serviceRunning = true val accessibilityEnabled = isAccessibilityServiceEnabled() val micPermissionGranted = ContextCompat.checkSelfPermission( this, android.Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED updateStatusLog() callback?.invoke(serviceRunning, accessibilityEnabled, micPermissionGranted) } private fun updateStatusLog() { val currentTime = System.currentTimeMillis() if (currentTime - lastStatusUpdateTime > STATUS_UPDATE_INTERVAL) { lastStatusUpdateTime = currentTime val micPermissionGranted = ContextCompat.checkSelfPermission( this, android.Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED val accessibilityEnabled = isAccessibilityServiceEnabled() Log.d( TAG, "updateServiceStatus: 서비스 실행 중: true, " + "접근성: $accessibilityEnabled, 마이크: $micPermissionGranted, " + "voiceService: true" ) } } private fun isAccessibilityServiceEnabled(): Boolean { try { val enabled = AutoEatsAccessibilityService.isServiceEnabled(this) val serviceConnected = accessibilityService?.get() != null Log.d(TAG, "isAccessibilityServiceEnabled: 접근성 서비스 활성화 상태: $enabled, 연결 상태: $serviceConnected") if (enabled && !serviceConnected && !pendingAccessibilityCheck) { Log.d(TAG, "isAccessibilityServiceEnabled: 접근성 서비스 자동 재연결 시도") ensureAccessibilityServiceConnected() } return enabled && serviceConnected } catch (e: Exception) { Log.e(TAG, "isAccessibilityServiceEnabled: 접근성 서비스 상태 확인 중 오류 발생", e) return false } } private fun provideMenuInfo() { Log.d(TAG, "provideMenuInfo: 메뉴 정보 제공") val menuInfo = "저희는 다음 메뉴를 제공합니다: " + "리아 불고기 베이컨, 데리버거, 클래식치즈버거, 한우불고기 버거, " + "나폴리맛피아 모짜렐라버거(토마토바질), 나폴리맛피아 모짜렐라버거(발사믹바질)입니다. " + "현재 추천메뉴는 리아 불고기 베이컨입니다. " + "어떤 메뉴를 주문하시겠어요?" speak(menuInfo) callback?.onAiResponse(menuInfo) } override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) { val koreanLocale = java.util.Locale.KOREAN tts.language = koreanLocale tts.setSpeechRate(1.2f) tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { Log.d(TAG, "TTS 발화 시작: $utteranceId") isTtsActive = true if (currentState == ServiceState.LISTENING) { stopVoiceRecognition() } if (utteranceId?.startsWith("greeting_") == true) { currentState = ServiceState.GREETING isGreetingMode = true } else { currentState = ServiceState.SPEAKING } } override fun onDone(utteranceId: String?) { Log.d(TAG, "TTS 발화 완료: $utteranceId") isTtsActive = false Handler(Looper.getMainLooper()).post { when { utteranceId?.startsWith("greeting_") == true -> { Log.d(TAG, "인사말 발화 완료 후 상태 변경 및 대기") currentState = ServiceState.IDLE isGreetingMode = false serviceScope.launch { delay(1500) if (currentState == ServiceState.IDLE && !isTtsActive) { startVoiceRecognition() } } } utteranceId?.startsWith("speak_") == true -> { Log.d(TAG, "일반 발화 완료 후 상태 변경") currentState = ServiceState.IDLE serviceScope.launch { delay(1000) if (currentState == ServiceState.IDLE && !isTtsActive) { startVoiceRecognition() } } } utteranceId?.startsWith("speak_no_listen_") == true -> { Log.d(TAG, "음성 인식 없는 발화 완료") currentState = ServiceState.IDLE } } } } override fun onError(utteranceId: String?) { Log.e(TAG, "TTS 발화 오류: $utteranceId") isTtsActive = false if (utteranceId?.startsWith("greeting_") == true) { isGreetingMode = false } Handler(Looper.getMainLooper()).post { currentState = ServiceState.IDLE serviceScope.launch { delay(500) if (currentState == ServiceState.IDLE && !isTtsActive) { startVoiceRecognition() } } } } }) findBestVoice(koreanLocale) Log.d(TAG, "TTS 초기화 완료") } else { Log.e(TAG, "TTS 초기화 실패: $status") } } }

File: speech/SpeechRecognizer.kt

kotlin
import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.util.Log import androidx.core.content.ContextCompat import com.metainai.autoeats.config.AppConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.io.File import java.io.FileOutputStream import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean class RTZRSpeechRecognizer( private val context: Context, private val resultCallback: (String) -> Unit, private val errorCallback: (Int) -> Unit ) { companion object { private const val TAG = "RTZRSpeechRecognizer" private const val BASE_URL = "https://openapi.vito.ai" private const val AUTH_ENDPOINT = "/v1/authenticate" private const val TRANSCRIBE_ENDPOINT = "/v1/transcribe" private const val SAMPLE_RATE = 16000 private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT private const val BUFFER_SIZE_FACTOR = 2 const val ERROR_NETWORK = 1001 const val ERROR_AUTH_FAILED = 1002 const val ERROR_TRANSCRIBE_FAILED = 1003 const val ERROR_FILE_NOT_FOUND = 1004 const val ERROR_POLLING_FAILED = 1005 const val ERROR_RECORDER_INIT_FAILED = 1006 } private val appConfig = AppConfig.getInstance(context.applicationContext) private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private var accessToken: String? = null private var tokenExpireTime: Long = 0 private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var isRecording = AtomicBoolean(false) private var isProcessing = AtomicBoolean(false) private var audioFile: File? = null private var audioRecord: AudioRecord? = null private var bufferSize: Int = AudioRecord.getMinBufferSize( SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT ) * BUFFER_SIZE_FACTOR private var silenceStartTime: Long = 0 private var isSpeaking = AtomicBoolean(false) private var silenceDetectionJob: kotlinx.coroutines.Job? = null private val SILENCE_THRESHOLD = 700 private val SILENCE_DURATION_MS = 1000 private val INITIAL_SILENCE_DURATION_MS = 15000 private var keywordList: List<String> = listOf( "나폴리맛피아", "모짜렐라버거", "토마토바질", "리아불고기베이컨", "데리버거", "클래식치즈버거", "한우불고기 버거", "세트", "단품", "주문", "완료", "취소", "한개", "두개", "세개", ) private suspend fun getAuthToken(): String? { if (accessToken != null && System.currentTimeMillis() / 1000 < tokenExpireTime - 600) { return accessToken } val clientId = appConfig.rtzrClientId val clientSecret = appConfig.rtzrClientSecret val requestBody = "client_id=$clientId&client_secret=$clientSecret" .toRequestBody("application/x-www-form-urlencoded".toMediaType()) val request = Request.Builder() .url("$BASE_URL$AUTH_ENDPOINT") .post(requestBody) .header("accept", "application/json") .build() return try { val response = client.newCall(request).execute() val responseBody = response.body?.string() ?: "" if (response.isSuccessful) { val jsonObject = JSONObject(responseBody) accessToken = jsonObject.getString("access_token") tokenExpireTime = jsonObject.getLong("expire_at") accessToken } else { Log.e(TAG, "Auth failed: $responseBody") null } } catch (e: Exception) { Log.e(TAG, "Exception during auth", e) null } } fun setKeywords(keywords: List<String>) { this.keywordList = keywords Log.d(TAG, "키워드 부스팅 설정: ${keywords.joinToString(", ")}") } fun addKeyword(keyword: String) { if (keyword.isNotBlank() && !keywordList.contains(keyword)) { keywordList = keywordList + keyword Log.d(TAG, "키워드 추가: $keyword") } } private suspend fun requestTranscribe(file: File): String? { val token = getAuthToken() ?: run { errorCallback(ERROR_AUTH_FAILED) return null } val configJsonBuilder = StringBuilder() configJsonBuilder.append(""" { "use_diarization": false, "use_itn": true, "use_disfluency_filter": true, "use_paragraph_splitter": true, "model_name": "sommers", "language": "ko" """) if (keywordList.isNotEmpty()) { configJsonBuilder.append(",\n") configJsonBuilder.append(""" "keywords": [ ${keywordList.joinToString(",") { "\"$it\"" }} ] """) } configJsonBuilder.append("\n}") val configJson = configJsonBuilder.toString() Log.d(TAG, "Transcribe 설정: $configJson") val requestBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("config", configJson) .addFormDataPart( "file", file.name, file.asRequestBody("audio/wav".toMediaTypeOrNull()) ) .build() val request = Request.Builder() .url("$BASE_URL$TRANSCRIBE_ENDPOINT") .post(requestBody) .header("Authorization", "Bearer $token") .header("accept", "application/json") .build() return try { val response = client.newCall(request).execute() val responseBody = response.body?.string() ?: "" if (response.isSuccessful) { val jsonObject = JSONObject(responseBody) jsonObject.getString("id") } else { Log.e(TAG, "Transcribe request failed: $responseBody") null } } catch (e: Exception) { Log.e(TAG, "Exception during transcribe request", e) null } } private suspend fun pollTranscribeResult(transcribeId: String): String? { val token = getAuthToken() ?: run { errorCallback(ERROR_AUTH_FAILED) return null } val request = Request.Builder() .url("$BASE_URL$TRANSCRIBE_ENDPOINT/$transcribeId") .get() .header("Authorization", "Bearer $token") .header("accept", "application/json") .build() var attempts = 0 val maxAttempts = 60 while (attempts < maxAttempts) { try { val response = client.newCall(request).execute() val responseBody = response.body?.string() ?: "" if (response.isSuccessful) { val jsonObject = JSONObject(responseBody) val status = jsonObject.getString("status") when (status) { "completed" -> { val results = jsonObject.getJSONObject("results") val utterances = results.getJSONArray("utterances") val stringBuilder = StringBuilder() for (i in 0 until utterances.length()) { val utterance = utterances.getJSONObject(i) stringBuilder.append(utterance.getString("msg")) if (i < utterances.length() - 1) { stringBuilder.append(" ") } } return stringBuilder.toString() } "transcribing" -> { delay(5000) attempts++ } "failed" -> { val error = jsonObject.getJSONObject("error") Log.e(TAG, "Transcribe failed: ${error.getString("message")}") return null } } } else { // API 호출 실패 Log.e(TAG, "Poll result failed: $responseBody") return null } } catch (e: Exception) { Log.e(TAG, "Exception during polling", e) return null } } Log.e(TAG, "Polling timed out after $maxAttempts attempts") return null } private fun writeWavHeader(outputStream: FileOutputStream, audioLength: Int) { val totalDataLen = audioLength + 36 val byteRate = SAMPLE_RATE * 2 val header = ByteArray(44) header[0] = 'R'.code.toByte() header[1] = 'I'.code.toByte() header[2] = 'F'.code.toByte() header[3] = 'F'.toByte() header[4] = (totalDataLen and 0xff).toByte() header[5] = (totalDataLen shr 8 and 0xff).toByte() header[6] = (totalDataLen shr 16 and 0xff).toByte() header[7] = (totalDataLen shr 24 and 0xff).toByte() header[8] = 'W'.code.toByte() header[9] = 'A'.code.toByte() header[10] = 'V'.code.toByte() header[11] = 'E'.code.toByte() header[12] = 'f'.code.toByte() header[13] = 'm'.code.toByte() header[14] = 't'.code.toByte() header[15] = ' '.code.toByte() header[16] = 16 header[17] = 0 header[18] = 0 header[19] = 0 header[20] = 1 header[21] = 0 header[22] = 1 header[23] = 0 header[24] = (SAMPLE_RATE and 0xff).toByte() header[25] = (SAMPLE_RATE shr 8 and 0xff).toByte() header[26] = (SAMPLE_RATE shr 16 and 0xff).toByte() header[27] = (SAMPLE_RATE shr 24 and 0xff).toByte() header[28] = (byteRate and 0xff).toByte() header[29] = (byteRate shr 8 and 0xff).toByte() header[30] = (byteRate shr 16 and 0xff).toByte() header[31] = (byteRate shr 24 and 0xff).toByte() header[32] = 2 header[33] = 0 header[34] = 16 header[35] = 0 header[36] = 'd'.code.toByte() header[37] = 'a'.code.toByte() header[38] = 't'.code.toByte() header[39] = 'a'.code.toByte() header[40] = (audioLength and 0xff).toByte() header[41] = (audioLength shr 8 and 0xff).toByte() header[42] = (audioLength shr 16 and 0xff).toByte() header[43] = (audioLength shr 24 and 0xff).toByte() outputStream.write(header, 0, 44) } private fun startSilenceDetection() { silenceDetectionJob?.cancel() silenceDetectionJob = coroutineScope.launch { delay(1000) while (isRecording.get()) { delay(500) val currentTime = System.currentTimeMillis() val silenceDuration = currentTime - silenceStartTime if (!isSpeaking.get() && silenceDuration > INITIAL_SILENCE_DURATION_MS) { Log.d(TAG, "초기 침묵 감지: $silenceDuration ms 동안 음성이 감지되지 않아 녹음을 중지합니다.") stopListening() break } if (isSpeaking.get() && silenceDuration > SILENCE_DURATION_MS) { Log.d(TAG, "침묵 감지: $silenceDuration ms 동안 음성이 감지되지 않아 녹음을 중지합니다.") stopListening() break } } } } fun startListening() { if (isRecording.get() || isProcessing.get()) { Log.d(TAG, "startListening: 이미 녹음 중이거나 처리 중입니다.") return } Log.d(TAG, "startListening: 녹음 시작 시도") if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { errorCallback(android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) return } try { audioFile = File.createTempFile("audio_recording", ".raw", context.cacheDir) audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize ) if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { errorCallback(ERROR_RECORDER_INIT_FAILED) return } isRecording.set(true) isSpeaking.set(false) silenceStartTime = System.currentTimeMillis() startSilenceDetection() coroutineScope.launch { val buffer = ByteArray(bufferSize) val fileOutputStream = FileOutputStream(audioFile) try { audioRecord?.startRecording() while (isRecording.get()) { val readBytes = audioRecord?.read(buffer, 0, buffer.size) ?: -1 if (readBytes > 0) { fileOutputStream.write(buffer, 0, readBytes) val amplitude = calculateAmplitude(buffer, readBytes) if (amplitude > SILENCE_THRESHOLD) { if (!isSpeaking.get()) { Log.d(TAG, "음성 감지: 사용자가 말하기 시작했습니다. 음량: $amplitude") isSpeaking.set(true) } silenceStartTime = System.currentTimeMillis() } } } fileOutputStream.close() } catch (e: Exception) { Log.e(TAG, "Error during recording", e) withContext(Dispatchers.Main) { errorCallback(ERROR_RECORDER_INIT_FAILED) } isRecording.set(false) fileOutputStream.close() audioRecord?.release() audioRecord = null audioFile?.delete() audioFile = null return@launch } val rawFile = audioFile if (rawFile != null && rawFile.exists()) { val wavFile = File(context.cacheDir, "audio_recording.wav") if (wavFile.exists()) { wavFile.delete() } try { val wavOutputStream = FileOutputStream(wavFile) val rawData = rawFile.readBytes() writeWavHeader(wavOutputStream, rawData.size) wavOutputStream.write(rawData) wavOutputStream.close() rawFile.delete() audioFile = wavFile Log.d(TAG, "WAV file created: ${wavFile.absolutePath}") } catch (e: Exception) { Log.e(TAG, "Failed to convert to WAV", e) withContext(Dispatchers.Main) { errorCallback(ERROR_RECORDER_INIT_FAILED) } rawFile.delete() audioFile = null } } } Log.d(TAG, "Recording started") } catch (e: Exception) { Log.e(TAG, "Failed to initialize recording", e) errorCallback(ERROR_RECORDER_INIT_FAILED) } } private fun calculateAmplitude(buffer: ByteArray, size: Int): Int { var sum = 0 val shorts = ByteBuffer.wrap(buffer, 0, size).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() val samples = ShortArray(shorts.remaining()) shorts.get(samples) for (sample in samples) { sum += Math.abs(sample.toInt()) } return if (samples.isNotEmpty()) sum / samples.size else 0 } fun stopListening() { if (!isRecording.getAndSet(false)) { Log.d(TAG, "stopListening: 이미 녹음이 중지되어 있습니다.") return } Log.d(TAG, "stopListening: 녹음 중지 및 결과 처리 시작") silenceDetectionJob?.cancel() silenceDetectionJob = null try { audioRecord?.stop() audioRecord?.release() audioRecord = null } catch (e: Exception) { Log.e(TAG, "Error stopping recording", e) } val currentFile = audioFile if (currentFile == null || !currentFile.exists()) { errorCallback(ERROR_FILE_NOT_FOUND) return } coroutineScope.launch { isProcessing.set(true) val transcribeId = requestTranscribe(currentFile) if (transcribeId != null) { val result = pollTranscribeResult(transcribeId) if (result != null) { withContext(Dispatchers.Main) { Log.d(TAG, "stopListening: 인식 결과: $result") resultCallback(result) } } else { withContext(Dispatchers.Main) { errorCallback(ERROR_POLLING_FAILED) } } } else { withContext(Dispatchers.Main) { errorCallback(ERROR_TRANSCRIBE_FAILED) } } try { currentFile.delete() } catch (e: IOException) { Log.e(TAG, "Failed to delete temp file", e) } isProcessing.set(false) } } fun destroy() { stopListening() isProcessing.set(false) silenceDetectionJob?.cancel() silenceDetectionJob = null coroutineScope.cancel() audioFile?.let { if (it.exists()) { it.delete() } } audioFile = null } }

File: ui/theme/Color.kt

kotlin
package com.metainai.autoeats.ui.theme import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260)

File: ui/theme/Theme.kt

kotlin
package com.metainai.autoeats.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 ) @Composable fun AutoEatsTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) }

File: ui/theme/Type.kt

kotlin
package com.metainai.autoeats.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp ) )

File: AutoEatsApplication.kt

kotlin
package com.metainai.autoeats import android.app.Application import android.content.Context import android.content.Intent import android.speech.tts.TextToSpeech import android.util.Log import com.metainai.autoeats.service.VoiceCommandService import java.util.* class AutoEatsApplication : Application() { companion object { lateinit var instance: AutoEatsApplication private set val appContext: Context get() = instance.applicationContext private const val TAG = "AutoEatsApplication" } lateinit var textToSpeech: TextToSpeech var isTestMode = true var voiceCommandService: VoiceCommandService? = null set(value) { Log.d(TAG, "VoiceCommandService 인스턴스 ${if (value != null) "설정" else "제거"}") field = value } override fun onCreate() { super.onCreate() instance = this textToSpeech = TextToSpeech(this) { status -> if (status == TextToSpeech.SUCCESS) { val result = textToSpeech.setLanguage(Locale.KOREAN) if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { textToSpeech.setLanguage(Locale.getDefault()) } textToSpeech.setSpeechRate(1.2f) } } } override fun onTerminate() { Log.d(TAG, "onTerminate: 애플리케이션 종료") try { if (::textToSpeech.isInitialized) { textToSpeech.stop() textToSpeech.shutdown() Log.d(TAG, "onTerminate: TTS 종료 완료") } voiceCommandService?.let { service -> try { service.stopVoiceRecognition() Log.d(TAG, "onTerminate: 음성 인식 중지 완료") val stopIntent = Intent(this, VoiceCommandService::class.java) stopIntent.action = "STOP_SERVICE" startService(stopIntent) Log.d(TAG, "onTerminate: 서비스 종료 인텐트 전송 완료") } catch (e: Exception) { Log.e(TAG, "onTerminate: 서비스 종료 중 오류 발생", e) } } voiceCommandService = null Log.d(TAG, "onTerminate: VoiceCommandService 참조 제거 완료") } catch (e: Exception) { Log.e(TAG, "onTerminate: 애플리케이션 종료 중 오류 발생", e) } finally { super.onTerminate() } } }

File: MainActivity.kt

kotlin
package com.metainai.autoeats import android.Manifest import android.annotation.SuppressLint import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection import android.content.pm.PackageManager import android.database.ContentObserver import android.net.Uri import android.os.Binder import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.Process import android.provider.Settings import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.animation.core.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.IconButton import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.layout.systemBars import androidx.core.content.ContextCompat import com.metainai.autoeats.accessibility.AutoEatsAccessibilityService import com.metainai.autoeats.service.VoiceCommandCallback import com.metainai.autoeats.service.VoiceCommandService import com.metainai.autoeats.ui.theme.AutoEatsTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch data class ChatMessage( val id: Long = System.currentTimeMillis(), val content: String, val isUser: Boolean, val timestamp: Long = System.currentTimeMillis() ) class MainActivity : ComponentActivity() { private var voiceService: VoiceCommandService? = null private var serviceConnection: ServiceConnection? = null private var accessibilitySettingsObserver: ContentObserver? = null private val serviceRunningState = mutableStateOf(false) private val accessibilityEnabledState = mutableStateOf(false) private val voiceServiceState = mutableStateOf(false) private val micPermissionState = mutableStateOf(false) private var isFirstStart = true private val chatMessages = mutableStateOf<List<ChatMessage>>(emptyList()) private val closeAppReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == "com.metainai.autoeats.CLOSE_APP") { Log.d("MainActivity", "closeAppReceiver: 앱 종료 브로드캐스트 수신") finishAndRemoveTask() android.os.Process.killProcess(android.os.Process.myPid()) System.exit(0) } } } fun addMessage(content: String, isUser: Boolean) { val newMessage = ChatMessage(content = content, isUser = isUser) chatMessages.value = chatMessages.value + newMessage } @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() if (intent?.getBooleanExtra("REQUEST_PERMISSIONS", false) == true) { checkAndRequestPermissions() } accessibilitySettingsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { updateServiceStatusLocal { running, accessibility, mic -> serviceRunningState.value = running accessibilityEnabledState.value = accessibility micPermissionState.value = mic if (!running && accessibility && mic) { startAndBindVoiceService() } } } } contentResolver.registerContentObserver( Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES), false, accessibilitySettingsObserver!! ) registerReceiver( closeAppReceiver, IntentFilter("com.metainai.autoeats.CLOSE_APP"), Context.RECEIVER_NOT_EXPORTED ) startAndBindVoiceService() setContent { AutoEatsTheme { Scaffold( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) ) { paddingValues -> Surface( modifier = Modifier .fillMaxSize() .padding(paddingValues), color = MaterialTheme.colorScheme.background ) { MainScreen( onVoiceRecognitionClick = { toggleVoiceRecognition() }, onPermissionsClick = { checkAndRequestPermissions() }, onAccessibilityClick = { openAccessibilitySettings() } ) } } } } } private fun startAndBindVoiceService() { if (serviceConnection != null && voiceService != null) { Log.d("MainActivity", "서비스가 이미 바인딩되어 있습니다.") return } if (ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) != PackageManager.PERMISSION_GRANTED) { Log.d("MainActivity", "마이크 권한이 없습니다. 권한 요청 화면으로 이동합니다.") checkAndRequestPermissions() return } val intent = Intent(this, VoiceCommandService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) } else { startService(intent) } if (serviceConnection == null) { bindToVoiceService() } } @Composable private fun MainScreen( onVoiceRecognitionClick: () -> Unit, onPermissionsClick: () -> Unit, onAccessibilityClick: () -> Unit ) { val context = LocalContext.current var serviceRunning by remember { mutableStateOf(false) } var accessibilityEnabled by remember { mutableStateOf(false) } var micPermissionGranted by remember { mutableStateOf(false) } var isListening by remember { mutableStateOf(false) } var recognizedText by remember { mutableStateOf("") } var recordingTimeSeconds by remember { mutableStateOf(0) } var recordingStartTime by remember { mutableStateOf(0L) } val messages by remember { chatMessages } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { updateServiceStatusLocal { running, accessibility, mic -> serviceRunning = running accessibilityEnabled = accessibility micPermissionGranted = mic } while (true) { delay(1000) updateServiceStatusLocal { running, accessibility, mic -> serviceRunning = running accessibilityEnabled = accessibility micPermissionGranted = mic } isListening = voiceService?.isListening() == true if (isListening) { if (recordingStartTime == 0L) { recordingStartTime = System.currentTimeMillis() } recordingTimeSeconds = ((System.currentTimeMillis() - recordingStartTime) / 1000).toInt() } else { recordingStartTime = 0L recordingTimeSeconds = 0 } } } LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { listState.animateScrollToItem(messages.size - 1) } } DisposableEffect(serviceRunningState.value, accessibilityEnabledState.value, micPermissionState.value) { serviceRunning = serviceRunningState.value accessibilityEnabled = accessibilityEnabledState.value micPermissionGranted = micPermissionState.value if (accessibilityEnabled && micPermissionGranted) { if (!serviceRunning) { coroutineScope.launch { startAndBindVoiceService() delay(1500) voiceService?.let { if (!it.isListening()) { it.startVoiceRecognition() } } } } else if (voiceService != null && !isListening) { voiceService?.startVoiceRecognition() } } // DisposableEffect에서는 인사말을 시작하지 않고, onResume에서 처리하도록 변경 onDispose { } } Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(4.dp)) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "서비스 상태", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium ) val allActive = serviceRunning && accessibilityEnabled && micPermissionGranted Text( text = if (allActive) "준비 완료" else "설정 필요", style = MaterialTheme.typography.bodyMedium, color = if (allActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error ) } Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { StatusChip( text = "서비스", isActive = serviceRunning ) StatusChip( text = "접근성", isActive = accessibilityEnabled ) StatusChip( text = "마이크", isActive = micPermissionGranted ) } } } Card( modifier = Modifier .fillMaxWidth() .weight(1f), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Box( modifier = Modifier.fillMaxSize() ) { if (messages.isEmpty()) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "대화 내용이 여기에 표시됩니다", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), textAlign = TextAlign.Center ) } } else { LazyColumn( modifier = Modifier .fillMaxSize() .padding(12.dp), state = listState, verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(messages) { message -> ChatBubble(message = message) } } } } } if (isListening) { RecordingIndicator(recordingTimeSeconds, recognizedText) } else { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 0.dp, vertical = 8.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Text( text = "음성 인식 대기 중...", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(12.dp) ) } } Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { Card( modifier = Modifier .weight(1f) .padding(4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), onClick = onPermissionsClick ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( text = "권한 설정", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } } Card( modifier = Modifier .weight(1f) .padding(4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), onClick = onAccessibilityClick ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( text = "접근성 설정", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } } } } } } @Composable fun ChatBubble(message: ChatMessage) { val alignment = if (message.isUser) Alignment.End else Alignment.Start Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), horizontalAlignment = alignment ) { Row( modifier = Modifier .fillMaxWidth(0.85f) .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { Text( text = if (message.isUser) "나" else "MiA", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) Spacer(modifier = Modifier.width(4.dp)) val formatter = java.text.SimpleDateFormat("a h:mm", java.util.Locale.KOREA) val timeString = formatter.format(java.util.Date(message.timestamp)) Text( text = timeString, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) ) } Card( modifier = Modifier .widthIn(max = 280.dp) .padding(horizontal = 8.dp), colors = CardDefaults.cardColors( containerColor = if (message.isUser) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) else MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f) ), shape = RoundedCornerShape( topStart = if (message.isUser) 12.dp else 4.dp, topEnd = if (message.isUser) 4.dp else 12.dp, bottomStart = 12.dp, bottomEnd = 12.dp ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, color = if (message.isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(12.dp) ) } } } @Composable fun RecordingIndicator(recordingTimeSeconds: Int, recognizedText: String = "") { Card( modifier = Modifier .fillMaxWidth() .padding(8.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = formatTime(recordingTimeSeconds), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "음성 인식 중...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) if (recognizedText.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) Text( text = "\"$recognizedText\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) } } } } @Composable fun StatusChip(text: String, isActive: Boolean) { Card( modifier = Modifier.padding(4.dp), colors = CardDefaults.cardColors( containerColor = if (isActive) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) else MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Text( text = text, style = MaterialTheme.typography.bodyMedium, color = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onErrorContainer ) Spacer(modifier = Modifier.width(4.dp)) Text( text = if (isActive) "&#10003;" else "&#10007;", color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyMedium ) } } } fun formatTime(seconds: Int): String { val minutes = seconds / 60 val remainingSeconds = seconds % 60 return String.format("%02d:%02d", minutes, remainingSeconds) } private fun bindToVoiceService() { serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is VoiceCommandService.LocalBinder) { voiceService = service.getService() Log.d("MainActivity", "서비스가 성공적으로 바인딩되었습니다.") voiceService?.setCallback(object : VoiceCommandCallback { override fun onRecognizedSpeech(text: String) { addMessage(text, true) } override fun onAiResponse(text: String) { addMessage(text, false) } }) } serviceRunningState.value = true updateServiceStatusLocal(null) } override fun onServiceDisconnected(name: ComponentName?) { voiceService = null serviceRunningState.value = false updateServiceStatusLocal(null) Log.d("MainActivity", "서비스 연결이 끊어졌습니다.") } } try { val intent = Intent(this, VoiceCommandService::class.java) bindService(intent, serviceConnection!!, Context.BIND_AUTO_CREATE) } catch (e: Exception) { Log.e("MainActivity", "서비스 바인딩 중 오류 발생: ${e.message}", e) } } private fun updateServiceStatusLocal(callback: ((Boolean, Boolean, Boolean) -> Unit)? = null) { val serviceRunning = isServiceRunning() val accessibilityEnabled = isAccessibilityServiceEnabled() val micPermissionGranted = ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED serviceRunningState.value = serviceRunning accessibilityEnabledState.value = accessibilityEnabled micPermissionState.value = micPermissionGranted voiceServiceState.value = voiceService != null callback?.invoke(serviceRunning, accessibilityEnabled, micPermissionGranted) } private fun toggleVoiceRecognition() { if (voiceService == null) { Log.d("MainActivity", "toggleVoiceRecognition: 서비스가 없으므로 시작합니다.") startAndBindVoiceService() Handler(Looper.getMainLooper()).postDelayed({ if (voiceService != null) { Log.d("MainActivity", "toggleVoiceRecognition: 서비스 바인딩 완료, 음성 인식 시작") voiceService?.let { if (!it.isListening()) { it.startVoiceRecognition() } } } else { Log.e("MainActivity", "toggleVoiceRecognition: 서비스 바인딩 실패") Toast.makeText(this@MainActivity, "서비스 연결에 실패했습니다. 다시 시도해주세요.", Toast.LENGTH_SHORT).show() } }, 1000) } else { voiceService?.let { if (it.isListening()) { Log.d("MainActivity", "toggleVoiceRecognition: 음성 인식 중지") it.stopVoiceRecognition() } else { Log.d("MainActivity", "toggleVoiceRecognition: 음성 인식 시작") it.startVoiceRecognition() } } } } private fun checkAndRequestPermissions() { if (ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) != PackageManager.PERMISSION_GRANTED) { androidx.core.app.ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.RECORD_AUDIO), 1 ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED) { androidx.core.app.ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2 ) } } } private fun openAccessibilitySettings() { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } private fun isAccessibilityServiceEnabled(): Boolean { return AutoEatsAccessibilityService.isServiceEnabled(this) } private fun isServiceRunning(): Boolean { if (voiceService != null) { return true } val manager = getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager for (service in manager.getRunningServices(Integer.MAX_VALUE)) { if (VoiceCommandService::class.java.name == service.service.className) { return true } } return false } override fun onResume() { super.onResume() updateServiceStatusLocal { running, accessibility, mic -> Log.d("MainActivity", "onResume: 서비스 상태 - 실행 중: $running, 접근성: $accessibility, 마이크: $mic") val allReady = running && accessibility && mic if (allReady) { if (isFirstStart && chatMessages.value.isEmpty()) { val greeting = "안녕하세요 MiA입니다. 어떤 버거를 주문하시겠습니까? 현재 추천메뉴는 나폴리맛피아 모짜렐라버거(토마토바질)입니다." isFirstStart = false voiceService?.stopVoiceRecognition() Handler(Looper.getMainLooper()).postDelayed({ Log.d("MainActivity", "onResume: 인사말 TTS 시작") voiceService?.speakGreeting(greeting) }, 1000) } else if (voiceService != null && !voiceService!!.isListening()) { Log.d("MainActivity", "onResume: 음성 인식 시작 (인사말 이후)") Handler(Looper.getMainLooper()).postDelayed({ voiceService?.startVoiceRecognition() }, 500) } } else if (!running && accessibility && mic) { Log.d("MainActivity", "onResume: 서비스가 실행 중이 아니므로 시작합니다.") startAndBindVoiceService() Handler(Looper.getMainLooper()).postDelayed({ updateServiceStatusLocal { newRunning, _, _ -> if (newRunning && isFirstStart) { onResume() } } }, 2000) } } } override fun onBackPressed() { super.onBackPressed() Log.d("MainActivity", "onBackPressed: 뒤로가기 버튼 클릭, 앱 종료") try { voiceService?.stopVoiceRecognition() if (serviceConnection != null) { try { unbindService(serviceConnection!!) serviceConnection = null } catch (e: Exception) { Log.e("MainActivity", "서비스 연결 해제 중 오류 발생", e) } } val stopIntent = Intent(this, VoiceCommandService::class.java) stopIntent.action = "STOP_SERVICE" startService(stopIntent) stopService(Intent(this, VoiceCommandService::class.java)) Handler(Looper.getMainLooper()).postDelayed({ finishAndRemoveTask() Process.killProcess(Process.myPid()) System.exit(0) }, 500) } catch (e: Exception) { Log.e("MainActivity", "서비스 종료 중 오류 발생", e) finishAndRemoveTask() Process.killProcess(Process.myPid()) System.exit(0) } } override fun onDestroy() { Log.d("MainActivity", "onDestroy: 앱 종료, 서비스 종료") try { try { unregisterReceiver(closeAppReceiver) Log.d("MainActivity", "onDestroy: 브로드캐스트 리시버 해제 완료") } catch (e: Exception) { Log.e("MainActivity", "브로드캐스트 리시버 해제 중 오류 발생", e) } voiceService?.stopVoiceRecognition() if (serviceConnection != null) { try { unbindService(serviceConnection!!) } catch (e: Exception) { Log.e("MainActivity", "서비스 연결 해제 중 오류 발생", e) } serviceConnection = null } val stopIntent = Intent(this, VoiceCommandService::class.java) stopIntent.action = "STOP_SERVICE" startService(stopIntent) stopService(Intent(this, VoiceCommandService::class.java)) accessibilitySettingsObserver?.let { try { contentResolver.unregisterContentObserver(it) } catch (e: Exception) { Log.e("MainActivity", "ContentObserver 해제 중 오류 발생", e) } } try { Handler(Looper.getMainLooper()).postDelayed({ android.os.Process.killProcess(android.os.Process.myPid()) System.exit(0) }, 500) } catch (e: Exception) { Log.e("MainActivity", "프로세스 종료 중 오류 발생", e) } } catch (e: Exception) { Log.e("MainActivity", "앱 종료 중 오류 발생", e) } finally { super.onDestroy() } } }

답변

아래는 전체 코드를 세부적으로 살펴본 뒤, 접근성 자동화를 시도하는 과정에서 “어떤 때는 잘 클릭되고 어떤 때는 안 되고, UI 노드 트리도 제때 잡히지 않는다”는 문제가 왜 발생하는지 근본적인 원인을 정리한 것입니다. 요약하자면 동적이고 잦은 UI 변동을 견고하게 처리하기 어려운 구현 방식(하드코딩된 좌표, 제한된 이벤트만 처리, 불안정한 sleep 기반 대기 로직 등)과 앱 패키지/노드 식별 문제, 음성인식(TTS)과 접근성 로직 간의 타이밍 충돌 등이 핵심 원인이 됩니다.


1) 접근성 이벤트 처리 방식의 문제

(1) TYPE_WINDOW_CONTENT_CHANGED 이벤트 무시

  • AutoEatsAccessibilityService 내부에서 이벤트 필터링 로직을 보면, ignoredEventTypesTYPE_WINDOW_CONTENT_CHANGEDTYPE_VIEW_SCROLLED가 들어가 있습니다.
  • 실제 앱(롯데잇츠) 화면이 갱신될 때는 대체로 TYPE_WINDOW_CONTENT_CHANGED 이벤트가 반복적으로 발생합니다.
  • 그러나 코드 상에서 이 이벤트를 무시해 버리면, 새로 나타난 노드 트리를 적시에 파악하지 못하고 놓칠 수 있습니다.
  • 이 때문에 화면 갱신 후 노드를 찾으려 할 때 rootInActiveWindow를 조회하더라도, 제대로 된 타이밍에 찾지 못하거나 UI가 확실히 렌더링되기 전에 지나가 버릴 가능성이 큽니다.

(2) 이벤트 스로틀(EVENT_THROTTLE_MS)로 인한 타이밍 이슈

  • AutoEatsAccessibilityServiceonAccessibilityEvent에서 lastEventProcessTime을 기준으로 500ms(기본값) 안에 들어오는 이벤트는 무시합니다.
  • UI가 짧은 시간 안에 여러 번 바뀌는 상황에서 500ms 사이에 중요한 이벤트가 발생하면, 그 이벤트를 놓치고 넘어갈 수 있습니다.
  • 특히 외부 앱이 자주 새로고침/리프레시하는 구조라면, 짧은 순간에 여러 화면 전환 이벤트가 쏟아질 수 있는데 스로틀 때문에 시기를 놓칠 가능성이 있습니다.

2) 노드 탐색/클릭 로직의 불안정성

(1) 뎁스 제한 + Thread.sleep() 기반 폴링

  • CommandExecutor에서 특정 노드를 찾을 때 waitForNode 식으로 repeat(timeoutSeconds)를 돌면서 Thread.sleep()을 이용해 1초 간격으로 검사합니다.
  • UI가 느리게 로드되거나, 중간에 렌더링이 늦게 이루어지는 경우를 충분히 고려하지 않고 고정된 sleep만 사용하기 때문에 타이밍 문제가 생깁니다.
  • 또한 노드를 찾는 재귀 함수(findNodeRecursive)의 탐색 뎁스를 엄격히 제한하거나, 조건(predicate) 한정이 매우 빡빡한 경우 실제로는 노드가 존재해도 검색이 실패할 수 있습니다.

(2) 하드코딩된 좌표 클릭 / 고정 스와이프

  • CommandExecutor 곳곳에서 노드를 찾지 못하면 (450f, 300f)처럼 고정 좌표를 탭하거나, 정해진 위치(예: 450f, 500f450f, 200f)로 스와이프합니다.
  • 화면 해상도, DPI, 화면 구성이 기기별로 다 다르기 때문에, 해상도가 다른 기기에서는 의도한 UI 요소에 정확히 닿지 않을 가능성이 높습니다.
  • 혹은 앱 내부에서 동적으로 UI가 레이아웃되어 있어 위치가 조금만 달라져도 탭 좌표가 빗나가서 클릭 실패가 날 수 있습니다.
  • 더욱이 “장바구니 버튼”이나 “픽업” 등을 클릭하려고 할 때 노드 탐색이 실패하면 이런 하드코딩 클릭으로 우회해버리는데, 화면 스크롤 상태나 UI가 열려있는 단계에 따라 좌표가 달라질 수 있으므로 불안정해집니다.

(3) contentDescription/text로만 노드를 판별

  • 실제 롯데잇츠 앱에서 해당 뷰들이 contentDescription을 갖지 않거나, 문자열이 앱 버전에 따라 달라질 수 있습니다.
  • 예: 픽업 뷰의 contentDescription이 상황에 따라 “픽업”이 아니라 “포장”으로 나올 수 있는 등, 변형된 문자열이 잡히면 탐색에 실패합니다.
  • (또한 packageNamekr.co.angelinus.gift.m으로 되어 있는데, 실제 롯데잇츠 앱 패키지와 불일치 가능성이 있습니다.)

3) 패키지명/앱 식별 문제

코드를 보면 config/AppConfig.ktDEFAULT_LOTTE_EATS_PACKAGE = "kr.co.angelinus.gift.m"가 박혀 있고,
CommandExecutor.launchLotteEatsApp()에서 패키지와 액티비티를 다음과 같이 사용 중입니다:

kotlin
val packageName = "kr.co.angelinus.gift.m" val activityName = "kr.co.angelinus.gift.m.MainActivity"
  • 실제 롯데잇츠 앱의 패키지명은 보통 kr.co.lotteria.eats 또는 유사 형태일 것이고, “Angel-in-us 커피” 관련 패키지가 들어가 있습니다.
  • 만약 실제 롯데잇츠 앱이 아니라 다른 앱을 대상으로 해서(혹은 과거에 임시로 만든 테스트 버전) 맞춰진 것이라면, 당연히 UI 구조나 노드 텍스트가 전혀 달라서 찾기 어려울 수 있습니다.
  • 이것이 가장 크게 “앱이 열렸는데도 클릭이 안 되는” 원인이 될 수 있습니다. 패키지 식별이 잘못되어 있으면 이벤트 수신이나 노드 트리 접근 자체를 오작동하게 만듭니다.

4) TTS/음성인식의 재진입 타이밍 충돌

  • AccessibilityUtils 내부와 VoiceCommandService에서 TTS가 말하는 동안은 음성 인식을 중단하고, 말이 끝나면 다시 음성 인식을 켜는 복잡한 로직이 있습니다.
  • TTS 발화가 여러 번 겹치면(activeTtsCount), 또는 TTS가 끝나기 전에 또 다른 이벤트가 들어오거나 사용자 명령이 들어오는 경우가 발생하면, 제대로 다시 음성 인식을 재개하지 못하는 경우가 있습니다.
  • 실제 로그를 보면, isTtsSpeakingisOrderCompleted 플래그를 중간에 잘못 인식해서 음성인식을 영영 재개하지 않거나, 타이밍이 꼬여서 다시 화면 조작 로직이 늦춰질 수 있습니다.
  • 이 때문에 UI가 변하는 “타이밍”에 접근성 코드를 실행시키지 못하여 클릭 실패로 이어질 가능성이 존재합니다.

5) 기타 잠재적 문제점

  1. 앱이 WebView 기반으로 구현된 영역이 있다면, AccessibilityNodeInfo로는 안쪽 컨텐츠를 잘 못 잡을 수 있습니다. 코드에서 WebView 노드를 찾긴 하지만, 실제로도 동적인 웹 컨텐츠를 제대로 스크롤/탐색하려면 더 정교한 로직이 필요합니다.
  2. setOrderCompleted(true) 시점에 음성 인식을 완전히 꺼 버리는데, 다음 명령을 인식해야 되는 상황이 오면 재활성화 로직이 꼬일 수 있습니다.
  3. 테스트 플로우에서 ‘절차적’으로 앱을 이용하는 방식(“매장 선택 → 버거 카테고리 → …” 식)이 실제 롯데잇츠의 UX 흐름과 어긋나거나, 스플래시 화면/팝업 같은 추가 화면이 있을 경우 로직이 중간에 막힐 수 있습니다.
  4. **단순 Thread.sleep()**에만 의존하는 대기 로직으로는 화면 전환, 네트워크 지연, 팝업 등 예외 상황에 대한 탄력적인 대응이 어렵습니다. “나중에 UI가 늦게 뜨면 그만큼 더 기다려야” 하는데, 지금은 고정된 sleep 타이머만 있기 때문입니다.

종합 정리

  1. 이벤트 무시: TYPE_WINDOW_CONTENT_CHANGED 등을 무시하기 때문에, 앱 화면이 바뀔 때마다 노드 트리를 제대로 갱신하지 못함.
  2. 하드코딩된 좌표 & 텍스트 매칭: 스크린 해상도나 앱 UI가 조금만 바뀌어도 노드를 못 찾거나 클릭 위치가 틀어짐.
  3. 앱 패키지명 불일치(가장 치명적): 실제 롯데잇츠 앱이 아니라 angelinus.gift.m으로 설정되어 있어, 접근성 이벤트 자체가 안 잡히거나 노드가 달라질 수 있음.
  4. TTS와 음성인식의 충돌: TTS가 길게 실행되거나 여러 번 겹치면 음성 인식 재개 타이밍이 꼬여 접근성 로직을 제때 호출 못함.
  5. Sleep 기반 비동기 처리: UI 변화 타이밍이 예측 불가능한데, 고정된 Thread.sleep()과 제한된 검색(waitForNode)만 사용하여 화면 전환 시점을 놓침.
  6. 오류 처리/재시도 부족: 노드 못 찾을 때 “정확히 왜 못 찾았는지” 로그는 남겨도, 재시도 로직이 결국 고정 좌표 탭 등 불안정한 우회에 그치고 있음.

결국 외부 앱(롯데잇츠)처럼 UI 구조나 컨텐츠가 자주 바뀌는 앱에 대해, 현재처럼 “특정 노드를 찾거나 못 찾으면 고정 좌표로 탭해보기” 식 접근은 안정성이 매우 떨어집니다. 게다가 이벤트를 충분히 수신하지 못하고, 음성/접근성 로직 타이밍이 꼬이면, “어쩌다 한 번 성공하고 대부분 실패” 같은 현상이 발생하게 됩니다.


개선/해결 방향(간단 요약)

  1. 이벤트 처리 범위: TYPE_WINDOW_CONTENT_CHANGEDTYPE_VIEW_SCROLLED도 필요하면 활용해, 실제 화면 업데이트 즉시 새 노드 정보를 캐치할 수 있게 한다.
  2. 노드 탐색 로직: Thread.sleep() 대신 HandlerCoroutine 딜레이로 UI 상태를 주기적으로 다시 확인하거나, 이벤트 콜백을 통해 동적으로 노드를 재탐색하는 구조로 바꾼다.
  3. 하드코딩된 좌표 제거: 앱 특정 뷰의 뷰 아이디나 텍스트, contentDescription 등을 정확히 파악하고(또는 목록을 전부 스캔해 확률적 매칭) 클릭을 수행한다. 웹뷰라면 performGlobalAction(AccessibilityService.GLOBAL_ACTION_SCROLL_*)처럼 스크롤 액션을 시도하는 방식을 고려한다.
  4. 패키지명/앱 식별 정확화: 실제 롯데잇츠 앱 패키지명 확인 후, AppConfig 등에 올바르게 설정.
  5. TTS & 음성인식 충돌 방지: TTS가 끝나는 시점을 좀 더 확실히 기다린 후 음성 인식을 재개하도록 단순화하거나, TTS 중에는 접근성 조작이 잠시 대기하도록 논리를 명료화한다.
  6. 추가 예외처리: 팝업, 튜토리얼 화면 등 예외 상황도 감지하기 위해 노드를 좀 더 폭넓게 스캔하고, 예상치 못한 화면이 뜨면 적절히 닫거나 재시도하는 로직을 둔다.

이런 부분을 보완하지 않으면, UI 구조 변동이 심한 외부 앱에 대한 접근성 자동화가 계속해서 “어쩌다 동작하거나, 일정 환경에서만 성공”하는 상태에 머물 가능성이 큽니다.

질문

만약 새로 앱을 재작성한다면 어떤방법및 솔루션을 중점적으로 정교하게 개발할 수 있을까요? 그리고 안드로이드 앱 프로젝트의 각 기능및 서비스및 모듈들의 의존성, 종속구조를 어떻게 설계할 수 있을까요? 그리고 현재 배달앱이 앱 내부에 webview로 배달앱의 웹사이트를 로드하고있는데, 접근성 노드들이 식별되긴하지만 스와이프, 클릭, 노드파싱등 불안정한데 어떻게 견고하게 조작할 수 있을까요? thinking후 솔루션을 출력하세요. 참고로 패키지명은 무시하세요

답변

아래 답변은 **“만약 새로 앱을 ‘접근성 기반으로 외부 배달 서비스(또는 웹뷰를 통한 주문 페이지)를 음성명령으로 제어’하는 기능을 정교하고 안정적으로 개발한다면, 어떤 방법과 솔루션에 집중해야 하는가?”**에 대한 가이드입니다.
또한, 앱 내 구조(모듈화, 서비스 의존성 등)와 WebView 기반 화면을 견고하게 조작하기 위한 접근성 전략도 함께 정리했습니다.


1. 큰 그림: 아키텍처 및 모듈 설계 방향

(1) 전체 구조 개념

  1. Speech / Voice 모듈:

    • 음성인식을 담당하는 모듈(예: Google STT나 타사 엔진 사용)과, 음성 피드백(TTS) 모듈을 분리하여 관리.
    • “마이크 녹음 → STT 요청 → 명령 분석(다음 단계로 전달)”의 흐름을 단일 클래스(혹은 Service)로 두지 말고, 별도의 SpeechManager / NLU(Natural Language Understanding) Manager / TTS Manager 등으로 나누어 재사용성을 높임.
    • 음성인식 중 TTS가 겹칠 경우 중단·재개 로직이 혼선 없이 동작하도록 별도의 AudioFocus나 “음성 세션” 관리 객체를 두고 상태머신(State Machine)을 설계.
  2. Command / Intent Handler:

    • 음성인식 결과(“버거 주문해줘” 등)를 처리해 “실제 어떤 액션(Accessibility)을 수행해야 하는지” 결정하는 중간 레이어.
    • GPT나 Rule-based NLU 로직이 있으면, 이를 캡슐화한 CommandProcessor를 둠(현재 GptService가 이 역할).
    • 최종적으로 “Accessibility 를 통해 UI 조작을 해야 한다”는 명령이 내려지면, 이 레이어에서 Accessibility 모듈에게 “매장 선택” “메뉴 스크롤” 등의 구체적인 함수를 호출해줌.
  3. Accessibility 모듈 (Service):

    • 실제로 외부 앱(혹은 WebView) 화면을 탐색, 노드를 찾아 클릭/스크롤 등 조작하는 핵심 담당.
    • 여러 화면 상황을 인지하여 UI 변화를 이벤트 기반(+ 추가 폴링)으로 추적.
    • “주문 완료” 등 특정 시점에는 다시 음성인식 모듈에 이벤트를 날려주거나, TTS 모듈에 안내를 요청.
    • 이 로직도 지나치게 거대화하지 않도록, 노드 검색/액션 수행을 분리한 유틸 클래스(예: NodeFinder, AccessibilityActions)로 나누는 것이 좋음.
  4. WebView 내 화면 제어:

    • 앱 내부 WebView라면, 여러 가지 방식을 검토:
      • AccessibilityService로 WebView 컨텐츠를 부분적으로만 탐색(기본적인 android.webkit.WebView 노드만 노출)하는 기존 방식은 상당히 불안정.
      • 가능하다면 WebView의 JS Bridge 또는 자체 JavaScript 주입을 통해 DOM을 직접 조작하는 방식을 병행하면 훨씬 안정적.
      • (외부 서비스 페이지를 그대로 로드해야 하는 상황이라면, 완전 자동화가 쉽지 않으므로, JS 삽입 허용이 안 된다면 접근성 API만으로 운영해야 하나, 이 경우에는 강력한 예외처리/반복 로직이 필요.)
  5. UI (MainActivity 등)

    • 실제 앱 화면은 간단히 현재 “접근성 서비스 상태”, “마이크 권한 여부”, “인식된 음성 텍스트” 등을 표시하고, “설정 열기/접근성 안내” 등을 제공하는 역할.
    • 음성 제어는 백그라운드에서 자동으로 이뤄지므로, 굳이 Activity가 전면에 있지 않아도 동작 가능.

(2) 의존성/종속 구조

  • VoiceCommandService(포그라운드 서비스) ↔ AccessibilityService는 서로 연결되지만, 한쪽이 독립적으로 동작해도 되도록(예: 접근성만 ON일 수도 있고, 음성 인식만 필요할 수도 있는 상황) 너무 강하게 묶이지 않게 설계.
  • DI(Dependency Injection) 라이브러리를 사용해, CommandProcessor, SpeechManager, AccessibilityManager 등을 모듈 단위로 주입받을 수 있게 하면 테스트/교체가 용이. (예: Hilt, Koin, Dagger 등)
  • 코루틴/Flow 기반 비동기 처리를 적용해, 이벤트 스트림(접근성 이벤트, 음성 이벤트 등)을 관찰하며 처리. Thread.sleep() 대신 Flow.combineLatest(), SharedFlow, StateFlow 등을 활용하면 훨씬 견고한 로직이 가능.

2. WebView + 접근성을 안정적으로 다루는 방안

(1) 웹 컨텐츠 직접 제어 (가장 바람직)

  • JS Bridge:
    • 만약 우리가 로드하는 페이지가 제어 가능한(내부 페이지거나, JS 삽입이 허용) 웹사이트라면, JavaScript 인터페이스를 삽입해서 직접 DOM을 조작(예: 특정 버튼 클릭)하는 것이 제일 안정적.
    • 이 경우, “document.querySelector(‘#menu-burger’)” 같은 식으로 DOM을 찾고 .click()을 실행, 결과를 콜백으로 앱에 알려주면 접근성 이벤트에 의존할 필요가 없다.
  • AccessibilityService만으로 웹 컨텐츠를 파싱하는 것은, WebView가 “커스텀 렌더링”을 할 때 노드를 정확히 안 내보낼 수 있고, 스크롤 액션이 잘 먹히지 않을 수 있음.
  • 따라서 가능한 한 JavaScript Injection을 쓰는 게 “불안정성을 최소화”하는 핵심 해법.

(2) 접근성 API만으로 WebView 컨텐츠 조작

만약 JS 삽입이 불가능하고, 웹 접근성만으로 처리해야 한다면:

  1. Event 콜백을 적극 수신: TYPE_WINDOW_CONTENT_CHANGED, TYPE_VIEW_SCROLLED 등을 무시하지 않고 모두 받음.
  2. 노드 탐색 시, 화면상 WebView 노드 내부를 꾸준히 재귀 탐색해야 하며, “페이지가 로딩 중인지” 등의 상태를 계속 확인.
  3. 프로그램적 스크롤: performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) 또는 GestureDescription 스와이프를 사용하되, “화면이 실제로 스크롤 완료되는 시점”을 반복 체크(글로벌 이벤트나 rootInActiveWindow 변화를 감지)하여 안정화.
  4. 노드 텍스트/컨텐트 변경 가능성을 대비해, 노드 검색 시 단순 “정확 매칭”이 아니라 여러 패턴을 허용하거나, “유사 텍스트”나 “부분 일치”로도 찾을 수 있게 로직을 개선.
  5. 동적 대기 로직: sleep이 아니라 “특정 노드가 보일 때까지 observe” 하는 식으로 FlowChannel을 만들어 UI 변경 이벤트가 들어오면 재탐색.
  6. 오류나 시간 초과 시에는 재시도 / 다음 단계로 넘어가는 예외처리를 철저히 작성.

3. 음성명령 + 접근성 병행 시 주의점/솔루션

  1. TTS / 음성인식 State Machine

    • TTS가 시작되면 음성인식을 중지 → TTS가 완료되면 음성인식을 재개.
    • 이 과정을 플래그 하나로만 제어하지 말고, 상태 머신을 설계해서 “SPEAKING” “LISTENING” “ORDER_PROCESSING” 등 명확히 상태를 구분.
    • 중간에 TTS가 여러 번 겹치는 경우, Queue를 두어 순차적으로 TTS를 말하고, 마지막 TTS가 끝난 후 음성인식을 재시작.
  2. 명령 수행 중 추가 명령 수신 방지

    • 실제로 화면 조작(접근성) 작업 중간에 사용자가 또 말하면 로직이 꼬일 수 있으므로, “주문 절차/화면 조작이 일정 단계 이상 진행 중이면” 음성인식을 임시로 off 하는 방식.
    • 접근성 동작이 끝나면 on.
    • 예: “매장 찾기 → 노드 클릭 → 대기” 이 한 덩어리 트랜잭션이면, 이 동안에는 음성인식을 중지해두고, 끝나면 다시 resume.
  3. 안내 멘트(TTS) 시점을 UI 조작 타이밍과 분리

    • UI 조작(노드 탐색/클릭)이 끝나고, 결과가 확실해진 뒤에만 TTS를 내보내야 “스와이프 도중 TTS가 끝나 음성인식이 시작되며 액션이 중단됨” 등의 문제가 줄어듦.
    • “명령 실행 → 성공/실패 콜백 → TTS안내 → 음성인식 재개” 순으로 명확한 시퀀스 유지.

4. 좀 더 구체적인 재작성 로드맵

  1. 모듈/레이어 설계

    1. SpeechModule (or SpeechManager):
      • STT(음성인식)과 TTS(합성) 제어를 합쳐 놓을 수도 있고, 별도 클래스로 분리할 수도 있음.
      • 단, TTS 시 음성인식 중단/재개 로직을 한 곳에서 통제.
    2. NluModule (or CommandProcessor):
      • GPT/Rule 기반으로 “버거 주문” “수량 설정” 등 Intent를 파싱 후, AccessibilityModule에게 명령 전송.
    3. AccessibilityModule (실제론 AccessibilityService + 유틸 클래스):
      • “메뉴 카테고리 열기” “화면 스크롤” “메뉴 항목 클릭” 등 고수준 함수를 제공.
      • 내부에서는 AccessibilityNodeInfo를 찾고, 필요하면 GestureDescription으로 탭/스와이프 수행.
      • WebView라면 (ACTION_SCROLL_FORWARD, ACTION_CLICK, performGesture 등)을 견고하게 반복 시도/이벤트 대기.
    4. Coordinator(or MainFlowManager):
      • “명령” → “Accessibility 동작”이 끝날 때까지 비동기 시퀀스를 제어. 중간에 상태 갱신 → TTS 안내 → 다음 단계 유도.
      • 음성인식 활성/비활성 컨트롤.
  2. 명령 실행 흐름 예시

text
사용자 음성 &rarr; STT &rarr; “버거 주문할래” 문장 획득 &darr; CommandProcessor (NLU) &rarr; Intent: ORDER_BURGER, Params: ... &darr; Coordinator 1) Coordinator가 TTS: "주문을 시작합니다." 2) TTS가 끝나면 STT 잠시 중단 3) AccessibilityModule로 "메인 화면에서 '버거' 카테고리를 찾고 클릭" 명령 - 노드 탐색 &rarr; 성공 시 클릭 - UI 갱신 기다림(이벤트) - 실패 시 재시도/오류 처리 4) 완료 콜백 수신 &rarr; TTS : "버거 카테고리를 열었습니다. 어떤 버거를 선택하시겠습니까?" 5) TTS가 끝나면 STT 재개
  1. 동적대기 & 이벤트 기반 로직

    • 각 단계에서 그냥 Thread.sleep()하지 말고, “화면이 바뀌었다” “원하는 노드가 나타났다” “탭 액션이 완료되었다” 같은 이벤트를 받아서, 그때 다음 단계로 넘어가는 식.
  2. WebView 개선

    • 가능하다면 “WebView에 JavaScriptBridge”를 구성해 webView.evaluateJavascript("document.querySelector('#xxx').click()") 식으로 DOM 조작 → 성공/실패를 콜백.
    • 외부 페이지라 커스텀 JS 삽입이 어렵다면 접근성 API만 사용. 그 경우에도, “실제 웹페이지 로딩 완료 시점”을 잡아야 하므로 “로딩 스피너 노드”나 “로딩 완료 후 표시되는 특정 뷰”가 나타날 때까지 반복 체크.

5. 결론적으로 집중해야 할 솔루션 정리

  1. 접근성 이벤트를 충분히 수신: TYPE_WINDOW_CONTENT_CHANGED, TYPE_VIEW_SCROLLED 등을 무시하지 않고 적절히 핸들링 → UI 변화를 추적.
  2. 하드코딩 좌표/텍스트를 최대한 지양: viewId, 여러 candidate text(동의어), DOM 탐색(JS) 등을 통해 유연한 검색.
  3. 음성/TTS 상태머신 설계: TTS 중 음성인식을 중지하고, TTS 종료 후 다시 재개. 동시에 “명령 처리 중”에는 추가 명령 제한.
  4. WebView를 제어:
    • 가능하면 JavaScript 인터페이스(Bridge)로 DOM 직접 조작.
    • 불가하면 AccessibilityService + gesture/scroll/event 기반 로직을 반복 재시도 방식으로 강화.
  5. 비동기 처리 개선:
    • Thread.sleep() 삭제하고, 코루틴의 suspendCancellableCoroutine 또는 Flow 기반 이벤트 처리.
    • “특정 노드를 찾을 때까지 n초 대기” 구조를 만들되, 노드가 생기면 즉시 처리. 타임아웃 시 오류 핸들링.
  6. 모듈 분리 & DI 적용: 음성 인식, 명령 분석, 접근성 조작, WebView/JS제어 로직을 각각 모듈화.
    • Service → Presenter(or Coordinator) → Model(모듈들) 구조 식으로 서로가 명확한 의존 관계를 갖도록 설계.

한줄 요약:

“앱 내부 WebView를 직접 DOM 조작(가능하다면 JS Bridge)하고, 접근성 이벤트를 놓치지 않도록 이벤트 기반 비동기 로직을 구성하며, 음성 인식(TTS)과 접근성 동작 사이의 타이밍 충돌을 상태머신으로 제어”
이것이 가장 견고하게 구현하는 핵심 전략입니다.

이 Q&A 공유하기