boot-welcome-usb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # boot-welcome-usb
2
+
3
+ Android AOA(Android Open Accessory) 프로토콜을 통해 USB로 연결된 Mac/PC와 통신하는 Expo Native Module.
4
+
5
+ ## 개요
6
+
7
+ WelcomeUSBFront 기능에서 사용되며, Android 태블릿(Accessory)이 Mac의 EventBoot-Desktop(Host)과 USB 케이블을 통해 QR 코드 접수 데이터를 주고받습니다.
8
+
9
+ ```
10
+ Android 태블릿 (이 모듈) ──USB 케이블── Mac (eventboot-desktop)
11
+ Accessory 모드 Host 모드
12
+ ```
13
+
14
+ ## 프로젝트 구조
15
+
16
+ ```
17
+ boot-welcome-usb/
18
+ ├── android/
19
+ │ ├── build.gradle
20
+ │ └── src/main/java/net/eventboot/welcomeusb/
21
+ │ ├── BootWelcomeUsbModule.kt # Expo Module 정의
22
+ │ ├── AoaAccessoryManager.kt # USB Accessory 연결 관리
23
+ │ └── AoaProtocol.kt # 메시지 프레이밍
24
+ ├── src/
25
+ │ ├── index.ts # 모듈 export
26
+ │ ├── BootWelcomeUsb.types.ts # TypeScript 타입
27
+ │ ├── BootWelcomeUsbModule.ts # 네이티브 모듈 바인딩
28
+ │ └── useUsbAoa.ts # React Hook
29
+ ├── example/ # 테스트용 예제 앱
30
+ ├── build/ # TypeScript 빌드 산출물
31
+ ├── expo-module.config.json
32
+ └── package.json
33
+ ```
34
+
35
+ ## 개발 환경 설정
36
+
37
+ ### 모듈 빌드
38
+
39
+ ```bash
40
+ cd modules/boot-welcome-usb
41
+
42
+ # TypeScript 빌드
43
+ npm run build
44
+
45
+ # 빌드 산출물 삭제
46
+ npm run clean
47
+
48
+ # 린트
49
+ npm run lint
50
+ ```
51
+
52
+ ### example 앱 실행
53
+
54
+ ```bash
55
+ cd modules/boot-welcome-usb/example
56
+
57
+ npm install
58
+ npx expo run:android
59
+ ```
60
+
61
+ > example 앱은 `package.json`의 `expo.autolinking.nativeModulesDir` 설정으로 상위 모듈을 자동 인식합니다.
62
+
63
+ ## eventboot 프로젝트에서 사용하기
64
+
65
+ ### 1. 의존성 등록
66
+
67
+ `package.json` (프로젝트 루트)에 로컬 경로로 추가:
68
+
69
+ ```json
70
+ {
71
+ "dependencies": {
72
+ "boot-welcome-usb": "./modules/boot-welcome-usb"
73
+ }
74
+ }
75
+ ```
76
+
77
+ ```bash
78
+ npm install
79
+ ```
80
+
81
+ ### 2. Config Plugin 등록
82
+
83
+ `app.json`의 `plugins` 배열에 AOA Config Plugin이 등록되어 있어야 합니다:
84
+
85
+ ```json
86
+ {
87
+ "plugins": [
88
+ "./plugins/withAndroidAoaAccessory"
89
+ ]
90
+ }
91
+ ```
92
+
93
+ 이 플러그인은 Android Manifest에 USB Accessory 관련 설정을 자동으로 추가합니다.
94
+
95
+ ### 3. 네이티브 빌드
96
+
97
+ Expo Native Module이므로 **네이티브 빌드가 필요**합니다. Expo Go에서는 동작하지 않습니다.
98
+
99
+ ```bash
100
+ # 개발 빌드
101
+ eas build --profile development --platform android
102
+
103
+ # 또는 로컬 빌드
104
+ npx expo run:android
105
+ ```
106
+
107
+ ## API 사용법
108
+
109
+ ### 기본 함수
110
+
111
+ ```typescript
112
+ import BootWelcomeUsbModule, {
113
+ getConnectionState,
114
+ requestPermission,
115
+ sendData,
116
+ addListener,
117
+ } from 'boot-welcome-usb';
118
+
119
+ // 연결 상태 확인
120
+ const state = await getConnectionState();
121
+ // 'DISCONNECTED' | 'CONNECTED' | 'AOA_SWITCHING' | 'AOA_READY' | 'ERROR'
122
+
123
+ // USB 권한 요청
124
+ const granted = await requestPermission();
125
+
126
+ // 데이터 전송 (JSON 문자열)
127
+ const success = await sendData(JSON.stringify({
128
+ id: 'uuid-123',
129
+ type: 'REQUEST',
130
+ path: '/scan',
131
+ body: JSON.stringify({ qrcode: 'TICKET-001' }),
132
+ }));
133
+ ```
134
+
135
+ ### 이벤트 리스너
136
+
137
+ ```typescript
138
+ import { addListener } from 'boot-welcome-usb';
139
+
140
+ // 연결 상태 변경
141
+ const sub1 = addListener('onConnectionStateChanged', (event) => {
142
+ console.log('상태:', event.state, event.message);
143
+ });
144
+
145
+ // 데이터 수신 (Host로부터 응답)
146
+ const sub2 = addListener('onDataReceived', (event) => {
147
+ const message = JSON.parse(event.messageJson);
148
+ console.log('수신:', message);
149
+ });
150
+
151
+ // 에러
152
+ const sub3 = addListener('onError', (event) => {
153
+ console.error('에러:', event.code, event.message);
154
+ });
155
+
156
+ // 정리
157
+ sub1.remove();
158
+ sub2.remove();
159
+ sub3.remove();
160
+ ```
161
+
162
+ ### React Hook
163
+
164
+ ```typescript
165
+ import { useUsbAoa } from 'boot-welcome-usb';
166
+
167
+ function MyComponent() {
168
+ const { connectionState, isConnected } = useUsbAoa();
169
+
170
+ return <Text>{isConnected ? '연결됨' : '미연결'}</Text>;
171
+ }
172
+ ```
173
+
174
+ ### useWelcomeUSBFront Hook (앱 내부)
175
+
176
+ 실제 WelcomeUSBFront 화면에서는 이 훅을 사용합니다. 기존 HTTP 방식과 동일한 `WelcomeDeskApiResult`를 반환합니다:
177
+
178
+ ```typescript
179
+ import { useWelcomeUSBFront } from '@/hooks/welcome/useWelcomeUSBFront';
180
+
181
+ function WelcomeUSBScreen() {
182
+ const { connectionState, isConnected, sendQRCode, requestPermission } = useWelcomeUSBFront();
183
+
184
+ const handleScan = async (qrcode: string) => {
185
+ const result = await sendQRCode(qrcode);
186
+ // result: { customer, receptionResult, isError, errorMessage }
187
+ };
188
+ }
189
+ ```
190
+
191
+ ## 통신 프로토콜
192
+
193
+ ### 메시지 프레이밍
194
+
195
+ USB 스트림은 메시지 경계가 없으므로 Length-Prefix 프레이밍을 사용합니다:
196
+
197
+ ```
198
+ +-------------------+------------------------+
199
+ | Length (4 bytes) | JSON Payload (N bytes) |
200
+ | Big-Endian uint32 | UTF-8 encoded |
201
+ +-------------------+------------------------+
202
+ ```
203
+
204
+ ### 메시지 형식
205
+
206
+ ```typescript
207
+ interface AoaMessage {
208
+ id: string; // UUID (요청-응답 매칭)
209
+ type: 'REQUEST' | 'RESPONSE' | 'PING' | 'PONG';
210
+ path: string; // "/scan", "/ping"
211
+ body?: string; // JSON 문자열
212
+ }
213
+ ```
214
+
215
+ ### 요청/응답 예시
216
+
217
+ **QR 스캔 요청** (Android → Mac):
218
+ ```json
219
+ {
220
+ "id": "550e8400-e29b-41d4-a716-446655440000",
221
+ "type": "REQUEST",
222
+ "path": "/scan",
223
+ "body": "{\"qrcode\":\"TICKET-12345\"}"
224
+ }
225
+ ```
226
+
227
+ **QR 스캔 응답** (Mac → Android):
228
+ ```json
229
+ {
230
+ "id": "550e8400-e29b-41d4-a716-446655440000",
231
+ "type": "RESPONSE",
232
+ "path": "/scan",
233
+ "body": "{\"data\":{\"customer\":{\"name\":\"홍길동\"},\"receptionResult\":\"OK_IN\"}}"
234
+ }
235
+ ```
236
+
237
+ ## 테스트 환경
238
+
239
+ - **Android 에뮬레이터**: USB Accessory 테스트 불가. **실기기 필수**.
240
+ - **ADB 충돌**: AOA 모드 전환 시 ADB 연결이 끊어질 수 있음. `adb kill-server` 후 테스트 권장.
241
+ - **Mac에서 EventBoot-Desktop 실행 필수**: USB Host 역할을 하는 `modules/eventboot-desktop` Electron 앱이 Mac에서 실행 중이어야 함.
@@ -0,0 +1,18 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'net.eventboot.welcomeusb'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "net.eventboot.welcomeusb"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,315 @@
1
+ package net.eventboot.welcomeusb
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.hardware.usb.UsbAccessory
9
+ import android.hardware.usb.UsbManager
10
+ import android.os.Build
11
+ import android.os.ParcelFileDescriptor
12
+ import android.util.Log
13
+ import java.io.FileInputStream
14
+ import java.io.FileOutputStream
15
+ import java.io.IOException
16
+
17
+ private const val TAG = "BootWelcomeUsb.Manager"
18
+
19
+ /**
20
+ * USB Accessory(AOA) 연결 수명주기를 관리하는 클래스.
21
+ *
22
+ * 주요 역할:
23
+ * - USB Accessory 연결/분리 BroadcastReceiver 등록 및 처리
24
+ * - USB 권한 요청 및 Accessory 열기
25
+ * - 백그라운드 스레드에서 수신 데이터를 읽어 MessageBuffer로 조립
26
+ * - Length-Prefix 프레이밍 프로토콜로 데이터 전송
27
+ *
28
+ * 연결 상태 흐름:
29
+ * DISCONNECTED → (권한 획득) → CONNECTED → (스트림 열기) → AOA_READY
30
+ * AOA_READY → (케이블 분리/에러) → DISCONNECTED
31
+ *
32
+ * @param context Android Context (BroadcastReceiver 등록 및 UsbManager 접근에 사용)
33
+ * @param onConnectionStateChanged 연결 상태 변경 콜백 (state, message)
34
+ * @param onDataReceived 완성된 JSON 메시지 수신 콜백
35
+ * @param onError 에러 발생 콜백 (code, message)
36
+ */
37
+ class AoaAccessoryManager(
38
+ private val context: Context,
39
+ private val onConnectionStateChanged: (String, String?) -> Unit,
40
+ private val onDataReceived: (String) -> Unit,
41
+ private val onError: (String, String) -> Unit
42
+ ) {
43
+ private var usbManager: UsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
44
+ private var fileDescriptor: ParcelFileDescriptor? = null
45
+ private var inputStream: FileInputStream? = null
46
+ private var outputStream: FileOutputStream? = null
47
+ private var readThread: Thread? = null
48
+ private var isRunning = false
49
+
50
+ /** 수신 바이트를 버퍼링하여 Length-Prefix 메시지로 조립 */
51
+ private val messageBuffer = MessageBuffer()
52
+
53
+ companion object {
54
+ /** USB 권한 요청 시 사용하는 커스텀 Broadcast Action */
55
+ const val ACTION_USB_PERMISSION = "net.eventboot.welcomeusb.USB_PERMISSION"
56
+ }
57
+
58
+ /**
59
+ * USB Accessory 분리 및 권한 응답을 수신하는 BroadcastReceiver.
60
+ * - ACTION_USB_ACCESSORY_DETACHED: 케이블 분리 시 연결 해제
61
+ * - ACTION_USB_PERMISSION: 권한 요청 결과 수신 후 Accessory 열기
62
+ */
63
+ private val usbReceiver = object : BroadcastReceiver() {
64
+ override fun onReceive(context: Context, intent: Intent) {
65
+ when (intent.action) {
66
+ UsbManager.ACTION_USB_ACCESSORY_DETACHED -> {
67
+ Log.d(TAG, "BroadcastReceiver: USB Accessory 분리됨")
68
+ closeAccessory()
69
+ onConnectionStateChanged("DISCONNECTED", "USB accessory detached")
70
+ }
71
+ ACTION_USB_PERMISSION -> {
72
+ val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
73
+ Log.d(TAG, "BroadcastReceiver: 권한 응답 granted=$granted")
74
+ if (granted) {
75
+ // Android 13(TIRAMISU)부터 타입 안전한 getParcelableExtra 사용
76
+ val accessory = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
77
+ intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
78
+ } else {
79
+ @Suppress("DEPRECATION")
80
+ intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY)
81
+ }
82
+ accessory?.let { openAccessory(it) }
83
+ } else {
84
+ onError("PERMISSION_DENIED", "USB permission denied")
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * BroadcastReceiver를 등록한다.
93
+ * 모듈 생성(OnCreate) 시 호출된다.
94
+ */
95
+ fun register() {
96
+ Log.d(TAG, "register: BroadcastReceiver 등록")
97
+ val filter = IntentFilter().apply {
98
+ addAction(UsbManager.ACTION_USB_ACCESSORY_DETACHED)
99
+ addAction(ACTION_USB_PERMISSION)
100
+ }
101
+ // USB 권한 응답(ACTION_USB_PERMISSION)은 시스템에서 보내므로 RECEIVER_EXPORTED 필요
102
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
103
+ context.registerReceiver(usbReceiver, filter, Context.RECEIVER_EXPORTED)
104
+ } else {
105
+ context.registerReceiver(usbReceiver, filter)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * BroadcastReceiver를 해제하고 USB 연결을 닫는다.
111
+ * 모듈 소멸(OnDestroy) 시 호출된다.
112
+ */
113
+ fun unregister() {
114
+ Log.d(TAG, "unregister: BroadcastReceiver 해제 및 연결 정리")
115
+ try { context.unregisterReceiver(usbReceiver) } catch (_: Exception) {}
116
+ closeAccessory()
117
+ }
118
+
119
+ /**
120
+ * 현재 USB 연결 상태를 문자열로 반환한다.
121
+ * - "AOA_READY": 스트림이 열려 있고 읽기 스레드가 동작 중 (데이터 송수신 가능)
122
+ * - "CONNECTED": FileDescriptor만 열린 상태
123
+ * - "DISCONNECTED": 연결 없음
124
+ */
125
+ fun getConnectionState(): String {
126
+ return when {
127
+ outputStream != null && isRunning -> "AOA_READY"
128
+ fileDescriptor != null -> "CONNECTED"
129
+ else -> "DISCONNECTED"
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 연결된 USB Accessory를 탐색하여 권한이 있으면 바로 열고,
135
+ * 없으면 권한을 요청한다.
136
+ * @return Accessory를 성공적으로 열었으면 true
137
+ */
138
+ fun checkAndOpenAccessory(): Boolean {
139
+ val accessories = usbManager.accessoryList
140
+ Log.d(TAG, "checkAndOpenAccessory: 연결된 Accessory 수=${accessories?.size ?: 0}")
141
+ if (accessories.isNullOrEmpty()) {
142
+ onConnectionStateChanged("DISCONNECTED", "No USB accessory found")
143
+ return false
144
+ }
145
+ val accessory = accessories[0]
146
+ Log.d(TAG, "checkAndOpenAccessory: manufacturer=${accessory.manufacturer}, model=${accessory.model}")
147
+ if (usbManager.hasPermission(accessory)) {
148
+ Log.d(TAG, "checkAndOpenAccessory: 권한 있음, Accessory 열기")
149
+ openAccessory(accessory)
150
+ return true
151
+ } else {
152
+ Log.d(TAG, "checkAndOpenAccessory: 권한 없음, 권한 요청")
153
+ requestPermission(accessory)
154
+ return false
155
+ }
156
+ }
157
+
158
+ /**
159
+ * USB Accessory 접근 권한을 시스템에 요청한다.
160
+ * 결과는 usbReceiver의 ACTION_USB_PERMISSION으로 수신된다.
161
+ * @param accessory 권한을 요청할 Accessory (null이면 첫 번째 연결된 Accessory 사용)
162
+ */
163
+ /**
164
+ * 실제 USB 연결이 유효한지 FileDescriptor를 검증한다.
165
+ * outputStream/isRunning 플래그만으로는 스트림이 깨진 상태를 감지할 수 없으므로,
166
+ * FileDescriptor.valid()로 실제 OS 레벨 유효성을 확인한다.
167
+ */
168
+ private fun isConnectionAlive(): Boolean {
169
+ val fd = fileDescriptor?.fileDescriptor ?: return false
170
+ return fd.valid() && outputStream != null && isRunning
171
+ }
172
+
173
+ fun requestPermission(accessory: UsbAccessory? = null) {
174
+ if (isConnectionAlive()) {
175
+ Log.d(TAG, "requestPermission: 연결이 유효하므로 무시")
176
+ return
177
+ }
178
+ // 상태값은 AOA_READY이지만 실제 연결이 깨진 경우 정리 후 재연결
179
+ if (fileDescriptor != null || isRunning) {
180
+ Log.d(TAG, "requestPermission: 유효하지 않은 연결 정리 후 재연결")
181
+ closeAccessory()
182
+ }
183
+ val target = accessory ?: usbManager.accessoryList?.firstOrNull()
184
+ if (target == null) {
185
+ Log.w(TAG, "requestPermission: Accessory를 찾을 수 없음")
186
+ onError("NO_ACCESSORY", "No USB accessory found")
187
+ return
188
+ }
189
+ Log.d(TAG, "requestPermission: 권한 요청 (${target.manufacturer} ${target.model})")
190
+ // Android 12(S)부터 PendingIntent에 mutability 플래그 필수
191
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
192
+ PendingIntent.FLAG_MUTABLE
193
+ } else {
194
+ 0
195
+ }
196
+ val intent = Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }
197
+ val permissionIntent = PendingIntent.getBroadcast(context, 0, intent, flags)
198
+ usbManager.requestPermission(target, permissionIntent)
199
+ }
200
+
201
+ /**
202
+ * USB Accessory를 열어 입출력 스트림을 생성하고 읽기 스레드를 시작한다.
203
+ * 성공 시 상태가 AOA_READY로 전환된다.
204
+ */
205
+ private fun openAccessory(accessory: UsbAccessory) {
206
+ try {
207
+ Log.d(TAG, "openAccessory: ${accessory.manufacturer} ${accessory.model}")
208
+ onConnectionStateChanged("CONNECTED", "Opening accessory: ${accessory.manufacturer} ${accessory.model}")
209
+ fileDescriptor = usbManager.openAccessory(accessory)
210
+ if (fileDescriptor == null) {
211
+ Log.e(TAG, "openAccessory: FileDescriptor가 null")
212
+ onError("OPEN_FAILED", "Failed to open USB accessory")
213
+ onConnectionStateChanged("ERROR", "Failed to open accessory")
214
+ return
215
+ }
216
+ val fd = fileDescriptor!!.fileDescriptor
217
+ inputStream = FileInputStream(fd)
218
+ outputStream = FileOutputStream(fd)
219
+ Log.d(TAG, "openAccessory: 스트림 생성 완료, 읽기 스레드 시작")
220
+ startReading()
221
+ onConnectionStateChanged("AOA_READY", "USB accessory connected and ready")
222
+ } catch (e: Exception) {
223
+ Log.e(TAG, "openAccessory: 에러 발생", e)
224
+ onError("OPEN_ERROR", "Error opening accessory: ${e.message}")
225
+ onConnectionStateChanged("ERROR", e.message)
226
+ closeAccessory()
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 백그라운드 데몬 스레드에서 USB InputStream을 지속적으로 읽는다.
232
+ * 수신된 바이트는 MessageBuffer에 누적되며, 완성된 메시지가 있으면 콜백으로 전달한다.
233
+ * 연결이 끊어지면(bytesRead < 0 또는 IOException) 루프를 종료하고 정리한다.
234
+ */
235
+ private fun startReading() {
236
+ isRunning = true
237
+ Log.d(TAG, "startReading: 읽기 스레드 시작")
238
+ readThread = Thread {
239
+ val buffer = ByteArray(16384)
240
+ while (isRunning) {
241
+ try {
242
+ val bytesRead = inputStream?.read(buffer) ?: -1
243
+ if (bytesRead > 0) {
244
+ Log.d(TAG, "read: ${bytesRead}바이트 수신")
245
+ // 수신 바이트를 MessageBuffer에 누적
246
+ messageBuffer.feed(buffer, 0, bytesRead)
247
+ // 완성된 메시지를 모두 추출하여 전달
248
+ var message = messageBuffer.poll()
249
+ while (message != null) {
250
+ Log.d(TAG, "read: 완성된 메시지 전달 (${message.length}자)")
251
+ onDataReceived(message)
252
+ message = messageBuffer.poll()
253
+ }
254
+ } else if (bytesRead < 0) {
255
+ Log.d(TAG, "read: EOF 수신, 루프 종료")
256
+ break
257
+ }
258
+ } catch (e: IOException) {
259
+ if (isRunning) {
260
+ Log.e(TAG, "read: IOException 발생", e)
261
+ onError("READ_ERROR", "Read error: ${e.message}")
262
+ }
263
+ break
264
+ }
265
+ }
266
+ // 외부에서 중지한 게 아니라면 연결 해제 처리
267
+ if (isRunning) {
268
+ Log.d(TAG, "read: 연결 해제 처리")
269
+ closeAccessory()
270
+ onConnectionStateChanged("DISCONNECTED", "Connection lost")
271
+ }
272
+ }.apply {
273
+ name = "AoaReadThread"
274
+ isDaemon = true
275
+ start()
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Length-Prefix 프레이밍으로 JSON 메시지를 USB Host(Mac)에 전송한다.
281
+ * @param json 전송할 JSON 문자열
282
+ * @return 전송 성공 여부
283
+ */
284
+ fun sendData(json: String): Boolean {
285
+ return try {
286
+ val encoded = AoaProtocol.encodeMessage(json)
287
+ Log.d(TAG, "sendData: ${encoded.size}바이트 전송 시도")
288
+ outputStream?.write(encoded)
289
+ outputStream?.flush()
290
+ Log.d(TAG, "sendData: 전송 완료")
291
+ true
292
+ } catch (e: IOException) {
293
+ Log.e(TAG, "sendData: 전송 실패", e)
294
+ onError("WRITE_ERROR", "Write error: ${e.message}")
295
+ false
296
+ }
297
+ }
298
+
299
+ /**
300
+ * USB 연결 관련 리소스를 모두 해제한다.
301
+ * 읽기 스레드 중지 → 입출력 스트림 닫기 → FileDescriptor 닫기 순서로 정리.
302
+ */
303
+ fun closeAccessory() {
304
+ Log.d(TAG, "closeAccessory: 리소스 해제 시작")
305
+ isRunning = false
306
+ try { readThread?.interrupt() } catch (_: Exception) {}
307
+ readThread = null
308
+ try { inputStream?.close() } catch (_: Exception) {}
309
+ inputStream = null
310
+ try { outputStream?.close() } catch (_: Exception) {}
311
+ outputStream = null
312
+ try { fileDescriptor?.close() } catch (_: Exception) {}
313
+ fileDescriptor = null
314
+ }
315
+ }
@@ -0,0 +1,125 @@
1
+ package net.eventboot.welcomeusb
2
+
3
+ import java.nio.ByteBuffer
4
+ import java.nio.ByteOrder
5
+
6
+ /**
7
+ * USB AOA 통신에 사용되는 Length-Prefix 프레이밍 프로토콜.
8
+ *
9
+ * USB 스트림은 TCP/IP와 달리 메시지 경계가 없으므로,
10
+ * 각 메시지 앞에 4바이트 길이 헤더를 붙여 수신 측에서 메시지 경계를 식별한다.
11
+ *
12
+ * 프레임 구조:
13
+ * +-------------------+------------------------+
14
+ * | Length (4 bytes) | JSON Payload (N bytes) |
15
+ * | Big-Endian uint32 | UTF-8 encoded |
16
+ * +-------------------+------------------------+
17
+ */
18
+ object AoaProtocol {
19
+ /**
20
+ * JSON 문자열을 Length-Prefix 프레임으로 인코딩한다.
21
+ * @param json 인코딩할 JSON 문자열
22
+ * @return [4바이트 Big-Endian 길이][JSON UTF-8 바이트] 형태의 바이트 배열
23
+ */
24
+ fun encodeMessage(json: String): ByteArray {
25
+ val jsonBytes = json.toByteArray(Charsets.UTF_8)
26
+ val buffer = ByteBuffer.allocate(4 + jsonBytes.size)
27
+ buffer.order(ByteOrder.BIG_ENDIAN)
28
+ buffer.putInt(jsonBytes.size)
29
+ buffer.put(jsonBytes)
30
+ return buffer.array()
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 부분 읽기(partial read)를 버퍼링하여 완성된 메시지를 추출하는 클래스.
36
+ *
37
+ * USB 스트림에서 read()가 반환하는 바이트 수는 메시지 단위와 무관하므로,
38
+ * 수신 바이트를 내부 버퍼에 누적한 뒤 Length-Prefix 헤더를 파싱하여
39
+ * 완성된 메시지만 추출한다.
40
+ *
41
+ * 사용 패턴:
42
+ * ```
43
+ * val buffer = MessageBuffer()
44
+ * buffer.feed(receivedBytes, 0, bytesRead)
45
+ * var msg = buffer.poll()
46
+ * while (msg != null) {
47
+ * processMessage(msg)
48
+ * msg = buffer.poll()
49
+ * }
50
+ * ```
51
+ */
52
+ class MessageBuffer {
53
+ /** 초기 64KB, 필요 시 자동 확장되는 내부 버퍼 */
54
+ private var buffer = ByteBuffer.allocate(65536).apply {
55
+ order(ByteOrder.BIG_ENDIAN)
56
+ }
57
+
58
+ /** 현재 버퍼에 쓰여진 바이트 수 (다음 쓰기 위치) */
59
+ private var writePosition = 0
60
+
61
+ /**
62
+ * 수신된 데이터를 내부 버퍼에 추가한다.
63
+ * 버퍼 용량이 부족하면 자동으로 2배 확장한다.
64
+ */
65
+ fun feed(data: ByteArray, offset: Int, length: Int) {
66
+ ensureCapacity(length)
67
+ System.arraycopy(data, offset, buffer.array(), writePosition, length)
68
+ writePosition += length
69
+ }
70
+
71
+ /**
72
+ * 버퍼에서 완성된 메시지 하나를 추출하여 반환한다.
73
+ * 아직 완성된 메시지가 없으면 null을 반환한다.
74
+ *
75
+ * 추출 후 남은 데이터는 버퍼 앞으로 이동(compaction)한다.
76
+ * 비정상적인 메시지 길이(음수 또는 10MB 초과)가 감지되면 버퍼를 초기화한다.
77
+ */
78
+ fun poll(): String? {
79
+ // 길이 헤더(4바이트)가 아직 도착하지 않음
80
+ if (writePosition < 4) return null
81
+
82
+ buffer.position(0)
83
+ buffer.order(ByteOrder.BIG_ENDIAN)
84
+ val messageLength = buffer.getInt(0)
85
+
86
+ // 비정상적인 메시지 길이 감지 시 버퍼 초기화
87
+ if (messageLength < 0 || messageLength > 10 * 1024 * 1024) {
88
+ writePosition = 0
89
+ return null
90
+ }
91
+
92
+ val totalNeeded = 4 + messageLength
93
+ // 메시지 본문이 아직 완전히 도착하지 않음
94
+ if (writePosition < totalNeeded) return null
95
+
96
+ // 완성된 메시지 추출
97
+ val jsonBytes = ByteArray(messageLength)
98
+ System.arraycopy(buffer.array(), 4, jsonBytes, 0, messageLength)
99
+
100
+ // 남은 데이터를 버퍼 앞으로 이동 (compaction)
101
+ val remaining = writePosition - totalNeeded
102
+ if (remaining > 0) {
103
+ System.arraycopy(buffer.array(), totalNeeded, buffer.array(), 0, remaining)
104
+ }
105
+ writePosition = remaining
106
+
107
+ return String(jsonBytes, Charsets.UTF_8)
108
+ }
109
+
110
+ /**
111
+ * 추가될 바이트를 수용할 수 있도록 버퍼 용량을 확보한다.
112
+ * 부족하면 기존 용량의 2배 또는 필요 크기 중 큰 값으로 확장한다.
113
+ */
114
+ private fun ensureCapacity(additionalBytes: Int) {
115
+ val required = writePosition + additionalBytes
116
+ if (required > buffer.capacity()) {
117
+ val newCapacity = maxOf(buffer.capacity() * 2, required)
118
+ val newBuffer = ByteBuffer.allocate(newCapacity).apply {
119
+ order(ByteOrder.BIG_ENDIAN)
120
+ }
121
+ System.arraycopy(buffer.array(), 0, newBuffer.array(), 0, writePosition)
122
+ buffer = newBuffer
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,92 @@
1
+ package net.eventboot.welcomeusb
2
+
3
+ import android.util.Log
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ private const val TAG = "BootWelcomeUsb"
8
+
9
+ /**
10
+ * USB AOA(Android Open Accessory) 통신을 위한 Expo Native Module.
11
+ *
12
+ * Android 태블릿(Accessory)이 Mac의 EventBoot-Desktop(Host)과
13
+ * USB 케이블을 통해 QR 코드 접수 데이터를 주고받는다.
14
+ *
15
+ * JavaScript에서 'BootWelcomeUsb' 이름으로 접근하며,
16
+ * 연결 상태 변경 / 데이터 수신 / 에러 이벤트를 JS 측으로 전달한다.
17
+ */
18
+ class BootWelcomeUsbModule : Module() {
19
+ private var manager: AoaAccessoryManager? = null
20
+
21
+ override fun definition() = ModuleDefinition {
22
+ // JS에서 requireNativeModule('BootWelcomeUsb')로 접근
23
+ Name("BootWelcomeUsb")
24
+
25
+ // JS로 전달되는 이벤트 정의
26
+ Events("onConnectionStateChanged", "onDataReceived", "onError")
27
+
28
+ // 모듈 생성 시 AoaAccessoryManager를 초기화하고 USB Accessory 연결을 시도
29
+ OnCreate {
30
+ Log.d(TAG, "OnCreate: 모듈 초기화 시작")
31
+ val ctx = appContext.reactContext ?: run {
32
+ Log.w(TAG, "OnCreate: reactContext가 null이므로 초기화 중단")
33
+ return@OnCreate
34
+ }
35
+ manager = AoaAccessoryManager(
36
+ context = ctx,
37
+ onConnectionStateChanged = { state, message ->
38
+ Log.d(TAG, "연결 상태 변경: state=$state, message=$message")
39
+ sendEvent("onConnectionStateChanged", mapOf(
40
+ "state" to state,
41
+ "message" to (message ?: "")
42
+ ))
43
+ },
44
+ onDataReceived = { messageJson ->
45
+ Log.d(TAG, "데이터 수신: ${messageJson.take(200)}")
46
+ sendEvent("onDataReceived", mapOf(
47
+ "messageJson" to messageJson
48
+ ))
49
+ },
50
+ onError = { code, message ->
51
+ Log.e(TAG, "에러 발생: code=$code, message=$message")
52
+ sendEvent("onError", mapOf(
53
+ "code" to code,
54
+ "message" to message
55
+ ))
56
+ }
57
+ )
58
+ manager?.register()
59
+ Log.d(TAG, "OnCreate: BroadcastReceiver 등록 완료")
60
+ manager?.checkAndOpenAccessory()
61
+ Log.d(TAG, "OnCreate: 모듈 초기화 완료")
62
+ }
63
+
64
+ // 모듈 소멸 시 BroadcastReceiver 해제 및 USB 연결 정리
65
+ OnDestroy {
66
+ Log.d(TAG, "OnDestroy: 모듈 소멸 시작")
67
+ manager?.unregister()
68
+ manager = null
69
+ Log.d(TAG, "OnDestroy: 모듈 소멸 완료")
70
+ }
71
+
72
+ // 현재 USB 연결 상태를 반환 (DISCONNECTED | CONNECTED | AOA_READY)
73
+ AsyncFunction("getConnectionState") {
74
+ val state = manager?.getConnectionState() ?: "DISCONNECTED"
75
+ Log.d(TAG, "getConnectionState: $state")
76
+ return@AsyncFunction state
77
+ }
78
+
79
+ // 사용자에게 USB Accessory 접근 권한을 요청
80
+ AsyncFunction("requestPermission") {
81
+ Log.d(TAG, "requestPermission 호출")
82
+ manager?.requestPermission()
83
+ return@AsyncFunction true
84
+ }
85
+
86
+ // Length-Prefix 프레이밍으로 JSON 메시지를 USB Host(Mac)에 전송
87
+ AsyncFunction("sendData") { messageJson: String ->
88
+ Log.d(TAG, "sendData: ${messageJson.take(200)}")
89
+ return@AsyncFunction manager?.sendData(messageJson) ?: false
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * USB AOA 연결 상태.
3
+ * - DISCONNECTED: USB Accessory 미연결
4
+ * - CONNECTED: Accessory가 감지되었으나 스트림이 아직 열리지 않은 상태
5
+ * - AOA_SWITCHING: AOA 모드로 전환 중 (현재 미사용, 향후 확장 예약)
6
+ * - AOA_READY: 입출력 스트림이 열려 데이터 송수신 가능
7
+ * - ERROR: 연결 또는 권한 오류
8
+ */
9
+ export type UsbAoaConnectionState = 'DISCONNECTED' | 'CONNECTED' | 'AOA_SWITCHING' | 'AOA_READY' | 'ERROR';
10
+ /**
11
+ * USB AOA 통신에 사용되는 메시지 구조.
12
+ * Android 태블릿(Accessory)과 Mac(Host) 간에 이 형식의 JSON을 주고받는다.
13
+ *
14
+ * @property id - UUID. 요청-응답 매칭에 사용
15
+ * @property type - 메시지 유형 (REQUEST/RESPONSE: 데이터 교환, PING/PONG: 연결 확인)
16
+ * @property path - 요청 경로 (예: "/scan", "/ping")
17
+ * @property body - JSON 문자열로 직렬화된 페이로드 (선택)
18
+ */
19
+ export interface AoaMessage {
20
+ id: string;
21
+ type: 'REQUEST' | 'RESPONSE' | 'PING' | 'PONG';
22
+ path: string;
23
+ body?: string;
24
+ }
25
+ /**
26
+ * 네이티브 모듈에서 JS로 전달되는 이벤트 맵.
27
+ * NativeModule<BootWelcomeUsbEvents> 제네릭에 사용되어 타입 안전한 이벤트 리스닝을 제공한다.
28
+ */
29
+ export type BootWelcomeUsbEvents = {
30
+ /** USB 연결 상태가 변경될 때 발생 */
31
+ onConnectionStateChanged: (params: {
32
+ state: UsbAoaConnectionState;
33
+ message?: string;
34
+ }) => void;
35
+ /** USB Host(Mac)로부터 완성된 JSON 메시지를 수신할 때 발생 */
36
+ onDataReceived: (params: {
37
+ messageJson: string;
38
+ }) => void;
39
+ /** USB 통신 중 에러가 발생할 때 발생 (code: 에러 코드, message: 에러 설명) */
40
+ onError: (params: {
41
+ code: string;
42
+ message: string;
43
+ }) => void;
44
+ };
45
+ //# sourceMappingURL=BootWelcomeUsb.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BootWelcomeUsb.types.d.ts","sourceRoot":"","sources":["../src/BootWelcomeUsb.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,cAAc,GAAG,WAAW,GAAG,eAAe,GAAG,WAAW,GAAG,OAAO,CAAC;AAE3G;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,0BAA0B;IAC1B,wBAAwB,EAAE,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,qBAAqB,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC/F,8CAA8C;IAC9C,cAAc,EAAE,CAAC,MAAM,EAAE;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1D,0DAA0D;IAC1D,OAAO,EAAE,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAC9D,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=BootWelcomeUsb.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BootWelcomeUsb.types.js","sourceRoot":"","sources":["../src/BootWelcomeUsb.types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * USB AOA 연결 상태.\n * - DISCONNECTED: USB Accessory 미연결\n * - CONNECTED: Accessory가 감지되었으나 스트림이 아직 열리지 않은 상태\n * - AOA_SWITCHING: AOA 모드로 전환 중 (현재 미사용, 향후 확장 예약)\n * - AOA_READY: 입출력 스트림이 열려 데이터 송수신 가능\n * - ERROR: 연결 또는 권한 오류\n */\nexport type UsbAoaConnectionState = 'DISCONNECTED' | 'CONNECTED' | 'AOA_SWITCHING' | 'AOA_READY' | 'ERROR';\n\n/**\n * USB AOA 통신에 사용되는 메시지 구조.\n * Android 태블릿(Accessory)과 Mac(Host) 간에 이 형식의 JSON을 주고받는다.\n *\n * @property id - UUID. 요청-응답 매칭에 사용\n * @property type - 메시지 유형 (REQUEST/RESPONSE: 데이터 교환, PING/PONG: 연결 확인)\n * @property path - 요청 경로 (예: \"/scan\", \"/ping\")\n * @property body - JSON 문자열로 직렬화된 페이로드 (선택)\n */\nexport interface AoaMessage {\n id: string;\n type: 'REQUEST' | 'RESPONSE' | 'PING' | 'PONG';\n path: string;\n body?: string;\n}\n\n/**\n * 네이티브 모듈에서 JS로 전달되는 이벤트 맵.\n * NativeModule<BootWelcomeUsbEvents> 제네릭에 사용되어 타입 안전한 이벤트 리스닝을 제공한다.\n */\nexport type BootWelcomeUsbEvents = {\n /** USB 연결 상태가 변경될 때 발생 */\n onConnectionStateChanged: (params: { state: UsbAoaConnectionState; message?: string }) => void;\n /** USB Host(Mac)로부터 완성된 JSON 메시지를 수신할 때 발생 */\n onDataReceived: (params: { messageJson: string }) => void;\n /** USB 통신 중 에러가 발생할 때 발생 (code: 에러 코드, message: 에러 설명) */\n onError: (params: { code: string; message: string }) => void;\n};\n"]}
@@ -0,0 +1,17 @@
1
+ import { NativeModule } from 'expo';
2
+ import { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';
3
+ /**
4
+ * Android 네이티브 모듈(BootWelcomeUsbModule.kt)의 TypeScript 타입 선언.
5
+ * NativeModule을 상속하여 이벤트 리스닝(addListener)과 AsyncFunction 호출을 타입 안전하게 제공한다.
6
+ */
7
+ declare class BootWelcomeUsbModule extends NativeModule<BootWelcomeUsbEvents> {
8
+ /** 현재 USB 연결 상태를 반환 (DISCONNECTED | CONNECTED | AOA_READY) */
9
+ getConnectionState(): Promise<string>;
10
+ /** 사용자에게 USB Accessory 접근 권한을 요청 */
11
+ requestPermission(): Promise<boolean>;
12
+ /** Length-Prefix 프레이밍으로 JSON 메시지를 USB Host(Mac)에 전송 */
13
+ sendData(messageJson: string): Promise<boolean>;
14
+ }
15
+ declare const _default: BootWelcomeUsbModule;
16
+ export default _default;
17
+ //# sourceMappingURL=BootWelcomeUsbModule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BootWelcomeUsbModule.d.ts","sourceRoot":"","sources":["../src/BootWelcomeUsbModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAE9D;;;GAGG;AACH,OAAO,OAAO,oBAAqB,SAAQ,YAAY,CAAC,oBAAoB,CAAC;IAC3E,8DAA8D;IAC9D,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC;IACrC,oCAAoC;IACpC,iBAAiB,IAAI,OAAO,CAAC,OAAO,CAAC;IACrC,uDAAuD;IACvD,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAChD;;AAID,wBAA2E"}
@@ -0,0 +1,5 @@
1
+ import { requireNativeModule } from 'expo';
2
+ // JSI를 통해 네이티브 모듈 객체를 로드한다.
3
+ // Android의 BootWelcomeUsbModule.kt에서 Name("BootWelcomeUsb")으로 등록된 모듈과 바인딩.
4
+ export default requireNativeModule('BootWelcomeUsb');
5
+ //# sourceMappingURL=BootWelcomeUsbModule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BootWelcomeUsbModule.js","sourceRoot":"","sources":["../src/BootWelcomeUsbModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAiBzD,4BAA4B;AAC5B,2EAA2E;AAC3E,eAAe,mBAAmB,CAAuB,gBAAgB,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\n\nimport { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';\n\n/**\n * Android 네이티브 모듈(BootWelcomeUsbModule.kt)의 TypeScript 타입 선언.\n * NativeModule을 상속하여 이벤트 리스닝(addListener)과 AsyncFunction 호출을 타입 안전하게 제공한다.\n */\ndeclare class BootWelcomeUsbModule extends NativeModule<BootWelcomeUsbEvents> {\n /** 현재 USB 연결 상태를 반환 (DISCONNECTED | CONNECTED | AOA_READY) */\n getConnectionState(): Promise<string>;\n /** 사용자에게 USB Accessory 접근 권한을 요청 */\n requestPermission(): Promise<boolean>;\n /** Length-Prefix 프레이밍으로 JSON 메시지를 USB Host(Mac)에 전송 */\n sendData(messageJson: string): Promise<boolean>;\n}\n\n// JSI를 통해 네이티브 모듈 객체를 로드한다.\n// Android의 BootWelcomeUsbModule.kt에서 Name(\"BootWelcomeUsb\")으로 등록된 모듈과 바인딩.\nexport default requireNativeModule<BootWelcomeUsbModule>('BootWelcomeUsb');\n"]}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * boot-welcome-usb 모듈의 공개 API.
3
+ *
4
+ * 네이티브 모듈(BootWelcomeUsb)이 이미 EventEmitter이므로
5
+ * 별도의 EventEmitter 생성 없이 모듈의 addListener를 직접 위임한다.
6
+ */
7
+ import type { EventSubscription } from 'expo-modules-core';
8
+ import BootWelcomeUsbModule from './BootWelcomeUsbModule';
9
+ import type { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';
10
+ export * from './BootWelcomeUsb.types';
11
+ export { useUsbAoa } from './useUsbAoa';
12
+ /** 이벤트 이름 유니온 타입 */
13
+ type EventName = keyof BootWelcomeUsbEvents;
14
+ /** 각 이벤트 리스너의 첫 번째 파라미터(페이로드) 타입 추출 */
15
+ type EventPayload<K extends EventName> = Parameters<BootWelcomeUsbEvents[K]>[0];
16
+ /**
17
+ * 네이티브 이벤트 리스너를 등록한다.
18
+ * 반환된 EventSubscription의 remove()를 호출하여 리스너를 해제할 수 있다.
19
+ *
20
+ * @example
21
+ * const sub = addListener('onConnectionStateChanged', (e) => {
22
+ * console.log(e.state, e.message);
23
+ * });
24
+ * // 정리
25
+ * sub.remove();
26
+ */
27
+ export declare function addListener<K extends EventName>(eventName: K, listener: (event: EventPayload<K>) => void): EventSubscription;
28
+ /** 현재 USB 연결 상태를 조회한다. */
29
+ export declare function getConnectionState(): Promise<string>;
30
+ /** 사용자에게 USB Accessory 접근 권한을 요청한다. */
31
+ export declare function requestPermission(): Promise<boolean>;
32
+ /**
33
+ * JSON 메시지를 USB Host(Mac)에 전송한다.
34
+ * 내부적으로 Length-Prefix 프레이밍이 적용된다.
35
+ * @param messageJson 전송할 JSON 문자열 (AoaMessage 형식 권장)
36
+ */
37
+ export declare function sendData(messageJson: string): Promise<boolean>;
38
+ export default BootWelcomeUsbModule;
39
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,OAAO,oBAAoB,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,oBAAoB;AACpB,KAAK,SAAS,GAAG,MAAM,oBAAoB,CAAC;AAC5C,uCAAuC;AACvC,KAAK,YAAY,CAAC,CAAC,SAAS,SAAS,IAAI,UAAU,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAEhF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,SAAS,EAC7C,SAAS,EAAE,CAAC,EACZ,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,GACzC,iBAAiB,CAEnB;AAED,0BAA0B;AAC1B,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAE1D;AAED,uCAAuC;AACvC,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,OAAO,CAAC,CAE1D;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEpE;AAED,eAAe,oBAAoB,CAAC"}
package/build/index.js ADDED
@@ -0,0 +1,35 @@
1
+ import BootWelcomeUsbModule from './BootWelcomeUsbModule';
2
+ export * from './BootWelcomeUsb.types';
3
+ export { useUsbAoa } from './useUsbAoa';
4
+ /**
5
+ * 네이티브 이벤트 리스너를 등록한다.
6
+ * 반환된 EventSubscription의 remove()를 호출하여 리스너를 해제할 수 있다.
7
+ *
8
+ * @example
9
+ * const sub = addListener('onConnectionStateChanged', (e) => {
10
+ * console.log(e.state, e.message);
11
+ * });
12
+ * // 정리
13
+ * sub.remove();
14
+ */
15
+ export function addListener(eventName, listener) {
16
+ return BootWelcomeUsbModule.addListener(eventName, listener);
17
+ }
18
+ /** 현재 USB 연결 상태를 조회한다. */
19
+ export async function getConnectionState() {
20
+ return await BootWelcomeUsbModule.getConnectionState();
21
+ }
22
+ /** 사용자에게 USB Accessory 접근 권한을 요청한다. */
23
+ export async function requestPermission() {
24
+ return await BootWelcomeUsbModule.requestPermission();
25
+ }
26
+ /**
27
+ * JSON 메시지를 USB Host(Mac)에 전송한다.
28
+ * 내부적으로 Length-Prefix 프레이밍이 적용된다.
29
+ * @param messageJson 전송할 JSON 문자열 (AoaMessage 형식 권장)
30
+ */
31
+ export async function sendData(messageJson) {
32
+ return await BootWelcomeUsbModule.sendData(messageJson);
33
+ }
34
+ export default BootWelcomeUsbModule;
35
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,oBAAoB,MAAM,wBAAwB,CAAC;AAG1D,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAOxC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CACzB,SAAY,EACZ,QAA0C;IAE1C,OAAO,oBAAoB,CAAC,WAAW,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAC/D,CAAC;AAED,0BAA0B;AAC1B,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,OAAO,MAAM,oBAAoB,CAAC,kBAAkB,EAAE,CAAC;AACzD,CAAC;AAED,uCAAuC;AACvC,MAAM,CAAC,KAAK,UAAU,iBAAiB;IACrC,OAAO,MAAM,oBAAoB,CAAC,iBAAiB,EAAE,CAAC;AACxD,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,WAAmB;IAChD,OAAO,MAAM,oBAAoB,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AAC1D,CAAC;AAED,eAAe,oBAAoB,CAAC","sourcesContent":["/**\n * boot-welcome-usb 모듈의 공개 API.\n *\n * 네이티브 모듈(BootWelcomeUsb)이 이미 EventEmitter이므로\n * 별도의 EventEmitter 생성 없이 모듈의 addListener를 직접 위임한다.\n */\nimport type { EventSubscription } from 'expo-modules-core';\n\nimport BootWelcomeUsbModule from './BootWelcomeUsbModule';\nimport type { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';\n\nexport * from './BootWelcomeUsb.types';\nexport { useUsbAoa } from './useUsbAoa';\n\n/** 이벤트 이름 유니온 타입 */\ntype EventName = keyof BootWelcomeUsbEvents;\n/** 각 이벤트 리스너의 첫 번째 파라미터(페이로드) 타입 추출 */\ntype EventPayload<K extends EventName> = Parameters<BootWelcomeUsbEvents[K]>[0];\n\n/**\n * 네이티브 이벤트 리스너를 등록한다.\n * 반환된 EventSubscription의 remove()를 호출하여 리스너를 해제할 수 있다.\n *\n * @example\n * const sub = addListener('onConnectionStateChanged', (e) => {\n * console.log(e.state, e.message);\n * });\n * // 정리\n * sub.remove();\n */\nexport function addListener<K extends EventName>(\n eventName: K,\n listener: (event: EventPayload<K>) => void,\n): EventSubscription {\n return BootWelcomeUsbModule.addListener(eventName, listener);\n}\n\n/** 현재 USB 연결 상태를 조회한다. */\nexport async function getConnectionState(): Promise<string> {\n return await BootWelcomeUsbModule.getConnectionState();\n}\n\n/** 사용자에게 USB Accessory 접근 권한을 요청한다. */\nexport async function requestPermission(): Promise<boolean> {\n return await BootWelcomeUsbModule.requestPermission();\n}\n\n/**\n * JSON 메시지를 USB Host(Mac)에 전송한다.\n * 내부적으로 Length-Prefix 프레이밍이 적용된다.\n * @param messageJson 전송할 JSON 문자열 (AoaMessage 형식 권장)\n */\nexport async function sendData(messageJson: string): Promise<boolean> {\n return await BootWelcomeUsbModule.sendData(messageJson);\n}\n\nexport default BootWelcomeUsbModule;\n"]}
@@ -0,0 +1,22 @@
1
+ import type { UsbAoaConnectionState } from './BootWelcomeUsb.types';
2
+ /**
3
+ * USB AOA 연결 상태를 React 상태로 제공하는 훅.
4
+ *
5
+ * 마운트 시 현재 연결 상태를 조회하고,
6
+ * onConnectionStateChanged 이벤트를 구독하여 상태 변경을 실시간 반영한다.
7
+ * 언마운트 시 이벤트 구독을 자동 해제한다.
8
+ *
9
+ * @returns connectionState - 현재 연결 상태 문자열
10
+ * @returns isConnected - AOA_READY 상태 여부 (데이터 송수신 가능 여부)
11
+ *
12
+ * @example
13
+ * function StatusBar() {
14
+ * const { connectionState, isConnected } = useUsbAoa();
15
+ * return <Text>{isConnected ? '연결됨' : connectionState}</Text>;
16
+ * }
17
+ */
18
+ export declare function useUsbAoa(): {
19
+ connectionState: UsbAoaConnectionState;
20
+ isConnected: boolean;
21
+ };
22
+ //# sourceMappingURL=useUsbAoa.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useUsbAoa.d.ts","sourceRoot":"","sources":["../src/useUsbAoa.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAGpE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS;;;EAuBxB"}
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { addListener, getConnectionState } from './index';
3
+ /**
4
+ * USB AOA 연결 상태를 React 상태로 제공하는 훅.
5
+ *
6
+ * 마운트 시 현재 연결 상태를 조회하고,
7
+ * onConnectionStateChanged 이벤트를 구독하여 상태 변경을 실시간 반영한다.
8
+ * 언마운트 시 이벤트 구독을 자동 해제한다.
9
+ *
10
+ * @returns connectionState - 현재 연결 상태 문자열
11
+ * @returns isConnected - AOA_READY 상태 여부 (데이터 송수신 가능 여부)
12
+ *
13
+ * @example
14
+ * function StatusBar() {
15
+ * const { connectionState, isConnected } = useUsbAoa();
16
+ * return <Text>{isConnected ? '연결됨' : connectionState}</Text>;
17
+ * }
18
+ */
19
+ export function useUsbAoa() {
20
+ const [connectionState, setConnectionState] = useState('DISCONNECTED');
21
+ useEffect(() => {
22
+ // 마운트 시 현재 상태를 한 번 조회
23
+ getConnectionState().then((state) => {
24
+ setConnectionState(state);
25
+ });
26
+ // 상태 변경 이벤트 구독
27
+ const subscription = addListener('onConnectionStateChanged', (event) => {
28
+ setConnectionState(event.state);
29
+ });
30
+ return () => {
31
+ subscription.remove();
32
+ };
33
+ }, []);
34
+ return {
35
+ connectionState,
36
+ isConnected: connectionState === 'AOA_READY',
37
+ };
38
+ }
39
+ //# sourceMappingURL=useUsbAoa.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useUsbAoa.js","sourceRoot":"","sources":["../src/useUsbAoa.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG5C,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAE1D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,SAAS;IACvB,MAAM,CAAC,eAAe,EAAE,kBAAkB,CAAC,GAAG,QAAQ,CAAwB,cAAc,CAAC,CAAC;IAE9F,SAAS,CAAC,GAAG,EAAE;QACb,sBAAsB;QACtB,kBAAkB,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE;YAClC,kBAAkB,CAAC,KAA8B,CAAC,CAAC;QACrD,CAAC,CAAC,CAAC;QAEH,eAAe;QACf,MAAM,YAAY,GAAG,WAAW,CAAC,0BAA0B,EAAE,CAAC,KAAK,EAAE,EAAE;YACrE,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAClC,CAAC,CAAC,CAAC;QAEH,OAAO,GAAG,EAAE;YACV,YAAY,CAAC,MAAM,EAAE,CAAC;QACxB,CAAC,CAAC;IACJ,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO;QACL,eAAe;QACf,WAAW,EAAE,eAAe,KAAK,WAAW;KAC7C,CAAC;AACJ,CAAC","sourcesContent":["import { useEffect, useState } from 'react';\n\nimport type { UsbAoaConnectionState } from './BootWelcomeUsb.types';\nimport { addListener, getConnectionState } from './index';\n\n/**\n * USB AOA 연결 상태를 React 상태로 제공하는 훅.\n *\n * 마운트 시 현재 연결 상태를 조회하고,\n * onConnectionStateChanged 이벤트를 구독하여 상태 변경을 실시간 반영한다.\n * 언마운트 시 이벤트 구독을 자동 해제한다.\n *\n * @returns connectionState - 현재 연결 상태 문자열\n * @returns isConnected - AOA_READY 상태 여부 (데이터 송수신 가능 여부)\n *\n * @example\n * function StatusBar() {\n * const { connectionState, isConnected } = useUsbAoa();\n * return <Text>{isConnected ? '연결됨' : connectionState}</Text>;\n * }\n */\nexport function useUsbAoa() {\n const [connectionState, setConnectionState] = useState<UsbAoaConnectionState>('DISCONNECTED');\n\n useEffect(() => {\n // 마운트 시 현재 상태를 한 번 조회\n getConnectionState().then((state) => {\n setConnectionState(state as UsbAoaConnectionState);\n });\n\n // 상태 변경 이벤트 구독\n const subscription = addListener('onConnectionStateChanged', (event) => {\n setConnectionState(event.state);\n });\n\n return () => {\n subscription.remove();\n };\n }, []);\n\n return {\n connectionState,\n isConnected: connectionState === 'AOA_READY',\n };\n}\n"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["net.eventboot.welcomeusb.BootWelcomeUsbModule"]
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "boot-welcome-usb",
3
+ "version": "0.1.0",
4
+ "description": "EventBoot Welcome AOA Module",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "prepare": "expo-module prepare",
13
+ "prepublishOnly": "expo-module prepublishOnly",
14
+ "expo-module": "expo-module",
15
+ "open:ios": "xed example/ios",
16
+ "open:android": "open -a \"Android Studio\" example/android"
17
+ },
18
+ "keywords": [
19
+ "react-native",
20
+ "expo",
21
+ "boot-welcome-usb",
22
+ "BootWelcomeUsb"
23
+ ],
24
+ "repository": "https://github.com/bbm-sghong/boot-welcome-usb",
25
+ "bugs": {
26
+ "url": "https://github.com/bbm-sghong/boot-welcome-usb/issues"
27
+ },
28
+ "author": "sghong <sghong@bbmobile.co.kr> (https://github.com/bbm-sghong)",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/bbm-sghong/boot-welcome-usb#readme",
31
+ "dependencies": {},
32
+ "devDependencies": {
33
+ "@types/react": "~19.0.10",
34
+ "expo": "53.0.20",
35
+ "expo-module-scripts": "^55.0.2",
36
+ "react": "19.0.0",
37
+ "react-native": "^0.79.5"
38
+ },
39
+ "peerDependencies": {
40
+ "expo": "^53.0.0",
41
+ "react": "^19.0.0",
42
+ "react-native": "^0.79.0"
43
+ }
44
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * USB AOA 연결 상태.
3
+ * - DISCONNECTED: USB Accessory 미연결
4
+ * - CONNECTED: Accessory가 감지되었으나 스트림이 아직 열리지 않은 상태
5
+ * - AOA_SWITCHING: AOA 모드로 전환 중 (현재 미사용, 향후 확장 예약)
6
+ * - AOA_READY: 입출력 스트림이 열려 데이터 송수신 가능
7
+ * - ERROR: 연결 또는 권한 오류
8
+ */
9
+ export type UsbAoaConnectionState = 'DISCONNECTED' | 'CONNECTED' | 'AOA_SWITCHING' | 'AOA_READY' | 'ERROR';
10
+
11
+ /**
12
+ * USB AOA 통신에 사용되는 메시지 구조.
13
+ * Android 태블릿(Accessory)과 Mac(Host) 간에 이 형식의 JSON을 주고받는다.
14
+ *
15
+ * @property id - UUID. 요청-응답 매칭에 사용
16
+ * @property type - 메시지 유형 (REQUEST/RESPONSE: 데이터 교환, PING/PONG: 연결 확인)
17
+ * @property path - 요청 경로 (예: "/scan", "/ping")
18
+ * @property body - JSON 문자열로 직렬화된 페이로드 (선택)
19
+ */
20
+ export interface AoaMessage {
21
+ id: string;
22
+ type: 'REQUEST' | 'RESPONSE' | 'PING' | 'PONG';
23
+ path: string;
24
+ body?: string;
25
+ }
26
+
27
+ /**
28
+ * 네이티브 모듈에서 JS로 전달되는 이벤트 맵.
29
+ * NativeModule<BootWelcomeUsbEvents> 제네릭에 사용되어 타입 안전한 이벤트 리스닝을 제공한다.
30
+ */
31
+ export type BootWelcomeUsbEvents = {
32
+ /** USB 연결 상태가 변경될 때 발생 */
33
+ onConnectionStateChanged: (params: { state: UsbAoaConnectionState; message?: string }) => void;
34
+ /** USB Host(Mac)로부터 완성된 JSON 메시지를 수신할 때 발생 */
35
+ onDataReceived: (params: { messageJson: string }) => void;
36
+ /** USB 통신 중 에러가 발생할 때 발생 (code: 에러 코드, message: 에러 설명) */
37
+ onError: (params: { code: string; message: string }) => void;
38
+ };
@@ -0,0 +1,20 @@
1
+ import { NativeModule, requireNativeModule } from 'expo';
2
+
3
+ import { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';
4
+
5
+ /**
6
+ * Android 네이티브 모듈(BootWelcomeUsbModule.kt)의 TypeScript 타입 선언.
7
+ * NativeModule을 상속하여 이벤트 리스닝(addListener)과 AsyncFunction 호출을 타입 안전하게 제공한다.
8
+ */
9
+ declare class BootWelcomeUsbModule extends NativeModule<BootWelcomeUsbEvents> {
10
+ /** 현재 USB 연결 상태를 반환 (DISCONNECTED | CONNECTED | AOA_READY) */
11
+ getConnectionState(): Promise<string>;
12
+ /** 사용자에게 USB Accessory 접근 권한을 요청 */
13
+ requestPermission(): Promise<boolean>;
14
+ /** Length-Prefix 프레이밍으로 JSON 메시지를 USB Host(Mac)에 전송 */
15
+ sendData(messageJson: string): Promise<boolean>;
16
+ }
17
+
18
+ // JSI를 통해 네이티브 모듈 객체를 로드한다.
19
+ // Android의 BootWelcomeUsbModule.kt에서 Name("BootWelcomeUsb")으로 등록된 모듈과 바인딩.
20
+ export default requireNativeModule<BootWelcomeUsbModule>('BootWelcomeUsb');
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * boot-welcome-usb 모듈의 공개 API.
3
+ *
4
+ * 네이티브 모듈(BootWelcomeUsb)이 이미 EventEmitter이므로
5
+ * 별도의 EventEmitter 생성 없이 모듈의 addListener를 직접 위임한다.
6
+ */
7
+ import type { EventSubscription } from 'expo-modules-core';
8
+
9
+ import BootWelcomeUsbModule from './BootWelcomeUsbModule';
10
+ import type { BootWelcomeUsbEvents } from './BootWelcomeUsb.types';
11
+
12
+ export * from './BootWelcomeUsb.types';
13
+ export { useUsbAoa } from './useUsbAoa';
14
+
15
+ /** 이벤트 이름 유니온 타입 */
16
+ type EventName = keyof BootWelcomeUsbEvents;
17
+ /** 각 이벤트 리스너의 첫 번째 파라미터(페이로드) 타입 추출 */
18
+ type EventPayload<K extends EventName> = Parameters<BootWelcomeUsbEvents[K]>[0];
19
+
20
+ /**
21
+ * 네이티브 이벤트 리스너를 등록한다.
22
+ * 반환된 EventSubscription의 remove()를 호출하여 리스너를 해제할 수 있다.
23
+ *
24
+ * @example
25
+ * const sub = addListener('onConnectionStateChanged', (e) => {
26
+ * console.log(e.state, e.message);
27
+ * });
28
+ * // 정리
29
+ * sub.remove();
30
+ */
31
+ export function addListener<K extends EventName>(
32
+ eventName: K,
33
+ listener: (event: EventPayload<K>) => void,
34
+ ): EventSubscription {
35
+ return BootWelcomeUsbModule.addListener(eventName, listener);
36
+ }
37
+
38
+ /** 현재 USB 연결 상태를 조회한다. */
39
+ export async function getConnectionState(): Promise<string> {
40
+ return await BootWelcomeUsbModule.getConnectionState();
41
+ }
42
+
43
+ /** 사용자에게 USB Accessory 접근 권한을 요청한다. */
44
+ export async function requestPermission(): Promise<boolean> {
45
+ return await BootWelcomeUsbModule.requestPermission();
46
+ }
47
+
48
+ /**
49
+ * JSON 메시지를 USB Host(Mac)에 전송한다.
50
+ * 내부적으로 Length-Prefix 프레이밍이 적용된다.
51
+ * @param messageJson 전송할 JSON 문자열 (AoaMessage 형식 권장)
52
+ */
53
+ export async function sendData(messageJson: string): Promise<boolean> {
54
+ return await BootWelcomeUsbModule.sendData(messageJson);
55
+ }
56
+
57
+ export default BootWelcomeUsbModule;
@@ -0,0 +1,45 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import type { UsbAoaConnectionState } from './BootWelcomeUsb.types';
4
+ import { addListener, getConnectionState } from './index';
5
+
6
+ /**
7
+ * USB AOA 연결 상태를 React 상태로 제공하는 훅.
8
+ *
9
+ * 마운트 시 현재 연결 상태를 조회하고,
10
+ * onConnectionStateChanged 이벤트를 구독하여 상태 변경을 실시간 반영한다.
11
+ * 언마운트 시 이벤트 구독을 자동 해제한다.
12
+ *
13
+ * @returns connectionState - 현재 연결 상태 문자열
14
+ * @returns isConnected - AOA_READY 상태 여부 (데이터 송수신 가능 여부)
15
+ *
16
+ * @example
17
+ * function StatusBar() {
18
+ * const { connectionState, isConnected } = useUsbAoa();
19
+ * return <Text>{isConnected ? '연결됨' : connectionState}</Text>;
20
+ * }
21
+ */
22
+ export function useUsbAoa() {
23
+ const [connectionState, setConnectionState] = useState<UsbAoaConnectionState>('DISCONNECTED');
24
+
25
+ useEffect(() => {
26
+ // 마운트 시 현재 상태를 한 번 조회
27
+ getConnectionState().then((state) => {
28
+ setConnectionState(state as UsbAoaConnectionState);
29
+ });
30
+
31
+ // 상태 변경 이벤트 구독
32
+ const subscription = addListener('onConnectionStateChanged', (event) => {
33
+ setConnectionState(event.state);
34
+ });
35
+
36
+ return () => {
37
+ subscription.remove();
38
+ };
39
+ }, []);
40
+
41
+ return {
42
+ connectionState,
43
+ isConnected: connectionState === 'AOA_READY',
44
+ };
45
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }