Сценарии использования ServerSocket

Продолжая тему ServerSocket
, в предыдущей части мы разобрали, как организовать общение между двумя процессами в рамках одного приложения. Теперь перейдем к более интересной задаче — взаимодействию между приложением и сайтом, открытым в браузере на одном устройстве.
Если бы оба участника обмена данными были Android
‑приложениями, мы могли бы использовать стандартные механизмы IPC
, такие как Intent
для отправки сообщений, ContentProvider
для доступа к данным или Binder
для более сложных взаимодействий. Но в нашем случае одна из сторон — браузер, в котором работает веб‑приложение. Браузер не имеет доступа к этим механизмам, так как работает в своем песочном окружении и не может напрямую взаимодействовать с компонентами Android.
Именно поэтому ServerSocket
становится удобным решением, позволяя Android
‑приложению создать локальный сервер, к которому браузер может подключаться как к обычному веб‑серверу. Это дает возможность гибко передавать данные между приложением и сайтом, обходя ограничения стандартных средств IPC
.
Сценарии использования
Такой подход позволит идентифицировать пользователя, который переходит из приложения в браузер на сайт, передавать данные в реальном времени, управлять воспроизведением контента на сайте, обновлять интерфейс браузера на основе событий в приложении, передавать файлы или содержимое буфера обмена.
В прошлый раз мы обменивались данными между процессами напрямую, без использования протоколов. Но в случае с браузером такой подход невозможен из‑за ограничений на низкоуровневые сетевые соединения. Браузер не позволяет устанавливать прямое TCP
‑соединение с ServerSocket
, поэтому нам необходимо использовать один из поддерживаемых протоколов — HTTP
или WebSocket
. В зависимости от выбора технологии серверная имплементация будет различаться.
Протокол HTTP
Для соединения по HTTP
на клиентской стороне можно использовать такой скрипт на HTML
:
Этот скрипт выполняет HTTP
‑запрос с браузера к локальному серверу, который работает на localhost:65111.
Создание серверной части
Серверный код будет такой:
fun run() {
// Создаем экземпляр класса ServerSocket
val server: ServerSocket = createServer()
// Дожидаемся подключение клиента
val clientSocket: Socket = server.accept()
// Обмениваемся сообщениями
handleClient(clientSocket)
}
Создание сервера:
val port = 65111
fun createServer(): ServerSocket {
// Задаем нужные параметры
val server = ServerSocket(
port = port,
backlog = 100,
bindAddr = InetAddress.getLoopbackAddress(),
)
// Устанавливаем таймаут
server.soTimeout = 60.seconds.inWholeMilliseconds.toInt()
return server
}
Задаем порт, о котором будет знать наш клиент. В этом случае можно подстраховаться и использовать массив портов, и тогда, когда один из портов окажется занятым, мы сможем переключится на другой. Код в этом случае будет выглядеть так:
val ports = listOf(65111, 65112, 65113, 65114, ...)
fun createServer(): ServerSocket {
for (port in ports) {
try {
return ServerSocket(port, …)
} catch (e: BindException) {
// log or do something
}
}
error("Could not create ServerSocket")
}
Для экономии ресурсов устройства устанавливаем таймаут на установку соединения. Если клиент в течение 60 секунд не подключится, сервер будет уничтожен, а точнее выбросится исключение SocketTimeoutException
.
Обмениваемся сообщениями. Вспоминаем, что HTTP
‑запрос — это текст, состоящий из стартовой строки и заголовков:
fun handleClient(clientSocket: Socket) {
val reader = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
// Читаем начальную строку запроса
val request = requestParts(reader)
// Читаем заголовки
val headers = readHeaders(reader)
// Отправляем сообщение
sendMessage(clientSocket)
writer.close()
clientSocket.close()
}
Перед HTTP
‑заголовками клиентский запрос начинается со стартовой строки запроса, которая содержит следующие компоненты:
метод запроса — определяет тип операции, например
GET
,POST
,PUT
,DELETE
;запрашиваемый ресурс — указывает на целевой ресурс, например
URL
на сервере;версию
HTTP
— версию протокола, используемую для запроса.
На основе этих данных можно выполнить внутреннюю логику для обработки запроса, сгенерировать соответствующий ответ и отправить его обратно клиенту. Для их получения нужно распарсить первую присылаемую строку:
fun requestParts(reader: BufferedReader): Map = buildMap {
val request = reader.readLine()
val requestParts = request.split(" ")
put("method", requestParts[0]) // Метод запроса (GET, POST и т. д.)
put("resource", requestParts[1]) // Запрашиваемый ресурс (URL)
put("httpVersion", requestParts[2]) // Версия протокола
}
После начальной строки запроса следуют HTTP
‑заголовки. Они предоставляют дополнительную информацию о запросе или клиенте. Например, информацию о браузере (User‑Agent), предпочтения по содержимому (Accept) и другое, что позволит более тонко настроить ответ.
Каждый заголовок состоит из имени заголовка, последующего двоеточия и значения и разделяется переводом строки:
fun readHeaders(reader: BufferedReader): MutableMap {
val headers = mutableMapOf()
var line = reader.readLine()
while (line.isNotEmpty()) {
val headerParts = line.split(":")
if (headerParts.size >= 2) {
val headerName = headerParts[0].trim()
val headerValue = headerParts[1].trim()
headers[headerName] = headerValue
}
line = reader.readLine()
}
return headers
}
Для отправки HTTP
‑ответа клиенту нужно отправить необходимые HTTP
‑заголовки.
HTTP/1.1 200 OK — строка ответа, указывающая на версию HTTP
‑протокола и статус ответа (200), который сообщает о том, что запрос был успешно обработан.
Content‑Type: text/plain — заголовок, определяющий MIME
‑тип содержимого ответа. В данном случае сообщается, что ответ представлен в формате обычного текста.
Access‑Control‑Allow‑Origin:* — заголовок используется для указания, что ресурс может быть доступен с любого источника.
Content‑Length: ${response.length} — заголовок, указывающий на длину тела ответа в байтах.
fun sendMessage(clientSocket: Socket) {
val writer = PrintWriter(clientSocket.getOutputStream(), true)
val response = "Hello Client!"
writer.println("HTTP/1.1 200 OK")
writer.println("Content-Type: text/plain")
writer.println("Access-Control-Allow-Origin: *")
writer.println("Content-Length: ${response.length}")
writer.println()
writer.println(response)
}
Завершается процедура закрытием потока вывода и самого сокета. На этом сервер готов отправить сообщение Hello World! каждому клиенту, который подключится.
Запустив сервер и открыв HTML
‑страницу в браузере мобильного устройства, мы можем увидеть результат.

