From 553785cffaf04d2fe455bf2a276f269452efbf8c Mon Sep 17 00:00:00 2001 From: Ostap Date: Thu, 21 Feb 2019 16:04:58 +0200 Subject: [PATCH 01/63] Fix isConnected (#39) Currently `isConnected` is true when `connection` exists. However, this is different from JS and Swift implementations. In [JS][1] and [Swift][2] implementations `isConnected` is true only when the socket is actually connected. One of the problems this causes was the channels were closed during the following scenario: 1. WebSocket connection established, one channel is joined.. 2. Internet/Wifi disabled -> connection fails. 3. We periodically call `phxSocket.connect()` to reconnect. 4. Channels [try to rejoin as `isConnected` is true][4], but `phx_join` messages are being queued instead as connection is still not established. 5. Internet/Wifi enabled -> multiple queued `phx_join` messages are sent, Phoniex Server relies `phx_close` to all join messages but last. On `phx_close` the channel is removed from the socket, even tho the logs show that the state is being received. `connect` method is changed to use `connection != null` check, the same way it's done in [JS version][3]. [1]: https://github.com/phoenixframework/phoenix/blob/93db5ff3adf4c91a1ff1996e819e7dd5dfbddf1a/assets/js/phoenix.js#L906 [2]: https://github.com/davidstump/SwiftPhoenixClient/blob/ade5c27051a96aeeedff1594cb3e176b57a02f96/Sources/Socket.swift#L180 [3]: https://github.com/phoenixframework/phoenix/blob/93db5ff3adf4c91a1ff1996e819e7dd5dfbddf1a/assets/js/phoenix.js#L782 [4]: https://github.com/dsrees/JavaPhoenixClient/blob/master/src/main/kotlin/org/phoenixframework/PhxChannel.kt#L74 --- .../kotlin/org/phoenixframework/PhxSocket.kt | 10 +++-- .../org/phoenixframework/PhxSocketTest.kt | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/phoenixframework/PhxSocket.kt b/src/main/kotlin/org/phoenixframework/PhxSocket.kt index 119f81b..437ea67 100644 --- a/src/main/kotlin/org/phoenixframework/PhxSocket.kt +++ b/src/main/kotlin/org/phoenixframework/PhxSocket.kt @@ -99,7 +99,6 @@ open class PhxSocket( /// Timer to use when attempting to reconnect private var reconnectTimer: PhxTimer? = null - private val gson: Gson = GsonBuilder() .setLenient() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) @@ -154,8 +153,8 @@ open class PhxSocket( // Public //------------------------------------------------------------------------------ /** True if the Socket is currently connected */ - val isConnected: Boolean - get() = connection != null + var isConnected: Boolean = false + private set /** * Disconnects the Socket @@ -173,7 +172,7 @@ open class PhxSocket( */ fun connect() { // Do not attempt to reconnect if already connected - if (isConnected) return + if (connection != null) return connection = client.newWebSocket(request, this) } @@ -440,6 +439,7 @@ open class PhxSocket( // WebSocketListener //------------------------------------------------------------------------------ override fun onOpen(webSocket: WebSocket?, response: Response?) { + isConnected = true this.onConnectionOpened() } @@ -451,10 +451,12 @@ open class PhxSocket( } override fun onClosed(webSocket: WebSocket?, code: Int, reason: String?) { + isConnected = false this.onConnectionClosed(code) } override fun onFailure(webSocket: WebSocket?, t: Throwable, response: Response?) { + isConnected = false this.onConnectionError(t, response) } } diff --git a/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt b/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt index 317c8ef..851f3b7 100644 --- a/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt +++ b/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt @@ -1,7 +1,9 @@ package org.phoenixframework import com.google.common.truth.Truth.assertThat +import okhttp3.WebSocket import org.junit.Test +import org.mockito.Mockito class PhxSocketTest { @@ -36,4 +38,44 @@ class PhxSocketTest { assertThat(PhxSocket("wss://localhost:4000/socket/websocket", spacesParams).endpoint.toString()) .isEqualTo("https://localhost:4000/socket/websocket?user_id=1&token=abc%20123") } + + @Test + fun isConnected_isTrue_WhenSocketConnected() { + val mockSocket = Mockito.mock(WebSocket::class.java) + val socket = PhxSocket("http://localhost:4000/socket/websocket") + + socket.onOpen(mockSocket, null) + + assertThat(socket.isConnected).isTrue() + } + + @Test + fun isConnected_isFalse_WhenSocketNotYetConnected() { + val socket = PhxSocket("http://localhost:4000/socket/websocket") + + + assertThat(socket.isConnected).isFalse() + } + + @Test + fun isConnected_isFalse_WhenSocketFailed() { + val mockSocket = Mockito.mock(WebSocket::class.java) + val socket = PhxSocket("http://localhost:4000/socket/websocket") + + socket.onFailure(mockSocket, RuntimeException(), null) + + + assertThat(socket.isConnected).isFalse() + } + + @Test + fun isConnected_isFalse_WhenSocketClosed() { + val mockSocket = Mockito.mock(WebSocket::class.java) + val socket = PhxSocket("http://localhost:4000/socket/websocket") + + socket.onClosed(mockSocket, 0, "closed") + + + assertThat(socket.isConnected).isFalse() + } } From 67baf71d048bdf4072eb53c3518d84a7d9a666b0 Mon Sep 17 00:00:00 2001 From: Ostap Date: Thu, 21 Feb 2019 16:30:36 +0200 Subject: [PATCH 02/63] Fix max reconnectAfterMs (#41) Fixes: https://github.com/dsrees/JavaPhoenixClient/issues/40 --- src/main/kotlin/org/phoenixframework/PhxSocket.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/phoenixframework/PhxSocket.kt b/src/main/kotlin/org/phoenixframework/PhxSocket.kt index 437ea67..1e6856a 100644 --- a/src/main/kotlin/org/phoenixframework/PhxSocket.kt +++ b/src/main/kotlin/org/phoenixframework/PhxSocket.kt @@ -46,7 +46,7 @@ open class PhxSocket( /** Interval between socket reconnect attempts */ var reconnectAfterMs: ((tries: Int) -> Long) = closure@{ - return@closure if (it >= 3) 100000 else longArrayOf(1000, 2000, 5000)[it] + return@closure if (it >= 3) 10000 else longArrayOf(1000, 2000, 5000)[it] } /** Hook for custom logging into the client */ From 22c396c4e801e73cfd835d943e8eb946c1fba75e Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 27 Feb 2019 08:43:18 -0500 Subject: [PATCH 03/63] Prepare version 0.1.7 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 61bcdf5..bcdceb7 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.1.6' + implementation 'com.github.dsrees:JavaPhoenixClient:0.1.7' } ``` diff --git a/build.gradle b/build.gradle index e2907db..d726fb2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group 'com.github.dsrees' -version '0.1.6' +version '0.1.7' sourceCompatibility = 1.8 From d7acb33472d8021eb616434178b0db31097e9f5e Mon Sep 17 00:00:00 2001 From: Dustin Conrad Date: Mon, 4 Mar 2019 08:13:46 -0800 Subject: [PATCH 04/63] Fix reply timeouts (#37) * Ensure that timeouts remove reply bindings * Remove unused imports in test * requested changes for timeout fix --- .../kotlin/org/phoenixframework/PhxPush.kt | 4 +- .../org/phoenixframework/PhxChannelTest.kt | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/org/phoenixframework/PhxPush.kt b/src/main/kotlin/org/phoenixframework/PhxPush.kt index 5666119..7f23bab 100644 --- a/src/main/kotlin/org/phoenixframework/PhxPush.kt +++ b/src/main/kotlin/org/phoenixframework/PhxPush.kt @@ -184,8 +184,8 @@ class PhxPush( val mutPayload = payload.toMutableMap() mutPayload["status"] = status - refEvent?.let { - val message = PhxMessage(it, "", "", mutPayload) + refEvent?.let { safeRefEvent -> + val message = PhxMessage(event = safeRefEvent, payload = mutPayload) this.channel.trigger(message) } } diff --git a/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt b/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt index 879fe68..a32d76d 100644 --- a/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt +++ b/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt @@ -1,17 +1,25 @@ package org.phoenixframework import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener import org.junit.Before import org.junit.Test +import org.mockito.ArgumentMatchers import org.mockito.Mockito +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.Spy import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger class PhxChannelTest { private val defaultRef = "1" + private val topic = "topic" @Spy var socket: PhxSocket = PhxSocket("http://localhost:4000/socket/websocket") @@ -23,7 +31,7 @@ class PhxChannelTest { Mockito.doReturn(defaultRef).`when`(socket).makeRef() socket.timeout = 1234 - channel = PhxChannel("topic", hashMapOf("one" to "two"), socket) + channel = PhxChannel(topic, hashMapOf("one" to "two"), socket) } @@ -149,4 +157,46 @@ class PhxChannelTest { CompletableFuture.allOf(f1, f3).get(10, TimeUnit.SECONDS) } + + @Test + fun `issue 36 - verify timeouts remove bindings`() { + // mock okhttp to get isConnected to return true for the socket + val mockOkHttp = Mockito.mock(OkHttpClient::class.java) + val mockSocket = Mockito.mock(WebSocket::class.java) + `when`(mockOkHttp.newWebSocket(ArgumentMatchers.any(Request::class.java), ArgumentMatchers.any(WebSocketListener::class.java))).thenReturn(mockSocket) + + // local mocks for this test + val localSocket = Mockito.spy(PhxSocket(url = "http://localhost:4000/socket/websocket", client = mockOkHttp)) + val localChannel = PhxChannel(topic, hashMapOf("one" to "two"), localSocket) + + // setup makeRef so it increments + val refCounter = AtomicInteger(1) + Mockito.doAnswer { + refCounter.getAndIncrement().toString() + }.`when`(localSocket).makeRef() + + //connect the socket + localSocket.connect() + + //join the channel + val joinPush = localChannel.join() + localChannel.trigger(PhxMessage( + ref = joinPush.ref!!, + joinRef = joinPush.ref!!, + event = PhxChannel.PhxEvent.REPLY.value, + topic = topic, + payload = mutableMapOf("status" to "ok"))) + + //get bindings + val originalBindingsSize = localChannel.bindings.size + val pushCount = 100 + repeat(pushCount) { + localChannel.push("some-event", mutableMapOf(), timeout = 500) + } + //verify binding count before timeouts + assertThat(localChannel.bindings.size).isEqualTo(originalBindingsSize + pushCount) + Thread.sleep(1000) + //verify binding count after timeouts + assertThat(localChannel.bindings.size).isEqualTo(originalBindingsSize) + } } From ccdb9b604a5ef07b6cbbfb28a5b72f3278308068 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Mon, 4 Mar 2019 11:15:05 -0500 Subject: [PATCH 05/63] Prepare version 0.1.8 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bcdceb7..d2bd5e5 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.1.7' + implementation 'com.github.dsrees:JavaPhoenixClient:0.1.8' } ``` diff --git a/build.gradle b/build.gradle index d726fb2..f5e0e51 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group 'com.github.dsrees' -version '0.1.7' +version '0.1.8' sourceCompatibility = 1.8 From e4efb05781c971cb1e6bc522218d270a2ce7a8c4 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Fri, 12 Apr 2019 18:48:16 -0400 Subject: [PATCH 06/63] Created new TimeoutTimer class which makes use of a ScheduledExecutorService (#42) --- build.gradle | 64 ++++++++-------- .../kotlin/org/phoenixframework/Socket.kt | 35 +++++++++ .../org/phoenixframework/TimeoutTimer.kt | 75 +++++++++++++++++++ .../org/phoenixframework/TimeoutTimerTest.kt | 67 +++++++++++++++++ 4 files changed, 209 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/org/phoenixframework/Socket.kt create mode 100644 src/main/kotlin/org/phoenixframework/TimeoutTimer.kt create mode 100644 src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt diff --git a/build.gradle b/build.gradle index f5e0e51..5088b85 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ buildscript { repositories { jcenter() } } plugins { - id 'java' - id 'org.jetbrains.kotlin.jvm' version '1.2.51' - id 'nebula.project' version '4.0.1' - id "nebula.maven-publish" version "7.2.4" - id 'nebula.nebula-bintray' version '3.5.5' + id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.2.51' + id 'nebula.project' version '4.0.1' + id "nebula.maven-publish" version "7.2.4" + id 'nebula.nebula-bintray' version '3.5.5' } group 'com.github.dsrees' @@ -13,45 +13,45 @@ version '0.1.8' sourceCompatibility = 1.8 repositories { - mavenCentral() + mavenCentral() } dependencies { - compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" - compile "com.google.code.gson:gson:2.8.5" - compile "com.squareup.okhttp3:okhttp:3.10.0" + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + compile "com.google.code.gson:gson:2.8.5" + compile "com.squareup.okhttp3:okhttp:3.10.0" - testCompile group: 'junit', name: 'junit', version: '4.12' - testCompile group: 'com.google.truth', name: 'truth', version: '0.42' - testCompile group: 'org.mockito', name: 'mockito-core', version: '2.19.1' - + testCompile group: 'junit', name: 'junit', version: '4.12' + testCompile group: 'com.google.truth', name: 'truth', version: '0.42' + testCompile group: 'org.mockito', name: 'mockito-core', version: '2.19.1' + testCompile group: 'com.nhaarman.mockitokotlin2', name: 'mockito-kotlin', version: '2.1.0' } compileKotlin { - kotlinOptions.jvmTarget = "1.8" + kotlinOptions.jvmTarget = "1.8" } compileTestKotlin { - kotlinOptions.jvmTarget = "1.8" + kotlinOptions.jvmTarget = "1.8" } bintray { - user = System.getenv('bintrayUser') - key = System.getenv('bintrayApiKey') - dryRun = false - publish = true - pkg { - repo = 'java-phoenix-client' - name = 'JavaPhoenixClient' - userOrg = user - websiteUrl = 'https://github.com/dsrees/JavaPhoenixClient' - issueTrackerUrl = 'https://github.com/dsrees/JavaPhoenixClient/issues' - vcsUrl = 'https://github.com/dsrees/JavaPhoenixClient.git' - licenses = ['MIT'] - version { - name = project.version - vcsTag = project.version - } + user = System.getenv('bintrayUser') + key = System.getenv('bintrayApiKey') + dryRun = false + publish = true + pkg { + repo = 'java-phoenix-client' + name = 'JavaPhoenixClient' + userOrg = user + websiteUrl = 'https://github.com/dsrees/JavaPhoenixClient' + issueTrackerUrl = 'https://github.com/dsrees/JavaPhoenixClient/issues' + vcsUrl = 'https://github.com/dsrees/JavaPhoenixClient.git' + licenses = ['MIT'] + version { + name = project.version + vcsTag = project.version } - publications = ['nebula'] + } + publications = ['nebula'] } diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt new file mode 100644 index 0000000..8832201 --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -0,0 +1,35 @@ +package org.phoenixframework + +import java.util.concurrent.ScheduledThreadPoolExecutor + +// Copyright (c) 2019 Daniel Rees +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +class Socket { + + /** + * All timers associated with a socket will share the same pool. Used for every Channel or + * Push that is sent through or created by a Socket instance. Different Socket instances will + * create individual thread pools. + */ + private val timerPool = ScheduledThreadPoolExecutor(8) + + +} \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt b/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt new file mode 100644 index 0000000..8b3b30b --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt @@ -0,0 +1,75 @@ +package org.phoenixframework + +import java.util.Timer +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.concurrent.schedule + +// Copyright (c) 2019 Daniel Rees +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/** + * A Timer class that schedules a callback to be called in the future. Can be configured + * to use a custom retry pattern, such as exponential backoff. + */ +class TimeoutTimer( + private val scheduledExecutorService: ScheduledExecutorService, + private val callback: () -> Unit, + private val timerCalculation: (tries: Int) -> Long +) { + + /** How many tries the Timer has attempted */ + private var tries: Int = 0 + + /** The task that has been scheduled to be executed */ + private var futureTask: ScheduledFuture<*>? = null + + /** + * Resets the Timer, clearing the number of current tries and stops + * any scheduled timeouts. + */ + fun reset() { + this.tries = 0 + this.clearTimer() + } + + /** Cancels any previous timeouts and scheduled a new one */ + fun scheduleTimeout() { + this.clearTimer() + + // Schedule a task to be performed after the calculated timeout in milliseconds + val timeout = timerCalculation(tries + 1) + this.futureTask = scheduledExecutorService.schedule({ + this.tries += 1 + this.callback.invoke() + }, timeout, TimeUnit.MILLISECONDS) + } + + //------------------------------------------------------------------------------ + // Private + //------------------------------------------------------------------------------ + private fun clearTimer() { + // Cancel the task from completing, allowing it to fi + this.futureTask?.cancel(true) + this.futureTask = null + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt b/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt new file mode 100644 index 0000000..644f2a9 --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt @@ -0,0 +1,67 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Test + +import org.junit.Before +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class TimeoutTimerTest { + + @Mock lateinit var mockFuture: ScheduledFuture<*> + @Mock lateinit var mockExecutorService: ScheduledExecutorService + + var callbackCallCount: Int = 0 + lateinit var timeoutTimer: TimeoutTimer + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + callbackCallCount = 0 + whenever(mockExecutorService.schedule(any(), any(), any())).thenReturn(mockFuture) + + timeoutTimer = TimeoutTimer( + scheduledExecutorService = mockExecutorService, + callback = { callbackCallCount += 1 }, + timerCalculation = { tries -> + if(tries > 3 ) 10000 else listOf(1000L, 2000L, 5000L)[tries -1] + }) + } + + @Test + fun `scheduleTimeout executes with backoff`() { + argumentCaptor<() -> Unit> { + timeoutTimer.scheduleTimeout() + verify(mockExecutorService).schedule(capture(), eq(1000L), eq(TimeUnit.MILLISECONDS)) + (lastValue as Runnable).run() + assertThat(callbackCallCount).isEqualTo(1) + + timeoutTimer.scheduleTimeout() + verify(mockExecutorService).schedule(capture(), eq(2000L), eq(TimeUnit.MILLISECONDS)) + (lastValue as Runnable).run() + assertThat(callbackCallCount).isEqualTo(2) + + timeoutTimer.scheduleTimeout() + verify(mockExecutorService).schedule(capture(), eq(5000L), eq(TimeUnit.MILLISECONDS)) + (lastValue as Runnable).run() + assertThat(callbackCallCount).isEqualTo(3) + + timeoutTimer.reset() + timeoutTimer.scheduleTimeout() + verify(mockExecutorService, times(2)).schedule(capture(), eq(1000L), eq(TimeUnit.MILLISECONDS)) + (lastValue as Runnable).run() + assertThat(callbackCallCount).isEqualTo(4) + } + } +} \ No newline at end of file From d04304892cd1d4de2a4539a950ac9769f5185b07 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Mon, 15 Apr 2019 22:30:04 -0400 Subject: [PATCH 07/63] Client without Phx* prefix and matching phoenix 1.4 specs (#43) * Created new class files * Channel implmentation * Fnished channel and socket classes * Finished presence implementatin * Bump dependency versions --- build.gradle | 6 +- .../kotlin/org/phoenixframework/Channel.kt | 359 +++++++++++++++ .../kotlin/org/phoenixframework/Defaults.kt | 26 ++ .../kotlin/org/phoenixframework/Message.kt | 33 ++ .../kotlin/org/phoenixframework/PhxSocket.kt | 5 - .../kotlin/org/phoenixframework/Presence.kt | 285 ++++++++++++ src/main/kotlin/org/phoenixframework/Push.kt | 175 +++++++ .../kotlin/org/phoenixframework/Socket.kt | 431 +++++++++++++++++- .../kotlin/org/phoenixframework/Transport.kt | 86 ++++ 9 files changed, 1391 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/org/phoenixframework/Channel.kt create mode 100644 src/main/kotlin/org/phoenixframework/Defaults.kt create mode 100644 src/main/kotlin/org/phoenixframework/Message.kt create mode 100644 src/main/kotlin/org/phoenixframework/Presence.kt create mode 100644 src/main/kotlin/org/phoenixframework/Push.kt create mode 100644 src/main/kotlin/org/phoenixframework/Transport.kt diff --git a/build.gradle b/build.gradle index 5088b85..7989f35 100644 --- a/build.gradle +++ b/build.gradle @@ -19,12 +19,12 @@ repositories { dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" compile "com.google.code.gson:gson:2.8.5" - compile "com.squareup.okhttp3:okhttp:3.10.0" + compile "com.squareup.okhttp3:okhttp:3.14.1" testCompile group: 'junit', name: 'junit', version: '4.12' - testCompile group: 'com.google.truth', name: 'truth', version: '0.42' - testCompile group: 'org.mockito', name: 'mockito-core', version: '2.19.1' + testCompile group: 'com.google.truth', name: 'truth', version: '0.44' + testCompile group: 'org.mockito', name: 'mockito-core', version: '2.27.0' testCompile group: 'com.nhaarman.mockitokotlin2', name: 'mockito-kotlin', version: '2.1.0' } diff --git a/src/main/kotlin/org/phoenixframework/Channel.kt b/src/main/kotlin/org/phoenixframework/Channel.kt new file mode 100644 index 0000000..a90ec1d --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Channel.kt @@ -0,0 +1,359 @@ +package org.phoenixframework + +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * Represents a binding to a Channel event + */ +data class Binding( + val event: String, + val ref: Int, + val callback: (Message) -> Unit +) + +/** + * Represents a Channel bound to a given topic + */ +class Channel( + val topic: String, + var params: Payload, + internal val socket: Socket +) { + + //------------------------------------------------------------------------------ + // Channel Nested Enums + //------------------------------------------------------------------------------ + /** States of a Channel */ + enum class State() { + CLOSED, + ERRORED, + JOINED, + JOINING, + LEAVING + } + + /** Channel specific events */ + enum class Event(val value: String) { + HEARTBEAT("heartbeat"), + JOIN("phx_join"), + LEAVE("phx_leave"), + REPLY("phx_reply"), + ERROR("phx_error"), + CLOSE("phx_close"); + + companion object { + /** True if the event is one of Phoenix's channel lifecycle events */ + fun isLifecycleEvent(event: String): Boolean { + return when (event) { + JOIN.value, + LEAVE.value, + REPLY.value, + ERROR.value, + CLOSE.value -> true + else -> false + } + } + } + } + + //------------------------------------------------------------------------------ + // Channel Attributes + //------------------------------------------------------------------------------ + /** Current state of the Channel */ + internal var state: Channel.State + + /** Collection of event bindings. */ + internal val bindings: ConcurrentLinkedQueue + + /** Tracks event binding ref counters */ + internal var bindingRef: Int + + /** Timeout when attempting to join a Channel */ + internal var timeout: Long + + /** Set to true once the channel has attempted to join */ + var joinedOnce: Boolean + + /** Push to send then attempting to join */ + var joinPush: Push + + /** Buffer of Pushes that will be sent once the Channel's socket connects */ + var pushBuffer: MutableList + + /** Timer to attempt rejoins */ + var rejoinTimer: TimeoutTimer + + /** + * Optional onMessage hook that can be provided. Receives all event messages for specialized + * handling before dispatching to the Channel event callbacks. + */ + var onMessage: (Message) -> Message = { it } + + init { + this.state = State.CLOSED + this.bindings = ConcurrentLinkedQueue() + this.bindingRef = 0 + this.timeout = socket.timeout + this.joinedOnce = false + this.pushBuffer = mutableListOf() + this.rejoinTimer = TimeoutTimer( + scheduledExecutorService = socket.timerPool, + callback = { rejoinUntilConnected() }, + timerCalculation = Defaults.steppedBackOff) + + // Setup Push to be sent when joining + this.joinPush = Push( + channel = this, + event = Channel.Event.JOIN.value, + payload = params, + timeout = timeout) + + // Perform once the Channel has joined + this.joinPush.receive("ok") { + // Mark the Channel as joined + this.state = State.JOINED + + // Reset the timer, preventing it from attempting to join again + this.rejoinTimer.reset() + + // Send any buffered messages and clear the buffer + this.pushBuffer.forEach { it.send() } + this.pushBuffer.clear() + } + + // Perform if Channel timed out while attempting to join + this.joinPush.receive("timeout") { message -> + + // Only handle a timeout if the Channel is in the 'joining' state + if (!this.isJoining) return@receive + + this.socket.logItems("Channel: timeouts $topic, $joinRef after $timeout ms") + + // Send a Push to the server to leave the Channel + val leavePush = Push( + channel = this, + event = Channel.Event.LEAVE.value) + leavePush.send() + + // Mark the Channel as in an error and attempt to rejoin + this.state = State.ERRORED + this.joinPush.reset() + this.rejoinTimer.scheduleTimeout() + } + + // Clean up when the channel closes + this.onClose { + // Reset any timer that may be on-going + this.rejoinTimer.reset() + + // Log that the channel was left + this.socket.logItems("Channel: close $topic") + + // Mark the channel as closed and remove it from the socket + this.state = State.CLOSED + this.socket.remove(this) + } + + // Handles an error, attempts to rejoin + this.onError { + // Do not emit error if the channel is in the process of leaving + // or if it has already closed + if (this.isLeaving || this.isClosed) return@onError + + // Log that the channel received an error + this.socket.logItems("Channel: error $topic") + + // Mark the channel as errored and attempt to rejoin + this.state = State.ERRORED + this.rejoinTimer.scheduleTimeout() + } + + // Perform when the join reply is received + this.on(Event.REPLY) { message -> + this.trigger(replyEventName(message.ref), message.payload, message.ref, message.joinRef) + } + } + + //------------------------------------------------------------------------------ + // Public Properties + //------------------------------------------------------------------------------ + /** The ref sent during the join message. */ + val joinRef: String? get() = joinPush.ref + + /** @return True if the Channel can push messages */ + val canPush: Boolean + get() = this.socket.isConnected && this.isJoined + + /** @return: True if the Channel has been closed */ + val isClosed: Boolean + get() = state == State.CLOSED + + /** @return: True if the Channel experienced an error */ + val isErrored: Boolean + get() = state == State.ERRORED + + /** @return: True if the channel has joined */ + val isJoined: Boolean + get() = state == State.JOINED + + /** @return: True if the channel has requested to join */ + val isJoining: Boolean + get() = state == State.JOINING + + /** @return: True if the channel has requested to leave */ + val isLeaving: Boolean + get() = state == State.LEAVING + + //------------------------------------------------------------------------------ + // Public + //------------------------------------------------------------------------------ + fun join(timeout: Long = Defaults.TIMEOUT): Push { + // Ensure that `.join()` is called only once per Channel instance + if (joinedOnce) { + throw IllegalStateException( + "Tried to join channel multiple times. `join()` can only be called once per channel") + } + + // Join the channel + this.joinedOnce = true + this.rejoin(timeout) + return joinPush + } + + fun onClose(callback: (Message) -> Unit): Int { + return this.on(Event.CLOSE, callback) + } + + fun onError(callback: (Message) -> Unit): Int { + return this.on(Event.ERROR, callback) + } + + fun onMessage(callback: (Message) -> Message) { + this.onMessage = callback + } + + fun on(event: Channel.Event, callback: (Message) -> Unit): Int { + return this.on(event.value, callback) + } + + fun on(event: String, callback: (Message) -> Unit): Int { + val ref = bindingRef + this.bindingRef = ref + 1 + + this.bindings.add(Binding(event, ref, callback)) + return ref + } + + fun off(event: String, ref: Int? = null) { + this.bindings.removeAll { bind -> + bind.event == event && (ref == null || ref == bind.ref) + } + } + + fun push(event: String, payload: Payload, timeout: Long = Defaults.TIMEOUT): Push { + if (!joinedOnce) { + // If the Channel has not been joined, throw an exception + throw RuntimeException( + "Tried to push $event to $topic before joining. Use channel.join() before pushing events") + } + + val pushEvent = Push(this, event, payload, timeout) + + if (canPush) { + pushEvent.send() + } else { + pushEvent.startTimeout() + pushBuffer.add(pushEvent) + } + + return pushEvent + } + + fun leave(timeout: Long = Defaults.TIMEOUT): Push { + this.state = State.LEAVING + + // Perform the same behavior if the channel leaves successfully or not + val onClose: ((Message) -> Unit) = { + this.socket.logItems("Channel: leave $topic") + this.trigger(it) + } + + // Push event to send to the server + val leavePush = Push( + channel = this, + event = Event.LEAVE.value, + timeout = timeout) + + leavePush + .receive("ok", onClose) + .receive("timeout", onClose) + leavePush.send() + + // If the Channel cannot send push events, trigger a success locally + if (!canPush) leavePush.trigger("ok", hashMapOf()) + + return leavePush + } + + //------------------------------------------------------------------------------ + // Internal + //------------------------------------------------------------------------------ + /** Checks if a Message's event belongs to this Channel instance */ + internal fun isMember(message: Message): Boolean { + if (message.topic != this.topic) return false + + val isLifecycleEvent = Event.isLifecycleEvent(message.event) + + // If the message is a lifecycle event and it is not a join for this channel, drop the outdated message + if (message.joinRef != null && isLifecycleEvent && message.joinRef != this.joinRef) { + this.socket.logItems("Channel: Dropping outdated message. ${message.topic}") + return false + } + + return true + } + + internal fun trigger( + event: String, + payload: Payload = hashMapOf(), + ref: String = "", + joinRef: String? = null + ) { + this.trigger(Message(ref, topic, event, payload, joinRef)) + } + + internal fun trigger(message: Message) { + // Inform the onMessage hook of the message + val handledMessage = this.onMessage(message) + + // Inform all matching event bindings of the message + this.bindings + .filter { it.event == message.event } + .forEach { it.callback(handledMessage) } + } + + /** Create an event with a given ref */ + internal fun replyEventName(ref: String): String { + return "chan_reply_$ref" + } + + //------------------------------------------------------------------------------ + // Private + //------------------------------------------------------------------------------ + /** Will continually attempt to rejoin the Channel on a timer. */ + private fun rejoinUntilConnected() { + this.rejoinTimer.scheduleTimeout() + if (this.socket.isConnected) this.rejoin() + } + + /** Sends the Channel's joinPush to the Server */ + private fun sendJoin(timeout: Long) { + this.state = State.JOINING + this.joinPush.resend(timeout) + } + + /** Rejoins the Channel e.g. after a disconnect */ + private fun rejoin(timeout: Long = Defaults.TIMEOUT) { + this.sendJoin(timeout) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Defaults.kt b/src/main/kotlin/org/phoenixframework/Defaults.kt new file mode 100644 index 0000000..6157833 --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Defaults.kt @@ -0,0 +1,26 @@ +package org.phoenixframework + +import com.google.gson.FieldNamingPolicy +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +object Defaults { + + /** Default timeout of 10s */ + const val TIMEOUT: Long = 10_000 + + /** Default heartbeat interval of 30s */ + const val HEARTBEAT: Long = 30_000 + + /** Default reconnect algorithm. Reconnects after 1s, 2s, 5s and then 10s thereafter */ + val steppedBackOff: (Int) -> Long = { tries -> + if (tries > 3) 10000 else listOf(1000L, 2000L, 5000L)[tries - 1] + } + + /** The default Gson configuration to use when parsing messages */ + val gson: Gson + get() = GsonBuilder() + .setLenient() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() +} \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Message.kt b/src/main/kotlin/org/phoenixframework/Message.kt new file mode 100644 index 0000000..053808f --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Message.kt @@ -0,0 +1,33 @@ +package org.phoenixframework + +import com.google.gson.annotations.SerializedName + +class Message( + /** The unique string ref. Empty if not present */ + @SerializedName("ref") + val ref: String = "", + + /** The message topic */ + @SerializedName("topic") + val topic: String = "", + + /** The message event name, for example "phx_join" or any other custom name */ + @SerializedName("event") + val event: String = "", + + /** The payload of the message */ + @SerializedName("payload") + val payload: Payload = HashMap(), + + /** The ref sent during a join event. Empty if not present. */ + @SerializedName("join_ref") + val joinRef: String? = null) { + + + /** + * Convenience var to access the message's payload's status. Equivalent + * to checking message.payload["status"] yourself + */ + val status: String? + get() = payload["status"] as? String +} diff --git a/src/main/kotlin/org/phoenixframework/PhxSocket.kt b/src/main/kotlin/org/phoenixframework/PhxSocket.kt index 1e6856a..d781542 100644 --- a/src/main/kotlin/org/phoenixframework/PhxSocket.kt +++ b/src/main/kotlin/org/phoenixframework/PhxSocket.kt @@ -11,11 +11,8 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import java.net.URL import java.util.Timer -import kotlin.collections.ArrayList -import kotlin.collections.HashMap import kotlin.concurrent.schedule -typealias Payload = Map /** Default timeout set to 10s */ const val DEFAULT_TIMEOUT: Long = 10000 @@ -23,8 +20,6 @@ const val DEFAULT_TIMEOUT: Long = 10000 /** Default heartbeat interval set to 30s */ const val DEFAULT_HEARTBEAT: Long = 30000 -/** The code used when the socket was closed without error */ -const val WS_CLOSE_NORMAL = 1000 /** The code used when the socket was closed after the heartbeat timer timed out */ const val WS_CLOSE_HEARTBEAT_ERROR = 5000 diff --git a/src/main/kotlin/org/phoenixframework/Presence.kt b/src/main/kotlin/org/phoenixframework/Presence.kt new file mode 100644 index 0000000..0b83b56 --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Presence.kt @@ -0,0 +1,285 @@ +package org.phoenixframework + +//------------------------------------------------------------------------------ +// Type Aliases +//------------------------------------------------------------------------------ +/** Meta details of a Presence. Just a dictionary of properties */ +typealias PresenceMeta = MutableMap + +/** A mapping of a String to an array of Metas. e.g. {"metas": [{id: 1}]} */ +typealias PresenceMap = MutableMap> + +/** A mapping of a Presence state to a mapping of Metas */ +typealias PresenceState = MutableMap + +/** + * Diff has keys "joins" and "leaves", pointing to a Presence.State each containing the users + * that joined and left. + */ +typealias PresenceDiff = MutableMap + +/** Closure signature of OnJoin callbacks */ +typealias OnJoin = (key: String, current: PresenceMap?, new: PresenceMap) -> Unit + +/** Closure signature for OnLeave callbacks */ +typealias OnLeave = (key: String, current: PresenceMap, left: PresenceMap) -> Unit + +/** Closure signature for OnSync callbacks */ +typealias OnSync = () -> Unit + +class Presence(channel: Channel, opts: Options = Options.defaults) { + + //------------------------------------------------------------------------------ + // Enums and Data classes + //------------------------------------------------------------------------------ + /** + * Custom options that can be provided when creating Presence + */ + data class Options(val events: Map) { + companion object { + + /** + * Default set of Options used when creating Presence. Uses the + * phoenix events "presence_state" and "presence_diff" + */ + val defaults: Options + get() = Options( + mapOf( + Events.STATE to "presence_state", + Events.DIFF to "presence_diff")) + } + } + + /** Collection of callbacks with default values */ + data class Caller( + var onJoin: OnJoin = { _, _, _ -> }, + var onLeave: OnLeave = { _, _, _ -> }, + var onSync: OnSync = {} + ) + + /** Presence Events of "state" and "diff" */ + enum class Events { + STATE, + DIFF + } + + //------------------------------------------------------------------------------ + // Properties + //------------------------------------------------------------------------------ + /** The channel the Presence belongs to */ + private val channel: Channel + + /** Caller to callback hooks */ + private val caller: Caller + + /** The state of the Presence */ + var state: PresenceState + private set + + /** Pending `join` and `leave` diffs that need to be synced */ + var pendingDiffs: MutableList + private set + + /** The channel's joinRef, set when state events occur */ + var joinRef: String? + private set + + /** True if the Presence has not yet initially synced */ + val isPendingSyncState: Boolean + get() = this.joinRef == null || (this.joinRef !== this.channel.joinRef) + + //------------------------------------------------------------------------------ + // Initialization + //------------------------------------------------------------------------------ + init { + this.state = mutableMapOf() + this.pendingDiffs = mutableListOf() + this.channel = channel + this.joinRef = null + this.caller = Presence.Caller() + + val stateEvent = opts.events[Events.STATE] + val diffEvent = opts.events[Events.DIFF] + + if (stateEvent != null && diffEvent != null) { + + this.channel.on(stateEvent) { message -> + val newState = message.payload.toMutableMap() as PresenceState + + this.joinRef = this.channel.joinRef + this.state = + Presence.syncState(state, newState, caller.onJoin, caller.onLeave) + + + this.pendingDiffs.forEach { diff -> + this.state = Presence.syncDiff(state, diff, caller.onJoin, caller.onLeave) + } + + this.pendingDiffs.clear() + this.caller.onSync() + } + + this.channel.on(diffEvent) { message -> + val diff = message.payload.toMutableMap() as PresenceDiff + if (isPendingSyncState) { + this.pendingDiffs.add(diff) + } else { + this.state = Presence.syncDiff(state, diff, caller.onJoin, caller.onLeave) + this.caller.onSync() + } + } + } + } + + //------------------------------------------------------------------------------ + // Callbacks + //------------------------------------------------------------------------------ + fun onJoin(callback: OnJoin) { + this.caller.onJoin = callback + } + + fun onLeave(callback: OnLeave) { + this.caller.onLeave = callback + } + + fun onSync(callback: OnSync) { + this.caller.onSync = callback + } + + //------------------------------------------------------------------------------ + // Listing + //------------------------------------------------------------------------------ + fun list(): List { + return this.listBy { it.value } + } + + fun listBy(transform: (Map.Entry) -> T): List { + return Presence.listBy(state, transform) + } + + fun filterBy(predicate: ((Map.Entry) -> Boolean)?): PresenceState { + return Presence.filter(state, predicate) + } + + //------------------------------------------------------------------------------ + // Syncing + //------------------------------------------------------------------------------ + companion object { + + /** + * Used to sync the list of presences on the server with the client's state. An optional + * `onJoin` and `onLeave` callback can be provided to react to changes in the client's local + * presences across disconnects and reconnects with the server. + * + */ + fun syncState( + currentState: PresenceState, + newState: PresenceState, + onJoin: OnJoin = { _, _, _ -> }, + onLeave: OnLeave = { _, _, _ -> } + ): PresenceState { + val state = currentState + val leaves: PresenceState = mutableMapOf() + val joins: PresenceState = mutableMapOf() + + state.forEach { key, presence -> + if (!newState.containsKey(key)) { + leaves[key] = presence + } + } + + newState.forEach { key, newPresence -> + state[key]?.let { currentPresence -> + val newRefs = newPresence["metas"]!!.map { meta -> meta["phx"] as String } + val curRefs = currentPresence["metas"]!!.map { meta -> meta["phx"] as String } + + val joinedMetas = newPresence["metas"]!!.filter { meta -> + curRefs.indexOf(meta["phx_ref"]) < 0 + } + val leftMetas = currentPresence["metas"]!!.filter { meta -> + newRefs.indexOf(meta["phx_ref"]) < 0 + } + + if (joinedMetas.isNotEmpty()) { + joins[key] = newPresence + joins[key]!!["metas"] = joinedMetas.toMutableList() + } + + if (leftMetas.isNotEmpty()) { + leaves[key] = currentPresence + leaves[key]!!["metas"] = leftMetas.toMutableList() + } + } ?: run { + joins[key] = newPresence + } + } + + val diff: PresenceDiff = mutableMapOf("joins" to joins, "leaves" to leaves) + return Presence.syncDiff(state, diff, onJoin, onLeave) + + } + + /** + * Used to sync a diff of presence join and leave events from the server, as they happen. + * Like `syncState`, `syncDiff` accepts optional `onJoin` and `onLeave` callbacks to react + * to a user joining or leaving from a device. + */ + fun syncDiff( + currentState: PresenceState, + diff: PresenceDiff, + onJoin: OnJoin = { _, _, _ -> }, + onLeave: OnLeave = { _, _, _ -> } + ): PresenceState { + val state = currentState + + // Sync the joined states and inform onJoin of new presence + diff["joins"]?.forEach { key, newPresence -> + val currentPresence = state[key] + state[key] = newPresence + + currentPresence?.let { curPresence -> + val joinedRefs = state[key]!!["metas"]!!.map { m -> m["phx_ref"] as String } + val curMetas = curPresence["metas"]!!.filter { m -> joinedRefs.indexOf(m["phx_ref"]) < 0 } + + state[key]!!["metas"]!!.addAll(0, curMetas) + } + + onJoin.invoke(key, currentPresence, newPresence) + } + + // Sync the left diff and inform onLeave of left presence + diff["leaves"]?.forEach { key, leftPresence -> + val curPresence = state[key] ?: return@forEach + + val refsToRemove = leftPresence["metas"]!!.map { it["phx_ref"] as String } + val keepMetas = + curPresence["metas"]!!.filter { m -> refsToRemove.indexOf(m["phx_ref"]) < 0 } + + curPresence["metas"] = keepMetas.toMutableList() + onLeave.invoke(key, curPresence, leftPresence) + + if (keepMetas.isNotEmpty()) { + state[key]!!["metas"] = keepMetas.toMutableList() + } else { + state.remove(key) + } + } + + return state + } + + fun filter( + presence: PresenceState, + predicate: ((Map.Entry) -> Boolean)? + ): PresenceState { + return presence.filter(predicate ?: { true }).toMutableMap() + } + + fun listBy( + presence: PresenceState, + transform: (Map.Entry) -> T + ): List { + return presence.map(transform) + } + } +} diff --git a/src/main/kotlin/org/phoenixframework/Push.kt b/src/main/kotlin/org/phoenixframework/Push.kt new file mode 100644 index 0000000..d0c11b6 --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Push.kt @@ -0,0 +1,175 @@ +package org.phoenixframework + +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * A Push represents an attempt to send a payload through a Channel for a specific event. + */ +class Push( + /** The channel the Push is being sent through */ + val channel: Channel, + /** The event the Push is targeting */ + val event: String, + /** The message to be sent */ + var payload: Payload = mapOf(), + /** Duration before the message is considered timed out and failed to send */ + var timeout: Long = Defaults.TIMEOUT +) { + + /** The server's response to the Push */ + var receivedMessage: Message? = null + + /** The task to be triggered if the Push times out */ + var timeoutTask: ScheduledFuture<*>? = null + + /** Hooks into a Push. Where .receive("ok", callback(Payload)) are stored */ + var receiveHooks: MutableMap Unit)>> = HashMap() + + /** True if the Push has been sent */ + var sent: Boolean = false + + /** The reference ID of the Push */ + var ref: String? = null + + /** The event that is associated with the reference ID of the Push */ + var refEvent: String? = null + + //------------------------------------------------------------------------------ + // Public + //------------------------------------------------------------------------------ + /** + * Resets and sends the Push + * @param timeout Optional. The push timeout. Default is 10_000ms = 10s + */ + fun resend(timeout: Long = Defaults.TIMEOUT) { + this.timeout = timeout + this.reset() + this.send() + } + + /** + * Sends the Push. If it has already timed out then the call will be ignored. use + * `resend(timeout:)` in this case. + */ + fun send() { + if (hasReceived("timeout")) return + + this.startTimeout() + this.sent = true + // TODO: this.channel.socket.push + // TODO: weak reference? + } + + /** + * Receive a specific event when sending an Outbound message + * + * Example: + * channel + * .send("event", myPayload) + * .receive("error") { } + */ + fun receive(status: String, callback: (Message) -> Unit): Push { + // If the message has already be received, pass it to the callback + receivedMessage?.let { if (hasReceived(status)) callback(it) } + + if (receiveHooks[status] == null) { + // Create a new array of hooks if no previous hook is associated with status + receiveHooks[status] = arrayListOf(callback) + } else { + // A previous hook for this status already exists. Just append the new hook + receiveHooks[status]?.add(callback) + } + + return this + } + + //------------------------------------------------------------------------------ + // Internal + //------------------------------------------------------------------------------ + /** Resets the Push as it was after it was first initialized. */ + internal fun reset() { + this.cancelRefEvent() + this.ref = null + this.refEvent = null + this.receivedMessage = null + this.sent = false + } + + /** + * Triggers an event to be sent through the Push's parent Channel + */ + internal fun trigger(status: String, payload: Payload) { + this.refEvent?.let { refEvent -> + val mutPayload = payload.toMutableMap() + mutPayload["status"] = status + + this.channel.trigger(refEvent, mutPayload) + } + } + + /** + * Schedules a timeout task which will be triggered after a specific timeout is reached + */ + internal fun startTimeout() { + // Cancel any existing timeout before starting a new one + this.timeoutTask?.let { if (!it.isCancelled) this.cancelTimeout() } + + // Get the ref of the Push + val ref = this.channel.socket.makeRef() + val refEvent = this.channel.replyEventName(ref) + + this.ref = ref + this.refEvent = refEvent + + // Subscribe to a reply from the server when the Push is received + this.channel.on(refEvent) { message -> + this.cancelRefEvent() + this.cancelTimeout() + this.receivedMessage = message + + // Check if there is an event receive hook to be informed + message.status?.let { status -> matchReceive(status, message) } + } + + // Setup and start the Timer + this.timeoutTask = channel.socket.timerPool.schedule({ + this.trigger("timeout", hashMapOf()) + }, timeout, TimeUnit.MILLISECONDS) + } + + + //------------------------------------------------------------------------------ + // Private + //------------------------------------------------------------------------------ + /** + * Finds the receiveHook which needs to be informed of a status response and passes it the message + * + * @param status Status which was received. e.g. "ok", "error", etc. + * @param message Message to pass to receive hook + */ + private fun matchReceive(status: String, message: Message) { + receiveHooks[status]?.forEach { it(message) } + } + + /** Removes receive hook from Channel regarding this Push */ + private fun cancelRefEvent() { + this.refEvent?.let { /* TODO: this.channel.off(it) */ } + } + + /** Cancels any ongoing timeout task */ + private fun cancelTimeout() { + this.timeoutTask?.cancel(true) + this.timeoutTask = null + } + + + + /** + * @param status Status to check if it has been received + * @return True if the status has already been received by the Push + */ + private fun hasReceived(status: String): Boolean { + return receivedMessage?.status == status + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt index 8832201..ee19905 100644 --- a/src/main/kotlin/org/phoenixframework/Socket.kt +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -1,6 +1,13 @@ package org.phoenixframework +import com.google.gson.Gson +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Response +import java.net.URL +import java.util.concurrent.ScheduledFuture import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit // Copyright (c) 2019 Daniel Rees // @@ -22,14 +29,424 @@ import java.util.concurrent.ScheduledThreadPoolExecutor // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class Socket { +/** Alias for a JSON mapping */ +typealias Payload = Map - /** - * All timers associated with a socket will share the same pool. Used for every Channel or - * Push that is sent through or created by a Socket instance. Different Socket instances will - * create individual thread pools. - */ - private val timerPool = ScheduledThreadPoolExecutor(8) +/** Data class that holds callbacks assigned to the socket */ +internal data class StateChangeCallbacks( + val open: MutableList<() -> Unit> = ArrayList(), + val close: MutableList<() -> Unit> = ArrayList(), + val error: MutableList<(Throwable, Response?) -> Unit> = ArrayList(), + val message: MutableList<(Message) -> Unit> = ArrayList() +) { + /** Clears all stored callbacks */ + fun release() { + open.clear() + close.clear() + error.clear() + message.clear() + } +} +/** The code used when the socket was closed without error */ +const val WS_CLOSE_NORMAL = 1000 + +/** + * Connects to a Phoenix Server + */ +class Socket( + url: String, + params: Payload? = null, + private val gson: Gson = Defaults.gson, + private val client: OkHttpClient = OkHttpClient.Builder().build() +) { + + //------------------------------------------------------------------------------ + // Public Attributes + //------------------------------------------------------------------------------ + /** + * The string WebSocket endpoint (ie `"ws://example.com/socket"`, + * `"wss://example.com"`, etc.) that was passed to the Socket during + * initialization. The URL endpoint will be modified by the Socket to + * include `"/websocket"` if missing. + */ + val endpoint: String + + /** The fully qualified socket URL */ + val endpointUrl: URL + + /** + * The optional params to pass when connecting. Must be set when + * initializing the Socket. These will be appended to the URL. + */ + val params: Payload? = params + + /** Timeout to use when opening a connection */ + var timeout: Long = Defaults.TIMEOUT + + /** Interval between sending a heartbeat */ + var heartbeatInterval: Long = Defaults.HEARTBEAT + + /** Internval between socket reconnect attempts */ + var reconnectAfterMs: ((Int) -> Long) = Defaults.steppedBackOff + + /** The optional function to receive logs */ + var logger: ((String) -> Unit)? = null + + /** Disables heartbeats from being sent. Default is false. */ + var skipHeartbeat: Boolean = false + + //------------------------------------------------------------------------------ + // Internal Attributes + //------------------------------------------------------------------------------ + /** + * All timers associated with a socket will share the same pool. Used for every Channel or + * Push that is sent through or created by a Socket instance. Different Socket instances will + * create individual thread pools. + */ + internal val timerPool = ScheduledThreadPoolExecutor(8) + + //------------------------------------------------------------------------------ + // Private Attributes + //------------------------------------------------------------------------------ + /** Returns the type of transport to use. Potentially expose for custom transports */ + private val transport: (URL) -> Transport = { WebSocketTransport(it, client) } + + /** Collection of callbacks for socket state changes */ + private val stateChangeCallbacks: StateChangeCallbacks = StateChangeCallbacks() + + /** Collection of unclosed channels created by the Socket */ + private var channels: MutableList = ArrayList() + + /** Buffers messages that need to be sent once the socket has connected */ + private var sendBuffer: MutableList<() -> Unit> = ArrayList() + + /** Ref counter for messages */ + private var ref: Int = 0 + + /** Task to be triggered in the future to send a heartbeat message */ + private var heartbeatTask: ScheduledFuture<*>? = null + + /** Ref counter for the last heartbeat that was sent */ + private var pendingHeartbeatRef: String? = null + + /** Timer to use when attempting to reconnect */ + private var reconnectTimer: TimeoutTimer + + //------------------------------------------------------------------------------ + // Connection Attributes + //------------------------------------------------------------------------------ + /** The underlying WebSocket connection */ + private var connection: Transport? = null + + //------------------------------------------------------------------------------ + // Initialization + //------------------------------------------------------------------------------ + init { + // Silently replace web socket URLs with HTTP URLs. + var mutableUrl = url + if (url.regionMatches(0, "ws:", 0, 3, ignoreCase = true)) { + mutableUrl = "http:" + url.substring(3) + } else if (url.regionMatches(0, "wss:", 0, 4, ignoreCase = true)) { + mutableUrl = "https:" + url.substring(4) + } + + // Ensure that the URL ends with "/websocket" + if (!mutableUrl.contains("/websocket")) { + // Do not duplicate '/' in path + if (mutableUrl.last() != '/') { + mutableUrl += "/" + } + + // append "websocket" to the path + mutableUrl += "websocket" + } + + // If there are query params, append them now + var httpUrl = HttpUrl.parse(mutableUrl) ?: throw IllegalArgumentException("invalid url: $url") + params?.let { + val httpBuilder = httpUrl.newBuilder() + it.forEach { (key, value) -> + httpBuilder.addQueryParameter(key, value.toString()) + } + + httpUrl = httpBuilder.build() + } + + this.endpoint = mutableUrl + this.endpointUrl = httpUrl.url() + + // Create reconnect timer + this.reconnectTimer = TimeoutTimer( + scheduledExecutorService = timerPool, + timerCalculation = reconnectAfterMs, + callback = { + // log(socket attempting to reconnect) + // this.teardown() { this.connect() } + }) + } + + //------------------------------------------------------------------------------ + // Public Properties + //------------------------------------------------------------------------------ + /** @return The socket protocol being used. e.g. "wss", "ws" */ + val protocol: String + get() = when (endpointUrl.protocol) { + "https" -> "wss" + "http" -> "ws" + else -> endpointUrl.protocol + } + + /** @return True if the connection exists and is open */ + val isConnected: Boolean + get() = this.connection?.readyState == ReadyState.OPEN + + //------------------------------------------------------------------------------ + // Public + //------------------------------------------------------------------------------ + fun connect() { + // Do not attempt to connect if already connected + if (isConnected) return + + this.connection = this.transport(endpointUrl) + this.connection?.onOpen = { onConnectionOpened() } + this.connection?.onClose = { code -> onConnectionClosed(code) } + this.connection?.onError = { t, r -> onConnectionError(t, r) } + this.connection?.onMessage = { m -> onConnectionMessage(m) } + this.connection?.connect() + } + + fun disconnect( + code: Int = WS_CLOSE_NORMAL, + reason: String? = null, + callback: (() -> Unit)? = null + ) { + this.reconnectTimer.reset() + this.teardown(code, reason, callback) + + } + + fun onOpen(callback: (() -> Unit)) { + this.stateChangeCallbacks.open.add(callback) + } + + fun onClose(callback: () -> Unit) { + this.stateChangeCallbacks.close.add(callback) + } + + fun onError(callback: (Throwable, Response?) -> Unit) { + this.stateChangeCallbacks.error.add(callback) + } + + fun onMessage(callback: (Message) -> Unit) { + this.stateChangeCallbacks.message.add(callback) + } + + fun removeAllCallbacks() { + this.stateChangeCallbacks.release() + } + + fun channel(topic: String, params: Payload = mapOf()): Channel { + val channel = Channel(topic, params, this) + this.channels.add(channel) + + return channel + } + + fun remove(channel: Channel) { + this.channels.removeAll { it.joinRef == channel.joinRef } + } + + //------------------------------------------------------------------------------ + // Internal + //------------------------------------------------------------------------------ + internal fun push( + topic: String, + event: String, + payload: Payload, + ref: String? = null, + joinRef: String? = null + ) { + + val callback: (() -> Unit) = { + val body = mutableMapOf() + body["topic"] = topic + body["event"] = event + body["payload"] = payload + + ref?.let { body["ref"] = it } + joinRef?.let { body["join_ref"] = it } + + val data = gson.toJson(body) + connection?.let { transport -> + this.logItems("Push: Sending $data") + transport.send(data) + } + } + + if (isConnected) { + // If the socket is connected, then execute the callback immediately. + callback.invoke() + } else { + // If the socket is not connected, add the push to a buffer which will + // be sent immediately upon connection. + sendBuffer.add(callback) + } + } + + /** @return the next message ref, accounting for overflows */ + internal fun makeRef(): String { + this.ref = if (ref == Int.MAX_VALUE) 0 else ref + 1 + return ref.toString() + } + + fun logItems(body: String) { + logger?.let { + it(body) + } + } + + //------------------------------------------------------------------------------ + // Private + //------------------------------------------------------------------------------ + private fun teardown( + code: Int = WS_CLOSE_NORMAL, + reason: String? = null, + callback: (() -> Unit)? = null + ) { + // Disconnect the transport + this.connection?.onClose = null + this.connection?.disconnect(code, reason) + this.connection = null + + // Heartbeats are no longer needed + this.heartbeatTask?.cancel(true) + this.heartbeatTask = null + + // Since the connections onClose was null'd out, inform all state callbacks + // that the Socket has closed + this.stateChangeCallbacks.close.forEach { it.invoke() } + callback?.invoke() + } + + /** Triggers an error event to all connected Channels */ + private fun triggerChannelError() { + this.channels.forEach { it.trigger(Channel.Event.ERROR.value) } + } + + /** Send all messages that were buffered before the socket opened */ + private fun flushSendBuffer() { + if (isConnected && sendBuffer.isNotEmpty()) { + this.sendBuffer.forEach { it.invoke() } + this.sendBuffer.clear() + } + } + + //------------------------------------------------------------------------------ + // Heartbeat + //------------------------------------------------------------------------------ + private fun resetHeartbeat() { + // Clear anything related to the previous heartbeat + this.pendingHeartbeatRef = null + this.heartbeatTask?.cancel(true) + this.heartbeatTask = null + + // Do not start up the heartbeat timer if skipHeartbeat is true + if (skipHeartbeat) return + heartbeatTask = timerPool.schedule({ + + }, heartbeatInterval, TimeUnit.MILLISECONDS) + } + + private fun sendHeartbeat() { + // Do not send if the connection is closed + if (!isConnected) return + + // If there is a pending heartbeat ref, then the last heartbeat was + // never acknowledged by the server. Close the connection and attempt + // to reconnect. + pendingHeartbeatRef?.let { + pendingHeartbeatRef = null + logItems("Transport: Heartbeat timeout. Attempt to re-establish connection") + + // Disconnect the socket manually. Do not use `teardown` or + // `disconnect` as they will nil out the websocket delegate + this.connection?.disconnect(WS_CLOSE_NORMAL, "Heartbeat timed out") + return + } + + // The last heartbeat was acknowledged by the server. Send another one + this.pendingHeartbeatRef = this.makeRef() + this.push( + topic = "phoenix", + event = Channel.Event.HEARTBEAT.value, + payload = mapOf(), + ref = pendingHeartbeatRef) + } + + //------------------------------------------------------------------------------ + // Connection Transport Hooks + //------------------------------------------------------------------------------ + private fun onConnectionOpened() { + this.logItems("Transport: Connected to $endpoint") + + // Send any messages that were waiting for a connection + this.flushSendBuffer() + + // Reset how the socket tried to reconnect + this.reconnectTimer.reset() + + // Restart the heartbeat timer + this.resetHeartbeat() + + // Inform all onOpen callbacks that the Socket has opened + this.stateChangeCallbacks.open.forEach { it.invoke() } + } + + private fun onConnectionClosed(code: Int) { + this.logItems("Transport: close") + this.triggerChannelError() + + // Prevent the heartbeat from triggering if the socket closed + this.heartbeatTask?.cancel(true) + this.heartbeatTask = null + + // Inform callbacks the socket closed + this.stateChangeCallbacks.close.forEach { it.invoke() } + + // If there was a non-normal event when the connection closed, attempt + // to schedule a reconnect attempt + if (code != WS_CLOSE_NORMAL) { + reconnectTimer.scheduleTimeout() + } + } + + private fun onConnectionMessage(rawMessage: String) { + this.logItems("Receive: $rawMessage") + + // Parse the message as JSON + val message = gson.fromJson(rawMessage, Message::class.java) + + // Clear heartbeat ref, preventing a heartbeat timeout disconnect + if (message.ref == pendingHeartbeatRef) pendingHeartbeatRef = null + + // Dispatch the message to all channels that belong to the topic + this.channels + .filter { it.isMember(message) } + .forEach { it.trigger(message) } + + // Inform all onMessage callbacks of the message + this.stateChangeCallbacks.message.forEach { it.invoke(message) } + } + + private fun onConnectionError(t: Throwable, response: Response?) { + this.logItems("Transport: error $t") + + // Send an error to all channels + this.triggerChannelError() + + // Inform any state callbacks of the error + this.stateChangeCallbacks.error.forEach { it.invoke(t, response) } + } } \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt new file mode 100644 index 0000000..556b195 --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -0,0 +1,86 @@ +package org.phoenixframework + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.net.URL + +enum class ReadyState { + CONNECTING, + OPEN, + CLOSING, + CLOSED +} + +interface Transport { + + val readyState: ReadyState + + var onOpen: (() -> Unit)? + var onError: ((Throwable, Response?) -> Unit)? + var onMessage: ((String) -> Unit)? + var onClose: ((Int) -> Unit)? + + fun connect() + fun disconnect(code: Int, reason: String? = null) + fun send(data: String) +} + +class WebSocketTransport( + private val url: URL, + private val okHttpClient: OkHttpClient +) : + WebSocketListener(), + Transport { + + private var connection: WebSocket? = null + + override var readyState: ReadyState = ReadyState.CLOSED + override var onOpen: (() -> Unit)? = null + override var onError: ((Throwable, Response?) -> Unit)? = null + override var onMessage: ((String) -> Unit)? = null + override var onClose: ((Int) -> Unit)? = null + + override fun connect() { + this.readyState = ReadyState.CONNECTING + val request = Request.Builder().url(url).build() + connection = okHttpClient.newWebSocket(request, this) + } + + override fun disconnect(code: Int, reason: String?) { + connection?.close(code, reason) + connection = null + } + + override fun send(data: String) { + connection?.send(data) + } + + //------------------------------------------------------------------------------ + // WebSocket Listener + //------------------------------------------------------------------------------ + override fun onOpen(webSocket: WebSocket, response: Response) { + this.readyState = ReadyState.OPEN + this.onOpen?.invoke() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + this.readyState = ReadyState.CLOSED + this.onError?.invoke(t, response) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + this.readyState = ReadyState.CLOSING + } + + override fun onMessage(webSocket: WebSocket, text: String) { + this.onMessage?.invoke(text) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + this.readyState = ReadyState.CLOSED + this.onClose?.invoke(code) + } +} \ No newline at end of file From ce34a27ea5f4a79935680c3ae10bf8365a78ec31 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 1 May 2019 11:48:33 -0400 Subject: [PATCH 08/63] Socket, Channel, and Presence Tests (#44) * Added jacoco for tests coverage * Added test for WebSocket Transport * Initial socket tests * Almost done with socket tests * Finished Socket tets * Added test for message * Getting channel tests started and updated kotlin plugin * getting to the hard stuff like testing time * Converted to use wrapper around schedule executor * Fixed bug in manual queue, passing timeout tests * Adding a lot of push-channel test * Finish channel specs * Deleting old client classes, starting presence tests * Making presence immutable: * progress with presence tests' * Fixed clone issues, finished sync tests * Finished presence tests * fixing some tests * Adding license --- build.gradle | 3 +- .../kotlin/org/phoenixframework/Channel.kt | 80 +- .../kotlin/org/phoenixframework/Defaults.kt | 22 + .../org/phoenixframework/DispatchQueue.kt | 80 ++ .../kotlin/org/phoenixframework/Message.kt | 22 + .../kotlin/org/phoenixframework/PhxChannel.kt | 383 ------ .../kotlin/org/phoenixframework/PhxMessage.kt | 33 - .../kotlin/org/phoenixframework/PhxPush.kt | 192 --- .../kotlin/org/phoenixframework/PhxSocket.kt | 457 -------- .../kotlin/org/phoenixframework/PhxTimer.kt | 46 - .../kotlin/org/phoenixframework/Presence.kt | 90 +- src/main/kotlin/org/phoenixframework/Push.kt | 39 +- .../kotlin/org/phoenixframework/Socket.kt | 121 +- .../org/phoenixframework/TimeoutTimer.kt | 59 +- .../kotlin/org/phoenixframework/Transport.kt | 90 +- .../org/phoenixframework/ChannelTest.kt | 1038 +++++++++++++++++ .../org/phoenixframework/MessageTest.kt | 21 + .../org/phoenixframework/PhxChannelTest.kt | 202 ---- .../org/phoenixframework/PhxMessageTest.kt | 22 - .../org/phoenixframework/PhxSocketTest.kt | 81 -- .../org/phoenixframework/PresenceTest.kt | 449 +++++++ .../kotlin/org/phoenixframework/SocketTest.kt | 683 +++++++++++ .../org/phoenixframework/TestUtilities.kt | 75 ++ .../org/phoenixframework/TimeoutTimerTest.kt | 33 +- .../WebSocketTransportTest.kt | 113 ++ .../org.mockito.plugins.MockMaker | 1 + 26 files changed, 2840 insertions(+), 1595 deletions(-) create mode 100644 src/main/kotlin/org/phoenixframework/DispatchQueue.kt delete mode 100644 src/main/kotlin/org/phoenixframework/PhxChannel.kt delete mode 100644 src/main/kotlin/org/phoenixframework/PhxMessage.kt delete mode 100644 src/main/kotlin/org/phoenixframework/PhxPush.kt delete mode 100644 src/main/kotlin/org/phoenixframework/PhxSocket.kt delete mode 100644 src/main/kotlin/org/phoenixframework/PhxTimer.kt create mode 100644 src/test/kotlin/org/phoenixframework/ChannelTest.kt create mode 100644 src/test/kotlin/org/phoenixframework/MessageTest.kt delete mode 100644 src/test/kotlin/org/phoenixframework/PhxChannelTest.kt delete mode 100644 src/test/kotlin/org/phoenixframework/PhxMessageTest.kt delete mode 100644 src/test/kotlin/org/phoenixframework/PhxSocketTest.kt create mode 100644 src/test/kotlin/org/phoenixframework/PresenceTest.kt create mode 100644 src/test/kotlin/org/phoenixframework/SocketTest.kt create mode 100644 src/test/kotlin/org/phoenixframework/TestUtilities.kt create mode 100644 src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/build.gradle b/build.gradle index 7989f35..20da684 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ buildscript { repositories { jcenter() } } plugins { id 'java' - id 'org.jetbrains.kotlin.jvm' version '1.2.51' + id 'jacoco' + id 'org.jetbrains.kotlin.jvm' version '1.3.31' id 'nebula.project' version '4.0.1' id "nebula.maven-publish" version "7.2.4" id 'nebula.nebula-bintray' version '3.5.5' diff --git a/src/main/kotlin/org/phoenixframework/Channel.kt b/src/main/kotlin/org/phoenixframework/Channel.kt index a90ec1d..5d357b9 100644 --- a/src/main/kotlin/org/phoenixframework/Channel.kt +++ b/src/main/kotlin/org/phoenixframework/Channel.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework import java.util.concurrent.ConcurrentLinkedQueue @@ -16,7 +38,7 @@ data class Binding( */ class Channel( val topic: String, - var params: Payload, + params: Payload, internal val socket: Socket ) { @@ -60,7 +82,7 @@ class Channel( // Channel Attributes //------------------------------------------------------------------------------ /** Current state of the Channel */ - internal var state: Channel.State + internal var state: State /** Collection of event bindings. */ internal val bindings: ConcurrentLinkedQueue @@ -71,23 +93,30 @@ class Channel( /** Timeout when attempting to join a Channel */ internal var timeout: Long + /** Params passed in through constructions and provided to the JoinPush */ + var params: Payload = params + set(value) { + joinPush.payload = value + field = value + } + /** Set to true once the channel has attempted to join */ - var joinedOnce: Boolean + internal var joinedOnce: Boolean /** Push to send then attempting to join */ - var joinPush: Push + internal var joinPush: Push /** Buffer of Pushes that will be sent once the Channel's socket connects */ - var pushBuffer: MutableList + internal var pushBuffer: MutableList /** Timer to attempt rejoins */ - var rejoinTimer: TimeoutTimer + internal var rejoinTimer: TimeoutTimer /** * Optional onMessage hook that can be provided. Receives all event messages for specialized * handling before dispatching to the Channel event callbacks. */ - var onMessage: (Message) -> Message = { it } + internal var onMessage: (Message) -> Message = { it } init { this.state = State.CLOSED @@ -97,14 +126,14 @@ class Channel( this.joinedOnce = false this.pushBuffer = mutableListOf() this.rejoinTimer = TimeoutTimer( - scheduledExecutorService = socket.timerPool, + dispatchQueue = socket.dispatchQueue, callback = { rejoinUntilConnected() }, - timerCalculation = Defaults.steppedBackOff) + timerCalculation = socket.reconnectAfterMs) // Setup Push to be sent when joining this.joinPush = Push( channel = this, - event = Channel.Event.JOIN.value, + event = Event.JOIN.value, payload = params, timeout = timeout) @@ -122,7 +151,7 @@ class Channel( } // Perform if Channel timed out while attempting to join - this.joinPush.receive("timeout") { message -> + this.joinPush.receive("timeout") { // Only handle a timeout if the Channel is in the 'joining' state if (!this.isJoining) return@receive @@ -132,7 +161,8 @@ class Channel( // Send a Push to the server to leave the Channel val leavePush = Push( channel = this, - event = Channel.Event.LEAVE.value) + event = Event.LEAVE.value, + timeout = this.timeout) leavePush.send() // Mark the Channel as in an error and attempt to rejoin @@ -207,7 +237,7 @@ class Channel( //------------------------------------------------------------------------------ // Public //------------------------------------------------------------------------------ - fun join(timeout: Long = Defaults.TIMEOUT): Push { + fun join(timeout: Long = this.timeout): Push { // Ensure that `.join()` is called only once per Channel instance if (joinedOnce) { throw IllegalStateException( @@ -232,7 +262,7 @@ class Channel( this.onMessage = callback } - fun on(event: Channel.Event, callback: (Message) -> Unit): Int { + fun on(event: Event, callback: (Message) -> Unit): Int { return this.on(event.value, callback) } @@ -250,7 +280,7 @@ class Channel( } } - fun push(event: String, payload: Payload, timeout: Long = Defaults.TIMEOUT): Push { + fun push(event: String, payload: Payload, timeout: Long = this.timeout): Push { if (!joinedOnce) { // If the Channel has not been joined, throw an exception throw RuntimeException( @@ -269,13 +299,18 @@ class Channel( return pushEvent } - fun leave(timeout: Long = Defaults.TIMEOUT): Push { + fun leave(timeout: Long = this.timeout): Push { + // Can push is dependent upon state == JOINED. Once we set it to LEAVING, then canPush + // will return false, so instead store it _before_ starting the leave + val canPush = this.canPush + + // Now set the state to leaving this.state = State.LEAVING // Perform the same behavior if the channel leaves successfully or not val onClose: ((Message) -> Unit) = { this.socket.logItems("Channel: leave $topic") - this.trigger(it) + this.trigger(Event.CLOSE, mapOf("reason" to "leave")) } // Push event to send to the server @@ -313,6 +348,15 @@ class Channel( return true } + internal fun trigger( + event: Event, + payload: Payload = hashMapOf(), + ref: String = "", + joinRef: String? = null + ) { + this.trigger(event.value, payload, ref, joinRef) + } + internal fun trigger( event: String, payload: Payload = hashMapOf(), @@ -353,7 +397,7 @@ class Channel( } /** Rejoins the Channel e.g. after a disconnect */ - private fun rejoin(timeout: Long = Defaults.TIMEOUT) { + private fun rejoin(timeout: Long = this.timeout) { this.sendJoin(timeout) } } \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Defaults.kt b/src/main/kotlin/org/phoenixframework/Defaults.kt index 6157833..0a1a6f3 100644 --- a/src/main/kotlin/org/phoenixframework/Defaults.kt +++ b/src/main/kotlin/org/phoenixframework/Defaults.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework import com.google.gson.FieldNamingPolicy diff --git a/src/main/kotlin/org/phoenixframework/DispatchQueue.kt b/src/main/kotlin/org/phoenixframework/DispatchQueue.kt new file mode 100644 index 0000000..7881c3a --- /dev/null +++ b/src/main/kotlin/org/phoenixframework/DispatchQueue.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.phoenixframework + +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit + +//------------------------------------------------------------------------------ +// Dispatch Queue Interfaces +//------------------------------------------------------------------------------ +/** + * Interface which abstracts away scheduling future tasks, allowing fake instances + * to be injected and manipulated during tests + */ +interface DispatchQueue { + /** Queue a Runnable to be executed after a given time unit delay */ + fun queue(delay: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem +} + +/** Abstracts away a future task */ +interface DispatchWorkItem { + /** True if the work item has been cancelled */ + val isCancelled: Boolean + + /** Cancels the item from executing */ + fun cancel() +} + +//------------------------------------------------------------------------------ +// Scheduled Dispatch Queue +//------------------------------------------------------------------------------ +/** + * A DispatchQueue that uses a ScheduledThreadPoolExecutor to schedule tasks to be executed + * in the future. + * + * Uses a default pool size of 8. Custom values can be provided during construction + */ +class ScheduledDispatchQueue(poolSize: Int = 8) : DispatchQueue { + + private var scheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(poolSize) + + override fun queue(delay: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem { + val scheduledFuture = scheduledThreadPoolExecutor.schedule(runnable, delay, unit) + return ScheduledDispatchWorkItem(scheduledFuture) + } +} + +/** + * A DispatchWorkItem that wraps a ScheduledFuture<*> created by a ScheduledDispatchQueue + */ +class ScheduledDispatchWorkItem(private val scheduledFuture: ScheduledFuture<*>) : DispatchWorkItem { + + override val isCancelled: Boolean + get() = this.scheduledFuture.isCancelled + + override fun cancel() { + this.scheduledFuture.cancel(true) + } +} diff --git a/src/main/kotlin/org/phoenixframework/Message.kt b/src/main/kotlin/org/phoenixframework/Message.kt index 053808f..386a5e2 100644 --- a/src/main/kotlin/org/phoenixframework/Message.kt +++ b/src/main/kotlin/org/phoenixframework/Message.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework import com.google.gson.annotations.SerializedName diff --git a/src/main/kotlin/org/phoenixframework/PhxChannel.kt b/src/main/kotlin/org/phoenixframework/PhxChannel.kt deleted file mode 100644 index 118ff24..0000000 --- a/src/main/kotlin/org/phoenixframework/PhxChannel.kt +++ /dev/null @@ -1,383 +0,0 @@ -package org.phoenixframework - -import java.lang.IllegalStateException -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue - - -class PhxChannel( - val topic: String, - val params: Payload, - val socket: PhxSocket -) { - - - /** Enumeration of the different states a channel can exist in */ - enum class PhxState(value: String) { - CLOSED("closed"), - ERRORED("errored"), - JOINED("joined"), - JOINING("joining"), - LEAVING("leaving") - } - - /** Enumeration of a variety of Channel specific events */ - enum class PhxEvent(val value: String) { - HEARTBEAT("heartbeat"), - JOIN("phx_join"), - LEAVE("phx_leave"), - REPLY("phx_reply"), - ERROR("phx_error"), - CLOSE("phx_close"); - - companion object { - fun isLifecycleEvent(event: String): Boolean { - return when (event) { - JOIN.value, - LEAVE.value, - REPLY.value, - ERROR.value, - CLOSE.value -> true - else -> false - } - } - } - } - - - var state: PhxChannel.PhxState - val bindings: ConcurrentHashMap Unit>>> - var bindingRef: Int - var timeout: Long - var joinedOnce: Boolean - var joinPush: PhxPush - var pushBuffer: MutableList - var rejoinTimer: PhxTimer? = null - var onMessage: (message: PhxMessage) -> PhxMessage = onMessage@{ - return@onMessage it - } - - - init { - this.state = PhxChannel.PhxState.CLOSED - this.bindings = ConcurrentHashMap() - this.bindingRef = 0 - this.timeout = socket.timeout - this.joinedOnce = false - this.pushBuffer = ArrayList() - this.joinPush = PhxPush(this, - PhxEvent.JOIN.value, params, timeout) - - // Create the Rejoin Timer that will be used in rejoin attempts - this.rejoinTimer = PhxTimer({ - this.rejoinTimer?.scheduleTimeout() - if (this.socket.isConnected) { - rejoin() - } - }, socket.reconnectAfterMs) - - // Perform once the Channel is joined - this.joinPush.receive("ok") { - this.state = PhxState.JOINED - this.rejoinTimer?.reset() - this.pushBuffer.forEach { it.send() } - this.pushBuffer = ArrayList() - } - - // Perform if the Push to join timed out - this.joinPush.receive("timeout") { - // Do not handle timeout if joining. Handled differently - if (this.isJoining) { - return@receive - } - this.socket.logItems("Channel: timeouts $topic, $joinRef after $timeout ms") - - val leavePush = PhxPush(this, PhxEvent.LEAVE.value, HashMap(), timeout) - leavePush.send() - - this.state = PhxState.ERRORED - this.joinPush.reset() - this.rejoinTimer?.scheduleTimeout() - } - - // Clean up when the channel closes - this.onClose { - this.rejoinTimer?.reset() - this.socket.logItems("Channel: close $topic") - this.state = PhxState.CLOSED - this.socket.remove(this) - } - - // Handles an error, attempts to rejoin - this.onError { - if (this.isLeaving || !this.isClosed) { - this.socket.logItems("Channel: error $topic") - this.state = PhxState.ERRORED - this.rejoinTimer?.scheduleTimeout() - } - } - - // Handles when a reply from the server comes back - this.on(PhxEvent.REPLY) { - val replyEventName = this.replyEventName(it.ref) - val replyMessage = PhxMessage(it.ref, it.topic, replyEventName, it.payload, it.joinRef) - this.trigger(replyMessage) - } - } - - - //------------------------------------------------------------------------------ - // Public - //------------------------------------------------------------------------------ - /** - * Joins the channel - * - * @param joinParams: Overrides the params given when channel was initialized - * @param timeout: Overrides the default timeout - * @return Push which receive hooks can be applied to - */ - fun join(joinParams: Payload? = null, timeout: Long? = null): PhxPush { - if (joinedOnce) { - throw IllegalStateException("Tried to join channel multiple times. `join()` can only be called once per channel") - } - - joinParams?.let { - this.joinPush.updatePayload(joinParams) - } - - this.joinedOnce = true - this.rejoin(timeout) - return joinPush - } - - /** - * Hook into channel close - * - * @param callback: Callback to be informed when the channel closes - * @return the ref counter of the subscription - */ - fun onClose(callback: (msg: PhxMessage) -> Unit): Int { - return this.on(PhxEvent.CLOSE, callback) - } - - /** - * Hook into channel error - * - * @param callback: Callback to be informed when the channel errors - * @return the ref counter of the subscription - */ - fun onError(callback: (msg: PhxMessage) -> Unit): Int { - return this.on(PhxEvent.ERROR, callback) - } - - /** - * Convenience method to take the Channel.Event enum. Same as channel.on(string) - */ - fun on(event: PhxChannel.PhxEvent, callback: (PhxMessage) -> Unit): Int { - return this.on(event.value, callback) - } - - /** - * Subscribes on channel events - * - * Subscription returns the ref counter which can be used later to - * unsubscribe the exact event listener - * - * Example: - * val ref1 = channel.on("event", do_stuff) - * val ref2 = channel.on("event", do_other_stuff) - * channel.off("event", ref1) - * - * This example will unsubscribe the "do_stuff" callback but not - * the "do_other_stuff" callback. - * - * @param event: Name of the event to subscribe to - * @param callback: Receives payload of the event - * @return: The subscriptions ref counter - */ - fun on(event: String, callback: (PhxMessage) -> Unit): Int { - val ref = bindingRef - this.bindingRef = ref + 1 - - this.bindings.getOrPut(event) { ConcurrentLinkedQueue() } - .add(ref to callback) - - return ref - } - - /** - * Unsubscribe from channel events. If ref counter is not provided, then - * all subscriptions for the event will be removed. - * - * Example: - * val ref1 = channel.on("event", do_stuff) - * val ref2 = channel.on("event", do_other_stuff) - * channel.off("event", ref1) - * - * This example will unsubscribe the "do_stuff" callback but not - * the "do_other_stuff" callback. - * - * @param event: Event to unsubscribe from - * @param ref: Optional. Ref counter returned when subscribed to event - */ - fun off(event: String, ref: Int? = null) { - // Remove any subscriptions that match the given event and ref ID. If no ref - // ID is given, then remove all subscriptions for an event. - if (ref != null) { - this.bindings[event]?.removeIf{ ref == it.first } - } else { - this.bindings.remove(event) - } - } - - /** - * Push a payload to the Channel - * - * @param event: Event to push - * @param payload: Payload to push - * @param timeout: Optional timeout. Default will be used - * @return [PhxPush] that can be hooked into - */ - fun push(event: String, payload: Payload, timeout: Long = DEFAULT_TIMEOUT): PhxPush { - if (!joinedOnce) { - // If the Channel has not been joined, throw an exception - throw RuntimeException("Tried to push $event to $topic before joining. Use channel.join() before pushing events") - } - - val pushEvent = PhxPush(this, event, payload, timeout) - if (canPush) { - pushEvent.send() - } else { - pushEvent.startTimeout() - pushBuffer.add(pushEvent) - } - - return pushEvent - } - - /** - * Leaves a channel - * - * Unsubscribe from server events and instructs Channel to terminate on Server - * - * Triggers .onClose() hooks - * - * To receive leave acknowledgements, use the receive hook to bind to the server ack - * - * Example: - * channel.leave().receive("ok) { print("left channel") } - * - * @param timeout: Optional timeout. Default will be used - */ - fun leave(timeout: Long = DEFAULT_TIMEOUT): PhxPush { - this.state = PhxState.LEAVING - - val onClose: ((PhxMessage) -> Unit) = { - this.socket.logItems("Channel: leave $topic") - this.trigger(it) - } - - val leavePush = PhxPush(this, PhxEvent.LEAVE.value, HashMap(), timeout) - leavePush - .receive("ok", onClose) - .receive("timeout", onClose) - - leavePush.send() - if (!canPush) { - leavePush.trigger("ok", HashMap()) - } - - return leavePush - } - - /** - * Override message hook. Receives all events for specialized message - * handling before dispatching to the channel callbacks - * - * @param callback: Callback which will receive the inbound message before - * it is dispatched to other callbacks. Must return a Message object. - */ - fun onMessage(callback: (message: PhxMessage) -> PhxMessage) { - this.onMessage = callback - } - - - //------------------------------------------------------------------------------ - // Internal - //------------------------------------------------------------------------------ - /** Checks if an event received by the socket belongs to the Channel */ - fun isMember(message: PhxMessage): Boolean { - if (message.topic != this.topic) { return false } - - val isLifecycleEvent = PhxEvent.isLifecycleEvent(message.event) - - // If the message is a lifecycle event and it is not a join for this channel, drop the outdated message - if (message.joinRef != null && isLifecycleEvent && message.joinRef != this.joinRef) { - this.socket.logItems("Channel: Dropping outdated message. ${message.topic}") - return false - } - - return true - } - - /** Sends the payload to join the Channel */ - fun sendJoin(timeout: Long) { - this.state = PhxState.JOINING - this.joinPush.resend(timeout) - - } - - /** Rejoins the Channel */ - fun rejoin(timeout: Long? = null) { - this.sendJoin(timeout ?: this.timeout) - } - - /** - * Triggers an event to the correct event binding created by `channel.on("event") - * - * @param message: Message that was received that will be sent to the correct binding - */ - fun trigger(message: PhxMessage) { - val handledMessage = onMessage(message) - this.bindings[message.event]?.forEach { it.second(handledMessage) } - } - - /** - * @param ref: The ref of the reply push event - * @return the name of the event - */ - fun replyEventName(ref: String): String { - return "chan_reply_$ref" - } - - /** The ref sent during the join message. */ - val joinRef: String - get() = joinPush.ref ?: "" - - /** - * @return True if the Channel can push messages, meaning the socket - * is connected and the channel is joined - */ - val canPush: Boolean - get() = this.socket.isConnected && this.isJoined - - /** @return: True if the Channel has been closed */ - val isClosed: Boolean - get() = state == PhxState.CLOSED - - /** @return: True if the Channel experienced an error */ - val isErrored: Boolean - get() = state == PhxState.ERRORED - - /** @return: True if the channel has joined */ - val isJoined: Boolean - get() = state == PhxState.JOINED - - /** @return: True if the channel has requested to join */ - val isJoining: Boolean - get() = state == PhxState.JOINING - - /** @return: True if the channel has requested to leave */ - val isLeaving: Boolean - get() = state == PhxState.LEAVING -} diff --git a/src/main/kotlin/org/phoenixframework/PhxMessage.kt b/src/main/kotlin/org/phoenixframework/PhxMessage.kt deleted file mode 100644 index 8150472..0000000 --- a/src/main/kotlin/org/phoenixframework/PhxMessage.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.phoenixframework - -import com.google.gson.annotations.SerializedName - -data class PhxMessage( - /** The unique string ref. Empty if not present */ - @SerializedName("ref") - val ref: String = "", - - /** The message topic */ - @SerializedName("topic") - val topic: String = "", - - /** The message event name, for example "phx_join" or any other custom name */ - @SerializedName("event") - val event: String = "", - - /** The payload of the message */ - @SerializedName("payload") - val payload: Payload = HashMap(), - - /** The ref sent during a join event. Empty if not present. */ - @SerializedName("join_ref") - val joinRef: String? = null) { - - - /** - * Convenience var to access the message's payload's status. Equivalent - * to checking message.payload["status"] yourself - */ - val status: String? - get() = payload["status"] as? String -} diff --git a/src/main/kotlin/org/phoenixframework/PhxPush.kt b/src/main/kotlin/org/phoenixframework/PhxPush.kt deleted file mode 100644 index 7f23bab..0000000 --- a/src/main/kotlin/org/phoenixframework/PhxPush.kt +++ /dev/null @@ -1,192 +0,0 @@ -package org.phoenixframework - -import java.util.* -import kotlin.collections.HashMap -import kotlin.concurrent.schedule - -class PhxPush( - val channel: PhxChannel, - val event: String, - var payload: Payload, - var timeout: Long -) { - - /** The server's response to the Push */ - var receivedMessage: PhxMessage? = null - - /** Timer which triggers a timeout event */ - var timeoutTimer: Timer? = null - - /** Hooks into a Push. Where .receive("ok", callback(Payload)) are stored */ - var receiveHooks: MutableMap Unit)>> = HashMap() - - /** True if the Push has been sent */ - var sent: Boolean = false - - /** The reference ID of the Push */ - var ref: String? = null - - /** The event that is associated with the reference ID of the Push */ - var refEvent: String? = null - - - //------------------------------------------------------------------------------ - // Public - //------------------------------------------------------------------------------ - /** Resend a Push */ - fun resend(timeout: Long = DEFAULT_TIMEOUT) { - this.timeout = timeout - this.reset() - this.send() - } - - /** - * Receive a specific event when sending an Outbound message - * - * Example: - * channel - * .send("event", myPayload) - * .receive("error") { } - */ - fun receive(status: String, callback: (message: PhxMessage) -> Unit): PhxPush { - // If the message has already be received, pass it to the callback - receivedMessage?.let { - if (hasReceivedStatus(status)) { - callback(it) - } - } - - // Create a new array of hooks if no previous hook is associated with status - if (receiveHooks[status] == null) { - receiveHooks[status] = arrayListOf(callback) - } else { - // A previous hook for this status already exists. Just append the new hook - receiveHooks[status]?.add(callback) - } - - return this - } - - - /** - * @param payload: New payload to be sent through with the Push - */ - fun updatePayload(payload: Payload) { - this.payload = payload - } - - //------------------------------------------------------------------------------ - // Internal - //------------------------------------------------------------------------------ - /** - * Sends the Push through the socket - */ - fun send() { - if (hasReceivedStatus("timeout")) { - return - } - - this.startTimeout() - this.sent = true - - this.channel.socket.push( - this.channel.topic, - this.event, - this.payload, - this.ref, - this.channel.joinRef) - } - - /** Resets the Push as it was after initialization */ - fun reset() { - this.cancelRefEvent() - this.ref = null - this.refEvent = null - this.receivedMessage = null - this.sent = false - } - - /** - * Finds the receiveHook which needs to be informed of a status response - * - * @param status: Status to find the hook for - * @param message: Message to send to the matched hook - */ - fun matchReceive(status: String, message: PhxMessage) { - receiveHooks[status]?.forEach { it(message) } - } - - /** - * Reverses the result of channel.on(event, callback) that spawned the Push - */ - fun cancelRefEvent() { - this.refEvent?.let { - this.channel.off(it) - } - } - - /** - * Cancels any ongoing Timeout timer - */ - fun cancelTimeout() { - this.timeoutTimer?.cancel() - this.timeoutTimer = null - } - - /** - * Starts the Timer which will trigger a timeout after a specific delay - * in milliseconds is reached. - */ - fun startTimeout() { - this.timeoutTimer?.cancel() - - val ref = this.channel.socket.makeRef() - this.ref = ref - - val refEvent = this.channel.replyEventName(ref) - this.refEvent = refEvent - - // If a response is received before the Timer triggers, cancel timer - // and match the received event to it's corresponding hook. - this.channel.on(refEvent) { - this.cancelRefEvent() - this.cancelTimeout() - this.receivedMessage = it - - // Check if there is an event status available - val message = it - message.status?.let { - this.matchReceive(it, message) - } - } - - // Start the timer. If the timer fires, then send a timeout event to the Push - this.timeoutTimer = Timer() - this.timeoutTimer?.schedule(timeout) { - trigger("timeout", HashMap()) - } - } - - /** - * Checks if a status has already been received by the Push. - * - * @param status: Status to check - * @return True if the Push has received the given status. False otherwise - */ - fun hasReceivedStatus(status: String): Boolean { - return receivedMessage?.status == status - } - - /** - * Triggers an event to be sent through the Channel - */ - fun trigger(status: String, payload: Payload) { - val mutPayload = payload.toMutableMap() - mutPayload["status"] = status - - refEvent?.let { safeRefEvent -> - val message = PhxMessage(event = safeRefEvent, payload = mutPayload) - this.channel.trigger(message) - } - } -} diff --git a/src/main/kotlin/org/phoenixframework/PhxSocket.kt b/src/main/kotlin/org/phoenixframework/PhxSocket.kt deleted file mode 100644 index d781542..0000000 --- a/src/main/kotlin/org/phoenixframework/PhxSocket.kt +++ /dev/null @@ -1,457 +0,0 @@ -package org.phoenixframework - -import com.google.gson.FieldNamingPolicy -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import java.net.URL -import java.util.Timer -import kotlin.concurrent.schedule - - -/** Default timeout set to 10s */ -const val DEFAULT_TIMEOUT: Long = 10000 - -/** Default heartbeat interval set to 30s */ -const val DEFAULT_HEARTBEAT: Long = 30000 - - -/** The code used when the socket was closed after the heartbeat timer timed out */ -const val WS_CLOSE_HEARTBEAT_ERROR = 5000 - -open class PhxSocket( - url: String, - params: Payload? = null, - private val client: OkHttpClient = OkHttpClient.Builder().build() -) : WebSocketListener() { - - //------------------------------------------------------------------------------ - // Public Attributes - //------------------------------------------------------------------------------ - /** Timeout to use when opening connections */ - var timeout: Long = DEFAULT_TIMEOUT - - /** Interval between sending a heartbeat */ - var heartbeatIntervalMs: Long = DEFAULT_HEARTBEAT - - /** Interval between socket reconnect attempts */ - var reconnectAfterMs: ((tries: Int) -> Long) = closure@{ - return@closure if (it >= 3) 10000 else longArrayOf(1000, 2000, 5000)[it] - } - - /** Hook for custom logging into the client */ - var logger: ((msg: String) -> Unit)? = null - - /** Disable sending Heartbeats by setting to true */ - var skipHeartbeat: Boolean = false - - /** - * Socket will attempt to reconnect if the Socket was closed. Will not - * reconnect if the Socket errored (e.g. connection refused.) Default - * is set to true - */ - var autoReconnect: Boolean = true - - - //------------------------------------------------------------------------------ - // Private Attributes - //------------------------------------------------------------------------------ - /// Collection of callbacks for onOpen socket events - private var onOpenCallbacks: MutableList<() -> Unit> = ArrayList() - - /// Collection of callbacks for onClose socket events - private var onCloseCallbacks: MutableList<() -> Unit> = ArrayList() - - /// Collection of callbacks for onError socket events - private var onErrorCallbacks: MutableList<(Throwable, Response?) -> Unit> = ArrayList() - - /// Collection of callbacks for onMessage socket events - private var onMessageCallbacks: MutableList<(PhxMessage) -> Unit> = ArrayList() - - /// Collection on channels created for the Socket - private var channels: MutableList = ArrayList() - - /// Buffers messages that need to be sent once the socket has connected - private var sendBuffer: MutableList<() -> Unit> = ArrayList() - - /// Ref counter for messages - private var ref: Int = 0 - - /// Internal endpoint that the Socket is connecting to - var endpoint: URL - - /// Timer that triggers sending new Heartbeat messages - private var heartbeatTimer: Timer? = null - - /// Ref counter for the last heartbeat that was sent - private var pendingHeartbeatRef: String? = null - - /// Timer to use when attempting to reconnect - private var reconnectTimer: PhxTimer? = null - - private val gson: Gson = GsonBuilder() - .setLenient() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create() - - private val request: Request - - /// WebSocket connection to the server - private var connection: WebSocket? = null - - - init { - - // Silently replace web socket URLs with HTTP URLs. - var mutableUrl = url - if (url.regionMatches(0, "ws:", 0, 3, ignoreCase = true)) { - mutableUrl = "http:" + url.substring(3) - } else if (url.regionMatches(0, "wss:", 0, 4, ignoreCase = true)) { - mutableUrl = "https:" + url.substring(4) - } - - var httpUrl = HttpUrl.parse(mutableUrl) ?: throw IllegalArgumentException("invalid url: $url") - - // If there are query params, append them now - params?.let { - val httpBuilder = httpUrl.newBuilder() - it.forEach { (key, value) -> - httpBuilder.addQueryParameter(key, value.toString()) - } - - httpUrl = httpBuilder.build() - } - - reconnectTimer = PhxTimer( - callback = { - disconnect().also { - connect() - } - }, - timerCalculation = reconnectAfterMs - ) - - // Hold reference to where the Socket is pointing to - this.endpoint = httpUrl.url() - - // Create the request and client that will be used to connect to the WebSocket - request = Request.Builder().url(httpUrl).build() - } - - - //------------------------------------------------------------------------------ - // Public - //------------------------------------------------------------------------------ - /** True if the Socket is currently connected */ - var isConnected: Boolean = false - private set - - /** - * Disconnects the Socket - */ - fun disconnect(code: Int = WS_CLOSE_NORMAL) { - connection?.close(WS_CLOSE_NORMAL, null) - connection = null - - } - - /** - * Connects the Socket. The params passed to the Socket on initialization - * will be sent through the connection. If the Socket is already connected, - * then this call will be ignored. - */ - fun connect() { - // Do not attempt to reconnect if already connected - if (connection != null) return - connection = client.newWebSocket(request, this) - } - - /** - * Registers a callback for connection open events - * - * Example: - * socket.onOpen { - * print("Socket Connection Opened") - * } - * - * @param callback: Callback to register - */ - fun onOpen(callback: () -> Unit) { - this.onOpenCallbacks.add(callback) - } - - - /** - * Registers a callback for connection close events - * - * Example: - * socket.onClose { - * print("Socket Connection Closed") - * } - * - * @param callback: Callback to register - */ - fun onClose(callback: () -> Unit) { - this.onCloseCallbacks.add(callback) - } - - /** - * Registers a callback for connection error events - * - * Example: - * socket.onError { error, response -> - * print("Socket Connection Error") - * } - * - * @param callback: Callback to register - */ - fun onError(callback: (Throwable?, Response?) -> Unit) { - this.onErrorCallbacks.add(callback) - } - - /** - * Registers a callback for connection message events - * - * Example: - * socket.onMessage { [unowned self] (message) in - * print("Socket Connection Message") - * } - * - * @param callback: Callback to register - */ - fun onMessage(callback: (PhxMessage) -> Unit) { - this.onMessageCallbacks.add(callback) - } - - - /** - * Releases all stored callback hooks (onError, onOpen, onClose, etc.) You should - * call this method when you are finished when the Socket in order to release - * any references held by the socket. - */ - fun removeAllCallbacks() { - this.onOpenCallbacks.clear() - this.onCloseCallbacks.clear() - this.onErrorCallbacks.clear() - this.onMessageCallbacks.clear() - } - - /** - * Removes the Channel from the socket. This does not cause the channel to inform - * the server that it is leaving so you should call channel.leave() first. - */ - fun remove(channel: PhxChannel) { - this.channels = channels - .filter { it.joinRef != channel.joinRef } - .toMutableList() - } - - /** - * Initializes a new Channel with the given topic - * - * Example: - * val channel = socket.channel("rooms", params) - */ - fun channel(topic: String, params: Payload? = null): PhxChannel { - val channel = PhxChannel(topic, params ?: HashMap(), this) - this.channels.add(channel) - return channel - } - - /** - * Sends data through the Socket - */ - open fun push(topic: String, - event: String, - payload: Payload, - ref: String? = null, - joinRef: String? = null) { - - val callback: (() -> Unit) = { - val body: MutableMap = HashMap() - body["topic"] = topic - body["event"] = event - body["payload"] = payload - - ref?.let { body["ref"] = it } - joinRef?.let { body["join_ref"] = it } - - val data = gson.toJson(body) - connection?.let { - this.logItems("Push: Sending $data") - it.send(data) - } - } - - // If the socket is connected, then execute the callback immediately - if (isConnected) { - callback() - } else { - // If the socket is not connected, add the push to a buffer which - // will be sent immediately upon connection - this.sendBuffer.add(callback) - } - } - - - /** - * @return the next message ref, accounting for overflows - */ - open fun makeRef(): String { - val newRef = this.ref + 1 - this.ref = if (newRef == Int.MAX_VALUE) 0 else newRef - - return newRef.toString() - } - - //------------------------------------------------------------------------------ - // Internal - //------------------------------------------------------------------------------ - fun logItems(body: String) { - logger?.let { - it(body) - } - } - - - //------------------------------------------------------------------------------ - // Private - //------------------------------------------------------------------------------ - - /** Triggers a message when the socket is opened */ - private fun onConnectionOpened() { - this.logItems("Transport: Connected to $endpoint") - this.flushSendBuffer() - this.reconnectTimer?.reset() - - // start sending heartbeats if enabled { - if (!skipHeartbeat) startHeartbeatTimer() - - // Inform all onOpen callbacks that the Socket as opened - this.onOpenCallbacks.forEach { it() } - } - - /** Triggers a message when the socket is closed */ - private fun onConnectionClosed(code: Int) { - this.logItems("Transport: close") - this.triggerChannelError() - - // Terminate any ongoing heartbeats - this.heartbeatTimer?.cancel() - - // Attempt to reconnect the socket. If the socket was closed normally, - // then do not attempt to reconnect - if (autoReconnect && code != WS_CLOSE_NORMAL) reconnectTimer?.scheduleTimeout() - - // Inform all onClose callbacks that the Socket closed - this.onCloseCallbacks.forEach { it() } - } - - /** Triggers a message when an error comes through the Socket */ - private fun onConnectionError(t: Throwable, response: Response?) { - this.logItems("Transport: error") - - // Inform all onError callbacks that an error occurred - this.onErrorCallbacks.forEach { it(t, response) } - - // Inform all channels that a socket error occurred - this.triggerChannelError() - - // There was an error, violently cancel the connection. This is a safe operation - // since the underlying WebSocket will no longer return messages to the Connection - // after a Failure - connection?.cancel() - connection = null - } - - /** Triggers a message to the correct Channel when it comes through the Socket */ - private fun onConnectionMessage(rawMessage: String) { - this.logItems("Receive: $rawMessage") - - val message = gson.fromJson(rawMessage, PhxMessage::class.java) - - // Dispatch the message to all channels that belong to the topic - this.channels - .filter { it.isMember(message) } - .forEach { it.trigger(message) } - - // Inform all onMessage callbacks of the message - this.onMessageCallbacks.forEach { it(message) } - - // Check if this message was a pending heartbeat - if (message.ref == pendingHeartbeatRef) { - this.logItems("Received Pending Heartbeat") - this.pendingHeartbeatRef = null - } - } - - /** Triggers an error event to all connected Channels */ - private fun triggerChannelError() { - val errorMessage = PhxMessage(event = PhxChannel.PhxEvent.ERROR.value) - this.channels.forEach { it.trigger(errorMessage) } - } - - /** Send all messages that were buffered before the socket opened */ - private fun flushSendBuffer() { - if (isConnected && sendBuffer.count() > 0) { - this.sendBuffer.forEach { it() } - this.sendBuffer.clear() - } - } - - - //------------------------------------------------------------------------------ - // Timers - //------------------------------------------------------------------------------ - /** Initializes a 30s */ - fun startHeartbeatTimer() { - heartbeatTimer?.cancel() - heartbeatTimer = null; - - heartbeatTimer = Timer() - heartbeatTimer?.schedule(heartbeatIntervalMs, heartbeatIntervalMs) { - if (!isConnected) return@schedule - - pendingHeartbeatRef?.let { - pendingHeartbeatRef = null - logItems("Transport: Heartbeat timeout. Attempt to re-establish connection") - disconnect(WS_CLOSE_HEARTBEAT_ERROR) - return@schedule - } - - pendingHeartbeatRef = makeRef() - push("phoenix", PhxChannel.PhxEvent.HEARTBEAT.value, HashMap(), pendingHeartbeatRef) - } - } - - - //------------------------------------------------------------------------------ - // WebSocketListener - //------------------------------------------------------------------------------ - override fun onOpen(webSocket: WebSocket?, response: Response?) { - isConnected = true - this.onConnectionOpened() - - } - - override fun onMessage(webSocket: WebSocket?, text: String?) { - text?.let { - this.onConnectionMessage(it) - } - } - - override fun onClosed(webSocket: WebSocket?, code: Int, reason: String?) { - isConnected = false - this.onConnectionClosed(code) - } - - override fun onFailure(webSocket: WebSocket?, t: Throwable, response: Response?) { - isConnected = false - this.onConnectionError(t, response) - } -} diff --git a/src/main/kotlin/org/phoenixframework/PhxTimer.kt b/src/main/kotlin/org/phoenixframework/PhxTimer.kt deleted file mode 100644 index 9d0a597..0000000 --- a/src/main/kotlin/org/phoenixframework/PhxTimer.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.phoenixframework - -import java.util.* -import kotlin.concurrent.schedule - -class PhxTimer( - private val callback: () -> Unit, - private val timerCalculation: (tries: Int) -> Long -) { - - // The underlying Java timer - private var timer: Timer? = null - // How many tries the Timer has attempted - private var tries: Int = 0 - - - /** - * Resets the Timer, clearing the number of current tries and stops - * any scheduled timeouts. - */ - fun reset() { - this.tries = 0 - this.clearTimer() - } - - /** Cancels any previous timeouts and scheduled a new one */ - fun scheduleTimeout() { - this.clearTimer() - - // Start up a new Timer - val timeout = timerCalculation(tries) - this.timer = Timer() - this.timer?.schedule(timeout) { - tries += 1 - callback() - } - } - - //------------------------------------------------------------------------------ - // Private - //------------------------------------------------------------------------------ - private fun clearTimer() { - this.timer?.cancel() - this.timer = null - } -} diff --git a/src/main/kotlin/org/phoenixframework/Presence.kt b/src/main/kotlin/org/phoenixframework/Presence.kt index 0b83b56..2077039 100644 --- a/src/main/kotlin/org/phoenixframework/Presence.kt +++ b/src/main/kotlin/org/phoenixframework/Presence.kt @@ -1,13 +1,35 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework //------------------------------------------------------------------------------ // Type Aliases //------------------------------------------------------------------------------ /** Meta details of a Presence. Just a dictionary of properties */ -typealias PresenceMeta = MutableMap +typealias PresenceMeta = Map /** A mapping of a String to an array of Metas. e.g. {"metas": [{id: 1}]} */ -typealias PresenceMap = MutableMap> +typealias PresenceMap = MutableMap> /** A mapping of a Presence state to a mapping of Metas */ typealias PresenceState = MutableMap @@ -67,14 +89,14 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { // Properties //------------------------------------------------------------------------------ /** The channel the Presence belongs to */ - private val channel: Channel + internal val channel: Channel /** Caller to callback hooks */ - private val caller: Caller + internal val caller: Caller /** The state of the Presence */ var state: PresenceState - private set + internal set /** Pending `join` and `leave` diffs that need to be synced */ var pendingDiffs: MutableList @@ -96,7 +118,7 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { this.pendingDiffs = mutableListOf() this.channel = channel this.joinRef = null - this.caller = Presence.Caller() + this.caller = Caller() val stateEvent = opts.events[Events.STATE] val diffEvent = opts.events[Events.DIFF] @@ -112,7 +134,7 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { this.pendingDiffs.forEach { diff -> - this.state = Presence.syncDiff(state, diff, caller.onJoin, caller.onLeave) + this.state = syncDiff(state, diff, caller.onJoin, caller.onLeave) } this.pendingDiffs.clear() @@ -124,7 +146,7 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { if (isPendingSyncState) { this.pendingDiffs.add(diff) } else { - this.state = Presence.syncDiff(state, diff, caller.onJoin, caller.onLeave) + this.state = syncDiff(state, diff, caller.onJoin, caller.onLeave) this.caller.onSync() } } @@ -166,6 +188,19 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { //------------------------------------------------------------------------------ companion object { + private fun cloneMap(map: PresenceMap): PresenceMap { + val clone: PresenceMap = mutableMapOf() + map.forEach { entry -> clone[entry.key] = entry.value.toList() } + return clone + } + + private fun cloneState(state: PresenceState): PresenceState { + val clone: PresenceState = mutableMapOf() + state.forEach { entry -> clone[entry.key] = cloneMap(entry.value) } + return clone + } + + /** * Used to sync the list of presences on the server with the client's state. An optional * `onJoin` and `onLeave` callback can be provided to react to changes in the client's local @@ -178,20 +213,20 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { onJoin: OnJoin = { _, _, _ -> }, onLeave: OnLeave = { _, _, _ -> } ): PresenceState { - val state = currentState + val state = cloneState(currentState) val leaves: PresenceState = mutableMapOf() val joins: PresenceState = mutableMapOf() - state.forEach { key, presence -> + state.forEach { (key, presence) -> if (!newState.containsKey(key)) { leaves[key] = presence } } - newState.forEach { key, newPresence -> + newState.forEach { (key, newPresence) -> state[key]?.let { currentPresence -> - val newRefs = newPresence["metas"]!!.map { meta -> meta["phx"] as String } - val curRefs = currentPresence["metas"]!!.map { meta -> meta["phx"] as String } + val newRefs = newPresence["metas"]!!.map { meta -> meta["phx_ref"] as String } + val curRefs = currentPresence["metas"]!!.map { meta -> meta["phx_ref"] as String } val joinedMetas = newPresence["metas"]!!.filter { meta -> curRefs.indexOf(meta["phx_ref"]) < 0 @@ -201,13 +236,13 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { } if (joinedMetas.isNotEmpty()) { - joins[key] = newPresence - joins[key]!!["metas"] = joinedMetas.toMutableList() + joins[key] = cloneMap(newPresence) + joins[key]!!["metas"] = joinedMetas } if (leftMetas.isNotEmpty()) { - leaves[key] = currentPresence - leaves[key]!!["metas"] = leftMetas.toMutableList() + leaves[key] = cloneMap(currentPresence) + leaves[key]!!["metas"] = leftMetas } } ?: run { joins[key] = newPresence @@ -215,7 +250,7 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { } val diff: PresenceDiff = mutableMapOf("joins" to joins, "leaves" to leaves) - return Presence.syncDiff(state, diff, onJoin, onLeave) + return syncDiff(state, diff, onJoin, onLeave) } @@ -230,18 +265,23 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { onJoin: OnJoin = { _, _, _ -> }, onLeave: OnLeave = { _, _, _ -> } ): PresenceState { - val state = currentState + val state = cloneState(currentState) // Sync the joined states and inform onJoin of new presence diff["joins"]?.forEach { key, newPresence -> val currentPresence = state[key] - state[key] = newPresence + state[key] = cloneMap(newPresence) currentPresence?.let { curPresence -> val joinedRefs = state[key]!!["metas"]!!.map { m -> m["phx_ref"] as String } val curMetas = curPresence["metas"]!!.filter { m -> joinedRefs.indexOf(m["phx_ref"]) < 0 } - state[key]!!["metas"]!!.addAll(0, curMetas) + // Data structures are immutable. Need to convert to a mutable copy, + // add the metas, and then reassign to the state + val mutableMetas = state[key]!!["metas"]!!.toMutableList() + mutableMetas.addAll(0, curMetas) + + state[key]!!["metas"] = mutableMetas } onJoin.invoke(key, currentPresence, newPresence) @@ -252,15 +292,11 @@ class Presence(channel: Channel, opts: Options = Options.defaults) { val curPresence = state[key] ?: return@forEach val refsToRemove = leftPresence["metas"]!!.map { it["phx_ref"] as String } - val keepMetas = + curPresence["metas"] = curPresence["metas"]!!.filter { m -> refsToRemove.indexOf(m["phx_ref"]) < 0 } - curPresence["metas"] = keepMetas.toMutableList() onLeave.invoke(key, curPresence, leftPresence) - - if (keepMetas.isNotEmpty()) { - state[key]!!["metas"] = keepMetas.toMutableList() - } else { + if (curPresence["metas"]?.isEmpty() == true) { state.remove(key) } } diff --git a/src/main/kotlin/org/phoenixframework/Push.kt b/src/main/kotlin/org/phoenixframework/Push.kt index d0c11b6..0690b97 100644 --- a/src/main/kotlin/org/phoenixframework/Push.kt +++ b/src/main/kotlin/org/phoenixframework/Push.kt @@ -1,6 +1,27 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework -import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit /** @@ -21,7 +42,7 @@ class Push( var receivedMessage: Message? = null /** The task to be triggered if the Push times out */ - var timeoutTask: ScheduledFuture<*>? = null + var timeoutTask: DispatchWorkItem? = null /** Hooks into a Push. Where .receive("ok", callback(Payload)) are stored */ var receiveHooks: MutableMap Unit)>> = HashMap() @@ -57,8 +78,7 @@ class Push( this.startTimeout() this.sent = true - // TODO: this.channel.socket.push - // TODO: weak reference? + this.channel.socket.push(channel.topic, event, payload, ref, channel.joinRef) } /** @@ -133,12 +153,11 @@ class Push( } // Setup and start the Timer - this.timeoutTask = channel.socket.timerPool.schedule({ + this.timeoutTask = channel.socket.dispatchQueue.queue(timeout, TimeUnit.MILLISECONDS) { this.trigger("timeout", hashMapOf()) - }, timeout, TimeUnit.MILLISECONDS) + } } - //------------------------------------------------------------------------------ // Private //------------------------------------------------------------------------------ @@ -154,17 +173,15 @@ class Push( /** Removes receive hook from Channel regarding this Push */ private fun cancelRefEvent() { - this.refEvent?.let { /* TODO: this.channel.off(it) */ } + this.refEvent?.let { this.channel.off(it) } } /** Cancels any ongoing timeout task */ private fun cancelTimeout() { - this.timeoutTask?.cancel(true) + this.timeoutTask?.cancel() this.timeoutTask = null } - - /** * @param status Status to check if it has been received * @return True if the status has already been received by the Push diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt index ee19905..6682539 100644 --- a/src/main/kotlin/org/phoenixframework/Socket.kt +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework import com.google.gson.Gson @@ -5,30 +27,8 @@ import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Response import java.net.URL -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit -// Copyright (c) 2019 Daniel Rees -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - /** Alias for a JSON mapping */ typealias Payload = Map @@ -104,52 +104,48 @@ class Socket( * Push that is sent through or created by a Socket instance. Different Socket instances will * create individual thread pools. */ - internal val timerPool = ScheduledThreadPoolExecutor(8) +// internal var timerPool: ScheduledExecutorService = ScheduledThreadPoolExecutor(8) + internal var dispatchQueue: DispatchQueue = ScheduledDispatchQueue() //------------------------------------------------------------------------------ // Private Attributes + // these are marked as `internal` so that they can be accessed during tests //------------------------------------------------------------------------------ /** Returns the type of transport to use. Potentially expose for custom transports */ - private val transport: (URL) -> Transport = { WebSocketTransport(it, client) } + internal var transport: (URL) -> Transport = { WebSocketTransport(it, client) } /** Collection of callbacks for socket state changes */ - private val stateChangeCallbacks: StateChangeCallbacks = StateChangeCallbacks() + internal val stateChangeCallbacks: StateChangeCallbacks = StateChangeCallbacks() /** Collection of unclosed channels created by the Socket */ - private var channels: MutableList = ArrayList() + internal var channels: MutableList = ArrayList() /** Buffers messages that need to be sent once the socket has connected */ - private var sendBuffer: MutableList<() -> Unit> = ArrayList() + internal var sendBuffer: MutableList<() -> Unit> = ArrayList() /** Ref counter for messages */ - private var ref: Int = 0 + internal var ref: Int = 0 /** Task to be triggered in the future to send a heartbeat message */ - private var heartbeatTask: ScheduledFuture<*>? = null + internal var heartbeatTask: DispatchWorkItem? = null /** Ref counter for the last heartbeat that was sent */ - private var pendingHeartbeatRef: String? = null + internal var pendingHeartbeatRef: String? = null /** Timer to use when attempting to reconnect */ - private var reconnectTimer: TimeoutTimer + internal var reconnectTimer: TimeoutTimer //------------------------------------------------------------------------------ // Connection Attributes //------------------------------------------------------------------------------ /** The underlying WebSocket connection */ - private var connection: Transport? = null + internal var connection: Transport? = null //------------------------------------------------------------------------------ // Initialization //------------------------------------------------------------------------------ init { - // Silently replace web socket URLs with HTTP URLs. var mutableUrl = url - if (url.regionMatches(0, "ws:", 0, 3, ignoreCase = true)) { - mutableUrl = "http:" + url.substring(3) - } else if (url.regionMatches(0, "wss:", 0, 4, ignoreCase = true)) { - mutableUrl = "https:" + url.substring(4) - } // Ensure that the URL ends with "/websocket" if (!mutableUrl.contains("/websocket")) { @@ -162,6 +158,16 @@ class Socket( mutableUrl += "websocket" } + // Store the endpoint before changing the protocol + this.endpoint = mutableUrl + + // Silently replace web socket URLs with HTTP URLs. + if (url.regionMatches(0, "ws:", 0, 3, ignoreCase = true)) { + mutableUrl = "http:" + url.substring(3) + } else if (url.regionMatches(0, "wss:", 0, 4, ignoreCase = true)) { + mutableUrl = "https:" + url.substring(4) + } + // If there are query params, append them now var httpUrl = HttpUrl.parse(mutableUrl) ?: throw IllegalArgumentException("invalid url: $url") params?.let { @@ -173,16 +179,16 @@ class Socket( httpUrl = httpBuilder.build() } - this.endpoint = mutableUrl + // Store the URL that will be used to establish a connection this.endpointUrl = httpUrl.url() // Create reconnect timer this.reconnectTimer = TimeoutTimer( - scheduledExecutorService = timerPool, + dispatchQueue = dispatchQueue, timerCalculation = reconnectAfterMs, callback = { - // log(socket attempting to reconnect) - // this.teardown() { this.connect() } + this.logItems("Socket attempting to reconnect") + this.teardown { this.connect() } }) } @@ -199,7 +205,7 @@ class Socket( /** @return True if the connection exists and is open */ val isConnected: Boolean - get() = this.connection?.readyState == ReadyState.OPEN + get() = this.connection?.readyState == Transport.ReadyState.OPEN //------------------------------------------------------------------------------ // Public @@ -301,9 +307,7 @@ class Socket( } fun logItems(body: String) { - logger?.let { - it(body) - } + logger?.invoke(body) } //------------------------------------------------------------------------------ @@ -320,7 +324,7 @@ class Socket( this.connection = null // Heartbeats are no longer needed - this.heartbeatTask?.cancel(true) + this.heartbeatTask?.cancel() this.heartbeatTask = null // Since the connections onClose was null'd out, inform all state callbacks @@ -335,7 +339,7 @@ class Socket( } /** Send all messages that were buffered before the socket opened */ - private fun flushSendBuffer() { + internal fun flushSendBuffer() { if (isConnected && sendBuffer.isNotEmpty()) { this.sendBuffer.forEach { it.invoke() } this.sendBuffer.clear() @@ -345,20 +349,19 @@ class Socket( //------------------------------------------------------------------------------ // Heartbeat //------------------------------------------------------------------------------ - private fun resetHeartbeat() { + internal fun resetHeartbeat() { // Clear anything related to the previous heartbeat this.pendingHeartbeatRef = null - this.heartbeatTask?.cancel(true) + this.heartbeatTask?.cancel() this.heartbeatTask = null // Do not start up the heartbeat timer if skipHeartbeat is true if (skipHeartbeat) return - heartbeatTask = timerPool.schedule({ - - }, heartbeatInterval, TimeUnit.MILLISECONDS) + heartbeatTask = + dispatchQueue.queue(heartbeatInterval, TimeUnit.MILLISECONDS) { sendHeartbeat() } } - private fun sendHeartbeat() { + internal fun sendHeartbeat() { // Do not send if the connection is closed if (!isConnected) return @@ -387,7 +390,7 @@ class Socket( //------------------------------------------------------------------------------ // Connection Transport Hooks //------------------------------------------------------------------------------ - private fun onConnectionOpened() { + internal fun onConnectionOpened() { this.logItems("Transport: Connected to $endpoint") // Send any messages that were waiting for a connection @@ -403,12 +406,12 @@ class Socket( this.stateChangeCallbacks.open.forEach { it.invoke() } } - private fun onConnectionClosed(code: Int) { + internal fun onConnectionClosed(code: Int) { this.logItems("Transport: close") this.triggerChannelError() // Prevent the heartbeat from triggering if the socket closed - this.heartbeatTask?.cancel(true) + this.heartbeatTask?.cancel() this.heartbeatTask = null // Inform callbacks the socket closed @@ -421,7 +424,7 @@ class Socket( } } - private fun onConnectionMessage(rawMessage: String) { + internal fun onConnectionMessage(rawMessage: String) { this.logItems("Receive: $rawMessage") // Parse the message as JSON @@ -439,7 +442,7 @@ class Socket( this.stateChangeCallbacks.message.forEach { it.invoke(message) } } - private fun onConnectionError(t: Throwable, response: Response?) { + internal fun onConnectionError(t: Throwable, response: Response?) { this.logItems("Transport: error $t") // Send an error to all channels diff --git a/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt b/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt index 8b3b30b..1faec4b 100644 --- a/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt +++ b/src/main/kotlin/org/phoenixframework/TimeoutTimer.kt @@ -1,38 +1,35 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework -import java.util.Timer -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit -import kotlin.concurrent.schedule - -// Copyright (c) 2019 Daniel Rees -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. /** * A Timer class that schedules a callback to be called in the future. Can be configured * to use a custom retry pattern, such as exponential backoff. */ class TimeoutTimer( - private val scheduledExecutorService: ScheduledExecutorService, + private val dispatchQueue: DispatchQueue, private val callback: () -> Unit, private val timerCalculation: (tries: Int) -> Long ) { @@ -41,7 +38,7 @@ class TimeoutTimer( private var tries: Int = 0 /** The task that has been scheduled to be executed */ - private var futureTask: ScheduledFuture<*>? = null + private var workItem: DispatchWorkItem? = null /** * Resets the Timer, clearing the number of current tries and stops @@ -58,10 +55,10 @@ class TimeoutTimer( // Schedule a task to be performed after the calculated timeout in milliseconds val timeout = timerCalculation(tries + 1) - this.futureTask = scheduledExecutorService.schedule({ + this.workItem = dispatchQueue.queue(timeout, TimeUnit.MILLISECONDS) { this.tries += 1 this.callback.invoke() - }, timeout, TimeUnit.MILLISECONDS) + } } //------------------------------------------------------------------------------ @@ -69,7 +66,7 @@ class TimeoutTimer( //------------------------------------------------------------------------------ private fun clearTimer() { // Cancel the task from completing, allowing it to fi - this.futureTask?.cancel(true) - this.futureTask = null + this.workItem?.cancel() + this.workItem = null } } \ No newline at end of file diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt index 556b195..dbe98c5 100644 --- a/src/main/kotlin/org/phoenixframework/Transport.kt +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2019 Daniel Rees + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + package org.phoenixframework import okhttp3.OkHttpClient @@ -7,27 +29,67 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import java.net.URL -enum class ReadyState { - CONNECTING, - OPEN, - CLOSING, - CLOSED -} - +/** + * Interface that defines different types of Transport layers. A default {@link WebSocketTransport} + * is provided which uses an OkHttp WebSocket to transport data between your Phoenix server. + * + * Future support may be added to provide your own custom Transport, such as a LongPoll + */ interface Transport { + /** Available ReadyStates of a {@link Transport}. */ + enum class ReadyState { + + /** The Transport is connecting to the server */ + CONNECTING, + + /** The Transport is connected and open */ + OPEN, + + /** The Transport is closing */ + CLOSING, + + /** The Transport is closed */ + CLOSED + } + + /** The state of the Transport. See {@link ReadyState} */ val readyState: ReadyState + /** Called when the Transport opens */ var onOpen: (() -> Unit)? + /** Called when the Transport receives an error */ var onError: ((Throwable, Response?) -> Unit)? + /** Called each time the Transport receives a message */ var onMessage: ((String) -> Unit)? + /** Called when the Transport closes */ var onClose: ((Int) -> Unit)? + /** Connect to the server */ fun connect() + + /** + * Disconnect from the Server + * + * @param code Status code as defined by Section 7.4 of RFC 6455. + * @param reason Reason for shutting down or {@code null}. + */ fun disconnect(code: Int, reason: String? = null) + + /** + * Sends text to the Server + */ fun send(data: String) } +/** + * A WebSocket implementation of a Transport that uses a WebSocket to facilitate sending + * and receiving data. + * + * @param url: URL to connect to + * @param okHttpClient: Custom client that can be pre-configured before connecting + */ class WebSocketTransport( private val url: URL, private val okHttpClient: OkHttpClient @@ -35,16 +97,16 @@ class WebSocketTransport( WebSocketListener(), Transport { - private var connection: WebSocket? = null + internal var connection: WebSocket? = null - override var readyState: ReadyState = ReadyState.CLOSED + override var readyState: Transport.ReadyState = Transport.ReadyState.CLOSED override var onOpen: (() -> Unit)? = null override var onError: ((Throwable, Response?) -> Unit)? = null override var onMessage: ((String) -> Unit)? = null override var onClose: ((Int) -> Unit)? = null override fun connect() { - this.readyState = ReadyState.CONNECTING + this.readyState = Transport.ReadyState.CONNECTING val request = Request.Builder().url(url).build() connection = okHttpClient.newWebSocket(request, this) } @@ -62,17 +124,17 @@ class WebSocketTransport( // WebSocket Listener //------------------------------------------------------------------------------ override fun onOpen(webSocket: WebSocket, response: Response) { - this.readyState = ReadyState.OPEN + this.readyState = Transport.ReadyState.OPEN this.onOpen?.invoke() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - this.readyState = ReadyState.CLOSED + this.readyState = Transport.ReadyState.CLOSED this.onError?.invoke(t, response) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - this.readyState = ReadyState.CLOSING + this.readyState = Transport.ReadyState.CLOSING } override fun onMessage(webSocket: WebSocket, text: String) { @@ -80,7 +142,7 @@ class WebSocketTransport( } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - this.readyState = ReadyState.CLOSED + this.readyState = Transport.ReadyState.CLOSED this.onClose?.invoke(code) } } \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/ChannelTest.kt b/src/test/kotlin/org/phoenixframework/ChannelTest.kt new file mode 100644 index 0000000..3803abd --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/ChannelTest.kt @@ -0,0 +1,1038 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.stubbing.Answer + +class ChannelTest { + + @Mock lateinit var socket: Socket + @Mock lateinit var mockCallback: ((Message) -> Unit) + + private val kDefaultRef = "1" + private val kDefaultTimeout = 10_000L + private val kDefaultPayload: Payload = mapOf("one" to "two") + private val kEmptyPayload: Payload = mapOf() + private val reconnectAfterMs: (Int) -> Long = Defaults.steppedBackOff + + lateinit var fakeClock: ManualDispatchQueue + lateinit var channel: Channel + + var mutableRef = 0 + var mutableRefAnswer: Answer = Answer { + mutableRef += 1 + mutableRef.toString() + } + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + mutableRef = 0 + fakeClock = ManualDispatchQueue() + + whenever(socket.dispatchQueue).thenReturn(fakeClock) + whenever(socket.makeRef()).thenReturn(kDefaultRef) + whenever(socket.timeout).thenReturn(kDefaultTimeout) + whenever(socket.reconnectAfterMs).thenReturn(reconnectAfterMs) + + channel = Channel("topic", kDefaultPayload, socket) + } + + @After + fun tearDown() { + fakeClock.reset() + } + + //------------------------------------------------------------------------------ + // Channel.Event + //------------------------------------------------------------------------------ + @Test + fun `isLifecycleEvent returns true for lifecycle events`() { + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.HEARTBEAT.value)).isFalse() + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.JOIN.value)).isTrue() + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.LEAVE.value)).isTrue() + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.REPLY.value)).isTrue() + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.ERROR.value)).isTrue() + assertThat(Channel.Event.isLifecycleEvent(Channel.Event.CLOSE.value)).isTrue() + assertThat(Channel.Event.isLifecycleEvent("random")).isFalse() + + } + //------------------------------------------------------------------------------ + // Channel Class + //------------------------------------------------------------------------------ + + /* constructor */ + @Test + fun `constructor() sets defaults`() { + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + assertThat(channel.topic).isEqualTo("topic") + assertThat(channel.params["one"]).isEqualTo("two") + assertThat(channel.socket).isEqualTo(socket) + assertThat(channel.timeout).isEqualTo(10_000L) + assertThat(channel.joinedOnce).isFalse() + assertThat(channel.joinPush).isNotNull() + assertThat(channel.pushBuffer).isEmpty() + } + + @Test + fun `constructor() sets up joinPush with literal params`() { + val joinPush = channel.joinPush + + assertThat(joinPush.channel).isEqualTo(channel) + assertThat(joinPush.payload["one"]).isEqualTo("two") + assertThat(joinPush.event).isEqualTo("phx_join") + assertThat(joinPush.timeout).isEqualTo(10_000L) + } + + /* onMessage */ + @Test + fun `onMessage() returns message by default`() { + val message = channel.onMessage.invoke(Message(ref = "original")) + assertThat(message.ref).isEqualTo("original") + } + + @Test + fun `onMessage() can be overidden`() { + channel.onMessage { Message(ref = "changed") } + + val message = channel.onMessage.invoke(Message(ref = "original")) + assertThat(message.ref).isEqualTo("changed") + } + + /* join params */ + @Test + fun `updating join params`() { + val params = mapOf("value" to 1) + val change = mapOf("value" to 2) + + channel = Channel("topic", params, socket) + val joinPush = channel.joinPush + + assertThat(joinPush.channel).isEqualTo(channel) + assertThat(joinPush.payload["value"]).isEqualTo(1) + assertThat(joinPush.event).isEqualTo("phx_join") + assertThat(joinPush.timeout).isEqualTo(10_000L) + + channel.params = change + assertThat(joinPush.channel).isEqualTo(channel) + assertThat(joinPush.payload["value"]).isEqualTo(2) + assertThat(channel.params["value"]).isEqualTo(2) + assertThat(joinPush.event).isEqualTo("phx_join") + assertThat(joinPush.timeout).isEqualTo(10_000L) + } + + /* join */ + @Test + fun `join() sets state to joining"`() { + channel.join() + assertThat(channel.state).isEqualTo(Channel.State.JOINING) + } + + @Test + fun `join() sets joinedOnce to true`() { + assertThat(channel.joinedOnce).isFalse() + + channel.join() + assertThat(channel.joinedOnce).isTrue() + } + + @Test + fun `join() throws if attempting to join multiple times`() { + var exceptionThrown = false + try { + channel.join() + channel.join() + } catch (e: Exception) { + exceptionThrown = true + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Tried to join channel multiple times. `join()` can only be called once per channel") + } + + assertThat(exceptionThrown).isTrue() + } + + @Test + fun `join() triggers socket push with channel params`() { + channel.join() + verify(socket).push("topic", "phx_join", kDefaultPayload, kDefaultRef, channel.joinRef) + } + + @Test + fun `join() can set timeout on joinPush`() { + val newTimeout = 20_000L + val joinPush = channel.joinPush + + assertThat(joinPush.timeout).isEqualTo(kDefaultTimeout) + channel.join(newTimeout) + assertThat(joinPush.timeout).isEqualTo(newTimeout) + } + + /* timeout behavior */ + @Test + fun `succeeds before timeout`() { + val joinPush = channel.joinPush + val timeout = channel.timeout + + channel.join() + verify(socket).push(any(), any(), any(), any(), any()) + + fakeClock.tick(timeout / 2) + + joinPush.trigger("ok", kEmptyPayload) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + + fakeClock.tick(timeout) + verify(socket, times(1)).push(any(), any(), any(), any(), any()) + } + + @Test + fun `retries with backoff after timeout`() { + var ref = 0 + whenever(socket.isConnected).thenReturn(true) + whenever(socket.makeRef()).thenAnswer { + ref += 1 + ref.toString() + } + + val joinPush = channel.joinPush + val timeout = channel.timeout + + channel.join() + verify(socket, times(1)).push(any(), any(), any(), any(), any()) + + fakeClock.tick(timeout) // leave push sent to the server + verify(socket, times(2)).push(any(), any(), any(), any(), any()) + + fakeClock.tick(1_000) // begin stepped backoff + verify(socket, times(3)).push(any(), any(), any(), any(), any()) + + fakeClock.tick(2_000) + verify(socket, times(4)).push(any(), any(), any(), any(), any()) + + fakeClock.tick(5_000) + verify(socket, times(5)).push(any(), any(), any(), any(), any()) + + fakeClock.tick(10_000) + verify(socket, times(6)).push(any(), any(), any(), any(), any()) + + joinPush.trigger("ok", kEmptyPayload) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + + fakeClock.tick(10_000) + verify(socket, times(6)).push(any(), any(), any(), any(), any()) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + } + + @Test + fun `with socket and join delay`() { + whenever(socket.isConnected).thenReturn(false) + val joinPush = channel.joinPush + + channel.join() + verify(socket, times(1)).push(any(), any(), any(), any(), any()) + + // Open the socket after a delay + fakeClock.tick(9_000) + verify(socket, times(1)).push(any(), any(), any(), any(), any()) + + // join request returns between timeouts + fakeClock.tick(1_000) + + whenever(socket.isConnected).thenReturn(true) + joinPush.trigger("ok", kEmptyPayload) + + assertThat(channel.state).isEqualTo(Channel.State.ERRORED) + + fakeClock.tick(1_000) + assertThat(channel.state).isEqualTo(Channel.State.JOINING) + + joinPush.trigger("ok", kEmptyPayload) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + + verify(socket, times(3)).push(any(), any(), any(), any(), any()) + } + + @Test + fun `with socket delay only`() { + whenever(socket.isConnected).thenReturn(false) + val joinPush = channel.joinPush + + channel.join() + + // connect socket after a delay + fakeClock.tick(6_000) + whenever(socket.isConnected).thenReturn(true) + + fakeClock.tick(5_000) + joinPush.trigger("ok", kEmptyPayload) + + fakeClock.tick(2_000) + assertThat(channel.state).isEqualTo(Channel.State.JOINING) + + joinPush.trigger("ok", kEmptyPayload) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + } + + /* Join Push */ + private fun setupJoinPushTests() { + whenever(socket.isConnected).thenReturn(true) + whenever(socket.makeRef()).thenAnswer(mutableRefAnswer) + channel.join() + } + + private fun receivesOk(joinPush: Push) { + fakeClock.tick(joinPush.timeout / 2) + joinPush.trigger("ok", mapOf("a" to "b")) + } + + private fun receivesTimeout(joinPush: Push) { + fakeClock.tick(joinPush.timeout * 2) + } + + private fun receivesError(joinPush: Push) { + fakeClock.tick(joinPush.timeout / 2) + joinPush.trigger("error", mapOf("a" to "b")) + } + + /* receives 'ok' */ + @Test + fun `joinPush - receivesOk - sets channel state to joined`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + assertThat(channel.state).isNotEqualTo(Channel.State.JOINED) + + receivesOk(joinPush) + assertThat(channel.state).isEqualTo(Channel.State.JOINED) + } + + @Test + fun `joinPush - receivesOk - triggers receive(ok) callback after ok response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockCallback = mock<(Message) -> Unit>() + joinPush.receive("ok", mockCallback) + + receivesOk(joinPush) + verify(mockCallback, times(1)).invoke(any()) + } + + @Test + fun `joinPush - receivesOk - triggers receive('ok') callback if ok response already received`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + receivesOk(joinPush) + + val mockCallback = mock<(Message) -> Unit>() + joinPush.receive("ok", mockCallback) + + verify(mockCallback, times(1)).invoke(any()) + } + + @Test + fun `joinPush - receivesOk - does not trigger other receive callbacks after ok response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockCallback = mock<(Message) -> Unit>() + joinPush + .receive("error", mockCallback) + .receive("timeout", mockCallback) + + receivesOk(joinPush) + receivesTimeout(joinPush) + verify(mockCallback, times(0)).invoke(any()) + } + + @Test + fun `joinPush - receivesOk - clears timeoutTimer workItem`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + assertThat(joinPush.timeoutTask).isNotNull() + + val mockTimeoutTask = mock() + joinPush.timeoutTask = mockTimeoutTask + + receivesOk(joinPush) + verify(mockTimeoutTask).cancel() + assertThat(joinPush.timeoutTask).isNull() + } + + @Test + fun `joinPush - receivesOk - sets receivedMessage`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + assertThat(joinPush.receivedMessage).isNull() + + receivesOk(joinPush) + assertThat(joinPush.receivedMessage?.payload).isEqualTo(mapOf("status" to "ok", "a" to "b")) + assertThat(joinPush.receivedMessage?.status).isEqualTo("ok") + } + + @Test + fun `joinPush - receivesOk - removes channel binding`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + var bindings = channel.getBindings("chan_reply_1") + assertThat(bindings).hasSize(1) + + receivesOk(joinPush) + bindings = channel.getBindings("chan_reply_1") + assertThat(bindings).isEmpty() + } + + @Test + fun `joinPush - receivesOk - resets channel rejoinTimer`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockRejoinTimer = mock() + channel.rejoinTimer = mockRejoinTimer + + receivesOk(joinPush) + verify(mockRejoinTimer, times(1)).reset() + } + + @Test + fun `joinPush - receivesOk - sends and empties channel's buffered pushEvents`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockPush = mock() + channel.pushBuffer.add(mockPush) + + receivesOk(joinPush) + verify(mockPush).send() + assertThat(channel.pushBuffer).isEmpty() + } + + /* receives 'timeout' */ + @Test + fun `joinPush - receives 'timeout' - sets channel state to errored`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + receivesTimeout(joinPush) + assertThat(channel.state).isEqualTo(Channel.State.ERRORED) + } + + @Test + fun `joinPush - receives 'timeout' - triggers receive('timeout') callback after ok response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockCallback = mock<(Message) -> Unit>() + joinPush.receive("timeout", mockCallback) + + receivesTimeout(joinPush) + verify(mockCallback).invoke(any()) + } + + @Test + fun `joinPush - receives 'timeout' - does not trigger other receive callbacks after timeout response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockOk = mock<(Message) -> Unit>() + val mockError = mock<(Message) -> Unit>() + val mockTimeout = mock<(Message) -> Unit>() + joinPush + .receive("ok", mockOk) + .receive("error", mockError) + .receive("timeout", mockTimeout) + + receivesTimeout(joinPush) + joinPush.trigger("ok", emptyMap()) + + verifyZeroInteractions(mockOk) + verifyZeroInteractions(mockError) + verify(mockTimeout).invoke(any()) + } + + @Test + fun `joinPush - receives 'timeout' - schedules rejoinTimer timeout`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockTimer = mock() + channel.rejoinTimer = mockTimer + + receivesTimeout(joinPush) + verify(mockTimer).scheduleTimeout() + } + + /* receives 'error' */ + @Test + fun `joinPush - receives 'error' - triggers receive('error') callback after error response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockCallback = mock<(Message) -> Unit>() + joinPush.receive("error", mockCallback) + + receivesError(joinPush) + verify(mockCallback).invoke(any()) + } + + @Test + fun `joinPush - receives 'error' - triggers receive('error') callback if error response already received"`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + receivesError(joinPush) + + val mockCallback = mock<(Message) -> Unit>() + joinPush.receive("error", mockCallback) + + verify(mockCallback).invoke(any()) + } + + @Test + fun `joinPush - receives 'error' - does not trigger other receive callbacks after ok response`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockCallback = mock<(Message) -> Unit>() + joinPush + .receive("ok", mockCallback) + .receive("timeout", mockCallback) + + receivesError(joinPush) + receivesTimeout(joinPush) + verifyZeroInteractions(mockCallback) + } + + @Test + fun `joinPush - receives 'error' - clears timeoutTimer workItem`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockTask = mock() + assertThat(joinPush.timeoutTask).isNotNull() + + joinPush.timeoutTask = mockTask + receivesError(joinPush) + + verify(mockTask).cancel() + assertThat(joinPush.timeoutTask).isNull() + } + + @Test + fun `joinPush - receives 'error' - sets receivedMessage`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + assertThat(joinPush.receivedMessage).isNull() + + receivesError(joinPush) + assertThat(joinPush.receivedMessage).isNotNull() + assertThat(joinPush.receivedMessage?.status).isEqualTo("error") + assertThat(joinPush.receivedMessage?.payload?.get("a")).isEqualTo("b") + } + + @Test + fun `joinPush - receives 'error' - removes channel binding`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + var bindings = channel.getBindings("chan_reply_1") + assertThat(bindings).hasSize(1) + + receivesError(joinPush) + bindings = channel.getBindings("chan_reply_1") + assertThat(bindings).isEmpty() + } + + @Test + fun `joinPush - receives 'error' - does not sets channel state to joined`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + receivesError(joinPush) + assertThat(channel.state).isNotEqualTo(Channel.State.JOINED) + } + + @Test + fun `joinPush - receives 'error' - does not trigger channel's buffered pushEvents`() { + setupJoinPushTests() + val joinPush = channel.joinPush + + val mockPush = mock() + channel.pushBuffer.add(mockPush) + + receivesError(joinPush) + verifyZeroInteractions(mockPush) + assertThat(channel.pushBuffer).hasSize(1) + } + + /* onError */ + private fun setupCallbackTests() { + whenever(socket.isConnected).thenReturn(true) + channel.join() + } + + @Test + fun `onError sets channel state to errored`() { + setupCallbackTests() + + assertThat(channel.state).isNotEqualTo(Channel.State.ERRORED) + + channel.trigger(Channel.Event.ERROR) + assertThat(channel.state).isEqualTo(Channel.State.ERRORED) + } + + @Test + fun `onError tries to rejoin with backoff`() { + setupCallbackTests() + + val mockTimer = mock() + channel.rejoinTimer = mockTimer + + channel.trigger(Channel.Event.ERROR) + verify(mockTimer).scheduleTimeout() + } + + @Test + fun `onError does not rejoin if leaving channel`() { + setupCallbackTests() + + channel.state = Channel.State.LEAVING + + val mockPush = mock() + channel.joinPush = mockPush + + channel.trigger(Channel.Event.ERROR) + + fakeClock.tick(1_000) + verify(mockPush, never()).send() + + fakeClock.tick(2_000) + verify(mockPush, never()).send() + + assertThat(channel.state).isEqualTo(Channel.State.LEAVING) + } + + @Test + fun `onError does nothing if channel is closed`() { + setupCallbackTests() + + channel.state = Channel.State.CLOSED + + val mockPush = mock() + channel.joinPush = mockPush + + channel.trigger(Channel.Event.ERROR) + + fakeClock.tick(1_000) + verify(mockPush, never()).send() + + fakeClock.tick(2_000) + verify(mockPush, never()).send() + + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + } + + @Test + fun `onError triggers additional callbacks`() { + setupCallbackTests() + + val mockCallback = mock<(Message) -> Unit>() + channel.onError(mockCallback) + + channel.trigger(Channel.Event.ERROR) + verify(mockCallback).invoke(any()) + } + + /* onClose */ + @Test + fun `onClose sets state to closed`() { + setupCallbackTests() + + assertThat(channel.state).isNotEqualTo(Channel.State.CLOSED) + + channel.trigger(Channel.Event.CLOSE) + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + } + + @Test + fun `onClose does not rejoin`() { + setupCallbackTests() + + val mockPush = mock() + channel.joinPush = mockPush + + channel.trigger(Channel.Event.CLOSE) + verify(mockPush, never()).send() + } + + @Test + fun `onClose resets the rejoin timer`() { + setupCallbackTests() + + val mockTimer = mock() + channel.rejoinTimer = mockTimer + + channel.trigger(Channel.Event.CLOSE) + verify(mockTimer).reset() + } + + @Test + fun `onClose removes self from socket`() { + setupCallbackTests() + + channel.trigger(Channel.Event.CLOSE) + verify(socket).remove(channel) + } + + @Test + fun `onClose triggers additional callbacks`() { + setupCallbackTests() + + val mockCallback = mock<(Message) -> Unit>() + channel.onClose(mockCallback) + + channel.trigger(Channel.Event.CLOSE) + verify(mockCallback).invoke(any()) + } + + /* canPush */ + @Test + fun `canPush returns true when socket connected and channel joined`() { + channel.state = Channel.State.JOINED + whenever(socket.isConnected).thenReturn(true) + + assertThat(channel.canPush).isTrue() + } + + @Test + fun `canPush otherwise returns false`() { + channel.state = Channel.State.JOINED + whenever(socket.isConnected).thenReturn(false) + assertThat(channel.canPush).isFalse() + + channel.state = Channel.State.JOINING + whenever(socket.isConnected).thenReturn(true) + assertThat(channel.canPush).isFalse() + + channel.state = Channel.State.JOINING + whenever(socket.isConnected).thenReturn(false) + assertThat(channel.canPush).isFalse() + } + + /* on(event:, callback:) */ + @Test + fun `on() sets up callback for event`() { + channel.trigger(event = "event", ref = kDefaultRef) + + channel.on("event", mockCallback) + channel.trigger(event = "event", ref = kDefaultRef) + verify(mockCallback, times(1)).invoke(any()) + } + + @Test + fun `on() other event callbacks are ignored`() { + val mockIgnoredCallback = mock<(Message) -> Unit>() + + channel.on("ignored_event", mockIgnoredCallback) + channel.trigger(event = "event", ref = kDefaultRef) + + channel.on("event", mockCallback) + channel.trigger(event = "event", ref = kDefaultRef) + + verify(mockIgnoredCallback, never()).invoke(any()) + } + + @Test + fun `on() generates unique refs for callbacks`() { + val ref1 = channel.on("event1") {} + val ref2 = channel.on("event2") {} + + assertThat(ref1).isNotEqualTo(ref2) + assertThat(ref1 + 1).isEqualTo(ref2) + } + + /* off */ + @Test + fun `off removes all callbacks for event`() { + val callback1 = mock<(Message) -> Unit>() + val callback2 = mock<(Message) -> Unit>() + val callback3 = mock<(Message) -> Unit>() + + channel.on("event", callback1) + channel.on("event", callback2) + channel.on("other", callback3) + + channel.off("event") + channel.trigger(event = "event", ref = kDefaultRef) + channel.trigger(event = "other", ref = kDefaultRef) + + verifyZeroInteractions(callback1) + verifyZeroInteractions(callback2) + verify(callback3, times(1)).invoke(any()) + } + + @Test + fun `off removes callback by ref`() { + val callback1 = mock<(Message) -> Unit>() + val callback2 = mock<(Message) -> Unit>() + + val ref1 = channel.on("event", callback1) + channel.on("event", callback2) + + channel.off("event", ref1) + channel.trigger(event = "event", ref = kDefaultRef) + + verifyZeroInteractions(callback1) + verify(callback2, times(1)).invoke(any()) + } + + /* push */ + @Test + fun `push sends push event when successfully joined`() { + whenever(socket.isConnected).thenReturn(true) + channel.join().trigger("ok", kEmptyPayload) + channel.push("event", mapOf("foo" to "bar")) + + verify(socket).push("topic", "event", mapOf("foo" to "bar"), channel.joinRef, kDefaultRef) + } + + @Test + fun `push enqueues push event to be sent once join has succeeded`() { + whenever(socket.isConnected).thenReturn(true) + + val joinPush = channel.join() + channel.push("event", mapOf("foo" to "bar")) + + verify(socket, never()).push(any(), any(), eq(mapOf("foo" to "bar")), any(), any()) + + fakeClock.tick(channel.timeout / 2) + joinPush.trigger("ok", kEmptyPayload) + + verify(socket).push(any(), any(), eq(mapOf("foo" to "bar")), any(), any()) + } + + @Test + fun `push does not push if channel join times out`() { + whenever(socket.isConnected).thenReturn(true) + + val joinPush = channel.join() + channel.push("event", mapOf("foo" to "bar")) + + verify(socket, never()).push(any(), any(), eq(mapOf("foo" to "bar")), any(), any()) + + fakeClock.tick(channel.timeout * 2) + joinPush.trigger("ok", kEmptyPayload) + + verify(socket, never()).push(any(), any(), eq(mapOf("foo" to "bar")), any(), any()) + } + + @Test + fun `push uses channel timeout by default`() { + whenever(socket.isConnected).thenReturn(true) + + channel.join().trigger("ok", kEmptyPayload) + channel + .push("event", mapOf("foo" to "bar")) + .receive("timeout", mockCallback) + + fakeClock.tick(channel.timeout / 2) + verifyZeroInteractions(mockCallback) + + fakeClock.tick(channel.timeout) + verify(mockCallback).invoke(any()) + } + + @Test + fun `push accepts timeout arg`() { + whenever(socket.isConnected).thenReturn(true) + + channel.join().trigger("ok", kEmptyPayload) + channel + .push("event", mapOf("foo" to "bar"), channel.timeout * 2) + .receive("timeout", mockCallback) + + fakeClock.tick(channel.timeout) + verifyZeroInteractions(mockCallback) + + fakeClock.tick(channel.timeout * 2) + verify(mockCallback).invoke(any()) + } + + @Test + fun `push does not time out after receiving 'ok'`() { + whenever(socket.isConnected).thenReturn(true) + + channel.join().trigger("ok", kEmptyPayload) + val push = channel + .push("event", mapOf("foo" to "bar"), channel.timeout * 2) + .receive("timeout", mockCallback) + + fakeClock.tick(channel.timeout / 2) + verifyZeroInteractions(mockCallback) + + push.trigger("ok", kEmptyPayload) + + fakeClock.tick(channel.timeout) + verifyZeroInteractions(mockCallback) + } + + @Test + fun `push throws if channel has not been joined`() { + whenever(socket.isConnected).thenReturn(true) + + var exceptionThrown = false + try { + channel.push("event", kEmptyPayload) + } catch (e: Exception) { + exceptionThrown = true + assertThat(e.message).isEqualTo( + "Tried to push event to topic before joining. Use channel.join() before pushing events") + } + + assertThat(exceptionThrown).isTrue() + } + + /* leave */ + private fun setupLeaveTests() { + whenever(socket.isConnected).thenReturn(true) + channel.join().trigger("ok", kEmptyPayload) + } + + @Test + fun `leave unsubscribes from server events`() { + this.setupLeaveTests() + + val joinRef = channel.joinRef + channel.leave() + + verify(socket).push("topic", "phx_leave", emptyMap(), joinRef, kDefaultRef) + } + + @Test + fun `leave closes channel on 'ok' from server`() { + this.setupLeaveTests() + + channel.leave().trigger("ok", kEmptyPayload) + verify(socket).remove(channel) + } + + @Test + fun `leave sets state to closed on 'ok' event`() { + this.setupLeaveTests() + + channel.leave().trigger("ok", kEmptyPayload) + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + } + + @Test + fun `leave sets state to leaving initially`() { + this.setupLeaveTests() + + channel.leave() + assertThat(channel.state).isEqualTo(Channel.State.LEAVING) + } + + @Test + fun `leave closes channel on timeout`() { + this.setupLeaveTests() + + channel.leave() + assertThat(channel.state).isEqualTo(Channel.State.LEAVING) + + fakeClock.tick(channel.timeout) + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + } + + @Test + fun `leave triggers immediately if cannot push`() { + whenever(socket.isConnected).thenReturn(false) + + channel.leave() + assertThat(channel.state).isEqualTo(Channel.State.CLOSED) + } + + /* State Accessors */ + @Test + fun `isClosed returns true if state is CLOSED`() { + channel.state = Channel.State.JOINED + assertThat(channel.isClosed).isFalse() + + channel.state = Channel.State.CLOSED + assertThat(channel.isClosed).isTrue() + } + + @Test + fun `isErrored returns true if state is ERRORED`() { + channel.state = Channel.State.JOINED + assertThat(channel.isErrored).isFalse() + + channel.state = Channel.State.ERRORED + assertThat(channel.isErrored).isTrue() + } + + @Test + fun `isJoined returns true if state is JOINED`() { + channel.state = Channel.State.JOINING + assertThat(channel.isJoined).isFalse() + + channel.state = Channel.State.JOINED + assertThat(channel.isJoined).isTrue() + } + + @Test + fun `isJoining returns true if state is JOINING`() { + channel.state = Channel.State.JOINED + assertThat(channel.isJoining).isFalse() + + channel.state = Channel.State.JOINING + assertThat(channel.isJoining).isTrue() + } + + @Test + fun `isLeaving returns true if state is LEAVING`() { + channel.state = Channel.State.JOINED + assertThat(channel.isLeaving).isFalse() + + channel.state = Channel.State.LEAVING + assertThat(channel.isLeaving).isTrue() + } + + /* isMember */ + @Test + fun `isMember returns false if topics are different`() { + val message = Message(topic = "other-topic") + assertThat(channel.isMember(message)).isFalse() + } + + @Test + fun `isMember drops outdated messages`() { + channel.joinPush.ref = "9" + val message = Message(topic = "topic", event = Channel.Event.LEAVE.value, joinRef = "7") + assertThat(channel.isMember(message)).isFalse() + } + + @Test + fun `isMember returns true if message belongs to channel`() { + val message = Message(topic = "topic", event = "msg:new") + assertThat(channel.isMember(message)).isTrue() + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/MessageTest.kt b/src/test/kotlin/org/phoenixframework/MessageTest.kt new file mode 100644 index 0000000..bae47a2 --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/MessageTest.kt @@ -0,0 +1,21 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MessageTest { + + @Test + fun `status returns the status from payload`() { + + val payload = mapOf("one" to "two", "status" to "ok") + val message = Message("ref", "topic", "event", payload, null) + + assertThat(message.ref).isEqualTo("ref") + assertThat(message.topic).isEqualTo("topic") + assertThat(message.event).isEqualTo("event") + assertThat(message.payload).isEqualTo(payload) + assertThat(message.joinRef).isNull() + assertThat(message.status).isEqualTo("ok") + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt b/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt deleted file mode 100644 index a32d76d..0000000 --- a/src/test/kotlin/org/phoenixframework/PhxChannelTest.kt +++ /dev/null @@ -1,202 +0,0 @@ -package org.phoenixframework - -import com.google.common.truth.Truth.assertThat -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito -import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations -import org.mockito.Spy -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -class PhxChannelTest { - - private val defaultRef = "1" - private val topic = "topic" - - @Spy - var socket: PhxSocket = PhxSocket("http://localhost:4000/socket/websocket") - lateinit var channel: PhxChannel - - @Before - fun setUp() { - MockitoAnnotations.initMocks(this) - Mockito.doReturn(defaultRef).`when`(socket).makeRef() - - socket.timeout = 1234 - channel = PhxChannel(topic, hashMapOf("one" to "two"), socket) - } - - - //------------------------------------------------------------------------------ - // Constructor - //------------------------------------------------------------------------------ - @Test - fun `constructor sets defaults`() { - assertThat(channel.isClosed).isTrue() - assertThat(channel.topic).isEqualTo("topic") - assertThat(channel.params["one"]).isEqualTo("two") - assertThat(channel.socket).isEqualTo(socket) - assertThat(channel.timeout).isEqualTo(1234) - assertThat(channel.joinedOnce).isFalse() - assertThat(channel.pushBuffer).isEmpty() - } - - @Test - fun `constructor sets up joinPush with params`() { - val joinPush = channel.joinPush - - assertThat(joinPush.channel).isEqualTo(channel) - assertThat(joinPush.payload["one"]).isEqualTo("two") - assertThat(joinPush.event).isEqualTo(PhxChannel.PhxEvent.JOIN.value) - assertThat(joinPush.timeout).isEqualTo(1234) - } - - - //------------------------------------------------------------------------------ - // Join - //------------------------------------------------------------------------------ - @Test - fun `it sets the state to joining`() { - channel.join() - assertThat(channel.isJoining).isTrue() - } - - @Test - fun `it updates the join parameters`() { - channel.join(hashMapOf("one" to "three")) - - val joinPush = channel.joinPush - assertThat(joinPush.payload["one"]).isEqualTo("three") - } - - @Test - fun `it sets joinedOnce to true`() { - assertThat(channel.joinedOnce).isFalse() - - channel.join() - assertThat(channel.joinedOnce).isTrue() - } - - @Test(expected = IllegalStateException::class) - fun `it throws if attempting to join multiple times`() { - channel.join() - channel.join() - } - - - - //------------------------------------------------------------------------------ - // .off() - //------------------------------------------------------------------------------ - @Test - fun `it removes all callbacks for events`() { - Mockito.doReturn(defaultRef).`when`(socket).makeRef() - - var aCalled = false - var bCalled = false - var cCalled = false - - channel.on("event") { aCalled = true } - channel.on("event") { bCalled = true } - channel.on("other") { cCalled = true } - - channel.off("event") - - channel.trigger(PhxMessage(event = "event", ref = defaultRef)) - channel.trigger(PhxMessage(event = "other", ref = defaultRef)) - - assertThat(aCalled).isFalse() - assertThat(bCalled).isFalse() - assertThat(cCalled).isTrue() - } - - @Test - fun `it removes callbacks by its ref`() { - var aCalled = false - var bCalled = false - - val aRef = channel.on("event") { aCalled = true } - channel.on("event") { bCalled = true } - - - channel.off("event", aRef) - - channel.trigger(PhxMessage(event = "event", ref = defaultRef)) - - assertThat(aCalled).isFalse() - assertThat(bCalled).isTrue() - } - - @Test - fun `Issue 22`() { - // This reproduces a concurrent modification exception. The original cause is most likely as follows: - // 1. Push (And receive) messages very quickly - // 2. PhxChannel.push, calls PhxPush.send() - // 3. PhxPush calls startTimeout(). - // 4. PhxPush.startTimeout() calls this.channel.on(refEvent) - This modifies the bindings list - // 5. any trigger (possibly from a timeout) can be iterating through the binding list that was modified in step 4. - - val f1 = CompletableFuture.runAsync { - for (i in 0..1000) { - channel.on("event-$i") { /** do nothing **/ } - } - } - val f3 = CompletableFuture.runAsync { - for (i in 0..1000) { - channel.trigger(PhxMessage(event = "event-$i", ref = defaultRef)) - } - } - - CompletableFuture.allOf(f1, f3).get(10, TimeUnit.SECONDS) - } - - @Test - fun `issue 36 - verify timeouts remove bindings`() { - // mock okhttp to get isConnected to return true for the socket - val mockOkHttp = Mockito.mock(OkHttpClient::class.java) - val mockSocket = Mockito.mock(WebSocket::class.java) - `when`(mockOkHttp.newWebSocket(ArgumentMatchers.any(Request::class.java), ArgumentMatchers.any(WebSocketListener::class.java))).thenReturn(mockSocket) - - // local mocks for this test - val localSocket = Mockito.spy(PhxSocket(url = "http://localhost:4000/socket/websocket", client = mockOkHttp)) - val localChannel = PhxChannel(topic, hashMapOf("one" to "two"), localSocket) - - // setup makeRef so it increments - val refCounter = AtomicInteger(1) - Mockito.doAnswer { - refCounter.getAndIncrement().toString() - }.`when`(localSocket).makeRef() - - //connect the socket - localSocket.connect() - - //join the channel - val joinPush = localChannel.join() - localChannel.trigger(PhxMessage( - ref = joinPush.ref!!, - joinRef = joinPush.ref!!, - event = PhxChannel.PhxEvent.REPLY.value, - topic = topic, - payload = mutableMapOf("status" to "ok"))) - - //get bindings - val originalBindingsSize = localChannel.bindings.size - val pushCount = 100 - repeat(pushCount) { - localChannel.push("some-event", mutableMapOf(), timeout = 500) - } - //verify binding count before timeouts - assertThat(localChannel.bindings.size).isEqualTo(originalBindingsSize + pushCount) - Thread.sleep(1000) - //verify binding count after timeouts - assertThat(localChannel.bindings.size).isEqualTo(originalBindingsSize) - } -} diff --git a/src/test/kotlin/org/phoenixframework/PhxMessageTest.kt b/src/test/kotlin/org/phoenixframework/PhxMessageTest.kt deleted file mode 100644 index d8131d0..0000000 --- a/src/test/kotlin/org/phoenixframework/PhxMessageTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.phoenixframework - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class PhxMessageTest { - - @Test - fun getStatus_returnsPayloadStatus() { - - val payload = hashMapOf("status" to "ok", "topic" to "chat:1") - - val message = PhxMessage("ref1", "chat:1", "event1", payload) - - assertThat(message.ref).isEqualTo("ref1") - assertThat(message.topic).isEqualTo("chat:1") - assertThat(message.event).isEqualTo("event1") - assertThat(message.payload["topic"]).isEqualTo("chat:1") - assertThat(message.status).isEqualTo("ok") - - } -} diff --git a/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt b/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt deleted file mode 100644 index 851f3b7..0000000 --- a/src/test/kotlin/org/phoenixframework/PhxSocketTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.phoenixframework - -import com.google.common.truth.Truth.assertThat -import okhttp3.WebSocket -import org.junit.Test -import org.mockito.Mockito - -class PhxSocketTest { - - @Test - fun init_buildsUrlProper() { - assertThat(PhxSocket("http://localhost:4000/socket/websocket").endpoint.toString()) - .isEqualTo("http://localhost:4000/socket/websocket") - - assertThat(PhxSocket("https://localhost:4000/socket/websocket").endpoint.toString()) - .isEqualTo("https://localhost:4000/socket/websocket") - - assertThat(PhxSocket("ws://localhost:4000/socket/websocket").endpoint.toString()) - .isEqualTo("http://localhost:4000/socket/websocket") - - assertThat(PhxSocket("wss://localhost:4000/socket/websocket").endpoint.toString()) - .isEqualTo("https://localhost:4000/socket/websocket") - - - // test params - val singleParam = hashMapOf("token" to "abc123") - assertThat(PhxSocket("ws://localhost:4000/socket/websocket", singleParam).endpoint.toString()) - .isEqualTo("http://localhost:4000/socket/websocket?token=abc123") - - - val multipleParams = hashMapOf("token" to "abc123", "user_id" to 1) - assertThat(PhxSocket("http://localhost:4000/socket/websocket", multipleParams).endpoint.toString()) - .isEqualTo("http://localhost:4000/socket/websocket?user_id=1&token=abc123") - - - // test params with spaces - val spacesParams = hashMapOf("token" to "abc 123", "user_id" to 1) - assertThat(PhxSocket("wss://localhost:4000/socket/websocket", spacesParams).endpoint.toString()) - .isEqualTo("https://localhost:4000/socket/websocket?user_id=1&token=abc%20123") - } - - @Test - fun isConnected_isTrue_WhenSocketConnected() { - val mockSocket = Mockito.mock(WebSocket::class.java) - val socket = PhxSocket("http://localhost:4000/socket/websocket") - - socket.onOpen(mockSocket, null) - - assertThat(socket.isConnected).isTrue() - } - - @Test - fun isConnected_isFalse_WhenSocketNotYetConnected() { - val socket = PhxSocket("http://localhost:4000/socket/websocket") - - - assertThat(socket.isConnected).isFalse() - } - - @Test - fun isConnected_isFalse_WhenSocketFailed() { - val mockSocket = Mockito.mock(WebSocket::class.java) - val socket = PhxSocket("http://localhost:4000/socket/websocket") - - socket.onFailure(mockSocket, RuntimeException(), null) - - - assertThat(socket.isConnected).isFalse() - } - - @Test - fun isConnected_isFalse_WhenSocketClosed() { - val mockSocket = Mockito.mock(WebSocket::class.java) - val socket = PhxSocket("http://localhost:4000/socket/websocket") - - socket.onClosed(mockSocket, 0, "closed") - - - assertThat(socket.isConnected).isFalse() - } -} diff --git a/src/test/kotlin/org/phoenixframework/PresenceTest.kt b/src/test/kotlin/org/phoenixframework/PresenceTest.kt new file mode 100644 index 0000000..2c1d291 --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/PresenceTest.kt @@ -0,0 +1,449 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class PresenceTest { + + @Mock lateinit var socket: Socket + + private val fixJoins: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf(mapOf("id" to 1, "phx_ref" to "1.2")))) + private val fixLeaves: PresenceState = mutableMapOf( + "u2" to mutableMapOf("metas" to listOf(mapOf("id" to 2, "phx_ref" to "2")))) + private val fixState: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf(mapOf("id" to 1, "phx_ref" to "1"))), + "u2" to mutableMapOf("metas" to listOf(mapOf("id" to 2, "phx_ref" to "2"))), + "u3" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3"))) + ) + + private val listByFirst: (Map.Entry) -> PresenceMeta = + { it.value["metas"]!!.first() } + + lateinit var channel: Channel + lateinit var presence: Presence + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + whenever(socket.timeout).thenReturn(Defaults.TIMEOUT) + whenever(socket.makeRef()).thenReturn("1") + whenever(socket.reconnectAfterMs).thenReturn { 1_000 } + whenever(socket.dispatchQueue).thenReturn(mock()) + + channel = Channel("topic", mapOf(), socket) + channel.joinPush.ref = "1" + + presence = Presence(channel) + } + + /* constructor */ + @Test + fun `constructor() sets defaults`() { + assertThat(presence.state).isEmpty() + assertThat(presence.pendingDiffs).isEmpty() + assertThat(presence.channel).isEqualTo(channel) + assertThat(presence.joinRef).isNull() + } + + @Test + fun `constructor() binds to channel with default arguments`() { + assertThat(presence.channel.getBindings("presence_state")).hasSize(1) + assertThat(presence.channel.getBindings("presence_diff")).hasSize(1) + } + + @Test + fun `constructor() binds to channel with custom options`() { + val channel = Channel("topic", mapOf(), socket) + val customOptions = Presence.Options(mapOf( + Presence.Events.STATE to "custom_state", + Presence.Events.DIFF to "custom_diff")) + + val p = Presence(channel, customOptions) + assertThat(p.channel.getBindings("presence_state")).isEmpty() + assertThat(p.channel.getBindings("presence_diff")).isEmpty() + assertThat(p.channel.getBindings("custom_state")).hasSize(1) + assertThat(p.channel.getBindings("custom_diff")).hasSize(1) + } + + @Test + fun `constructor() syncs state and diffs`() { + val user1: PresenceMap = mutableMapOf("metas" to mutableListOf( + mapOf("id" to 1, "phx_ref" to "1"))) + val user2: PresenceMap = mutableMapOf("metas" to mutableListOf( + mapOf("id" to 2, "phx_ref" to "2"))) + val newState: PresenceState = mutableMapOf("u1" to user1, "u2" to user2) + + channel.trigger("presence_state", newState, "1") + val s = presence.listBy(listByFirst) + assertThat(s).hasSize(2) + assertThat(s[0]["id"]).isEqualTo(1) + assertThat(s[0]["phx_ref"]).isEqualTo("1") + + assertThat(s[1]["id"]).isEqualTo(2) + assertThat(s[1]["phx_ref"]).isEqualTo("2") + + channel.trigger("presence_diff", mapOf("joins" to emptyMap(), "leaves" to mapOf("u1" to user1))) + val l = presence.listBy(listByFirst) + assertThat(l).hasSize(1) + assertThat(l[0]["id"]).isEqualTo(2) + assertThat(l[0]["phx_ref"]).isEqualTo("2") + } + + @Test + fun `constructor() applies pending diff if state is not yet synced`() { + val onJoins = mutableListOf>() + val onLeaves = mutableListOf>() + + presence.onJoin { key, current, new -> onJoins.add(Triple(key, current, new)) } + presence.onLeave { key, current, left -> onLeaves.add(Triple(key, current, left)) } + + val user1 = mutableMapOf("metas" to mutableListOf(mutableMapOf("id" to 1, "phx_ref" to "1"))) + val user2 = mutableMapOf("metas" to mutableListOf(mutableMapOf("id" to 2, "phx_ref" to "2"))) + val user3 = mutableMapOf("metas" to mutableListOf(mutableMapOf("id" to 3, "phx_ref" to "3"))) + + val newState = mutableMapOf("u1" to user1, "u2" to user2) + val leaves = mapOf("u2" to user2) + + val payload1 = mapOf("joins" to emptyMap(), "leaves" to leaves) + channel.trigger("presence_diff", payload1, "") + + // There is no state + assertThat(presence.listBy(listByFirst)).isEmpty() + + // pending diffs 1 + assertThat(presence.pendingDiffs).hasSize(1) + assertThat(presence.pendingDiffs[0]["joins"]).isEmpty() + assertThat(presence.pendingDiffs[0]["leaves"]).isEqualTo(leaves) + + channel.trigger("presence_state", newState, "") + assertThat(onLeaves).hasSize(1) + assertThat(onLeaves[0].first).isEqualTo("u2") + assertThat(onLeaves[0].second["metas"]).isEmpty() + assertThat(onLeaves[0].third["metas"]!![0]["id"]).isEqualTo(2) + + val s = presence.listBy(listByFirst) + assertThat(s).hasSize(1) + assertThat(s[0]["id"]).isEqualTo(1) + assertThat(s[0]["phx_ref"]).isEqualTo("1") + assertThat(presence.pendingDiffs).isEmpty() + + assertThat(onJoins).hasSize(2) + assertThat(onJoins[0].first).isEqualTo("u1") + assertThat(onJoins[0].second).isNull() + assertThat(onJoins[0].third["metas"]!![0]["id"]).isEqualTo(1) + + assertThat(onJoins[1].first).isEqualTo("u2") + assertThat(onJoins[1].second).isNull() + assertThat(onJoins[1].third["metas"]!![0]["id"]).isEqualTo(2) + + // disconnect then reconnect + assertThat(presence.isPendingSyncState).isFalse() + channel.joinPush.ref = "2" + assertThat(presence.isPendingSyncState).isTrue() + + + channel.trigger("presence_diff", + mapOf("joins" to mapOf(), "leaves" to mapOf("u1" to user1))) + val d = presence.listBy(listByFirst) + assertThat(d).hasSize(1) + assertThat(d[0]["id"]).isEqualTo(1) + assertThat(d[0]["phx_ref"]).isEqualTo("1") + + + channel.trigger("presence_state", + mapOf("u1" to user1, "u3" to user3)) + val s2 = presence.listBy(listByFirst) + assertThat(s2).hasSize(1) + assertThat(s2[0]["id"]).isEqualTo(3) + assertThat(s2[0]["phx_ref"]).isEqualTo("3") + } + + /* sync state */ + @Test + fun `syncState() syncs empty state`() { + val newState: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf(mapOf("id" to 1, "phx_ref" to "1")))) + var state: PresenceState = mutableMapOf() + val stateBefore = state + + Presence.syncState(state, newState) + assertThat(state).isEqualTo(stateBefore) + + state = Presence.syncState(state, newState) + assertThat(state).isEqualTo(newState) + } + + @Test + fun `syncState() onJoins new presences and onLeaves left presences`() { + val newState = fixState + var state = mutableMapOf( + "u4" to mutableMapOf("metas" to listOf(mapOf("id" to 4, "phx_ref" to "4")))) + + val joined: PresenceDiff = mutableMapOf() + val left: PresenceDiff = mutableMapOf() + + val onJoin: OnJoin = { key, current, newPres -> + val joinState: PresenceState = mutableMapOf("newPres" to newPres) + current?.let { c -> joinState["current"] = c } + + joined[key] = joinState + } + + val onLeave: OnLeave = { key, current, leftPres -> + left[key] = mutableMapOf("current" to current, "leftPres" to leftPres) + } + + val stateBefore = state + Presence.syncState(state, newState, onJoin, onLeave) + assertThat(state).isEqualTo(stateBefore) + + state = Presence.syncState(state, newState, onJoin, onLeave) + assertThat(state).isEqualTo(newState) + + // asset equality in joined + val joinedExpectation: PresenceDiff = mutableMapOf( + "u1" to mutableMapOf("newPres" to mutableMapOf( + "metas" to listOf(mapOf("id" to 1, "phx_ref" to "1")))), + "u2" to mutableMapOf("newPres" to mutableMapOf( + "metas" to listOf(mapOf("id" to 2, "phx_ref" to "2")))), + "u3" to mutableMapOf("newPres" to mutableMapOf( + "metas" to listOf(mapOf("id" to 3, "phx_ref" to "3")))) + ) + + assertThat(joined).isEqualTo(joinedExpectation) + + // assert equality in left + val leftExpectation: PresenceDiff = mutableMapOf( + "u4" to mutableMapOf( + "current" to mutableMapOf( + "metas" to mutableListOf()), + "leftPres" to mutableMapOf( + "metas" to listOf(mapOf("id" to 4, "phx_ref" to "4")))) + ) + assertThat(left).isEqualTo(leftExpectation) + } + + @Test + fun `syncState() onJoins only newly added metas`() { + var state = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3")))) + val newState = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.new") + ))) + + val joined: PresenceDiff = mutableMapOf() + val left: PresenceDiff = mutableMapOf() + + val onJoin: OnJoin = { key, current, newPres -> + val joinState: PresenceState = mutableMapOf("newPres" to newPres) + current?.let { c -> joinState["current"] = c } + + joined[key] = joinState + } + + val onLeave: OnLeave = { key, current, leftPres -> + left[key] = mutableMapOf("current" to current, "leftPres" to leftPres) + } + + state = Presence.syncState(state, newState, onJoin, onLeave) + assertThat(state).isEqualTo(newState) + + // asset equality in joined + val joinedExpectation: PresenceDiff = mutableMapOf( + "u3" to mutableMapOf( + "newPres" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3.new"))), + "current" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3"))) + + )) + assertThat(joined).isEqualTo(joinedExpectation) + + // assert equality in left + assertThat(left).isEmpty() + } + + @Test + fun `syncState() onLeaves only newly removed metas`() { + val newState = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3")))) + var state = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.left") + ))) + + val joined: PresenceDiff = mutableMapOf() + val left: PresenceDiff = mutableMapOf() + + val onJoin: OnJoin = { key, current, newPres -> + val joinState: PresenceState = mutableMapOf("newPres" to newPres) + current?.let { c -> joinState["current"] = c } + + joined[key] = joinState + } + + val onLeave: OnLeave = { key, current, leftPres -> + left[key] = mutableMapOf("current" to current, "leftPres" to leftPres) + } + + state = Presence.syncState(state, newState, onJoin, onLeave) + assertThat(state).isEqualTo(newState) + + // asset equality in joined + val leftExpectation: PresenceDiff = mutableMapOf( + "u3" to mutableMapOf( + "leftPres" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3.left"))), + "current" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3"))) + + )) + assertThat(left).isEqualTo(leftExpectation) + + // assert equality in left + assertThat(joined).isEmpty() + } + + @Test + fun `syncState() syncs both joined and left metas`() { + + val newState = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.new") + ))) + + var state = mutableMapOf( + "u3" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.left") + ))) + + val joined: PresenceDiff = mutableMapOf() + val left: PresenceDiff = mutableMapOf() + + val onJoin: OnJoin = { key, current, newPres -> + val joinState: PresenceState = mutableMapOf("newPres" to newPres) + current?.let { c -> joinState["current"] = c } + + joined[key] = joinState + } + + val onLeave: OnLeave = { key, current, leftPres -> + left[key] = mutableMapOf("current" to current, "leftPres" to leftPres) + } + + state = Presence.syncState(state, newState, onJoin, onLeave) + assertThat(state).isEqualTo(newState) + + // asset equality in joined + val joinedExpectation: PresenceDiff = mutableMapOf( + "u3" to mutableMapOf( + "newPres" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3.new"))), + "current" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.left"))) + )) + assertThat(joined).isEqualTo(joinedExpectation) + + // assert equality in left + val leftExpectation: PresenceDiff = mutableMapOf( + "u3" to mutableMapOf( + "leftPres" to mutableMapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3.left"))), + "current" to mutableMapOf("metas" to listOf( + mapOf("id" to 3, "phx_ref" to "3"), + mapOf("id" to 3, "phx_ref" to "3.new"))) + )) + assertThat(left).isEqualTo(leftExpectation) + } + + /* syncDiff */ + @Test + fun `syncDiff() syncs empty state`() { + val joins: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to + listOf(mapOf("id" to 1, "phx_ref" to "1")))) + var state: PresenceState = mutableMapOf() + + Presence.syncDiff(state, mutableMapOf("joins" to joins, "leaves" to mutableMapOf())) + assertThat(state).isEmpty() + + state = Presence.syncDiff(state, mutableMapOf("joins" to joins, "leaves" to mutableMapOf())) + assertThat(state).isEqualTo(joins) + } + + @Test + fun `syncDiff() removes presence when meta is empty and adds additional meta`() { + var state = fixState + val diff: PresenceDiff = mutableMapOf("joins" to fixJoins, "leaves" to fixLeaves) + state = Presence.syncDiff(state, diff) + + val expectation: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to + listOf( + mapOf("id" to 1, "phx_ref" to "1"), + mapOf("id" to 1, "phx_ref" to "1.2") + ) + ), + "u3" to mutableMapOf("metas" to + listOf(mapOf("id" to 3, "phx_ref" to "3")) + ) + ) + + assertThat(state).isEqualTo(expectation) + } + + @Test + fun `syncDiff() removes meta while leaving key if other metas exist`() { + var state = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf( + mapOf("id" to 1, "phx_ref" to "1"), + mapOf("id" to 1, "phx_ref" to "1.2") + ))) + + val leaves = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf( + mapOf("id" to 1, "phx_ref" to "1") + ))) + val diff: PresenceDiff = mutableMapOf("joins" to mutableMapOf(), "leaves" to leaves) + state = Presence.syncDiff(state, diff) + + val expectedState = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf(mapOf("id" to 1, "phx_ref" to "1.2")))) + assertThat(state).isEqualTo(expectedState) + } + + /* listBy */ + @Test + fun `listBy() lists full presence by default`() { + presence.state = fixState + + val listExpectation = listOf( + mapOf("metas" to listOf(mapOf("id" to 1, "phx_ref" to "1"))), + mapOf("metas" to listOf(mapOf("id" to 2, "phx_ref" to "2"))), + mapOf("metas" to listOf(mapOf("id" to 3, "phx_ref" to "3"))) + ) + + assertThat(presence.list()).isEqualTo(listExpectation) + } + + @Test + fun `listBy() lists with custom function`() { + val state: PresenceState = mutableMapOf( + "u1" to mutableMapOf("metas" to listOf( + mapOf("id" to 1, "phx_ref" to "1.first"), + mapOf("id" to 1, "phx_ref" to "1.second")) + ) + ) + + presence.state = state + val listBy = presence.listBy { it.value["metas"]!!.first() } + assertThat(listBy).isEqualTo(listOf(mapOf("id" to 1, "phx_ref" to "1.first"))) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/SocketTest.kt b/src/test/kotlin/org/phoenixframework/SocketTest.kt new file mode 100644 index 0000000..8edf55d --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/SocketTest.kt @@ -0,0 +1,683 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.spy +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import okhttp3.OkHttpClient +import okhttp3.Response +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import java.net.URL +import java.util.concurrent.TimeUnit + +class SocketTest { + + @Mock lateinit var okHttpClient: OkHttpClient + @Mock lateinit var mockDispatchQueue: DispatchQueue + + lateinit var connection: Transport + lateinit var socket: Socket + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + connection = spy(WebSocketTransport(URL("https://localhost:4000/socket"), okHttpClient)) + + socket = Socket("wss://localhost:4000/socket") + socket.transport = { connection } + socket.dispatchQueue = mockDispatchQueue + + } + + /* constructor */ + @Test + fun `constructor sets defaults`() { + val socket = Socket("wss://localhost:4000/socket") + + assertThat(socket.params).isNull() + assertThat(socket.channels).isEmpty() + assertThat(socket.sendBuffer).isEmpty() + assertThat(socket.ref).isEqualTo(0) + assertThat(socket.endpoint).isEqualTo("wss://localhost:4000/socket/websocket") + assertThat(socket.stateChangeCallbacks.open).isEmpty() + assertThat(socket.stateChangeCallbacks.close).isEmpty() + assertThat(socket.stateChangeCallbacks.error).isEmpty() + assertThat(socket.stateChangeCallbacks.message).isEmpty() + assertThat(socket.timeout).isEqualTo(Defaults.TIMEOUT) + assertThat(socket.heartbeatInterval).isEqualTo(Defaults.HEARTBEAT) + assertThat(socket.logger).isNull() + assertThat(socket.reconnectAfterMs(1)).isEqualTo(1000) + assertThat(socket.reconnectAfterMs(2)).isEqualTo(2000) + assertThat(socket.reconnectAfterMs(3)).isEqualTo(5000) + assertThat(socket.reconnectAfterMs(4)).isEqualTo(10000) + assertThat(socket.reconnectAfterMs(5)).isEqualTo(10000) + } + + @Test + fun `constructor overrides some defaults`() { + val socket = Socket("wss://localhost:4000/socket/", mapOf("one" to 2)) + socket.timeout = 40_000 + socket.heartbeatInterval = 60_000 + socket.logger = { } + socket.reconnectAfterMs = { 10 } + + assertThat(socket.params).isEqualTo(mapOf("one" to 2)) + assertThat(socket.endpoint).isEqualTo("wss://localhost:4000/socket/websocket") + assertThat(socket.timeout).isEqualTo(40_000) + assertThat(socket.heartbeatInterval).isEqualTo(60_000) + assertThat(socket.logger).isNotNull() + assertThat(socket.reconnectAfterMs(1)).isEqualTo(10) + assertThat(socket.reconnectAfterMs(2)).isEqualTo(10) + } + + @Test + fun `constructor constructs with a valid URL`() { + // Test different schemes + assertThat(Socket("http://localhost:4000/socket/websocket").endpointUrl.toString()) + .isEqualTo("http://localhost:4000/socket/websocket") + + assertThat(Socket("https://localhost:4000/socket/websocket").endpointUrl.toString()) + .isEqualTo("https://localhost:4000/socket/websocket") + + assertThat(Socket("ws://localhost:4000/socket/websocket").endpointUrl.toString()) + .isEqualTo("http://localhost:4000/socket/websocket") + + assertThat(Socket("wss://localhost:4000/socket/websocket").endpointUrl.toString()) + .isEqualTo("https://localhost:4000/socket/websocket") + + // test params + val singleParam = hashMapOf("token" to "abc123") + assertThat(Socket("ws://localhost:4000/socket/websocket", singleParam).endpointUrl.toString()) + .isEqualTo("http://localhost:4000/socket/websocket?token=abc123") + + val multipleParams = hashMapOf("token" to "abc123", "user_id" to 1) + assertThat( + Socket("http://localhost:4000/socket/websocket", multipleParams).endpointUrl.toString()) + .isEqualTo("http://localhost:4000/socket/websocket?user_id=1&token=abc123") + + // test params with spaces + val spacesParams = hashMapOf("token" to "abc 123", "user_id" to 1) + assertThat(Socket("wss://localhost:4000/socket/websocket", spacesParams).endpointUrl.toString()) + .isEqualTo("https://localhost:4000/socket/websocket?user_id=1&token=abc%20123") + } + + + /* protocol */ + @Test + fun `protocol returns wss when protocol is https`() { + val socket = Socket("https://example.com/") + assertThat(socket.protocol).isEqualTo("wss") + } + + @Test + fun `protocol returns ws when protocol is http`() { + val socket = Socket("http://example.com/") + assertThat(socket.protocol).isEqualTo("ws") + } + + @Test + fun `protocol returns value if not https or http`() { + val socket = Socket("wss://example.com/") + assertThat(socket.protocol).isEqualTo("wss") + } + + /* isConnected */ + @Test + fun `isConnected returns false if connection is null`() { + assertThat(socket.isConnected).isFalse() + } + + @Test + fun `isConnected is false if state is not open`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.CLOSING) + + socket.connection = connection + assertThat(socket.isConnected).isFalse() + } + + @Test + fun `isConnected is true if state open`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + + socket.connection = connection + assertThat(socket.isConnected).isTrue() + } + + + /* connect() */ + @Test + fun `connect() establishes websocket connection with endpoint`() { + socket.connect() + assertThat(socket.connection).isNotNull() + } + + @Test + fun `connect() sets callbacks for connection`() { + var open = 0 + socket.onOpen { open += 1 } + + var close = 0 + socket.onClose { close += 1 } + + var lastError: Throwable? = null + var lastResponse: Response? = null + socket.onError { throwable, response -> + lastError = throwable + lastResponse = response + } + + var lastMessage: Message? = null + socket.onMessage { lastMessage = it } + + socket.connect() + + socket.connection?.onOpen?.invoke() + assertThat(open).isEqualTo(1) + + socket.connection?.onClose?.invoke(1000) + assertThat(close).isEqualTo(1) + + socket.connection?.onError?.invoke(Throwable(), null) + assertThat(lastError).isNotNull() + assertThat(lastResponse).isNull() + + val data = mapOf( + "topic" to "topic", + "event" to "event", + "payload" to mapOf("go" to true), + "status" to "status" + ) + + val json = Defaults.gson.toJson(data) + socket.connection?.onMessage?.invoke(json) + assertThat(lastMessage?.payload).isEqualTo(mapOf("go" to true)) + } + + @Test + fun `connect() removes callbacks`() { + var open = 0 + socket.onOpen { open += 1 } + + var close = 0 + socket.onClose { close += 1 } + + var lastError: Throwable? = null + var lastResponse: Response? = null + socket.onError { throwable, response -> + lastError = throwable + lastResponse = response + } + + var lastMessage: Message? = null + socket.onMessage { lastMessage = it } + + socket.removeAllCallbacks() + socket.connect() + + socket.connection?.onOpen?.invoke() + assertThat(open).isEqualTo(0) + + socket.connection?.onClose?.invoke(1000) + assertThat(close).isEqualTo(0) + + socket.connection?.onError?.invoke(Throwable(), null) + assertThat(lastError).isNull() + assertThat(lastResponse).isNull() + + val data = mapOf( + "topic" to "topic", + "event" to "event", + "payload" to mapOf("go" to true), + "status" to "status" + ) + + val json = Defaults.gson.toJson(data) + socket.connection?.onMessage?.invoke(json) + assertThat(lastMessage?.payload).isNull() + } + + @Test + fun `connect() does not connect if already connected`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + socket.connect() + + verify(connection, times(1)).connect() + } + + /* disconnect */ + @Test + fun `disconnect() removes existing connection`() { + socket.connect() + socket.disconnect() + + assertThat(socket.connection).isNull() + verify(connection).disconnect(WS_CLOSE_NORMAL) + } + + @Test + fun `disconnect() calls callback`() { + val mockCallback = mock<() -> Unit> {} + + socket.disconnect(callback = mockCallback) + verify(mockCallback).invoke() + } + + @Test + fun `disconnect() calls connection close callback`() { + socket.connect() + socket.disconnect(10, "reason") + verify(connection).disconnect(10, "reason") + } + + @Test + fun `disconnect() resets reconnect timer`() { + val mockTimer = mock() + socket.reconnectTimer = mockTimer + + socket.disconnect() + verify(mockTimer).reset() + } + + @Test + fun `disconnect() cancels and releases heartbeat timer`() { + val mockTask = mock() + socket.heartbeatTask = mockTask + + socket.disconnect() + verify(mockTask).cancel() + assertThat(socket.heartbeatTask).isNull() + } + + @Test + fun `disconnect() does nothing if not connected`() { + socket.disconnect() + verifyZeroInteractions(connection) + } + + /* channel */ + @Test + fun `channel() returns channel with given topic and params`() { + val channel = socket.channel("topic", mapOf("one" to "two")) + + assertThat(channel.socket).isEqualTo(socket) + assertThat(channel.topic).isEqualTo("topic") + assertThat(channel.params["one"]).isEqualTo("two") + } + + @Test + fun `channel() adds channel to socket's channel list`() { + assertThat(socket.channels).isEmpty() + + val channel = socket.channel("topic", mapOf("one" to "two")) + + assertThat(socket.channels).hasSize(1) + assertThat(socket.channels.first()).isEqualTo(channel) + } + + @Test + fun `remove() removes given channel from channels`() { + val channel1 = socket.channel("topic-1") + val channel2 = socket.channel("topic-2") + + channel1.joinPush.ref = "1" + channel2.joinPush.ref = "2" + + socket.remove(channel1) + assertThat(socket.channels).doesNotContain(channel1) + assertThat(socket.channels).contains(channel2) + } + + /* push */ + @Test + fun `push() sends data to connection when connected`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + + socket.connect() + socket.push("topic", "event", mapOf("one" to "two"), "ref", "join-ref") + + val expect = + "{\"topic\":\"topic\",\"event\":\"event\",\"payload\":{\"one\":\"two\"},\"ref\":\"ref\",\"join_ref\":\"join-ref\"}" + verify(connection).send(expect) + } + + @Test + fun `push() excludes ref information if not passed`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + + socket.connect() + socket.push("topic", "event", mapOf("one" to "two")) + + val expect = "{\"topic\":\"topic\",\"event\":\"event\",\"payload\":{\"one\":\"two\"}}" + verify(connection).send(expect) + } + + @Test + fun `push() buffers data when not connected`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.CLOSED) + socket.connect() + + socket.push("topic", "event1", mapOf("one" to "two")) + verify(connection, never()).send(any()) + assertThat(socket.sendBuffer).hasSize(1) + + socket.push("topic", "event2", mapOf("one" to "two")) + verify(connection, never()).send(any()) + assertThat(socket.sendBuffer).hasSize(2) + + socket.sendBuffer.forEach { it.invoke() } + verify(connection, times(2)).send(any()) + } + + /* makeRef */ + @Test + fun `makeRef() returns next message ref`() { + assertThat(socket.ref).isEqualTo(0) + assertThat(socket.makeRef()).isEqualTo("1") + assertThat(socket.ref).isEqualTo(1) + assertThat(socket.makeRef()).isEqualTo("2") + assertThat(socket.ref).isEqualTo(2) + } + + @Test + fun `makeRef() resets to 0 if it hits max int`() { + socket.ref = Int.MAX_VALUE + + assertThat(socket.makeRef()).isEqualTo("0") + assertThat(socket.ref).isEqualTo(0) + } + + /* sendHeartbeat */ + @Test + fun `sendHeartbeat() closes socket when heartbeat is not ack'd within heartbeat window`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + + socket.sendHeartbeat() + verify(connection, never()).disconnect(any(), any()) + assertThat(socket.pendingHeartbeatRef).isNotNull() + + socket.sendHeartbeat() + verify(connection).disconnect(WS_CLOSE_NORMAL, "Heartbeat timed out") + assertThat(socket.pendingHeartbeatRef).isNull() + } + + @Test + fun `sendHeartbeat() pushes heartbeat data when connected`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + + socket.sendHeartbeat() + + val expected = "{\"topic\":\"phoenix\",\"event\":\"heartbeat\",\"payload\":{},\"ref\":\"1\"}" + assertThat(socket.pendingHeartbeatRef).isEqualTo(socket.ref.toString()) + verify(connection).send(expected) + } + + @Test + fun `sendHeartbeat() does nothing when not connected`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.CLOSED) + socket.connect() + socket.sendHeartbeat() + + verify(connection, never()).disconnect(any(), any()) + verify(connection, never()).send(any()) + } + + /* flushSendBuffer */ + @Test + fun `flushSendBuffer() invokes callbacks in buffer when connected`() { + var oneCalled = 0 + socket.sendBuffer.add { oneCalled += 1 } + var twoCalled = 0 + socket.sendBuffer.add { twoCalled += 1 } + val threeCalled = 0 + + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + + // does nothing if not connected + socket.flushSendBuffer() + assertThat(oneCalled).isEqualTo(0) + + // connect + socket.connect() + + // sends once connected + socket.flushSendBuffer() + assertThat(oneCalled).isEqualTo(1) + assertThat(twoCalled).isEqualTo(1) + assertThat(threeCalled).isEqualTo(0) + } + + @Test + fun `flushSendBuffer() empties send buffer`() { + socket.sendBuffer.add { } + + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + + assertThat(socket.sendBuffer).isNotEmpty() + socket.flushSendBuffer() + + assertThat(socket.sendBuffer).isEmpty() + } + + /* onConnectionOpen */ + @Test + fun `onConnectionOpened() flushes the send buffer`() { + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + + var oneCalled = 0 + socket.sendBuffer.add { oneCalled += 1 } + + socket.onConnectionOpened() + assertThat(oneCalled).isEqualTo(1) + assertThat(socket.sendBuffer).isEmpty() + } + + @Test + fun `onConnectionOpened() resets reconnect timer`() { + val mockTimer = mock() + socket.reconnectTimer = mockTimer + + socket.onConnectionOpened() + verify(mockTimer).reset() + } + + @Test + fun `onConnectionOpened() resets the heartbeat`() { + val mockTask = mock() + socket.heartbeatTask = mockTask + + socket.onConnectionOpened() + verify(mockTask).cancel() + verify(mockDispatchQueue).queue(any(), any(), any()) + } + + @Test + fun `onConnectionOpened() invokes all onOpen callbacks`() { + var oneCalled = 0 + socket.onOpen { oneCalled += 1 } + var twoCalled = 0 + socket.onOpen { twoCalled += 1 } + var threeCalled = 0 + socket.onClose { threeCalled += 1 } + + socket.onConnectionOpened() + assertThat(oneCalled).isEqualTo(1) + assertThat(twoCalled).isEqualTo(1) + assertThat(threeCalled).isEqualTo(0) + } + + /* resetHeartbeat */ + @Test + fun `resetHeartbeat() clears any pending heartbeat`() { + socket.pendingHeartbeatRef = "1" + socket.resetHeartbeat() + + assertThat(socket.pendingHeartbeatRef).isNull() + } + + @Test + fun `resetHeartbeat() does not schedule heartbeat if skipHeartbeat == true`() { + socket.skipHeartbeat = true + socket.resetHeartbeat() + + verifyZeroInteractions(mockDispatchQueue) + } + + @Test + fun `resetHeartbeat() creates a future heartbeat task`() { + val mockTask = mock() + whenever(mockDispatchQueue.queue(any(), any(), any())).thenReturn(mockTask) + + whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) + socket.connect() + socket.heartbeatInterval = 5_000 + + assertThat(socket.heartbeatTask).isNull() + socket.resetHeartbeat() + + assertThat(socket.heartbeatTask).isNotNull() + argumentCaptor<() -> Unit> { + verify(mockDispatchQueue).queue(eq(5_000L), eq(TimeUnit.MILLISECONDS), capture()) + + // fire the task + allValues.first().invoke() + + val expected = "{\"topic\":\"phoenix\",\"event\":\"heartbeat\",\"payload\":{},\"ref\":\"1\"}" + verify(connection).send(expected) + } + } + + /* onConnectionClosed */ + @Test + fun `onConnectionClosed() it does not schedule reconnectTimer timeout if normal close`() { + val mockTimer = mock() + socket.reconnectTimer = mockTimer + + socket.onConnectionClosed(WS_CLOSE_NORMAL) + verify(mockTimer, never()).scheduleTimeout() + } + + @Test + fun `onConnectionClosed schedules reconnectTimer if not normal close`() { + val mockTimer = mock() + socket.reconnectTimer = mockTimer + + socket.onConnectionClosed(1001) + verify(mockTimer).scheduleTimeout() + } + + @Test + fun `onConnectionClosed() cancels heartbeat task`() { + val mockTask = mock() + socket.heartbeatTask = mockTask + + socket.onConnectionClosed(1000) + verify(mockTask).cancel() + assertThat(socket.heartbeatTask).isNull() + } + + @Test + fun `onConnectionClosed() triggers onClose callbacks`() { + var oneCalled = 0 + socket.onClose { oneCalled += 1 } + var twoCalled = 0 + socket.onClose { twoCalled += 1 } + var threeCalled = 0 + socket.onOpen { threeCalled += 1 } + + socket.onConnectionClosed(1000) + assertThat(oneCalled).isEqualTo(1) + assertThat(twoCalled).isEqualTo(1) + assertThat(threeCalled).isEqualTo(0) + } + + @Test + fun `onConnectionClosed() triggers channel error`() { + val channel = mock() + socket.channels.add(channel) + + socket.onConnectionClosed(1001) + verify(channel).trigger("phx_error") + } + + /* onConnectionError */ + @Test + fun `onConnectionError() triggers onClose callbacks`() { + var oneCalled = 0 + socket.onError { _, _ -> oneCalled += 1 } + var twoCalled = 0 + socket.onError { _, _ -> twoCalled += 1 } + var threeCalled = 0 + socket.onOpen { threeCalled += 1 } + + socket.onConnectionError(Throwable(), null) + assertThat(oneCalled).isEqualTo(1) + assertThat(twoCalled).isEqualTo(1) + assertThat(threeCalled).isEqualTo(0) + } + + @Test + fun `onConnectionError() triggers channel error`() { + val channel = mock() + socket.channels.add(channel) + + socket.onConnectionError(Throwable(), null) + verify(channel).trigger("phx_error") + } + + @Test + fun `onConnectionMessage() parses raw messages and triggers channel event`() { + val targetChannel = mock() + whenever(targetChannel.isMember(any())).thenReturn(true) + val otherChannel = mock() + whenever(otherChannel.isMember(any())).thenReturn(false) + + socket.channels.add(targetChannel) + socket.channels.add(otherChannel) + + val rawMessage = + "{\"topic\":\"topic\",\"event\":\"event\",\"payload\":{\"one\":\"two\"},\"status\":\"ok\"}" + socket.onConnectionMessage(rawMessage) + + verify(targetChannel).trigger(message = any()) + verify(otherChannel, never()).trigger(message = any()) + } + + @Test + fun `onConnectionMessage() invokes onMessage callbacks`() { + var message: Message? = null + socket.onMessage { message = it } + + val rawMessage = + "{\"topic\":\"topic\",\"event\":\"event\",\"payload\":{\"one\":\"two\"},\"status\":\"ok\"}" + socket.onConnectionMessage(rawMessage) + + assertThat(message?.topic).isEqualTo("topic") + assertThat(message?.event).isEqualTo("event") + } + + @Test + fun `onConnectionMessage() clears pending heartbeat`() { + socket.pendingHeartbeatRef = "5" + + val rawMessage = + "{\"topic\":\"topic\",\"event\":\"event\",\"payload\":{\"one\":\"two\"},\"ref\":\"5\"}" + socket.onConnectionMessage(rawMessage) + assertThat(socket.pendingHeartbeatRef).isNull() + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/TestUtilities.kt b/src/test/kotlin/org/phoenixframework/TestUtilities.kt new file mode 100644 index 0000000..3671821 --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/TestUtilities.kt @@ -0,0 +1,75 @@ +package org.phoenixframework + +import java.util.concurrent.TimeUnit + +//------------------------------------------------------------------------------ +// Dispatch Queue +//------------------------------------------------------------------------------ +class ManualDispatchQueue : DispatchQueue { + + private var tickTime: Long = 0 + private val tickTimeUnit: TimeUnit = TimeUnit.MILLISECONDS + private var workItems: MutableList = mutableListOf() + + fun reset() { + this.tickTime = 0 + this.workItems = mutableListOf() + } + + fun tick(duration: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) { + val durationInMs = tickTimeUnit.convert(duration, unit) + + // Advance the fake clock + this.tickTime += durationInMs + + // Filter all work items that are due to be fired and have not been + // cancelled. Return early if there are no items to fire + val pastDueWorkItems = workItems.filter { it.deadline <= this.tickTime && !it.isCancelled } + + // if no items are due, then return early + if (pastDueWorkItems.isEmpty()) return + + // Perform all work items that are due + pastDueWorkItems.forEach { it.perform() } + + // Remove all work items that are past due or canceled + workItems.removeAll { it.deadline <= this.tickTime || it.isCancelled } + } + + + override fun queue(delay: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem { + // Converts the given unit and delay to the unit used by this class + val delayInMs = tickTimeUnit.convert(delay, unit) + val deadline = tickTime + delayInMs + + val workItem = ManualDispatchWorkItem(runnable, deadline) + workItems.add(workItem) + + return workItem + } +} + +//------------------------------------------------------------------------------ +// Work Item +//------------------------------------------------------------------------------ +class ManualDispatchWorkItem( + private val runnable: () -> Unit, + val deadline: Long +) : DispatchWorkItem { + + override var isCancelled: Boolean = false + + override fun cancel() { this.isCancelled = true } + + fun perform() { + if (isCancelled) return + runnable.invoke() + } +} + +//------------------------------------------------------------------------------ +// Channel Extension +//------------------------------------------------------------------------------ +fun Channel.getBindings(event: String): List { + return bindings.toList().filter { it.event == event } +} \ No newline at end of file diff --git a/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt b/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt index 644f2a9..282e7e5 100644 --- a/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt +++ b/src/test/kotlin/org/phoenixframework/TimeoutTimerTest.kt @@ -7,32 +7,29 @@ import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever -import org.junit.Test - import org.junit.Before +import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit class TimeoutTimerTest { - @Mock lateinit var mockFuture: ScheduledFuture<*> - @Mock lateinit var mockExecutorService: ScheduledExecutorService + @Mock lateinit var mockWorkItem: DispatchWorkItem + @Mock lateinit var mockDispatchQueue: DispatchQueue - var callbackCallCount: Int = 0 - lateinit var timeoutTimer: TimeoutTimer + private var callbackCallCount: Int = 0 + private lateinit var timeoutTimer: TimeoutTimer @Before fun setUp() { MockitoAnnotations.initMocks(this) callbackCallCount = 0 - whenever(mockExecutorService.schedule(any(), any(), any())).thenReturn(mockFuture) + whenever(mockDispatchQueue.queue(any(), any(), any())).thenReturn(mockWorkItem) timeoutTimer = TimeoutTimer( - scheduledExecutorService = mockExecutorService, + dispatchQueue = mockDispatchQueue, callback = { callbackCallCount += 1 }, timerCalculation = { tries -> if(tries > 3 ) 10000 else listOf(1000L, 2000L, 5000L)[tries -1] @@ -43,24 +40,24 @@ class TimeoutTimerTest { fun `scheduleTimeout executes with backoff`() { argumentCaptor<() -> Unit> { timeoutTimer.scheduleTimeout() - verify(mockExecutorService).schedule(capture(), eq(1000L), eq(TimeUnit.MILLISECONDS)) - (lastValue as Runnable).run() + verify(mockDispatchQueue).queue(eq(1000L), eq(TimeUnit.MILLISECONDS), capture()) + lastValue.invoke() assertThat(callbackCallCount).isEqualTo(1) timeoutTimer.scheduleTimeout() - verify(mockExecutorService).schedule(capture(), eq(2000L), eq(TimeUnit.MILLISECONDS)) - (lastValue as Runnable).run() + verify(mockDispatchQueue).queue(eq(2000L), eq(TimeUnit.MILLISECONDS), capture()) + lastValue.invoke() assertThat(callbackCallCount).isEqualTo(2) timeoutTimer.scheduleTimeout() - verify(mockExecutorService).schedule(capture(), eq(5000L), eq(TimeUnit.MILLISECONDS)) - (lastValue as Runnable).run() + verify(mockDispatchQueue).queue(eq(5000L), eq(TimeUnit.MILLISECONDS), capture()) + lastValue.invoke() assertThat(callbackCallCount).isEqualTo(3) timeoutTimer.reset() timeoutTimer.scheduleTimeout() - verify(mockExecutorService, times(2)).schedule(capture(), eq(1000L), eq(TimeUnit.MILLISECONDS)) - (lastValue as Runnable).run() + verify(mockDispatchQueue, times(2)).queue(eq(1000L), eq(TimeUnit.MILLISECONDS), capture()) + lastValue.invoke() assertThat(callbackCallCount).isEqualTo(4) } } diff --git a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt new file mode 100644 index 0000000..fbc955e --- /dev/null +++ b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt @@ -0,0 +1,113 @@ +package org.phoenixframework + +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import java.net.URL + +class WebSocketTransportTest { + + @Mock lateinit var mockClient: OkHttpClient + @Mock lateinit var mockWebSocket: WebSocket + @Mock lateinit var mockChannel: Channel + @Mock lateinit var mockResponse: Response + + lateinit var transport: WebSocketTransport + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + val url = URL("http://localhost:400/socket/websocket") + transport = WebSocketTransport(url, mockClient) + } + + @Test + fun `connect sets ready state to CONNECTING and creates connection`() { + whenever(mockClient.newWebSocket(any(), any())).thenReturn(mockWebSocket) + + transport.connect() + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CONNECTING) + assertThat(transport.connection).isNotNull() + } + + @Test + fun `disconnect closes and releases the connection`() { + transport.connection = mockWebSocket + + transport.disconnect(10, "Test reason") + verify(mockWebSocket).close(10, "Test reason") + assertThat(transport.connection).isNull() + } + + @Test + fun `send sends text through the connection`() { + transport.connection = mockWebSocket + + transport.send("some data") + verify(mockWebSocket).send("some data") + } + + @Test + fun `onOpen sets ready state to OPEN and invokes the onOpen callback`() { + val mockClosure = mock<() -> Unit>() + transport.onOpen = mockClosure + + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSED) + + transport.onOpen(mockWebSocket, mockResponse) + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.OPEN) + verify(mockClosure).invoke() + } + + @Test + fun `onFailure sets ready state to CLOSED and invokes onError callback`() { + val mockClosure = mock<(Throwable, Response?) -> Unit>() + transport.onError = mockClosure + + transport.readyState = Transport.ReadyState.CONNECTING + + val throwable = Throwable() + transport.onFailure(mockWebSocket, throwable, mockResponse) + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSED) + verify(mockClosure).invoke(throwable, mockResponse) + } + + @Test + fun `onClosing sets ready state to CLOSING`() { + transport.readyState = Transport.ReadyState.OPEN + + transport.onClosing(mockWebSocket, 10, "reason") + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSING) + } + + @Test + fun `onMessage invokes onMessage closure`() { + val mockClosure = mock<(String) -> Unit>() + transport.onMessage = mockClosure + + transport.onMessage(mockWebSocket, "text") + verify(mockClosure).invoke("text") + } + + @Test + fun `onClosed sets readyState to CLOSED and invokes closure`() { + val mockClosure = mock<(Int) -> Unit>() + transport.onClose = mockClosure + + transport.readyState = Transport.ReadyState.CONNECTING + + transport.onClosed(mockWebSocket, 10, "reason") + assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSED) + verify(mockClosure).invoke(10) + } +} \ No newline at end of file diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..1f0955d --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From ffde1ce3928e41b5e2e0f37dc3a4f244afda97e5 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 1 May 2019 11:52:17 -0400 Subject: [PATCH 09/63] Prepare version 0.2.0 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2bd5e5..9d1b0a9 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.1.8' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.0' } ``` diff --git a/build.gradle b/build.gradle index 20da684..2c518a8 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.1.8' +version '0.2.0' sourceCompatibility = 1.8 From d87173db5873dfa16f7a30e4f563bec83c4bcc97 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 1 May 2019 11:59:25 -0400 Subject: [PATCH 10/63] Updated readme for socket name changes --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9d1b0a9..7816012 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ fun connectToChatRoom() { // Create the Socket val params = hashMapOf("token" to "abc123") - val socket = PhxSocket("http://localhost:4000/socket/websocket", multipleParams) + val socket = Socket("http://localhost:4000/socket/websocket", params) // Listen to events on the Socket socket.logger = { Log.d("TAG", it) } @@ -27,9 +27,9 @@ fun connectToChatRoom() { // Join channels and listen to events val chatroom = socket.channel("chatroom:general") - chatroom.on("new_message") { - // `it` is a PhxMessage object - val payload = it.payload + chatroom.on("new_message") { message -> + val payload = message.payload + ... } chatroom.join() @@ -46,9 +46,9 @@ val client = OkHttpClient.Builder() .build() val params = hashMapOf("token" to "abc123") -val socket = PhxSocket("http://localhost:4000/socket/websocket", - multipleParams, - client) +val socket = Socket("http://localhost:4000/socket/websocket", + params, + client) ``` From 0c3e28b4b7a34f7466019eeb0143b80463b7e5f2 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 12:51:43 -0400 Subject: [PATCH 11/63] Downgrade okhttp to 3.12.2 (#47) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2c518a8..c357033 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ repositories { dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" compile "com.google.code.gson:gson:2.8.5" - compile "com.squareup.okhttp3:okhttp:3.14.1" + compile "com.squareup.okhttp3:okhttp:3.12.2" testCompile group: 'junit', name: 'junit', version: '4.12' From e7cbc79bfa1703fede09ab3d06a7081a1dfda9d5 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 12:52:34 -0400 Subject: [PATCH 12/63] Prepare version 0.2.1 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7816012..1ec1a05 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.0' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.1' } ``` diff --git a/build.gradle b/build.gradle index c357033..c3c492c 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.0' +version '0.2.1' sourceCompatibility = 1.8 From 3179fae8705747e6a59929986d4d8f3171167edc Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 14:09:35 -0400 Subject: [PATCH 13/63] Fix WebSocketTransport not kicking off reconnect strategy (#49) * Fixed failures not triggering reconnect strategy * Added tests for transport reconnect --- .../kotlin/org/phoenixframework/Socket.kt | 6 ++++ .../kotlin/org/phoenixframework/Transport.kt | 13 ++++++++ .../WebSocketTransportTest.kt | 33 ++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt index 6682539..78c0b7c 100644 --- a/src/main/kotlin/org/phoenixframework/Socket.kt +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -51,6 +51,12 @@ internal data class StateChangeCallbacks( /** The code used when the socket was closed without error */ const val WS_CLOSE_NORMAL = 1000 +/** The socket was closed due to a SocketException. Likely the client lost connectivity */ +const val WS_CLOSE_SOCKET_EXCEPTION = 4000 + +/** The socket was closed due to an EOFException. Likely the server abruptly closed */ +const val WS_CLOSE_EOF_EXCEPTION = 4001 + /** * Connects to a Phoenix Server */ diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt index dbe98c5..f3dc13a 100644 --- a/src/main/kotlin/org/phoenixframework/Transport.kt +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -27,6 +27,9 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.io.EOFException +import java.net.ConnectException +import java.net.SocketException import java.net.URL /** @@ -131,6 +134,16 @@ class WebSocketTransport( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { this.readyState = Transport.ReadyState.CLOSED this.onError?.invoke(t, response) + + // Do not attempt to recover if the initial connection was refused + if (t is ConnectException) return + + // Check if the socket was closed for some recoverable reason + if (t is SocketException) { + this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "Socket Exception") + } else if (t is EOFException) { + this.onClosed(webSocket, WS_CLOSE_EOF_EXCEPTION, "EOF Exception") + } } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { diff --git a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt index fbc955e..7eb1b01 100644 --- a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt +++ b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import okhttp3.OkHttpClient import okhttp3.Response @@ -12,13 +13,14 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations +import java.io.EOFException +import java.net.SocketException import java.net.URL class WebSocketTransportTest { @Mock lateinit var mockClient: OkHttpClient @Mock lateinit var mockWebSocket: WebSocket - @Mock lateinit var mockChannel: Channel @Mock lateinit var mockResponse: Response lateinit var transport: WebSocketTransport @@ -72,6 +74,8 @@ class WebSocketTransportTest { @Test fun `onFailure sets ready state to CLOSED and invokes onError callback`() { val mockClosure = mock<(Throwable, Response?) -> Unit>() + val mockOnClose = mock<(Int) -> Unit>() + transport.onClose = mockOnClose transport.onError = mockClosure transport.readyState = Transport.ReadyState.CONNECTING @@ -80,6 +84,33 @@ class WebSocketTransportTest { transport.onFailure(mockWebSocket, throwable, mockResponse) assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSED) verify(mockClosure).invoke(throwable, mockResponse) + verifyZeroInteractions(mockOnClose) + } + + @Test + fun `onFailure also triggers onClose for SocketException`() { + val mockOnError = mock<(Throwable, Response?) -> Unit>() + val mockOnClose = mock<(Int) -> Unit>() + transport.onClose = mockOnClose + transport.onError = mockOnError + + val throwable = SocketException() + transport.onFailure(mockWebSocket, throwable, mockResponse) + verify(mockOnError).invoke(throwable, mockResponse) + verify(mockOnClose).invoke(4000) + } + + @Test + fun `onFailure also triggers onClose for EOFException`() { + val mockOnError = mock<(Throwable, Response?) -> Unit>() + val mockOnClose = mock<(Int) -> Unit>() + transport.onClose = mockOnClose + transport.onError = mockOnError + + val throwable = EOFException() + transport.onFailure(mockWebSocket, throwable, mockResponse) + verify(mockOnError).invoke(throwable, mockResponse) + verify(mockOnClose).invoke(4001) } @Test From 4c7db7ac0eb30c872f7a1b87f35c0965c872e0f4 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 14:11:11 -0400 Subject: [PATCH 14/63] Prepare version 0.2.2 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ec1a05..6c7cbdb 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.1' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.2' } ``` diff --git a/build.gradle b/build.gradle index c3c492c..c09f833 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.1' +version '0.2.2' sourceCompatibility = 1.8 From f33dea94c9baa6b080f5edef3175306e0e4351b0 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 14:23:29 -0400 Subject: [PATCH 15/63] Removed check of connectexception --- src/main/kotlin/org/phoenixframework/Transport.kt | 3 --- .../kotlin/org/phoenixframework/WebSocketTransportTest.kt | 4 ---- 2 files changed, 7 deletions(-) diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt index f3dc13a..0509859 100644 --- a/src/main/kotlin/org/phoenixframework/Transport.kt +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -135,9 +135,6 @@ class WebSocketTransport( this.readyState = Transport.ReadyState.CLOSED this.onError?.invoke(t, response) - // Do not attempt to recover if the initial connection was refused - if (t is ConnectException) return - // Check if the socket was closed for some recoverable reason if (t is SocketException) { this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "Socket Exception") diff --git a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt index 7eb1b01..153cc6d 100644 --- a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt +++ b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt @@ -4,7 +4,6 @@ import com.google.common.truth.Truth.assertThat import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import okhttp3.OkHttpClient import okhttp3.Response @@ -74,8 +73,6 @@ class WebSocketTransportTest { @Test fun `onFailure sets ready state to CLOSED and invokes onError callback`() { val mockClosure = mock<(Throwable, Response?) -> Unit>() - val mockOnClose = mock<(Int) -> Unit>() - transport.onClose = mockOnClose transport.onError = mockClosure transport.readyState = Transport.ReadyState.CONNECTING @@ -84,7 +81,6 @@ class WebSocketTransportTest { transport.onFailure(mockWebSocket, throwable, mockResponse) assertThat(transport.readyState).isEqualTo(Transport.ReadyState.CLOSED) verify(mockClosure).invoke(throwable, mockResponse) - verifyZeroInteractions(mockOnClose) } @Test From 2733ed59829148b6d2e9eaddb116e6d83c1f241e Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 14:24:02 -0400 Subject: [PATCH 16/63] Prepare version 0.2.3 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c7cbdb..e6de713 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.2' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.3' } ``` diff --git a/build.gradle b/build.gradle index c09f833..eb0b0dc 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.2' +version '0.2.3' sourceCompatibility = 1.8 From dd2d61fcd1d4ad52a177c98cc184f956a4f7b031 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 15:01:13 -0400 Subject: [PATCH 17/63] Added support for SSLException (#50) --- src/main/kotlin/org/phoenixframework/Socket.kt | 5 ++++- .../kotlin/org/phoenixframework/Transport.kt | 8 +++++--- .../phoenixframework/WebSocketTransportTest.kt | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt index 78c0b7c..4e667ab 100644 --- a/src/main/kotlin/org/phoenixframework/Socket.kt +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -54,8 +54,11 @@ const val WS_CLOSE_NORMAL = 1000 /** The socket was closed due to a SocketException. Likely the client lost connectivity */ const val WS_CLOSE_SOCKET_EXCEPTION = 4000 +/** The socket was closed due to an SSLException. Likely the client lost connectivity */ +const val WS_CLOSE_SSL_EXCEPTION = 4001 + /** The socket was closed due to an EOFException. Likely the server abruptly closed */ -const val WS_CLOSE_EOF_EXCEPTION = 4001 +const val WS_CLOSE_EOF_EXCEPTION = 4002 /** * Connects to a Phoenix Server diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt index 0509859..e27eb70 100644 --- a/src/main/kotlin/org/phoenixframework/Transport.kt +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -28,9 +28,9 @@ import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import java.io.EOFException -import java.net.ConnectException import java.net.SocketException import java.net.URL +import javax.net.ssl.SSLException /** * Interface that defines different types of Transport layers. A default {@link WebSocketTransport} @@ -137,9 +137,11 @@ class WebSocketTransport( // Check if the socket was closed for some recoverable reason if (t is SocketException) { - this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "Socket Exception") + this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "SocketException") + } else if (t is SSLException) { + this.onClosed(webSocket, WS_CLOSE_SSL_EXCEPTION, "SSLException") } else if (t is EOFException) { - this.onClosed(webSocket, WS_CLOSE_EOF_EXCEPTION, "EOF Exception") + this.onClosed(webSocket, WS_CLOSE_EOF_EXCEPTION, "EOFException") } } diff --git a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt index 153cc6d..881b542 100644 --- a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt +++ b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt @@ -15,6 +15,7 @@ import org.mockito.MockitoAnnotations import java.io.EOFException import java.net.SocketException import java.net.URL +import javax.net.ssl.SSLException class WebSocketTransportTest { @@ -96,6 +97,19 @@ class WebSocketTransportTest { verify(mockOnClose).invoke(4000) } + @Test + fun `onFailure also triggers onClose for SSLException`() { + val mockOnError = mock<(Throwable, Response?) -> Unit>() + val mockOnClose = mock<(Int) -> Unit>() + transport.onClose = mockOnClose + transport.onError = mockOnError + + val throwable = SSLException("t") + transport.onFailure(mockWebSocket, throwable, mockResponse) + verify(mockOnError).invoke(throwable, mockResponse) + verify(mockOnClose).invoke(4001) + } + @Test fun `onFailure also triggers onClose for EOFException`() { val mockOnError = mock<(Throwable, Response?) -> Unit>() @@ -106,7 +120,7 @@ class WebSocketTransportTest { val throwable = EOFException() transport.onFailure(mockWebSocket, throwable, mockResponse) verify(mockOnError).invoke(throwable, mockResponse) - verify(mockOnClose).invoke(4001) + verify(mockOnClose).invoke(4002) } @Test From 2fd3720c9df3122c09cc87bdce85b51346e7f7a5 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 15:01:51 -0400 Subject: [PATCH 18/63] Prepare version 0.2.4 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e6de713..c8625f1 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.3' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.4' } ``` diff --git a/build.gradle b/build.gradle index eb0b0dc..53b1f5b 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.3' +version '0.2.4' sourceCompatibility = 1.8 From 86a20a21da6c8b2c4ec343ece276c2202087edc8 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Tue, 14 May 2019 15:10:54 -0400 Subject: [PATCH 19/63] Prepare 0.2.5 --- README.md | 2 +- build.gradle | 2 +- src/main/kotlin/org/phoenixframework/Transport.kt | 12 +++--------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c8625f1..90699ca 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.4' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.5' } ``` diff --git a/build.gradle b/build.gradle index 53b1f5b..c85d87a 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.4' +version '0.2.5' sourceCompatibility = 1.8 diff --git a/src/main/kotlin/org/phoenixframework/Transport.kt b/src/main/kotlin/org/phoenixframework/Transport.kt index e27eb70..dd5c1ac 100644 --- a/src/main/kotlin/org/phoenixframework/Transport.kt +++ b/src/main/kotlin/org/phoenixframework/Transport.kt @@ -27,10 +27,8 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import java.io.EOFException -import java.net.SocketException +import java.io.IOException import java.net.URL -import javax.net.ssl.SSLException /** * Interface that defines different types of Transport layers. A default {@link WebSocketTransport} @@ -136,12 +134,8 @@ class WebSocketTransport( this.onError?.invoke(t, response) // Check if the socket was closed for some recoverable reason - if (t is SocketException) { - this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "SocketException") - } else if (t is SSLException) { - this.onClosed(webSocket, WS_CLOSE_SSL_EXCEPTION, "SSLException") - } else if (t is EOFException) { - this.onClosed(webSocket, WS_CLOSE_EOF_EXCEPTION, "EOFException") + when (t) { + is IOException -> this.onClosed(webSocket, WS_CLOSE_SOCKET_EXCEPTION, "IOException") } } From f65ba256b13b637a3b315837949c195c2f5a3eeb Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 22 May 2019 13:44:31 -0400 Subject: [PATCH 20/63] Moving heartbeat task to repeat at fixed rate (#52) --- .../org/phoenixframework/DispatchQueue.kt | 17 +++++++ .../kotlin/org/phoenixframework/Socket.kt | 10 ++--- .../kotlin/org/phoenixframework/SocketTest.kt | 6 +-- .../org/phoenixframework/TestUtilities.kt | 44 ++++++++++++++++--- .../WebSocketTransportTest.kt | 4 +- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/org/phoenixframework/DispatchQueue.kt b/src/main/kotlin/org/phoenixframework/DispatchQueue.kt index 7881c3a..bd996f4 100644 --- a/src/main/kotlin/org/phoenixframework/DispatchQueue.kt +++ b/src/main/kotlin/org/phoenixframework/DispatchQueue.kt @@ -36,6 +36,13 @@ import java.util.concurrent.TimeUnit interface DispatchQueue { /** Queue a Runnable to be executed after a given time unit delay */ fun queue(delay: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem + + /** + * Creates and executes a periodic action that becomes enabled first after the given initial + * delay, and subsequently with the given period; that is, executions will commence after + * initialDelay, then initialDelay + period, then initialDelay + 2 * period, and so on. + */ + fun queueAtFixedRate(delay: Long, period: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem } /** Abstracts away a future task */ @@ -64,6 +71,16 @@ class ScheduledDispatchQueue(poolSize: Int = 8) : DispatchQueue { val scheduledFuture = scheduledThreadPoolExecutor.schedule(runnable, delay, unit) return ScheduledDispatchWorkItem(scheduledFuture) } + + override fun queueAtFixedRate( + delay: Long, + period: Long, + unit: TimeUnit, + runnable: () -> Unit + ): DispatchWorkItem { + val scheduledFuture = scheduledThreadPoolExecutor.scheduleAtFixedRate(runnable, delay, period, unit) + return ScheduledDispatchWorkItem(scheduledFuture) + } } /** diff --git a/src/main/kotlin/org/phoenixframework/Socket.kt b/src/main/kotlin/org/phoenixframework/Socket.kt index 4e667ab..15f920b 100644 --- a/src/main/kotlin/org/phoenixframework/Socket.kt +++ b/src/main/kotlin/org/phoenixframework/Socket.kt @@ -54,11 +54,6 @@ const val WS_CLOSE_NORMAL = 1000 /** The socket was closed due to a SocketException. Likely the client lost connectivity */ const val WS_CLOSE_SOCKET_EXCEPTION = 4000 -/** The socket was closed due to an SSLException. Likely the client lost connectivity */ -const val WS_CLOSE_SSL_EXCEPTION = 4001 - -/** The socket was closed due to an EOFException. Likely the server abruptly closed */ -const val WS_CLOSE_EOF_EXCEPTION = 4002 /** * Connects to a Phoenix Server @@ -366,8 +361,11 @@ class Socket( // Do not start up the heartbeat timer if skipHeartbeat is true if (skipHeartbeat) return + val delay = heartbeatInterval + val period = heartbeatInterval + heartbeatTask = - dispatchQueue.queue(heartbeatInterval, TimeUnit.MILLISECONDS) { sendHeartbeat() } + dispatchQueue.queueAtFixedRate(delay, period, TimeUnit.MILLISECONDS) { sendHeartbeat() } } internal fun sendHeartbeat() { diff --git a/src/test/kotlin/org/phoenixframework/SocketTest.kt b/src/test/kotlin/org/phoenixframework/SocketTest.kt index 8edf55d..620d501 100644 --- a/src/test/kotlin/org/phoenixframework/SocketTest.kt +++ b/src/test/kotlin/org/phoenixframework/SocketTest.kt @@ -503,7 +503,7 @@ class SocketTest { socket.onConnectionOpened() verify(mockTask).cancel() - verify(mockDispatchQueue).queue(any(), any(), any()) + verify(mockDispatchQueue).queueAtFixedRate(any(), any(), any(), any()) } @Test @@ -541,7 +541,7 @@ class SocketTest { @Test fun `resetHeartbeat() creates a future heartbeat task`() { val mockTask = mock() - whenever(mockDispatchQueue.queue(any(), any(), any())).thenReturn(mockTask) + whenever(mockDispatchQueue.queueAtFixedRate(any(), any(), any(), any())).thenReturn(mockTask) whenever(connection.readyState).thenReturn(Transport.ReadyState.OPEN) socket.connect() @@ -552,7 +552,7 @@ class SocketTest { assertThat(socket.heartbeatTask).isNotNull() argumentCaptor<() -> Unit> { - verify(mockDispatchQueue).queue(eq(5_000L), eq(TimeUnit.MILLISECONDS), capture()) + verify(mockDispatchQueue).queueAtFixedRate(eq(5_000L), eq(5_000L), eq(TimeUnit.MILLISECONDS), capture()) // fire the task allValues.first().invoke() diff --git a/src/test/kotlin/org/phoenixframework/TestUtilities.kt b/src/test/kotlin/org/phoenixframework/TestUtilities.kt index 3671821..3b4d193 100644 --- a/src/test/kotlin/org/phoenixframework/TestUtilities.kt +++ b/src/test/kotlin/org/phoenixframework/TestUtilities.kt @@ -24,7 +24,7 @@ class ManualDispatchQueue : DispatchQueue { // Filter all work items that are due to be fired and have not been // cancelled. Return early if there are no items to fire - val pastDueWorkItems = workItems.filter { it.deadline <= this.tickTime && !it.isCancelled } + val pastDueWorkItems = workItems.filter { it.isPastDue(tickTime) && !it.isCancelled } // if no items are due, then return early if (pastDueWorkItems.isEmpty()) return @@ -33,10 +33,9 @@ class ManualDispatchQueue : DispatchQueue { pastDueWorkItems.forEach { it.perform() } // Remove all work items that are past due or canceled - workItems.removeAll { it.deadline <= this.tickTime || it.isCancelled } + workItems.removeAll { it.isPastDue(tickTime) || it.isCancelled } } - override fun queue(delay: Long, unit: TimeUnit, runnable: () -> Unit): DispatchWorkItem { // Converts the given unit and delay to the unit used by this class val delayInMs = tickTimeUnit.convert(delay, unit) @@ -47,6 +46,23 @@ class ManualDispatchQueue : DispatchQueue { return workItem } + + override fun queueAtFixedRate( + delay: Long, + period: Long, + unit: TimeUnit, + runnable: () -> Unit + ): DispatchWorkItem { + + val delayInMs = tickTimeUnit.convert(delay, unit) + val periodInMs = tickTimeUnit.convert(period, unit) + val deadline = tickTime + delayInMs + + val workItem = ManualDispatchWorkItem(runnable, deadline, periodInMs) + workItems.add(workItem) + + return workItem + } } //------------------------------------------------------------------------------ @@ -54,16 +70,32 @@ class ManualDispatchQueue : DispatchQueue { //------------------------------------------------------------------------------ class ManualDispatchWorkItem( private val runnable: () -> Unit, - val deadline: Long + private var deadline: Long, + private val period: Long = 0 ) : DispatchWorkItem { - override var isCancelled: Boolean = false + private var performCount = 0 - override fun cancel() { this.isCancelled = true } + + // Test + fun isPastDue(tickTime: Long): Boolean { + return this.deadline <= tickTime + } fun perform() { if (isCancelled) return runnable.invoke() + performCount += 1 + + // If the task is repeatable, then schedule the next deadline after the given period + deadline += (performCount * period) + } + + // DispatchWorkItem + override var isCancelled: Boolean = false + + override fun cancel() { + this.isCancelled = true } } diff --git a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt index 881b542..cc2353b 100644 --- a/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt +++ b/src/test/kotlin/org/phoenixframework/WebSocketTransportTest.kt @@ -107,7 +107,7 @@ class WebSocketTransportTest { val throwable = SSLException("t") transport.onFailure(mockWebSocket, throwable, mockResponse) verify(mockOnError).invoke(throwable, mockResponse) - verify(mockOnClose).invoke(4001) + verify(mockOnClose).invoke(4000) } @Test @@ -120,7 +120,7 @@ class WebSocketTransportTest { val throwable = EOFException() transport.onFailure(mockWebSocket, throwable, mockResponse) verify(mockOnError).invoke(throwable, mockResponse) - verify(mockOnClose).invoke(4002) + verify(mockOnClose).invoke(4000) } @Test From 55e68ce72d5d8f376abe8e7f21354e0e293fd946 Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Wed, 22 May 2019 13:45:37 -0400 Subject: [PATCH 21/63] Prepare version 0.2.6 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90699ca..26e4e91 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ repositories { and then add the library. See [releases](https://github.com/dsrees/JavaPhoenixClient/releases) for the latest version ```$xslt dependencies { - implementation 'com.github.dsrees:JavaPhoenixClient:0.2.5' + implementation 'com.github.dsrees:JavaPhoenixClient:0.2.6' } ``` diff --git a/build.gradle b/build.gradle index c85d87a..b0052f0 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'com.github.dsrees' -version '0.2.5' +version '0.2.6' sourceCompatibility = 1.8 From 94396145a283cbf62e9a13ea846f2c6e35da1e4a Mon Sep 17 00:00:00 2001 From: Daniel Rees Date: Fri, 7 Jun 2019 13:56:45 -0400 Subject: [PATCH 22/63] Chat Example (#55) * Chat example * Added example and fixed reconnect issues * bump client version * cleanup * Building using jar --- ChatExample/.gitignore | 13 ++ ChatExample/app/.gitignore | 1 + ChatExample/app/build.gradle | 53 ++++++ .../app/libs/JavaPhoenixClient-0.2.6.jar | Bin 0 -> 106710 bytes ChatExample/app/proguard-rules.pro | 21 +++ ChatExample/app/src/main/AndroidManifest.xml | 24 +++ .../github/dsrees/chatexample/MainActivity.kt | 140 ++++++++++++++ .../dsrees/chatexample/MessagesAdapter.kt | 34 ++++ .../drawable-v24/ic_launcher_foreground.xml | 34 ++++ .../res/drawable/ic_launcher_background.xml | 74 ++++++++ .../app/src/main/res/layout/activity_main.xml | 65 +++++++ .../app/src/main/res/layout/item_message.xml | 6 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2963 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4905 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2060 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2783 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4490 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6895 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6387 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10413 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9128 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15132 bytes .../app/src/main/res/values/colors.xml | 6 + .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/styles.xml | 11 ++ .../main/res/xml/network_security_config.xml | 6 + ChatExample/build.gradle | 28 +++ ChatExample/gradle.properties | 21 +++ ChatExample/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + ChatExample/gradlew | 172 ++++++++++++++++++ ChatExample/gradlew.bat | 84 +++++++++ ChatExample/settings.gradle | 1 + .../kotlin/org/phoenixframework/Transport.kt | 2 +- 36 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 ChatExample/.gitignore create mode 100644 ChatExample/app/.gitignore create mode 100644 ChatExample/app/build.gradle create mode 100644 ChatExample/app/libs/JavaPhoenixClient-0.2.6.jar create mode 100644 ChatExample/app/proguard-rules.pro create mode 100644 ChatExample/app/src/main/AndroidManifest.xml create mode 100644 ChatExample/app/src/main/java/com/github/dsrees/chatexample/MainActivity.kt create mode 100644 ChatExample/app/src/main/java/com/github/dsrees/chatexample/MessagesAdapter.kt create mode 100644 ChatExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 ChatExample/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 ChatExample/app/src/main/res/layout/activity_main.xml create mode 100644 ChatExample/app/src/main/res/layout/item_message.xml create mode 100644 ChatExample/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 ChatExample/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 ChatExample/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 ChatExample/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 ChatExample/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 ChatExample/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 ChatExample/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 ChatExample/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 ChatExample/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 ChatExample/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 ChatExample/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 ChatExample/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 ChatExample/app/src/main/res/values/colors.xml create mode 100644 ChatExample/app/src/main/res/values/strings.xml create mode 100644 ChatExample/app/src/main/res/values/styles.xml create mode 100644 ChatExample/app/src/main/res/xml/network_security_config.xml create mode 100644 ChatExample/build.gradle create mode 100644 ChatExample/gradle.properties create mode 100644 ChatExample/gradle/wrapper/gradle-wrapper.jar create mode 100644 ChatExample/gradle/wrapper/gradle-wrapper.properties create mode 100755 ChatExample/gradlew create mode 100644 ChatExample/gradlew.bat create mode 100644 ChatExample/settings.gradle diff --git a/ChatExample/.gitignore b/ChatExample/.gitignore new file mode 100644 index 0000000..2b75303 --- /dev/null +++ b/ChatExample/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/ChatExample/app/.gitignore b/ChatExample/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/ChatExample/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/ChatExample/app/build.gradle b/ChatExample/app/build.gradle new file mode 100644 index 0000000..14c607e --- /dev/null +++ b/ChatExample/app/build.gradle @@ -0,0 +1,53 @@ +apply plugin: 'com.android.application' + +apply plugin: 'kotlin-android' + +apply plugin: 'kotlin-android-extensions' + +android { + compileSdkVersion 28 + defaultConfig { + applicationId "com.github.dsrees.chatexample" + minSdkVersion 19 + targetSdkVersion 28 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + targetCompatibility = "8" + sourceCompatibility = "8" + } +} + +dependencies { + /* + To update the JavaPhoenixClient, either use the latest dependency from jcenter + OR run + `./gradlew jar` + and copy + `/build/lib/*.jar` to `/ChatExample/app/libs` + and comment out the jcenter dependency + */ + implementation fileTree(dir: 'libs', include: ['*.jar']) +// implementation 'com.github.dsrees:JavaPhoenixClient:0.2.3' + + + compile "com.google.code.gson:gson:2.8.5" + compile "com.squareup.okhttp3:okhttp:3.12.2" + + + implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation 'androidx.recyclerview:recyclerview:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + +} diff --git a/ChatExample/app/libs/JavaPhoenixClient-0.2.6.jar b/ChatExample/app/libs/JavaPhoenixClient-0.2.6.jar new file mode 100644 index 0000000000000000000000000000000000000000..3dc0c82a1f18e8c0c4c6ce1d4ecc0ff0458fbba6 GIT binary patch literal 106710 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
j;k?xu4zFFTzobxcOm33q z=YYvahp?$zG{6wEA=G!{EURBcBu!!+l)QJa!K*Xoy4-+on{6_4#r0s`*iVvf3Aw}X z-=tuWz`6s%zS0A$?rEAs6~8VAUGvywYoCI#jNmdiLLM1oa-NP2XCnDHklH z(e_xwM>Y=(1x0)tx*5E5rIxAlKDvth8?(K~sJIXRw-*`IiA6V>N0iSd>YCY^ndfHs znqBqye!fBeHl<_Ib?WYTu<6H;?$LbmP~mg$1~!vq_1Qvdds=R9AvdRzu1(#`0-|^Q zN_L@QRhN#`=M!m=bN6D^b|*nx-lx2p)?@-njh7|iS9*;VUjJf~_`Gpez$YFZkO4UR z2|k2u8ZO;@$N|y6JKTWQB%ZU@JkkDD-YZw2O}7=?(W=lcZbs5-Q7q?cS?76cQ+FM&HEmYN00p8@A{VRYjbh2 z?wNhhJTtTQTF(r-x4nab1Hd&;L2K)KQQPxyf%DWiU^0YLp{^`lG?Lo_uL*HlmvPAK zJT>dJ?P2*}K~$T(SQMY+{jwssoRdN;q6;r!RGZjr0h{pNJ4~JCo;^12b8{qvATe#e zMW=7C`aWvSs!YxNkVF=k&6p;fZ?Z+=gcs47ilZY^+beIXewwzZ0knHHQB11}TmxMz zNPtNcjBcy2_-dh_Y2w?H3ruxX(2`?!l)~XT{~`Xi3W0D%I8l8hO$|89aa|0tJ=3oK zd6Q9rSD8|Evu%9A^Y5A({V^@LyHqke$j#UDV??Xgr4uv+?kz%|pU`wSG>yIpa%Z8e zWl%cO;Uy~_GnDhHYAt|wfFLjKPm(jqicyc;v5(J=Vt?DN<|+B zm;3fq4tlLpYAy%V-J0&fnx7@Vo1-a`N>^j_!gHG|w-^1uRAh)RyWx#@HpK-M$f=v3 zogw^I(qIC4#M~2L&K9=()j5n<{Gn=+1}2yuj!;k;tdrIY@!gkC#SbLOTw(uK(-tPRbn^uCt-{oepEBsIa_WS&xoq(%)%{r5ud7e zeeq(IX}g}|#7Q*XgLD?HGBf5BbWz}}o|4{ST#BgqSSl@qK^6Nonz4Hy`$+}GYDjAo zgO=mH3#!HHSpWuIx}?^6RQ363iKa`s9JI6xL5kw0lcBAh=KO6Yb6LAL>gR|nurASGz`>8YWV1(OhiSa*Fp80~ z6BTn|a!?Wz=9el&p}x5gpdzLlt~DjH8wxb!wsPQdy(AoOeylI>2Uk_SIYZ)oR>9{h zI_xnHBXCM4y(D$2UmlwCZ}O-?wI1W)kTuCLX!hv$7WO`CTfcHZ8M%WJhB@uZ+>JFc zc-|}YK7^k~FeH%sDzebJJkpk@U$=S$nh2@he~`X#iMFjMS(}(&R@~&$lTI8kVMV^i zHc`GdgC5BPVRUzet%ylrU0Pf*3MXc}y-|LEx(4T*rZ7!F5}g|-I_!dq9n(ZN+gjOU z?Sq#Z@10+7*X*S;SG-`^Oy-6NlQf%SOi+h~Mg zWgkoT8&3%b;%n5jF;o%4@$dLCsEJ?fMcC4L3Y_X!t7p|gXZGQjeuxe8{>EtNplNbk zFBYB_sJL_kUzN$?)HnGUZCclRQX+6jaK+=afp2?KOLs*J8=ta)oi=e6yT^X zt<2>NHaa#dPY-me#^!bP@%`pZ1_4$YL28f&?93br*X#r-^tiaRFnN2Y8$u$bSmR~2 z??wl38!0#|4QU8U3dRBKX(i}Vl$qn#;1kN<9D4Tmd%;dNt=__;Ea1X#)i?;Mx(Kj6 z_}8x6jm9%1$%E;~j z-dbag6&c6-X-;I|TXE_DeV)zpc)~Ff6Rb8iks64;g^!t* z+D_!zR;!`;XbU6@TbKyzBN%?~Qb@}O5n!8HIu%CWKwrodA76qF-L{8ywE3BdM$e~P zeJQafnEps}pTq`A(fcfES7gmW5_xrqS^x)&5EO?j6Tf@@IT0oD92V3$_tjkM8bl}D zFev|YAv%I+dbj1ajL42f#6VfJV!{^}T8K>wHpu4(FjsA1NuX|-HB87*ho{`dULaLu z4pZFkU_${4sk0Q#4xV7I(-zbuW{12=8J3zdZNy1NQ1*)E8qCZ9@@{56ctugVC73G; zak||r%oeSo-)g~@?XN4maj6|XfaBJ=V1SR}?pM$!CfE+INFpt#!NgN~$-r4!vsz}y zWM%Lri(A#oW}x;Iic+xz7+7L|N&Qf)<`yysd=~xl>sr1Sv7I)1FxGpLj#$n9y7Lvb z*>QljuNKvv12b!B<#`LDFQA|G??KyqCdgjbYZZ{W?x)0Z`s}?d7+}Z%%9uPXIn8}G zguEJy38Ea*V#@Nibha(JD<~sU4%5)-6A~y&_vO7ra@QN>OsSXc!!jlU$F+7pZ z^9(J*C&Qm5;15oJGT?X32(lhP4e@b=v+&pqy$IYvIE_@y-oCN6ap;Yc3g=lJoq%zY9sd|ObAJ(ik`mV%((bxwd$1-RP zdN#Cf?M3jNtc^JbTs66qSviz20o>*NTThl3hINs;aUxbe;;>yCX}f_!OAy8*{55`g zxqVdKA75zz;^+$C_=TgF8;8C*jN>a#wd?s94#96L=J6zh^mA7Y6@_PrgA}u4R=<<_ z#tK?kW{84pt*AR_P+zZE5j26$IyNd*rsHuulw=*VwrSc%1l<15hDMG4q1%@NY0`^3 zOoAVZIg^;((ZD*z=OBxMXKToQ^GE*80ue2c{S2s33&PDg_6U`i zZ1D2MC(GG)F3ax4+{g*HYi==o`ZG#+$IhzH1$LB^jK?GY> z83eg<84u>8PT{yAT$K=v>xU3}4+iEJkO(e5$zcr-5Nss26ubrGMq;X#S?CRg>jTc( zmM6H&BBHxh3^(M7uyZ^hX$NL!fe8ya4<29vxXm`+5RPnT2sh;w5pJy@tV_eG8O!eK zF*pn3nnY~|B|`a2d#Bprr2DYe*WaCR(~85!Z{PZsOm}{;*m9Fn`2uo{Hwt4oqS#{Y zSG(0FW7=EHH>B`+(W5C6iY<|M-qf}f%iyDyT5c(%TKD4g_Oi;26yxR*#@>rp=+BvZ zY4QU7PT>&24QIw~Otvf7(~cIukYi&?#JE@~aXaZ)$H`CQxNq)_gV|1kBG+)OEn7gG z92~wS9H~OdL^g zh0^MYWN8fh*pV@l{1Numn)O-`?K)=paR7hiamCoQqBRSdb9O5(-VSu^41~gs;gnL4 zbJ&+EYD*9>oMOV1gw11TgqRb>5nc)j95LWy8+tG!8NN$MUxI}A{og`RwA-msfGZwu z5K@5E!+8ajHWFCBD*Ps;mmtI%L|qu2M?k&F$Ha65lU?d^cSBW|Xm2D0_exNYuatLY zJL(PSmA(m}f?ntOg@;Qz4~cDB=(^xY1^Q>GcOATA>cb z-3>;ynA=veXj4lk#)h8VmKD>n=lMNJ#%?RLqxIVlFE}pn;^j|jM(ziE&JWHHp{L{~ zg2}~F*x}GGyFclg#1y8+?=LJK+cturp{heEA41#o&VsaqZVpIng1-DNdC?Az8pNuB zZTl)l2>ZSJSa6*bAfP)29`o=M*!Iz-?Q_Qu^;mv*kgr}zxz3S?2;fq@{nF{gsV2eB ztAqO{!JK>CIQVlM;w8X}S{>UfoTj8S(46G`?6dE0aSn21r?Hn^mMZ1p_I$K7mOyqo zaf6+O48Dx1v*eJYnx5G0#cPu}(a)0?@#*k>nIj)3sHQ$9hO4zR+67$ zN#rOz15uW@#$!~nr$D1{&s~23JDL^G++PtkXKD~ZuNPF}0bu%CRbnE3ErzEF_T1sM zdP>LS2^^nNM!pupqNf(lXQsCKgo;2NI>-@E&xaUvOL8|6@8ytpQL`y4=8`>NNi*YM z95g4Y0L{Q}3aBGj@VRmlX5qslZFYj=wGkZ9rFOU}TzvLbGKbjgfn`(CsXFbr**xQz z6L)d1DI>HhQZ=@=5PWD0^U>Q>4nM0E8JL=ws4b3FSDJvUEJRKqRV}S?w59Z~e8095 zDd&fufNpQubzA61=$RQ*dyBFFX>f}ubE`FN;#&xNfM8u&fE7LuprkZ_5>ZACzMzU~ z`bL5qd=#_J|J=izX3TGN2lng=A$PWPVE~D+sjrqdkGd2+I<{nYGn$?UpU#5J&(qxB z>Y3wnIh--OsP^JJm0Jb`bsi>R{(Fo-RwMyE=;rFHU7Q6n>Y?XvqClr{TROTz4+;$p z^%uX?=V^ZS5yz;!fkQA`>9;&)O8ej)ef}I@5>8krPiaG|p%m+*q8&KP{`=Q4##X)G zKeHv}`dmlWD{@91(Ie0Xg`oJ_h!b7g9oyk*x~0^sM%!sHwrEd<`aSbGQ@u~-IdW0d z_X!k4c=&jemUGRFut>FQ-okNq)WCNLE|XLVq1$F$1}n3!PYz~=ZS~qRDnR|>XdI$P zKCEZ=b=|Y1J2ueM*5)=2*_Y36%ZEd?SBj!N-kDAnPVa45_}qbQR0OE|FjhdaJZo|B za1{71@2fYzGofj0#IJ7#up=3pN6C%kj@n#-kE5x8WcniLJ=^ZVI>B4j=WgZmzRWD-f;@D}V2@2czc{G4yvj37(C?=# zy-#|;0~_GTntk)Ro&w;E4gS8p#O>4vd2T!Q=DAZS`ILdUjA$x5bE$K?07Ks%_x?Ph zKm4XmD;~JhII-w(X=#zICY*y{a9B(%ZtBi4&G^J#HCo}A zSV6I-4`O`>!FpF{A%#BgSCDH$6%y|t36_v!j)<7wC14$B4v4I^nfX1-_R5gfn?2!0 z1&r2dQAjH)Z7ceThEWcF`SPW^s88KT@MHF7_E@J@*Z$9+#z()ZH&IVMXWu3 z?lV!F@G7BnB#$@H$~>;%nB+zSL6ZaaQvp&zW?#vrDuFG_6}HjI+iRevph7{|@CdgQ zgK&l^l-5qhkA$MAN+BdUf`h*96zMexCql-&hPM`;B$dU*Z2~JRB~HL>tZ^vFG;El{ z9X6(4%zd_|do5PzmSd`;t`$&AgQy&r-=jl8a=c53Jf#9{TnuVFp<=}SDtysAv!?3Z z7q@6(W5{x_T7ok!`E~AQ-irORaQ{p*L)x17i zTu8|BI&b4`zB^&I&fn3!XWT!8I?Ia}O&u73zBo9&e9kJYas4g1-861=VGo^i7)2Bp zIVqbc^l&VmWRvWs=i;LGmfhW>3W1G)Ge%SxMA?7QK%)_%b`lGN2JF*^LrsxLrC}~l zFRC~XwKanG9HM*DZ|wP7Atxa5yWhOghH|CLQc>DIKxnxRszXFL%tOlb@erI?zPMCNG3%1;2JKNSnPXvX#4V$Jz}7yXm28RB`#lnBSmRe`I-YuY@Wa$DUpo2`}(8~=M>fA zgXKxF4E+3=6+`@0r1BhPgYso=qoWA$3HJv+h1v=y2?1a6-o649blF;B^Hdqt9YsmD zV??Bg*9u^QaRr^T+0f&3l)&BZ$l()_knVdL3T%p$ha-VD61%a`zze^ClKA(n)*TO~ zW#*kot@d5^#rcYz+#Y+oZ;t`TXs`6&M4yMjEyOmujZ-#e`!Bu+h@?swu4yX};PBsL za=e@Llw>5&P89z(nj9D|1&e=j#mW*o9UXrb-D=k-ewy`}ja9-^Y8Bz#i8ih%A0HS! z??TMWsJr-&?Tg#M`Mg9I2UfW6w5~tn3+%~+5V&FcDml@NDfY*$jHRXdOjCBgs5(k! zS?hf8Vw%(!8vnA_iMbs9A}9VXFmnP>318SPlz6F&<%EXM9Q}C$P$+9Or&|zbLc8&r zwn7OXO}jINMdoe3{q@MLNC^qLZ1AmLE?8oRHn1=5iQxT-eR0p!KK9^Cz(yTSfo78M zdD)N^0$V6_nNm+Z=!f+Q9mhI?#Q0+vG1m{+ACX=HDuKT!)#B_`Ii#AN5D?%zi#MFx z+NXjb+Ph3|%9$#%rE31>9gS@kqS6FY1|c-L5UpZX@+@>4PB2noh@s?OjR6%xRre-;)}G`xfTwWC3SW;(Ihh)V_?L?}?&(KL=z5JZ~>$OYu678IwN+*5TI{Zda>QQLf zx>;-323dJwkff=!^V?a`K9AD8XJb^@VSDi{inRSL^~FH*P~WLfalmKQjU@1?^#zzA zM&)5fVx(&oGeUG3hXRiri0_+}Dt_G}ePi}Uv%U4F0Sy*rjdbKoqz7$K{XA>|<|;h}Y?OIpUm zGH^Me^^VB>Mg2>y#o%Rrn=lqKj$lJ?yPWPF>vLri#h{#2?$ViU zRr<4zzO-3IzGq{lktcF`NyjIOx;VohQ66GcirIG)Mm0}QQ1WR752e>^QjW7xvAh=> z@Dt(c7&&1KtdOEA=BouO@wi;4RwJpS#$)=9ON7=I;X2eiz^eKwWuuYYHV?u8noe#d z&ZI+gb=0|#VE~5qdwj`aSli2+w|AOD8^$t=NkIm17Z5Wy>n`b)yy&YH@)Febrsl*PGcc^`XBkdPC?mV#J*;)jRFV2szSwHj+-1!jReB>3 zzZ;bBg0O!-19V(XOuoH^eyfg(Q?7H0(t4L>&Td3iaQ9^WuE`kKlXfO6lAkKvPNify z54k`iHd6BbIe>$V(|5AJpU0@3`goTXJ+;5Z`a|u9la;1rcbIU{BHo(&yp z&9_wfV&D^F>ySY_~R98$7yl;-4WCA)uBGJ&8N09kvPTP~MX4q1kwarW8bpDr{n)mMn5Q5J^uhEak%& zu;Cc7AtJbcqN8~rLfVJ%m0W8K6Zj;X>;!FBu8G5YXxrwCSb6CJn?A`NBJ8Yaot|f; zq{?~04wOaZv>g{ZVdq&49p@D-&vmTo_q)4hV@~X|-ioqJ0G}wRWr*wai0pE7MJ}4< zlj zE41+l4g51YDQmv%yt}Pd=Nr`~er35ZH=WQ%%UEZAk`nv}r18P-hZ!WY9=AOxhm56@ zCf7s=!b$jJ2-g4-t0F!G=Z}O}{R7v;%*w-(#;Bha(l^5l9zvD2_e;@JfE5m2&Z=fWDz;_u=oZgsK5+o$ z>U)PqWFPA?!2M#H9xP9yA06+&ygIwUBCE(iR}UaF+&Crm4Gaq}vAF;dlD7EB3+0}W z;oh9Bag;MFP{+gsZL5;*no05Da^kI^wVJB6x!Ihpd6cudwVKPdxx?|s2ccN7Q8yii zqu7ouo*vutPtr5-SLJf|B#k+E`^1iLO-=1P>cd=)U}5*7S65XMLiM7T!>kr`HH+ch zDO-cty$hgm-&RZl>AN^@oUT2mt%8S}wDRK%M#e794V(BM5MWelN;{;cY0o@P!pbhq zu)QT35Vq+vmvg_62*>i}6pJCg|=CvQH|jcK5`3)$q8Sh7%ah$tnm+64q8zS^q2 zLS_4s{Vgxhm5=t~2+m^tY_6v-k=bi>aaCDJz!jBD=(H^14kD3_`TYVctu# z!13I14ivv{QR7U|7U|`9Ho6rubjQ&#y`>PaTBhWStudhsO3*%{ zAw~zyG+pOPaZcpSDpc%T%E10uB9l$mrey#zNY6GJc=ZAy8R-xPErYpLN9L}TBENd( z?BWzuCZ2bhzyQXSU$xVsSQ&WR%PI6FyWw`z=l8_ums4R|#@EKhOf5x_U!{&vny>L! zt8G)(2$ji#kEN=!R_7v}brVH9_WW4Oq0v91ppJHX$lP0e&co*LK3kv4k4;n_>LXo} zBRr_2)X)hY-0pqfx!Lk%_Z{41;%@JU7OYsriA?R0b#0j>*hOEBD^SXjt#M;=WVF%{ z-D8UU6&R)GwFB#9xAH|K>fm&`!>9n>xE=^E%S^hX7S-E4r zGrGRFDp~f);Ye=A6K3^pv+W6@4JK{2+eN+pbPZJ4fao*BbFmK&MLB`F?;#w7`)Zck zEv*W{ELvu|P87a56htBKg~Pa4^6*uB2Cv^Q&twjkh<3JA^Hhc*+|rZ5%nM1W#>z^|7_4?5 zlTFydQBFLX;d-zjCyH8#Ez37lvA`K5&0&ZV&elz zv4V3tEnvav-&dj@*@R<>AF*L~SwI7B6NrqQiK$mdWq{37(z-c5jV+Q2Lu-~11XT^w zFTmr~ijPb z^+;bQ34~rVj&I#ZYbLI1s`wr!L}o{QszWbc&m;9WMXLd58H7VU4nti&c<7K6mMk3U zRGOT{9X;|z$Ze1!BS`xZn~ouz+d&!t_TWJaF|QLs^ce8lEnE&$$D9wbqM#g_4!K>7 zF);`ysig;3tIo*&Du?-#JMUfxgn^FfUDLV553WbB-}7BKrY$b|AG1az7LQiA)2)M! zg7uy7JKmtYr>0Y3=kKky3N1vr5?edSnX+hOH#e#z=c_ZA z$r_dc(;|;%;Q48PbZn>gBqtgb@sNn?h&9D`3R*Tm($%6&u-KTYYk10nWnhzP^C0X= z`H2^JJg2K#4Wo!E&w=(fw1D!0}vZxvO#K=PV_ z2sYA53iW7MvTu#$E(g=fFjsEDqf59CyGPq8&9LQSN00(yA&IUNF%JG#=}@rxuaFBr zF-l)h(S}1oi&~A%cE*+G*!zZ~TrsKs3-S3p+ zmFcRz9K;-A4~}PV+7;P9F@&7#o{`Ng9#L;s-O@J z^|Tp1Ckeo~35AOwMSF%B$@_AIY}p+Gkee+1?Xut15IHV8Th-m%7E0-BbS2Eo&cw5D zXmKK0X8af0RF3f7MPuJY9NPn@;2lvRs2N7OS5r$Mkt*m>o7I-1S*KoHYV2LTqolQ~ z4{0!j)WN$l!&fF8kd-vygS)&rDr6A1=LwGcO6T5uDIw46fq_yogD;*}NkT6>djwk+ zES`m)1#Ng-oQIB9F79gqpM}UFZ2L(s0aC@(%Q+~erQ7w*!2|Qub;6g^O~ix6Phie! zNn)%7RXAP;oZomqyVP~q6ZXrk<#-!!aCv(h=WA|z8#gBd7FF@(uVnW2kUO-VwTH^w zVn&SyXq8EFu-I|2qbxnA^6<>gZ;vO>1M z_Lq&sjEeHP69i^{1H0A;wKSC?1v%X4)yc4yu8w>s{WwfkDy4>~lr-o@*wYVoOY|>z z5|;DVIz5kVhAm<*Bzua@QGIRsvrnt1tYdd5gWjtf^0?^gCdnN}=p`s-!ugvh!?c6b ze81638I;HNvhs{42z?FT4^r0rY=y~PKLcc?_dQiDl0RL@&{;n*uXzBtj(Fcb4vLew z-;gwjdFk^|W-P1&$y(w7y#Aw@xXo9}llU6{|cv2D~AV^LOcN1-j4Wf%uz z^b9Qm_BaE?{kYGs8*gu(@vEklq&9w&WSWq-=28iIh1$Kk;$7Tvf6N|YmS}dHT5T#Z zhi`LdapC~cLHu!I-=BhxD=%|R4AvF-Gk;#{EkQ#QuWram|Fxh+)tFZsoui>I$hX2^ zKc{{s81Hdx{7VBz_IODHt&gjVhARPEC_&#}e!UIAlru`#hP-DL6()7NV(D8#s=;uj zxA;5^i?ML2UO~0+Ky-j#Ge81;$ZyCL9o-yZ#^v1r?p>LLJ_4^K=0Y<7IwKg9I#w50 z{H!4*wQd{!hQ7KfB)NHDlXIu;46%V;0C`7VSL*8~o<2 z%{_ZlzuR(E2Z1A3RP4dmw^+xa^~qt4_&cWUgz4?oGcB>T zZV_zf+kxEuJt2CDe1hcJB=T!kL^bJCNr~3N>U5)%^B)QHqR|1dfMN^<=}5d@l`6~k zAr)wVT;b$W;*QBmY?Kgq{}S%gU?Cf8OuG+V?WLY%QxVeGb}kL5wO?SP0G}s`g9A|7 z)Ll6z#3^T5uEt zA?wP$AYKjoJO@jKc2}^c9!Q*g8TWm!AgL|ZLoRaVtJii81$B;&=X*1?@i&Hc=eJ%A zqei-uW0Mp80dBEx9=_n-FYi_RfR7*4f{%{^%#xirO^TS6u$nTd?lm>%+FHr%Z1!)r zPi2}qRToer(r*F}`*@&>H~I^})FH@E4UPKlozGsz-+>>@>^SH?7~7fjJ7*v;8gdGL z)#5%EVKg)n^<7G@yf7|r8d7Cl+!Mw@r=a=n4|xnPa)=V)#=Qf&(Xh$oYifDHXud7BX zhbpPoW+p>AM9gl-Iz}vIY+IUTK4Ub;Mkzx&LCnZTDQDC%iE%43EkZblBFWMQGb2Ji z=R=Y%iOM@0vf(_H9H}I#$!(Y<_-*SfvB@p#BC)Qqcv%+8;SK~Dw>&Z2RLLBrq|V*7 z(QTHb;>m5IB-4owE0*~JX)h&a`5Xt9`Sj5u78jA6kX`FKD(3l|QP(8+E$ck7nC-EG zfK8j7QPOnj2(itb3Z)#mB&wlpt|Z$n>oBnh>-h9h>muwO7;=0qpLLb&#_#?ba)82CWN_gB?^Y>)?E`F(`_Y!0AOR7VLP4z}Dq5!1h>JuHT(VtDnj5$ako?V_Zh13N zLS5ei+>F^BrR#aX_^a9BVa?mME3jLHduU)+ZtCY4P;KwUYzG@I>*t*Y90ulZuLlR4 zK&wB$_6F~o7k<6RN>tT2ZF8P~{dHANDxsKE#5C5AA;&S*s&^UsI1me5{IZXS2)1)d zt9Zq}vO{{k&9G-BdE0VcZ2iozG?V&kudUJC(YMb6MNGOcdTYvDb90Q74zpmAjgA|# zSaLQLZ4J3Osul3`pv@d=GdirXb~Q_lF&9SsAHJ(!8tSajRuKefp50b>A?Z|GSnRYy z(Rxz2ZpnyvLw1*cSMz@75Z!^u@mdx`(mNR?%?Igi9|7z5;hCi+D+F4DB*~WcmT9jb z#vQC7qxzImf608tz8C?8Hz+J$>N10O6YGJapOD(f5=V1ilwJIsF2-PYB}IbT@Hiz| z(?AV(!zMT0q5XBdiFAv1w9Ubho@@?LC%pA^{qvmCGk^cEi1&ue6us*Ut#mvXB@tlS zi}Yj(qN9cSSGuv)7z#m%I*KK$e5hti{inh2#Jg%7l4-bGExc2!%%HtH=Fq6#_HYxl zBz-V&i_#q$NG=oAOinvQrJv1wwNnTPFt2Jw6zE}j9-;VZ6?P+ii_1T>)_4jo=$=T5 zNkp7_J6Wtaa!1qMRE`K5RF(8bSO8f=15GPx3)T&rEhPYlEriS>i~VfEXZ9?3I3ZP@ z&O2&Vejk4#H(WPPLM4qhRM8)j|Im;#Xk#Xqu@KHsNt-Y)9zG^2C(N2;UiQEZ^X_g| zLS%nJh9MhVmSLKEqCvj$ppKf^A2a4yVxl%AK-iBHrD;=6N;j~~rr>k5dXiB;eU%28 z5$`A5oG?7&F_pnrWgDXhBwQ|4wi)Xp|=^z37D$#0SvH#+Z5kXTyj=%_UngZ^(8O3~IO=v(}p{7;j*- z-_1~_mn(hRdZ9&5$qnbG8L%O3V>5RVf~SWfa1#tkBPi98B05Up-qq-XXDqDd9_f8o zjxFtQGR+5znJ2`QmNz7vseUDec1n32dWgAo58e|Ib9KgVJQh`ZqtG5GrJJE0B$hj z7lW&C76*4Kf*f_x00oTKWd1bA4J<0CNZ!6OsyT!h2RZ&e^}Un{1>Wa{jwFVzjCc9| z%Nw!;88ojBGK<9sRQ=w1S3`qMVh~YfqID2(7fO&Ft_xk;cpFi#V|SrucZD?@!P8g0oj~%z<^kBf*x^p7|k=<9Nv{Z zofZYH-Y`#2x(b8)W0!VcudlsysztKJ*fw*vEI3FI+y`!`cKT9)oC##t${3L_%-O*c z%>X6z0ahDFPND!QKdBs5v;&-ynansU76w!KYVz}%5|IPjNb1@2)Y_4VOqM2-nuh}# z?)r~crVAU%#9QxciVZ7Qku>vFM$@{nsC&IvT)PNG%J;12;-^Acnw>d5W_=om? zj!<0I?h-qv9dBd-ZUA5hz8-rZiSo*DQVNRkQ449>YDxf~E;2Q+=QcI~m|9aAnOPef zn5vtYJ?`?;*3i(<1-?ABT&Z4Dh25rGH+%}b9jov2uSjp78#}p8H^wV=Paj(ut6!5j zM8*qVx?S5FLq0_QNB6i#`N94nucUw@92tRo4GI5m`R0~p<^W4;1ArAEOkMml4Qj{Y zH7Y^l7B_42omIA9Yak1gVyo6gC2BR7wn2=PwX0JvN$*&f)j&hlrCnY6rv~n#VXWME zK~CpA=E3n~Zg4JX6N(DWc(86AuNT?9L>&%AAUgrzcbU6VpEP`Y-$}dfE`x+TR9;5Z z(HLV)lF%wjqY%-f?kMPfK1x{IHz!~9EY#oV^s8fIVUCxEBRIOnS_=wen!aw?n@I@D z9>_>%_5n_}{$MRfXBHUsjSukA@fEoo4JT}G(mJhvd9cAzNFKm8J>0+bgD_q{9}2OI zj82xKH3;NP>&$$zPEq%q3aWij-$GjC8~q}J4Z4gubV?neXbbD6ikq)_QW8ytn{zz^ z9xeT|1y}a|4M`mE5<8FwfI|JwmY~d;-UD~}J7;e5i-gME=MRK^+6|7mB&+Sy=>l?2 zx`A-A;QAG@O%+9YoOW&OSHKJa`V18eSe8OTfo$k>i2*GE0<_3~4qz*uzYau@ea^=p z|8|n!PpfkrfzSq8697CH^~YJj_e>Acf1W1FDJICrD=kAM%J)x%$IWw(C;bo%td##c z9tAf7am_6CsQ-D!qX#|y$&WM8ew+aEc*e?KXPE!jjdd+GO#pUgmPWroJPJ+!#A&Vp zl$H-9;Qikak7~aJB0mcKH-sh74NbKH1l+$gG~_YR)%~&a^`DA9?zt&K#}+XJ=G8&q z$O_6|r1S%S^G7Lv?I5?d)HJm+H?#bE^Y}lZMa{m7buOk-2myEMTEYHzY}^m@Opi~% zkMw8gzqae@@EBN`Yg%jTOWFWz0RPSA|Ac?!Ck9CvTN0?_7(8&+>JMz-d!`2#;uG*( z2Bta&rh0$IWACbjNCz^Z1;l&|IzNs~k3#e(@P0HxG5a&WkB;D76JNjsv?3^Qb{@eG zNZ@;>MCR!Z;c;;uQ#LTZh@dQK&^#-(Kwjm31I0zdKrP0rU`)| zFu|xB1Bp_XvN1K))G`JT(EMBOqx(@G;YNM}Mj?IR^8R&so=`m@_lbU^|5={z{wyUv zqe@W*=ssA$ZM#6&zmH6h9-z=a+C$=T0;82Ru(QLDP49nQpa0V&kDuU+EvbheFqA|A z@qtwRePntVvOR_UcYyiN#0-Kbw9b79#_I#x;|l1ejDO=kIz8LR|B3u}=+ZSXwgy=K zDR4d7U9;RCga|032i-8TBSbAjuHGF#anIRQmr5THM^)z|8ayvF6eL;!qglxq;l$0<*!d{x@XuzkvTc zvE@&`@rVrUbTRN450rpv|GH{aEuTXE*Q$BKx3$_BGOK{0J3-+0v@>n<1omHP;?MMY z9257709o(@vhZsh847&@@BfM;Pq-Y`8F7UXkc~G$BmSB!Eu)^0A_}mw($sq*`POuc zgzEyahKT+s*F<~H6M%o!A#_i9!S2oh zZV}K6e1Wl(?1zHD_e_tl{3jrP=ty8}VD-nk<}s!3Y=(bj1>!CN`TR8j13RhzdHMdG z(g~yhnmP{uXxsYFp!^utT*;OM)POt$0>MdsQ5wGN3Gg3z@`+mMkzE~;a~E=;%IQGV zx4)p4RXlv4sBr`c~p00Qm<0e@X#IMq)8{#`7Y{^ePJvfHDwR2RyXc0g=X zU?KKvz;UR10{bszf7BpPEM1qB4%{-JGoYaSzSNkTo&f&aA^r}M|7j8apOpQFO6QRp z(xio9IV2Deeqc-k7G!@PnI4?qo{|C#K7Z!f7Grwa7RbdUkmp~s+}QLJU_WMM0&4(( zxj8_G3s~)m|G7YYR2Qj3w1g74=n(%Eg%jtWknod~|5gD{#GF^l_)U)mbqLUokL{>_ z9GM;{D^DQ*@DW~H;I;kFtC)Xt^XMnsO|;SBz@@?u)c#jL0pEB6|2IE*BANP0F!EIZ z39kVLIKm%V1K%?}mbRV%{+p6NaPb&3LJ9Bk?}1e+8IX_15!yeFOpnQfCm?^ujK3@@ zXku>sL@H(bRs?$tq~h^%>(@|6aQp=P-x~ji>nCu}Qmvwsfw=TQ6aLDC>e>Im6}JAj z)gJvVUL?gY4G3upv>L$=8i4Pa9_*J-K>qNGKbY&0+lSe=DIhOEKR&P8 zkAv&~nVlbv_4}~mpYVS8Pr&~O$A8xM_lcoD0cDAw0Q^heU+0JZuITUc4}an=l0AX@ zx8weE8shJ`zYh%hi3>sh1nv*3{WU)5cf{W(b^JtJeE$UEPfh>6R{Yo8j^D-n-d6vo zm^ij4#QaeFiE8c9AAau){u7mg^M9cJu}}E#TL0dT_b2{KzW;&$heY(aj(_hF`4e$W z_zA?n9qvyZBY)TU_ZEFW@xO{af&b6o^HjsW-^Kji&g`d{BgrSk{4nPq%C<*hes4bX z6ERZm3B>;r^Y4ZI=o!C1Rq+!J#`Ouf|I@AiV9rM|zuy-9Ddx+kC&c{V;jf#d|77+t zoBn?9 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MainActivity.kt b/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MainActivity.kt new file mode 100644 index 0000000..e07fe46 --- /dev/null +++ b/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MainActivity.kt @@ -0,0 +1,140 @@ +package com.github.dsrees.chatexample + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.util.Log +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.activity_main.* +import org.phoenixframework.Channel +import org.phoenixframework.Socket + +class MainActivity : AppCompatActivity() { + + companion object { + const val TAG = "MainActivity" + } + + private val messagesAdapter = MessagesAdapter() + private val layoutManager = LinearLayoutManager(this) + + + // Use when connecting to https://github.com/dwyl/phoenix-chat-example + private val socket = Socket("https://phxchat.herokuapp.com/socket/websocket") + private val topic = "rooms:lobby" + + // Use when connecting to local server +// private val socket = Socket("ws://10.0.2.2:4000/socket/websocket") +// private val topic = "room:lobby" + + private var lobbyChannel: Channel? = null + + private val username: String + get() = username_input.text.toString() + + private val message: String + get() = message_input.text.toString() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + + layoutManager.stackFromEnd = true + + messages_recycler_view.layoutManager = layoutManager + messages_recycler_view.adapter = messagesAdapter + + socket.onOpen { + this.addText("Socket Opened") + runOnUiThread { connect_button.text = "Disconnect" } + } + + socket.onClose { + this.addText("Socket Closed") + runOnUiThread { connect_button.text = "Connect" } + } + + socket.onError { throwable, response -> + Log.e(TAG, "Socket Errored $response", throwable) + this.addText("Socket Error") + } + + socket.logger = { + Log.d(TAG, "SOCKET $it") + } + + + connect_button.setOnClickListener { + if (socket.isConnected) { + this.disconnectAndLeave() + } else { + this.disconnectAndLeave() + this.connectAndJoin() + } + } + + send_button.setOnClickListener { sendMessage() } + } + + private fun sendMessage() { + val payload = mapOf("user" to username, "body" to message) + this.lobbyChannel?.push("new:msg", payload) + ?.receive("ok") { Log.d(TAG, "success $it") } + ?.receive("error") { Log.d(TAG, "error $it") } + + message_input.text.clear() + } + + private fun disconnectAndLeave() { + // Be sure the leave the channel or call socket.remove(lobbyChannel) + lobbyChannel?.leave() + socket.disconnect { this.addText("Socket Disconnected") } + } + + private fun connectAndJoin() { + val channel = socket.channel(topic, mapOf("status" to "joining")) + channel.on("join") { + this.addText("You joined the room") + } + + channel.on("new:msg") { message -> + val payload = message.payload + val username = payload["user"] as? String + val body = payload["body"] + + + if (username != null && body != null) { + this.addText("[$username] $body") + } + } + + channel.on("user:entered") { + this.addText("[anonymous entered]") + } + + this.lobbyChannel = channel + channel + .join() + .receive("ok") { + this.addText("Joined Channel") + } + .receive("error") { + this.addText("Failed to join channel: ${it.payload}") + } + + + this.socket.connect() + } + + private fun addText(message: String) { + runOnUiThread { + this.messagesAdapter.add(message) + layoutManager.smoothScrollToPosition(messages_recycler_view, null, messagesAdapter.itemCount) + } + + } + +} diff --git a/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MessagesAdapter.kt b/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MessagesAdapter.kt new file mode 100644 index 0000000..e99b294 --- /dev/null +++ b/ChatExample/app/src/main/java/com/github/dsrees/chatexample/MessagesAdapter.kt @@ -0,0 +1,34 @@ +package com.github.dsrees.chatexample + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class MessagesAdapter : RecyclerView.Adapter() { + + private var messages: MutableList = mutableListOf() + + fun add(message: String) { + messages.add(message) + notifyItemInserted(messages.size) + } + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_message, parent, false) + return ViewHolder(view) + } + + override fun getItemCount(): Int = messages.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.label.text = messages[position] + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val label: TextView = itemView.findViewById(R.id.item_message_label) + } + +} \ No newline at end of file diff --git a/ChatExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/ChatExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..6348baa --- /dev/null +++ b/ChatExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/ChatExample/app/src/main/res/drawable/ic_launcher_background.xml b/ChatExample/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a0ad202 --- /dev/null +++ b/ChatExample/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatExample/app/src/main/res/layout/activity_main.xml b/ChatExample/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..bc78d34 --- /dev/null +++ b/ChatExample/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,65 @@ + + + +