From 389d9851db00b5b853f67fd3b3a80617d38384ad Mon Sep 17 00:00:00 2001 From: Anton Shestak Date: Wed, 25 Aug 2021 16:29:30 +0700 Subject: [PATCH 01/26] #149 added HttpClientEngine support to iOS, sample don't work --- gradle/wrapper/gradle-wrapper.properties | 2 +- .../moko/network/HttpClientEngineConfig.kt | 2 + .../dev/icerock/moko/network/IosWebSocket.kt | 132 ++++++++++++++++++ .../moko/network/WSIosHttpClientEngine.kt | 75 ++++++++++ .../moko/network/createHttpClientEngine.kt | 8 ++ .../moko/network/createHttpClientEngine.kt | 7 + .../src/main/res/layout/activity_main.xml | 5 + sample/ios-app/Podfile.lock | 2 +- .../xcshareddata/IDEWorkspaceChecks.plist | 8 ++ .../src/Resources/Base.lproj/Main.storyboard | 29 +++- sample/ios-app/src/TestViewController.swift | 8 ++ .../com/icerockdev/library/TestViewModel.kt | 52 +++++++ 12 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 network/src/iosMain/kotlin/dev/icerock/moko/network/IosWebSocket.kt create mode 100644 network/src/iosMain/kotlin/dev/icerock/moko/network/WSIosHttpClientEngine.kt create mode 100644 network/src/iosMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt create mode 100644 network/src/jvmMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt create mode 100644 sample/ios-app/TestProj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index af7be50..05679dc 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/network/src/commonMain/kotlin/dev/icerock/moko/network/HttpClientEngineConfig.kt b/network/src/commonMain/kotlin/dev/icerock/moko/network/HttpClientEngineConfig.kt index a51d090..be9ea49 100644 --- a/network/src/commonMain/kotlin/dev/icerock/moko/network/HttpClientEngineConfig.kt +++ b/network/src/commonMain/kotlin/dev/icerock/moko/network/HttpClientEngineConfig.kt @@ -16,3 +16,5 @@ class HttpClientEngineConfig { } expect fun createHttpClientEngine(block: HttpClientEngineConfig.() -> Unit = {}): HttpClientEngine + +expect fun createHttpClientEngine(): HttpClientEngine \ No newline at end of file diff --git a/network/src/iosMain/kotlin/dev/icerock/moko/network/IosWebSocket.kt b/network/src/iosMain/kotlin/dev/icerock/moko/network/IosWebSocket.kt new file mode 100644 index 0000000..c88c21b --- /dev/null +++ b/network/src/iosMain/kotlin/dev/icerock/moko/network/IosWebSocket.kt @@ -0,0 +1,132 @@ +package dev.icerock.moko.network + +import io.ktor.client.features.websocket.WebSocketException +import io.ktor.http.cio.websocket.CloseReason +import io.ktor.http.cio.websocket.DefaultWebSocketSession +import io.ktor.http.cio.websocket.ExperimentalWebSocketExtensionApi +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.WebSocketExtension +import io.ktor.http.cio.websocket.readText +import io.ktor.util.InternalAPI +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import platform.Foundation.NSData +import platform.Foundation.NSOperationQueue +import platform.Foundation.NSURL +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionWebSocketCloseCode +import platform.Foundation.NSURLSessionWebSocketDelegateProtocol +import platform.Foundation.NSURLSessionWebSocketMessage +import platform.Foundation.NSURLSessionWebSocketTask +import platform.darwin.NSObject +import kotlin.coroutines.CoroutineContext + +internal class IosWebSocket( + socketEndpoint: NSURL, + override val coroutineContext: CoroutineContext +) : DefaultWebSocketSession { + internal val originResponse: CompletableDeferred = CompletableDeferred() + + private val webSocket: NSURLSessionWebSocketTask + + private val _incoming = Channel() + private val _outgoing = Channel() + private val _closeReason = CompletableDeferred() + + override val incoming: ReceiveChannel = _incoming + override val outgoing: SendChannel = _outgoing + override val closeReason: Deferred = _closeReason + + @ExperimentalWebSocketExtensionApi + override val extensions: List> + get() = emptyList() + + override var maxFrameSize: Long + get() = throw WebSocketException("websocket doesn't support max frame size.") + set(_) = throw WebSocketException("websocket doesn't support max frame size.") + + override suspend fun flush() = Unit + + @OptIn(ExperimentalWebSocketExtensionApi::class) + @InternalAPI + override fun start(negotiatedExtensions: List>) { + require(negotiatedExtensions.isEmpty()) { "Extensions are not supported." } + } + + init { + val urlSession = NSURLSession.sessionWithConfiguration( + configuration = NSURLSessionConfiguration.defaultSessionConfiguration(), + delegate = object : NSObject(), NSURLSessionWebSocketDelegateProtocol { + override fun URLSession( + session: NSURLSession, + webSocketTask: NSURLSessionWebSocketTask, + didOpenWithProtocol: String? + ) { + originResponse.complete(didOpenWithProtocol) + } + + override fun URLSession( + session: NSURLSession, + webSocketTask: NSURLSessionWebSocketTask, + didCloseWithCode: NSURLSessionWebSocketCloseCode, + reason: NSData? + ) { + val closeReason = CloseReason( + code = CloseReason.Codes.PROTOCOL_ERROR, + message = "$didCloseWithCode : ${reason.toString()}" + ) + _closeReason.complete(closeReason) + } + }, + delegateQueue = NSOperationQueue.currentQueue() + ) + println("urlSession was built: $urlSession") + webSocket = urlSession.webSocketTaskWithURL(socketEndpoint) + + CoroutineScope(coroutineContext).launch { + _outgoing.consumeEach { frame -> + if (frame is Frame.Text) { + val message = NSURLSessionWebSocketMessage(frame.readText()) + webSocket.sendMessage(message) { nsError -> + if (nsError != null) throw Exception(nsError.description) + } + } + + } + } + + listenMessages() + } + + fun start() { + println("urlSession will resume") + webSocket.resume() + println("urlSession did resume") + } + + private fun listenMessages() { + webSocket.receiveMessageWithCompletionHandler { message, nsError -> + when { + nsError != null -> { + throw Exception(nsError.description) + } + message != null -> { + message.string?.let { _incoming.trySend(Frame.Text(it)) } + } + } + listenMessages() + } + } + + override fun terminate() { + coroutineContext.cancel() + } +} \ No newline at end of file diff --git a/network/src/iosMain/kotlin/dev/icerock/moko/network/WSIosHttpClientEngine.kt b/network/src/iosMain/kotlin/dev/icerock/moko/network/WSIosHttpClientEngine.kt new file mode 100644 index 0000000..e570ba8 --- /dev/null +++ b/network/src/iosMain/kotlin/dev/icerock/moko/network/WSIosHttpClientEngine.kt @@ -0,0 +1,75 @@ +package dev.icerock.moko.network + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.HttpClientEngineCapability +import io.ktor.client.engine.HttpClientEngineConfig +import io.ktor.client.engine.callContext +import io.ktor.client.features.websocket.WebSocketCapability +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.client.request.isUpgradeRequest +import io.ktor.http.Headers +import io.ktor.http.HttpProtocolVersion +import io.ktor.http.HttpStatusCode +import io.ktor.util.InternalAPI +import io.ktor.util.date.GMTDate +import kotlinx.coroutines.CoroutineDispatcher +import platform.Foundation.NSURL +import kotlin.coroutines.CoroutineContext + +class WSIosHttpClientEngine( + private val wrappedEngine: HttpClientEngine +) : HttpClientEngine { + + override val supportedCapabilities: Set> + get() = wrappedEngine.supportedCapabilities + setOf(WebSocketCapability) + + override val config: HttpClientEngineConfig + get() = wrappedEngine.config + + override val dispatcher: CoroutineDispatcher + get() = wrappedEngine.dispatcher + + override val coroutineContext: CoroutineContext + get() = wrappedEngine.coroutineContext + + @InternalAPI + override suspend fun execute(data: HttpRequestData): HttpResponseData { + println("execute $data") + + val callContext = callContext() + return if (data.isUpgradeRequest()) { + executeWebSocketRequest(data, callContext) + } else { + wrappedEngine.execute(data) + } + } + + private suspend fun executeWebSocketRequest( + data: HttpRequestData, + callContext: CoroutineContext + ): HttpResponseData { + val requestTime = GMTDate() + val url: String = data.url.toString() + val socketEndpoint = NSURL.URLWithString(url)!! + + println("start session to $socketEndpoint") + val session = IosWebSocket(socketEndpoint, callContext).apply { start() } + + val originResponse = session.originResponse.await() + println("opened protocol: $originResponse") + + return HttpResponseData( + statusCode = HttpStatusCode.OK, + requestTime = requestTime, + headers = Headers.Empty, + version = HttpProtocolVersion.HTTP_1_0, // read from originResponse + body = session, + callContext = callContext + ) + } + + override fun close() { + wrappedEngine.close() + } +} diff --git a/network/src/iosMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt b/network/src/iosMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt new file mode 100644 index 0000000..6f1d15a --- /dev/null +++ b/network/src/iosMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt @@ -0,0 +1,8 @@ +package dev.icerock.moko.network + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.ios.Ios + +actual fun createHttpClientEngine(): HttpClientEngine { + return WSIosHttpClientEngine(Ios.create { }) +} \ No newline at end of file diff --git a/network/src/jvmMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt b/network/src/jvmMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt new file mode 100644 index 0000000..5fbc9fa --- /dev/null +++ b/network/src/jvmMain/kotlin/dev/icerock/moko/network/createHttpClientEngine.kt @@ -0,0 +1,7 @@ +package dev.icerock.moko.network + +import io.ktor.client.engine.HttpClientEngine + +actual fun createHttpClientEngine(): HttpClientEngine { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/sample/android-app/src/main/res/layout/activity_main.xml b/sample/android-app/src/main/res/layout/activity_main.xml index 08ce665..7ef8b21 100755 --- a/sample/android-app/src/main/res/layout/activity_main.xml +++ b/sample/android-app/src/main/res/layout/activity_main.xml @@ -27,5 +27,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Refresh" /> + - + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. - + + @@ -70,4 +79,12 @@ + + + + + + + + diff --git a/sample/ios-app/src/TestViewController.swift b/sample/ios-app/src/TestViewController.swift index 32240c9..284c4b1 100755 --- a/sample/ios-app/src/TestViewController.swift +++ b/sample/ios-app/src/TestViewController.swift @@ -20,12 +20,20 @@ class TestViewController: UIViewController { viewModel.petInfo.addObserver { [weak self] info in self?.textView.text = info as String? } + + viewModel.websocketInfo.addObserver { [weak self] info in + self?.textView.text = info as String? + } } @IBAction func onRefreshPressed() { viewModel.onRefreshPressed() } + @IBAction func onRefreshWebsocketPressed() { + viewModel.onRefreshWebsocketPressed() + } + deinit { viewModel.onCleared() } diff --git a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/TestViewModel.kt b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/TestViewModel.kt index 727fe5b..0f88ae8 100644 --- a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/TestViewModel.kt +++ b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/TestViewModel.kt @@ -13,6 +13,7 @@ import dev.icerock.moko.mvvm.livedata.MutableLiveData import dev.icerock.moko.mvvm.livedata.readOnly import dev.icerock.moko.mvvm.viewmodel.ViewModel import dev.icerock.moko.network.LanguageProvider +import dev.icerock.moko.network.createHttpClientEngine import dev.icerock.moko.network.features.LanguageFeature import dev.icerock.moko.network.features.TokenFeature import dev.icerock.moko.network.generated.apis.PetApi @@ -21,6 +22,14 @@ import io.ktor.client.HttpClient import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logger import io.ktor.client.features.logging.Logging +import io.ktor.client.features.websocket.WebSockets +import io.ktor.client.features.websocket.webSocket +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.readText +import io.ktor.http.cio.websocket.send +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import news.apis.NewsApi @@ -77,6 +86,9 @@ class TestViewModel : ViewModel() { private val _petInfo = MutableLiveData(null) val petInfo: LiveData = _petInfo.readOnly() + private val _websocketInfo = MutableLiveData(null) + val websocketInfo: LiveData = _websocketInfo.readOnly() + init { reloadPet() loadNews() @@ -86,6 +98,10 @@ class TestViewModel : ViewModel() { reloadPet() } + fun onRefreshWebsocketPressed() { + reloadWebsocket() + } + private fun reloadPet() { viewModelScope.launch { exceptionHandler.handle { @@ -95,6 +111,42 @@ class TestViewModel : ViewModel() { } } + private fun reloadWebsocket() { + viewModelScope.launch { + val httpClient = HttpClient(createHttpClientEngine()) { + install(WebSockets) + } + viewModelScope.launch { + _websocketInfo.value += "try connect websocket\n" + + httpClient.webSocket("wss://echo.websocket.org") { + _websocketInfo.value += "connected websocket\n" + + val incomingJob = launch { + incoming.consumeEach { frame -> + println(frame.toString()) + + if (frame is Frame.Text) { + val text: String = frame.readText() + _websocketInfo.value += "received $text\n" + + outgoing.send(Frame.Text(">$text")) + _websocketInfo.value += "send response\n" + } + } + } + send("Hello world!") + _websocketInfo.value += "send first message\n" + + incomingJob.join() + _websocketInfo.value += "incoming job end\n" + } + + _websocketInfo.value += "websocket closed\n" + } + } + } + @Suppress("TooGenericExceptionCaught") private fun loadNews() { viewModelScope.launch { From 42af4a9d0629c17c1893079de9aa24fdcd1417bf Mon Sep 17 00:00:00 2001 From: Anton Shestak Date: Thu, 26 Aug 2021 13:31:37 +0700 Subject: [PATCH 02/26] #149 updated constraints for buttons --- .../src/Resources/Base.lproj/Main.storyboard | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sample/ios-app/src/Resources/Base.lproj/Main.storyboard b/sample/ios-app/src/Resources/Base.lproj/Main.storyboard index 75d3e64..f953fd3 100755 --- a/sample/ios-app/src/Resources/Base.lproj/Main.storyboard +++ b/sample/ios-app/src/Resources/Base.lproj/Main.storyboard @@ -35,8 +35,8 @@ -