Протокол WebSocket
В случаях, когда требуется беспрерывное общение, стоит переключится на протокол WebSocket
: он обеспечит двустороннюю связь в реальном времени и минимальную задержку. Преобразуем скрипт клиентской части в чат:
Изменится серверный код:
fun run() {
// Создание сервера не изменится
val server: ServerSocket = createServer()
val clientSocket: Socket = server.accept()
// Изменится установка соединения с клиентом
handleClient(clientSocket)
}
fun handleClient(clientSocket: Socket) {
val inputStream = clientSocket.getInputStream()
val outputStream = clientSocket.getOutputStream()
val reader = BufferedReader(InputStreamReader(inputStream))
val writer = PrintWriter(outputStream)
// Считываем заголовки
val headers = readHeaders(reader)
// Рукопожатие
handshake(writer, headers)
// Обмен сообщениями
runCommunication(inputStream, outputStream)
}
Считывание заголовков остается неизменным. На этот раз они понадобятся нам для этапа Handshake.
Установление WebSocket
‑соединения начинается с критического этапа — рукопожатия (handshake), которое обеспечивает переключение с HTTP
‑протокола на WebSocket
. Рассмотрим, как это реализуется в коде:
fun handshake(writer: PrintWriter, headers: Map) {
// Вычисляем ключ для подтверждения установки соединения
val encodedKey = calculateWebSocketAccept(headers["Sec-WebSocket-Key"]
// Отправляем заголовки на успешное рукопожатие, это константы, закрепленные в RFC для установки соединения по websocket
writer.println("HTTP/1.1 101 Switching Protocols")
writer.println("Upgrade: websocket")
writer.println("Connection: Upgrade")
// Ключ, подтверждающий успешное установление соединения
writer.println("Sec-WebSocket-Accept: $encodedKey")
writer.println()
writer.flush()
}
Метод calculateWebSocketAccept вычисляет ключ подтверждения для обеспечения безопасности соединения, принимает строку Sec-WebSocket-Key
, которую клиент отправляет в запросе. Это значение преобразуется и отправляется обратно клиенту для завершения рукопожатия.
Процесс вычисления ключа описан в RFC 6455. А вот так выглядит реализация:
fun calculateWebSocketAccept(clientWebSocketKey: String): String {
// Константа из RFС для вычисления ключа
val WEB_SOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
val concatenated = clientWebSocketKey + WEB_SOCKET_GUID
val sha1 = MessageDigest.getInstance("SHA-1")
val hashBytes = sha1.digest(concatenated.toByteArray())
val encodedBytes = Base64.getEncoder().encode(hashBytes)
return String(encodedBytes)
}
После успешного соединения с клиентом можем начать общение. Дожидаемся сообщения от клиента и отправляем ему ответ:
fun runCommunication(input: InputStream, output: OutputStream) {
while (true) {
// Сообщения, получаемые из сокета, присылаются в виде фрейма
val receivedFrame = readSocketFrame(input)
// Отправляем ответ на сообщение клиента
sendText(output, "Hello Client!")
}
}
Функция readSocketFrame отвечает за получение фрейма сообщения от клиента. В WebSocket
сообщения разбиваются на фреймы.
Фрейм — это последовательность байтов, где первые шесть байтов содержат служебную информацию, а оставшиеся байты — данные.

Как происходит чтение фрейма:
private fun readSocketFrame(input: InputStream): String {
// Первый байт, биты в нем рассказывают о типе сообщения, для примера не важно, пропускаем описание каждого бита в этом байте
val b1 = input.read()
// Второй байт
val b2 = input.read()
// Последние 7 битов обозначают длину сообщения (максимальная длина сообщения — 125 байт, если нужно больше, придется разбираться, как это устроено)
val messageLength = (b2 and 0b01111111).toLong()
// Следующие 4 байта — это маска, которой зашифровано сообщение
val maskKey = input.getBytes(4)
// Все последующие байты — это наше сообщение
val payload = input.getBytes(messageLength)
// Расшифровываем сообщение
unmaskedPayload(payload, maskKey)
// Возвращаем результат
return String(payload)
}
// Алгоритм из RFC для расшифровывания сообщения
private fun unmaskedPayload(payload: ByteArray, maskKey: ByteArray) {
for (index in payload.indices) {
payload[index] = payload[index] xor maskKey[index % 4]
}
}
Отправляем ответ, где первые два байта — это заголовки и остальное тело сообщения:
private fun sendText(output: OutputStream, text: String) {
val utf8Bytes = text.toByteArray()
// Первый байт, говорящий, что мы отправляет текст
output.write(0b10000001)
// Второй байт — длина сообщения
output.write(utf8Bytes.size)
// И само сообщение
output.write(utf8Bytes)
output.flush()
}
В примере я минимально рассмотрел протокол Websocket
. Описанной функциональности хватит для банального общения текстом, когда не нужно дробить большие сообщения на маленькие кусочки. Вот что получилось:

В результате такой сервер может работать недолго, если приложение находится в фоновом режиме. Но в случае, когда пользователь переходит напрямую из вашего приложения на ваш сайт в браузере, этого должно быть достаточно.

Выводы
Мы рассмотрели, как организовать общение между двумя приложениями на одном устройстве, где клиентом выступает веб‑страница в браузере. Разобрали, какие существуют ограничения на формат общения, а также ограничения по времени работы из‑за политики OC управления фоновыми процессами.
В следующей статье расскажу, как организовать общение приложений в одной Wi-Fi
‑сети.