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

93f4e002c8d09fca992c5563c68b2771.png

Продолжая тему 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‑страницу в браузере мобильного устройства, мы можем увидеть результат.

4610a16b7c065365c82429fa6e0b293f.gif

Протокол 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. Описанной функциональности хватит для банального общения текстом, когда не нужно дробить большие сообщения на маленькие кусочки. Вот что получилось:

Для демонстрации использовал Split Screen mode
Для демонстрации использовал Split Screen mode

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

График показывает, что время работы ServerSocket на обычных вендорских устройствах составляет до 8 минут, в то время как на Samsung сервер убивается системой уже через 2 минуты
График показывает, что время работы ServerSocket на обычных вендорских устройствах составляет до 8 минут, в то время как на Samsung сервер убивается системой уже через 2 минуты

Выводы

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

В следующей статье расскажу, как организовать общение приложений в одной Wi-Fi‑сети.

© Habrahabr.ru