ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Android 테더링 구현 핵심 정리(USB·WiFi 기기 간 데이터 이동)-고슴도치 군단
    카테고리 없음 2026. 4. 27. 19:20

    테더링 개요

    Android 기기에서 테더링은 크게 USB 테더링과 WiFi 테더링(소프트 AP) 두 가지로 나뉨.

    USB 테더링은 케이블로 연결된 PC나 다른 기기에 인터넷을 공유하는 방식이며, WiFi 테더링은 소프트 AP를 열어 무선으로 여러 기기가 접속할 수 있게 하는 방식임.

    일반 앱과 시스템 앱이 접근할 수 있는 API 범위가 다르므로, 사용 목적에 맞는 방식을 선택하는 것이 중요함.



    WiFi 테더링 구현

    Android 8.0(API 26) 이상에서는 WifiManager.startLocalOnlyHotspot()을 사용해 인터넷 공유 없이 기기 간 P2P 통신용 핫스팟을 열 수 있음.

    이 방식은 시스템 권한 없이도 동작하며, SSID와 비밀번호는 콜백으로 자동 발급됨.

    아래는 기본적인 LocalOnlyHotspot 구현 예시임.

    class HotspotManager(private val context: Context) {
    
        private val wifiManager: WifiManager =
            context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
    
        private var reservation: WifiManager.LocalOnlyHotspotReservation? = null
    
        fun startLocalOnlyHotspot(onStarted: (ssid: String, password: String) -> Unit) {
            wifiManager.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
                override fun onStarted(r: WifiManager.LocalOnlyHotspotReservation) {
                    reservation = r
                    val config = r.softApConfiguration
                    val ssid = config.ssid ?: "Unknown"
                    val password = config.passphrase ?: ""
                    onStarted(ssid, password)
                }
                override fun onStopped() { reservation = null }
                override fun onFailed(reason: Int) {
                    Log.e("Hotspot", "Failed: $reason")
                }
            }, Handler(Looper.getMainLooper()))
        }
    
        fun stopHotspot() {
            reservation?.close()
            reservation = null
        }
    }

    인터넷까지 공유하는 전체 핫스팟 제어는 TETHER_PRIVILEGED 권한이 필요하며, 이는 시스템 서명 앱에서만 허용됨.



    USB 테더링 구현

    USB 테더링은 공개 API가 없어 ConnectivityManager의 숨겨진 메서드를 Reflection으로 접근하는 방식이 사용됨.

    기기마다 인터페이스 이름(rndis, usb)이 다를 수 있으므로, 테더링 활성 여부 확인 시 다양한 접두사를 고려해야 함.

    class UsbTetheringManager(private val context: Context) {
    
        fun isUsbTetheringEnabled(): Boolean {
            return try {
                val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
                val method = cm.javaClass.getMethod("getTetheredIfaces")
                val ifaces = method.invoke(cm) as Array<*>
                ifaces.any {
                    it.toString().startsWith("rndis") || it.toString().startsWith("usb")
                }
            } catch (e: Exception) { false }
        }
    
        fun setUsbTethering(enable: Boolean): Boolean {
            return try {
                val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
                val method = cm.javaClass.getDeclaredMethod("setUsbTethering", Boolean::class.java)
                method.isAccessible = true
                val result = method.invoke(cm, enable) as Int
                result == ConnectivityManager.TETHER_ERROR_NO_ERROR
            } catch (e: Exception) {
                Log.e("USB Tethering", "Error: ${e.message}")
                false
            }
        }
    
        fun openTetheringSettings() {
            val intent = Intent(android.provider.Settings.ACTION_WIRELESS_SETTINGS)
            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
            context.startActivity(intent)
        }
    }

    Reflection 방식은 일부 기기에서 동작하지 않을 수 있으며, 이 경우 사용자가 직접 설정 화면에서 활성화하도록 유도하는 방식을 병행하는 것이 권장됨.



    WiFi Direct 기기 탐색 및 연결

    WiFi Direct는 인터넷 없이 두 기기를 직접 연결하는 P2P 방식임.

    WifiP2pManager를 이용해 주변 기기를 탐색하고 연결 요청을 보낼 수 있으며, 연결 후 Group Owner의 IP 주소를 파일 서버 주소로 활용함.

    class WifiDirectTransfer(private val context: Context) : WifiP2pManager.PeerListListener {
    
        private val manager: WifiP2pManager =
            context.getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager
        private val channel: WifiP2pManager.Channel =
            manager.initialize(context, Looper.getMainLooper(), null)
    
        fun discoverPeers() {
            manager.discoverPeers(channel, object : WifiP2pManager.ActionListener {
                override fun onSuccess() { Log.d("P2P", "Discovery started") }
                override fun onFailure(reason: Int) { Log.e("P2P", "Discovery failed: $reason") }
            })
        }
    
        override fun onPeersAvailable(peerList: WifiP2pDeviceList) {
            peerList.deviceList.forEach { device ->
                Log.d("P2P", "Found: ${device.deviceName} - ${device.deviceAddress}")
            }
        }
    
        fun connectToDevice(device: WifiP2pDevice) {
            val config = WifiP2pConfig().apply {
                deviceAddress = device.deviceAddress
                wps.setup = WpsInfo.PBC
            }
            manager.connect(channel, config, object : WifiP2pManager.ActionListener {
                override fun onSuccess() { Log.d("P2P", "Connection initiated") }
                override fun onFailure(reason: Int) { Log.e("P2P", "Connect failed: $reason") }
            })
        }
    
        fun requestConnectionInfo(callback: (WifiP2pInfo) -> Unit) {
            manager.requestConnectionInfo(channel) { info -> callback(info) }
        }
    }

    위치 권한(ACCESS_FINE_LOCATION)이 없으면 기기 탐색이 동작하지 않으므로, 런타임 권한 요청을 반드시 포함해야 함.



    소켓 기반 파일 전송

    핫스팟 또는 WiFi Direct로 연결된 후에는 소켓 서버/클라이언트로 실제 파일을 전송할 수 있음.

    서버 측에서는 ServerSocket을 열어 파일명과 바이트 데이터를 수신하고, 클라이언트 측에서는 서버 IP에 연결해 파일을 전송함.

    // 서버 (핫스팟 Host)
    class FileTransferServer(private val port: Int = 8888) {
        private var serverSocket: ServerSocket? = null
    
        fun startServer(onFileReceived: (fileName: String, bytes: ByteArray) -> Unit) {
            Thread {
                serverSocket = ServerSocket(port)
                while (true) {
                    try {
                        val client = serverSocket!!.accept()
                        Thread {
                            client.use {
                                val dis = DataInputStream(it.getInputStream())
                                val nameLen = dis.readInt()
                                val nameBytes = ByteArray(nameLen).also { b -> dis.readFully(b) }
                                val fileName = String(nameBytes, Charsets.UTF_8)
                                val fileSize = dis.readLong()
                                val buffer = ByteArray(fileSize.toInt()).also { b -> dis.readFully(b) }
                                onFileReceived(fileName, buffer)
                            }
                        }.start()
                    } catch (e: Exception) { if (serverSocket?.isClosed == true) break }
                }
            }.start()
        }
    
        fun stop() { serverSocket?.close() }
    }
    
    // 클라이언트 (접속자)
    class FileTransferClient(private val serverIp: String, private val port: Int = 8888) {
    
        fun sendFile(fileName: String, fileBytes: ByteArray, onProgress: (Int) -> Unit = {}) {
            Thread {
                Socket(serverIp, port).use { socket ->
                    val dos = DataOutputStream(socket.getOutputStream())
                    val nameBytes = fileName.toByteArray(Charsets.UTF_8)
                    dos.writeInt(nameBytes.size)
                    dos.write(nameBytes)
                    dos.writeLong(fileBytes.size.toLong())
                    var offset = 0
                    val chunk = 4096
                    while (offset < fileBytes.size) {
                        val len = minOf(chunk, fileBytes.size - offset)
                        dos.write(fileBytes, offset, len)
                        offset += len
                        onProgress(offset * 100 / fileBytes.size)
                    }
                    dos.flush()
                }
            }.start()
        }
    }

    전송 진행률은 onProgress 콜백으로 UI에 반영할 수 있으며, 대용량 파일의 경우 스트리밍 방식으로 처리하는 것을 권장함.



    NanoHTTPD HTTP 파일 서버

    NanoHTTPD를 사용하면 HTTP 기반 파일 서버를 앱 내에서 띄울 수 있어, 브라우저에서도 파일을 업로드·다운로드할 수 있음.

    build.gradleimplementation 'org.nanohttpd:nanohttpd:2.3.1'을 추가한 후 아래와 같이 구현함.

    class MobileFileServer(private val rootDir: File, port: Int = 8080) : NanoHTTPD(port) {
    
        override fun serve(session: IHTTPSession): Response {
            val uri = session.uri
            return when {
                uri == "/list" -> {
                    val json = rootDir.listFiles()
                        ?.joinToString(",", "[", "]") { "\"${it.name}\"" } ?: "[]"
                    newFixedLengthResponse(Response.Status.OK, "application/json", json)
                }
                uri.startsWith("/download/") -> {
                    val file = File(rootDir, uri.removePrefix("/download/"))
                    if (file.exists())
                        newFixedLengthResponse(
                            Response.Status.OK, "application/octet-stream",
                            file.inputStream(), file.length()
                        ).also { it.addHeader("Content-Disposition", "attachment; filename=\"${file.name}\"") }
                    else
                        newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
                }
                uri == "/upload" && session.method == Method.POST -> {
                    val files = mutableMapOf<String, String>()
                    session.parseBody(files)
                    val tmpPath = files["file"]
                    val fileName = session.parameters["filename"]?.firstOrNull()
                        ?: "upload_${System.currentTimeMillis()}"
                    if (tmpPath != null) File(tmpPath).copyTo(File(rootDir, fileName), overwrite = true)
                    newFixedLengthResponse("OK")
                }
                else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "404")
            }
        }
    }

    핫스팟 게이트웨이 기본 IP는 192.168.43.1이며, 동적으로 확인하려면 WifiManager.dhcpInfo.gateway를 파싱해서 사용함.



    권한 및 방식 비교

    각 기능별로 필요한 권한과 제한 사항을 정리하면 아래와 같음.

    기능 필요 권한 제한
    WiFi 상태 확인 ACCESS_WIFI_STATE 없음
    LocalOnlyHotspot CHANGE_WIFI_STATE API 26+
    전체 핫스팟 제어 TETHER_PRIVILEGED 시스템 앱 전용
    USB 테더링 토글 CHANGE_NETWORK_STATE + Reflection 기기마다 다름
    WiFi Direct ACCESS_FINE_LOCATION 위치 권한 필수

    일반 앱은 startLocalOnlyHotspot() 범위 내에서만 핫스팟을 제어할 수 있으며, 인터넷 공유가 필요하면 사용자가 직접 설정에서 활성화해야 함.

    USB 테더링은 Reflection 방식이 일부 기기에서만 동작하므로, 제품 앱보다는 사내 도구 수준에서 사용하는 것이 적합함.



    마무리

    Android 테더링 구현은 일반 앱·시스템 앱 여부에 따라 접근 가능한 API가 크게 달라짐.

    기기 간 파일 이동이 목적이라면 WiFi Direct + 소켓 서버 조합이 가장 안정적이며, 브라우저 접근까지 원하면 NanoHTTPD HTTP 서버 방식을 추가로 고려할 수 있음.

    권한 범위와 기기 호환성을 충분히 검토한 뒤 구현 방식을 선택하는 것을 권장함.

Designed by Tistory.