등록 App ↔ DeviceAgent 인터페이스
구현 협의용 최신 문서: DeviceAgent ↔ 등록 App 구현 요청서. 아래는 이전 상세·Sample 자료다. 직접 구현 책임과 현재 취소·재시도 기준은 신규 요청서를 먼저 확인한다.
화면·복구 보강: 등록 단계별 완료 화면, Binder 단절·start 응답 유실·앱 재시작, 관련 연동 시험. 제품 제안에서는 전송 성공으로 완료 화면을 표시하지 않고, 상태 조회로 복구하며 녹음 start를 자동 반복하지 않는다.
등록 화면을 만드는 쪽과 DeviceAgent를 구현하는 쪽이 맞출 값이다. 등록 App은 제어 Bundle을 보내고 상태 Bundle을 받는다. 녹음·임베딩은 AI-Agent가 처리하며 원본 음성을 등록 App에서 보내지 않는다.
개인화 선택과 서버 관심사
등록 App의 setVoiceProfilePersonalization(enabled:Boolean)은 개인화 사용 동의 후 프로필 생성을 시작하는 명령이다. 관심사 목록을 저장하는 명령이 아니다.
| 단계 | 주체 → 대상 | 전달·처리 |
|---|---|---|
| 개인화 선택 | 등록 App → DeviceAgent → AI-Agent | enabled를 최종 프로필 personalizationEnabled에 기록 |
| 등록 완료 | AI-Agent → DeviceAgent → 등록 App·IoTAgent | voiceProfileStateChanged(COMPLETED, voiceId). 등록 App은 완료 화면 표시 |
| 최초 관심사 등록 | 모바일 App → 샘플 Backend | 완료 이벤트 후 프로필 목록 갱신, voiceId 선택, 관심사 HTTPS PUT |
| 상시 관심사 변경 | 모바일 App → Backend | 화자별 설정에서 같은 HTTPS PUT으로 전체 목록 교체 |
| 대화 중 조회 | AI-Agent → DeviceAgent → IoTAgent → Backend | getVoicePersonalization; 결과는 역방향. 등록 App에는 전달하지 않음 |
샘플 Backend는 기기 목록의 personalizationEnabled로 프로필을 확인한다. 동의하지 않은 프로필에 대한 관심사 저장은 거절하며, 과거 필드가 없는 프로필을 동의한 것으로 취급하지 않는다. 제품 계정 동의 연계는 별도다. COMPLETED가 LOCAL_TEST_ACK에서 나올 수 있으므로 실제 Cloud 저장 완료와 혼동하지 않는다.
DeviceAgent 중심 구조
SVG 구조 그림 크게 보기 · 등록 App·AI-Agent ↔ DeviceAgent 전체 인터페이스 정의
등록 App은 화면과 사용자 입력을 담당하고, 등록·인식을 수행하는 서비스는 AI-Agent다. 아래 그림은 그중 등록 과정의 중계 순서다. DeviceAgent가 직접 녹음하거나 화자를 식별하는 구조는 아니다.
1. 주고받는 것
| 방향 | 수단 | 전달 값 | 처리 주체 |
|---|---|---|---|
| DeviceAgent → 등록 App | 명시적 Activity Intent | requestId, 닉네임, 발음, 언어, 초기 개인화 값 | 등록 App이 화면 표시 |
| 등록 App → DeviceAgent | sendModuleCommand(input, output) | 시작·재녹음·취소·개인화 등 제어 Bundle | DeviceAgent가 AI-Agent로 중계 |
| DeviceAgent → 등록 App | onModuleCallback(Bundle) | voiceProfileStateChanged | 등록 App이 진행/오류/완료 화면 갱신 |
요청의 반환값은 전달 수락 여부이고 최종 결과가 아니다. 등록 App은 output Bundle이 아니라 callback으로 화면을 갱신한다.
2. 화면 실행: DeviceAgent → 등록 App
| 항목 | 값 |
|---|---|
| package | com.sk.airbot.voiceprofileregistration |
| class | com.sk.airbot.voiceprofileregistration.VoiceProfileRegistrationActivity |
| action | com.sk.airbot.voiceprofile.action.REGISTER |
| flags | FLAG_ACTIVITY_NEW_TASK · FLAG_ACTIVITY_CLEAR_TOP |
<a id="activity-flags"></a>
화면 실행 옵션(Flag)
Flag는 Android에 화면을 어떻게 열지 지정하는 옵션이다. 등록 데이터나 녹음 제어 명령이 아니다. Task는 뒤로가기로 이동하는 화면들의 묶음이다.
| 옵션 | 의미 | 등록 화면에서의 용도 |
|---|---|---|
FLAG_ACTIVITY_NEW_TASK |
적합한 기존 Task가 있으면 활용하고, 없으면 새 Task를 만든다. 항상 새 화면을 만드는 뜻은 아니다. | DeviceAgent의 서비스에서 등록 Activity를 열 때 사용한다. |
FLAG_ACTIVITY_CLEAR_TOP |
선택된 Task에 등록 Activity가 이미 있으면 그 위에 쌓인 다른 Activity를 종료한다. | 기존 등록 화면으로 돌아오도록 한다. |
두 옵션은 비트 OR로 함께 지정한다. 표의 가운데 점은 두 옵션을 나열한 표기다.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
예를 들어 화면 순서가 등록 화면 → 다른 화면이면 등록 화면 위의 다른 화면을 정리한다. 등록 화면 자체의 재사용 여부는 Manifest의 launchMode와 다른 Flag에도 영향을 받는다. 기본 standard에서는 CLEAR_TOP으로 등록 Activity까지 종료하고 다시 만들 수 있으며, 재사용되는 경우 새 요청은 onNewIntent()로 전달된다.
이 옵션만으로 녹음 취소, 등록 상태 초기화, 중복 요청 방지가 처리되지는 않는다. 이 처리는 별도의 등록·취소 메서드와 requestId 확인으로 관리해야 한다. Activity 내부 화면 전환까지 자동으로 초기화하는 옵션도 아니다.
참고: Android 공식 문서: Task와 화면 스택.
| Intent extra | 타입 | 전달 조건 | 의미 |
|---|---|---|---|
| requestId | String | 시작·취소 | 같은 등록 작업을 구분 |
| nickname | String | 시작 | 사용자 표시 이름 |
| locale | String | 시작 | 한국어 ko-KR, 영어 en-US. 아래 언어값 규약 참조 |
| personalizationEnabled | Boolean | 시작 | 초기 개인화 값 |
| command | String | 취소 시 cancel | 기존 등록 화면에 취소 전달 |
원격 시작은 화면만 연다. 사용자가 시작 버튼을 눌러야 등록 App이 start Bundle을 보낸다. 원격 취소도 현재 Activity를 거친다. 다른 활성 요청이 있거나 Activity 실행에 실패한 경우의 원격 실패 응답은 보완 대상이다.
<a id="locale-contract"></a>
locale 언어값 규약
다음은 한·영 연동에서 송신 측이 사용할 값이다. 현재 코드의 허용 검사와는 구분한다.
| 항목 | 정의 |
|---|---|
| 키·타입 | locale:String |
| 한국어 | ko-KR |
| 영어 | en-US |
| 표기 | 언어 소문자 + 하이픈 + 지역 대문자. ko, en, ko_KR, 한국어로 대체하지 않음 |
| 전송 조건 | 시작 요청에 명시적으로 포함. 빈 문자열·공백·null을 보내지 않음 |
| 의미 | 등록 작업에 전달되는 언어 메타데이터. 이 필드만으로 기기의 언어 설정이나 STT/TTS 엔진을 변경하는 명령은 아님 |
| 다른 언어 | 이 한·영 연동 규약에는 정의하지 않음. 문자열이 수락된다고 해당 언어 기능이 지원되는 것은 아님 |
현재 구현의 기본값·검증 차이
| 경로 | 실제 동작 |
|---|---|
| DeviceAgent 원격 시작 검증 | trim 후 비어 있지 않고 길이 32자 이하인지 검사. ko-KR/en-US 목록 검사와 표기 정규화는 없음 |
| DeviceAgent Activity Intent 작성 | locale 누락 시 ko-KR 기본값을 지정하는 코드가 있으나, 원격 시작은 앞선 필수값 검사를 통과해야 함 |
| 등록 App에서 시작 버튼 | Intent locale이 있으면 그대로 전달. 없으면 Locale.getDefault().toLanguageTag() 사용. 영어가 항상 en-US라는 보장은 없음 |
| AI-Agent 세션 생성 | locale이 빈 값/공백이면 ko-KR; 그 외 값은 그대로 세션에 보존 |
| 잘못된 값 응답 | 빈 값·길이 초과 원격 요청은 검증 거절 대상. 목록 밖 언어를 위한 전용 오류 코드·거절 처리는 현재 없음 |
따라서 송신 측은 위의 정확한 한·영 값을 보내야 한다. 수신 측에서도 이 두 값만 허용하도록 강제하려면 별도 코드 변경이 필요하다. 이번 문서 수정으로 검증 동작이 바뀌지는 않는다.
3. Proxy 연결과 공통 Bundle
연결: DeviceControlProxy.connect(context, callback, true, callbackClassName).
현재 callbackClassName은 등록 Activity의 전체 클래스명이다. 연결되면 getVoiceProfileEnrollmentState를 요청한다. 수신은 IDeviceControlProxyCallback.onModuleCallback(outdata)다.
등록 App의 모든 제어 요청은 다음 네 필드를 포함한다.
| 키 | Android 타입 | 필수 | 값·의미 |
|---|---|---|---|
| method | String | 필수 | 다음 표의 메서드 이름 |
| contractVersion | String | 필수 | voice-profile-v2 |
| requestId | String | 필수 | 실행 Intent에서 받은 등록 작업 ID |
| issuedAtMs | Long | 현재 클라이언트 항상 전송 | System.currentTimeMillis() |
등록 App 클라이언트는 현재 expiresAtMs를 넣지 않는다. 원격 MQTT의 만료 검사와 UI 내부 등록 제어의 공통 필드를 혼동하지 않는다.
예시: 연결 후 현재 상태 조회
아래는 실제 클라이언트의 연결·송신 흐름을 간추린 예시다. context는 앱 Context, requestId는 실행 Intent에서 받은 값이다. 문자열 키는 읽기 쉽게 표시했으며 실제 구현에서는 VoiceProfileContract 상수를 사용한다.
val callback = object : DeviceControlProxy.IDeviceControlProxyCallback {
override fun onServiceConnected() {
val input = Bundle().apply {
putString("method", "getVoiceProfileEnrollmentState")
putString("contractVersion", "voice-profile-v2")
putString("requestId", requestId)
putLong("issuedAtMs", System.currentTimeMillis())
}
val service = DeviceControlProxy.getService() ?: return
try {
val result = service.sendModuleCommand(input, Bundle())
if (result < 0) return // 전송 거절. 상태 수신으로 처리하지 않는다.
} catch (error: android.os.RemoteException) {
return // 연결 오류. 화면에서 재연결 안내가 필요하다.
}
}
override fun onServiceDisconnected() {
// 연결 해제 상태를 UI에 전달한다.
}
override fun onModuleCallback(outdata: Bundle?) {
if (outdata?.getString("method") != "voiceProfileStateChanged") return
if (outdata.getString("requestId") != requestId) return
val state = outdata.getString("state") ?: return
// state를 UI에 전달한다. 화면 갱신은 메인 스레드에서 처리한다.
}
}
if (DeviceControlProxy.connected()) {
DeviceControlProxy.setDeviceControlProxyCallback(callback)
callback.onServiceConnected()
} else {
DeviceControlProxy.connect(
context.applicationContext, callback, true,
"com.sk.airbot.voiceprofileregistration.VoiceProfileRegistrationActivity"
)
}
흐름은 Proxy 연결 → 조회 Bundle 송신 → DeviceAgent가 AI-Agent로 중계 → 상태 콜백 수신 → UI 반영이다. sendModuleCommand()의 0 이상 반환값은 전송 수락이지 등록 완료가 아니다. 실제 상태는 콜백의 state로 확인한다. 예시는 흐름 설명용이며 화면의 오류 표시와 연결 수명 관리는 생략했다. 기존 클라이언트는 수신 값을 Listener로 전달하며, 위 예시는 현재 작업의 requestId를 확인할 위치도 함께 보여준다.
<a id="ui-commands"></a>
4. 버튼별 요청과 기대 결과
추가 입력은 3절 공통 Bundle에 붙인다. 모든 키는 실제 문자열이며 구현에서는 각 모듈의 Contract 상수를 사용한다.
| UI 동작 | method | 추가 키 : 타입 | 받는 결과·주의 |
|---|---|---|---|
| 연결/화면 복귀 | getVoiceProfileEnrollmentState | 없음 | 현재 상태. 해당 세션 없음은 WAITING_START |
| 등록 시작 | startVoiceProfileEnrollment | nickname:String, :String, locale:String, userId:String | RECORDING_SET 또는 거절 상태. userId는 기존 호환 값이며 등록 식별 키로 사용 안 함 |
| 다시 녹음 | retryVoiceProfileSet | setIndex:Int | 현재 세트 재시작. 임의 세트 선택 API 아님 |
| 나가기 확인창 | pauseVoiceProfileEnrollmentForExit | setIndex:Int | 현재 별도 상태 ACK 없음. pause callback을 기다리면 안 됨 |
| 나가기 확정/원격 취소 | cancelVoiceProfileEnrollment | reason:String | CANCELLED. 요청 ID 불일치/세션 없음은 무응답 가능 |
| 개인화 선택 저장 | setVoiceProfilePersonalization | enabled:Boolean | 로컬 프로필 생성 시작. personalizationEnabled 키로 보내지 않음 |
| 생성 재시도 | retryVoiceProfileCreation | 없음 | 상태 callback. 세트가 비워진 실패 경로에서는 무동작 가능 |
| 전달 재시도 | retryVoiceProfileDelivery | 없음 | 기존 pending 프로필 전달. 새 프로필 생성 아님 |
| 완료 화면 닫기 | closeVoiceProfileRegistration | 없음 | 완료 프로필을 삭제하지 않음. 별도 close ACK를 기다리지 않음 |
nickname은 trim 후 1~40자이며 경로 구분자·제어 문자 금지다. 발음 보조 필드를 입력받거나 전달하지 않는다.
4.1 즉시 반환값: 전송 단계
호출 형식은 sendModuleCommand(input: Bundle, output: Bundle): Int다. 등록 제어는 두 번째 Bundle에서 결과를 읽는 방식이 아니라 별도 상태 콜백을 받는다.
| 값/상황 | 현재 의미 | 등록 App 처리 |
|---|---|---|
0 |
DeviceAgent가 요청 중계 경로를 수행함 | 상태 콜백을 확인한다. AI-Agent의 실행·완료를 보장하지 않음 |
-1 |
등록 요청의 공통 형식 검사 실패 | contractVersion·requestId 등 입력 확인 |
-2 |
호출자 허용 검사 실패 | 호출 앱·권한 구성 확인 |
-3 |
IoTAgent 원격 요청에서 등록 Activity 실행 실패 | 원격 화면 실행 경로의 오류. 등록 App의 일반 제어 반환값과 구분 |
Service가 null |
Binder 연결 안 됨 | 클라이언트 send()는 false 반환. 재연결 후 상태 조회 |
RemoteException |
Binder 통신 예외 | 클라이언트 send()는 false 반환. 성공으로 표시하지 않음 |
현재 DeviceAgentVoiceProfileClient.send()는 Int를 그대로 노출하지 않고 0 이상이면 true, 음수·연결 실패·통신 예외이면 false로 변환한다. 따라서 UI에서 -1/-2를 구별하려면 클라이언트 반환형도 확장해야 한다. 문서에 적힌 오류 코드만으로 현재 UI가 세부 오류를 구분하는 것은 아니다.
4.2 비동기 결과: 버튼별 상태와 파라미터
모든 상태 결과는 onModuleCallback(Bundle)의 method="voiceProfileStateChanged"로 받는다. 요청 메서드마다 별도의 ...Response가 오는 구조가 아니다. 공통 필드의 타입·의미는 상태 콜백 필드를 따른다.
| 요청 method | 실행 조건·추가 입력 | 수신 상태와 확인 필드 | 응답이 없거나 실패할 수 있는 경우 |
|---|---|---|---|
getVoiceProfileEnrollmentState |
공통 Bundle만 전송 | 현재 state, setIndex, progress, terminal; 해당 작업이 없으면 WAITING_START |
Binder 미연결·중계 실패 시 수신 보장 없음 |
startVoiceProfileEnrollment |
nickname:String, :String, locale:String, 기존 userId:String |
녹음 시작은 RECORDING_SET; 거절은 reasonCode와 state로 판정 |
동일 요청 재전송이 안전한 중복 제거로 처리되지 않음 |
retryVoiceProfileSet |
같은 requestId의 비종료 세션. UI가 setIndex:Int 전송 |
RECORDING_SET, 현재 세트의 setIndex, progress |
Service는 전달받은 setIndex로 세트를 선택하지 않음. 세션 없음·ID 불일치·terminal이면 무응답 |
pauseVoiceProfileEnrollmentForExit |
같은 requestId이며 RECORDING_SET; UI가 setIndex:Int 전송 |
별도 상태 콜백/ACK 없음 | 조건 불일치 시 무동작. PAUSED 같은 enum을 기다리지 않음 |
cancelVoiceProfileEnrollment |
reason:String, 같은 requestId의 세션 |
CANCELLED, reasonCode="USER_CANCELLED", terminal=true |
Service는 입력 reason을 그대로 돌려주지 않음. 세션 없음·ID 불일치면 무응답 |
setVoiceProfilePersonalization |
enabled:Boolean; 같은 requestId이며 PERSONALIZATION_SETTINGS |
CREATING_LOCAL_PROFILE → 생성·전달 상태; 최종 성공은 COMPLETED와 voiceId |
단계 불일치·필요 세트 부족 시 무응답 가능 |
retryVoiceProfileCreation |
같은 requestId와 필요한 녹음 세트 보유 | CREATING_LOCAL_PROFILE 이후 상태; 품질 실패는 SET_REJECTED, 생성 실패는 PROFILE_FAILED |
실패 후 세트가 이미 비워졌으면 재시도가 실행되지 않을 수 있음 |
retryVoiceProfileDelivery |
같은 requestId의 pending 프로필 존재 | PENDING_DEVICE_DELIVERY → COMPLETED 또는 DELIVERY_FAILED; 실패는 reasonCode 확인 |
pending 없음·ID 불일치 시 무응답 |
closeVoiceProfileRegistration |
같은 requestId의 세션 | 비종료 세션이면 취소 처리로 CANCELLED; 이미 종료된 세션이면 별도 close ACK 없음 |
세션 없음·ID 불일치 시 무응답. 완료 프로필 삭제 명령이 아님 |
COMPLETED에 필요한 voiceId는 Bundle에는 있지만 현재 등록 App의 상태 모델에는 복사되지 않는다. DELIVERY_FAILED는 저장·동기화 실패 경로이며 단순히 음성 녹음 실패를 뜻하지 않는다. 무응답 경로에는 현재 공통 타임아웃 응답 규약이 없으므로, 전송 true만으로 화면을 완료 처리해서는 안 된다.
실제 송신 예제
val input = Bundle().apply {
putString("method", "startVoiceProfileEnrollment")
putString("contractVersion", "voice-profile-v2")
putString("requestId", requestId) // Intent에서 받은 값
putLong("issuedAtMs", System.currentTimeMillis())
putString("nickname", "Niky")
putString("locale", "ko-KR")
putString("userId", "") // 기존 클라이언트 호환 필드
}
val transportResult = DeviceControlProxy.getService()
?.sendModuleCommand(input, Bundle())
// 수락 여부만 확인. 녹음/완료 화면은 상태 callback으로 전환한다.
<a id="state-fields"></a>
5. 상태 수신: DeviceAgent → 등록 App
AI-Agent가 만든 상태를 DeviceAgent가 복사해 등록 App(CALLER_VOICE_PROFILE_REGISTRATION=8)과 IoTAgent(CALLER_IOT=2)에 전달한다.
예시: 1번째 녹음 시작을 두 앱에 알림
AI-Agent가 requestId="vp-example-001" 작업의 녹음을 시작했다고 가정한다. 아래 값은 설명용 예시이며 실행 로그가 아니다.
AI-Agent: 상태 Bundle 생성
method = "voiceProfileStateChanged"
contractVersion = "voice-profile-v2"
requestId = "vp-example-001"
stateSequence = 2L
state = "RECORDING_SET"
setIndex = 1, setCount = 5, progress = 0
reasonCode = "", terminal = false, nickname = "Niky"
|
v
DeviceAgent: 값은 바꾸지 않고 대상별 Bundle로 복사하여 콜백 전달
|
+--> 등록 App [대상 번호 8]
| onModuleCallback(Bundle)
| 같은 requestId 확인 → 1/5 녹음 중 화면 표시
|
+--> IoTAgent [대상 번호 2]
onModuleCallback(Bundle)
원격 요청 측에 전달할 상태 이벤트로 처리
CALLER_VOICE_PROFILE_REGISTRATION=8과 CALLER_IOT=2는 DeviceAgent 내부의 콜백 수신 대상 번호다. 녹음 상태 enum이나 requestId가 아니며 위 상태 Bundle에 추가로 넣는 필드도 아니다. 두 앱은 같은 requestId·stateSequence·state를 받지만 사용하는 목적이 다르다. 등록 App은 기기 화면을 갱신하고, IoTAgent는 원격 상태 전달을 담당한다.
두 수신처의 처리 완료나 동시 수신까지 보장하는 ACK는 아니다. 현재 IoTAgent의 외부 결과 전달은 로컬 시험 Bridge 경로이며, 이 콜백 수신만으로 제품 AWS IoT Core 발행이나 모바일 수신이 완료됐다고 판단하면 안 된다. 상태 Bundle에 원본 음성이나 임베딩은 포함하지 않는다.
| Bundle 키 | 타입 | 포함 조건 | 의미 |
|---|---|---|---|
| method | String | 항상 | voiceProfileStateChanged |
| contractVersion | String | 항상 | voice-profile-v2 |
| requestId | String | 항상 | 화면의 작업 ID와 비교 |
| stateSequence | Long | 항상 | 상태 순번 |
| state | String | 항상 | AI-Agent 상태 enum 이름 |
| setIndex | Int | 항상 | 현재 세트 번호 |
| setCount | Int | 항상 | 전체 세트 수, 현재 5 |
| progress | Int | 항상 | 진행률 0~100 |
| reasonCode | String | 항상 | 실패 사유, 정상은 빈 문자열 가능 |
| terminal | Boolean | 항상 | 등록 작업 종료 여부 |
| nickname | String | 항상 | 표시 이름, 초기 조회는 빈 문자열 가능 |
| voiceId | String | 생성 후 | 등록된 화자 키. COMPLETED에 필요 |
{
"method": "voiceProfileStateChanged",
"contractVersion": "voice-profile-v2",
"requestId": "vp-example-001",
"stateSequence": 2,
"state": "RECORDING_SET",
"setIndex": 1,
"setCount": 5,
"progress": 20,
"reasonCode": "",
"terminal": false,
"nickname": "Niky"
}
위 JSON은 Bundle 값 설명용이며 Binder에 JSON 문자열을 넣는 예제가 아니다. 상태 Bundle에는 success가 없다.
현재 등록 App의 VoiceProfileStateUpdate 변환은 상태·순번·세트·진행률·사유·terminal을 읽는다. nickname/voiceId는 Bundle에 있어도 이 UI 모델에는 옮기지 않는다. 등록 App에서 완료 ID를 직접 사용할 기능을 추가한다면 수신 모델도 확장해야 한다.
<a id="ui-state"></a>
6. 화면 판정과 예외
| 수신/상황 | 등록 App 처리 |
|---|---|
| 다른 requestId | 현재 화면을 덮어쓰지 않음 |
| 이전 이하 stateSequence | 오래된 이벤트 무시. Service 순번 역전은 송신측 보완 필요 |
| SET_REJECTED | 재녹음 표시; 생성 품질 실패는 1세트부터 다시 녹음 |
| PERSONALIZATION_SETTINGS | 개인화 사용 여부 선택. 발음 수정 화면 아님 |
| CREATING_LOCAL_PROFILE | 생성 진행 |
| PENDING_DEVICE_DELIVERY | DeviceAgent 전달 대기 |
| PROFILE_FAILED/DELIVERY_FAILED | reasonCode·terminal·실제 재시도 가능 경로로 화면 결정 |
| COMPLETED | 완료 화면. 로컬 시험 ACK인지 제품 Cloud ACK인지는 별도 확인 |
| CANCELLED | 취소 화면/종료. 생성 중 늦은 완료 방어는 미완성 |
| Binder 연결 끊김 | 등록 성공으로 간주하지 않음. 재연결 후 상태 조회 |
DeviceAgent는 등록 제어를 CALLER_LLM=1로 전달하고 ModuleCmdCallbackPolicy의 상수 허용 목록을 맞춰야 한다. 현재 클라이언트는 sendModuleCommand 반환값 >=0을 수락으로 취급한다. -1/-2/-3은 분기별 오류이며 최종 등록 결과 enum이 아니다.
7. 클라이언트가 실제로 수신·적용하는 순서
DeviceAgentVoiceProfileClient와 VoiceProfileUiState의 현재 처리다. 표의 기본값은 누락을 허용하자는 권장 사양이 아니라 수신 구현의 동작이다. 송신자는 앞의 필드 명세대로 값을 모두 전달해야 한다.
| 순서 | 검사·변환 | 결과 |
|---|---|---|
| 1 | method가 voiceProfileStateChanged인지 |
다른 method는 화면 상태로 처리하지 않음 |
| 2 | requestId 존재, 알려진 stage 문자열인지 | 없거나 알 수 없는 상태이면 무시 |
| 3 | Bundle → 상태 객체 | setIndex 기본 1, setCount 기본 5, progress 기본 0, reasonCode 기본 빈 문자열, stateSequence 기본 -1, terminal 기본 false |
| 4 | UI의 requestId와 일치하는지 | 다른 등록 요청의 이벤트 무시 |
| 5 | stateSequence가 현재 값보다 큰지, UI가 이미 terminal인지 |
같거나 작은 순번 및 종료 후 이벤트 무시 |
| 6 | 화면 표시 범위 보정 | setCount 최소 1, setIndex는 1..setCount, progress는 0..100으로 제한 |
클라이언트 자체는 callback의 contractVersion을 별도로 검증하지 않는다. DeviceAgent 검증을 통과한 메시지를 받는 구조다. 또한 callback의 nickname·voiceId는 현재 UI 상태 객체로 복사하지 않는다. 화면에서 이 값의 변경까지 반영된다고 가정하면 안 된다.
예: 같은 요청에서 순번 12의 VALIDATING_SET을 먼저 적용한 뒤 순번 11의 RECORDING_SET이 도착해도 녹음 화면으로 돌아가지 않는다. 반대로 서비스 재시작으로 순번이 초기화되면 기존 UI가 새 이벤트를 거부할 수 있어, 재연결만으로 세션 복구가 완성되지는 않는다.
8. 연결·나가기·타임아웃
| 상황 | 현재 구현 | 완료 여부를 판단할 때 주의할 점 |
|---|---|---|
| Proxy 연결 성공 | listener에 연결 알림 후 getVoiceProfileEnrollmentState 요청 |
연결 성공과 등록 상태 수신은 별도 |
| 연결 끊김 | UI connected 값을 false로 변경 | 자동 등록 재시작·서버 동기화·세션 복원을 보장하지 않음 |
| 버튼 요청 | sendModuleCommand(...) >= 0을 Boolean으로 반환; 미연결·RemoteException은 false |
일반 버튼 처리의 여러 경로가 반환값을 사용하지 않음. 전송 실패 안내·재시도 보완 필요 |
| 녹음 중 나가기 창 표시 | pauseVoiceProfileEnrollmentForExit 송신 후 확인창 |
별도 pause 완료 ACK 없음 |
| 나가기 창에서 계속하기 | 창을 닫고 RECORDING_SET이면 retryVoiceProfileSet 송신 |
중단한 세트를 다시 녹음하는 요청 |
| 나가기 확정 | cancelVoiceProfileEnrollment(reason=USER_EXIT_CONFIRMED) 후 즉시 finishAndRemoveTask |
CANCELLED callback을 기다리지 않고 닫음. 화면 종료가 Service 캡처 종료의 증거는 아님 |
| 모바일에서 취소 | 연결 후 cancel(...MOBILE_CANCELLED)의 반환이 true이면 화면 종료 |
true는 전송 단계 결과이며 취소 완료 ACK가 아님 |
| WAITING_START | 5분 타이머 만료 시 UI_STATE_TIMEOUT으로 취소 요청 후 화면 종료 | Live 무발화 5초와 다른 등록 UI 전용 값 |
| COMPLETED | 60초 타이머 만료 시 취소 요청 후 화면 종료 | 완료 화면 자동 닫기 동작; 완료 프로필 삭제 요청이 아님 |
| 녹음·생성·전달 중 | 이 Activity의 상태별 종료 타이머 없음 | Service 캡처 제한과 별개. 생성·전달 무응답은 무기한 화면 대기 가능 |
오류 표시와 확인할 화면
| reasonCode | 현재 오류 문구 분류 | 확인할 버튼 동작 |
|---|---|---|
| NO_SPEECH / TOO_SHORT / INSUFFICIENT_SPEECH | 음성 부족 안내 | SET_REJECTED의 다시 녹음 → retryVoiceProfileSet |
| TOO_QUIET | 작은 음량 안내 | 같은 세트 재녹음 |
| MULTI_SPEAKER / INCONSISTENT_SPEAKER | 여러 화자 안내 | 현재 Service가 지정한 세트부터 재녹음 |
| 그 외 | 일반 녹음 오류 안내 | stage가 PROFILE_FAILED이면 생성 재시도, DELIVERY_FAILED이면 전달 재시도 |
오류 문구는 reasonCode, 버튼 행동은 stage에 따라 결정된다. 동일 오류 문구가 나온다고 같은 재시도 method를 보내는 것은 아니다.
화면 연동 시험표
아래는 실기기에서 추가 확인할 조건이며 완료된 시험 결과는 아니다.
| 번호 | 재현 조건 | 확인할 결과 |
|---|---|---|
| UI-01 | 다른 요청·역순·중복 callback 주입 | 현재 화면이 바뀌지 않음 |
| UI-02 | 녹음 오류 후 다시 녹음 | retryVoiceProfileSet의 requestId 유지, 새 RECORDING_SET 수신 |
| UI-03 | 연결 끊긴 상태에서 버튼 누름 | 전송 실패와 화면 대기를 기록; 성공으로 표시하지 않음 |
| UI-04 | 녹음 중 나가기 확정 | UI 종료와 별도로 Service 캡처 정지·CANCELLED·마이크 복구 확인 |
| UI-05 | 같은 UI를 둔 채 Service 재시작 | 순번 초기화로 callback이 막히는지 확인 |
| UI-06 | WAITING_START 5분 / COMPLETED 60초 경과 | UI_STATE_TIMEOUT 요청 및 화면 종료 확인 |
구현 근거·검증 범위
2026-09-18 SoC 작업본의 DeviceAgentVoiceProfileClient.kt, DeviceAgent.java, ModuleCmdCallbackPolicy, AI-Agent VoiceProfileIpcContract.kt를 대조했다. 문서·정적 검토이며 새 실기기 시험 결과는 아니다.