diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index 9db42edd8..a26c62692 100644 --- a/src/SDK/Language/Android.php +++ b/src/SDK/Language/Android.php @@ -136,8 +136,14 @@ public function getFiles() ], [ 'scope' => 'default', - 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/models/Error.kt', - 'template' => '/android/library/src/main/java/io/appwrite/models/Error.kt.twig', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/extensions/CollectionExtensions.kt', + 'template' => '/android/library/src/main/java/io/appwrite/extensions/CollectionExtensions.kt.twig', + 'minify' => false, + ], + [ + 'scope' => 'default', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/models/RealtimeModels.kt', + 'template' => '/android/library/src/main/java/io/appwrite/models/RealtimeModels.kt.twig', 'minify' => false, ], [ @@ -160,10 +166,16 @@ public function getFiles() ], [ 'scope' => 'default', - 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/services/BaseService.kt', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/services/Service.kt', 'template' => '/android/library/src/main/java/io/appwrite/services/Service.kt.twig', 'minify' => false, ], + [ + 'scope' => 'default', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/services/Realtime.kt', + 'template' => '/android/library/src/main/java/io/appwrite/services/Realtime.kt.twig', + 'minify' => false, + ], [ 'scope' => 'service', 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/services/{{service.name | caseUcfirst}}.kt', diff --git a/templates/android/.github/workflows/publish.yml b/templates/android/.github/workflows/publish.yml index b777478bf..20b880d0c 100644 --- a/templates/android/.github/workflows/publish.yml +++ b/templates/android/.github/workflows/publish.yml @@ -50,4 +50,5 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} SIGNING_SECRET_KEY_RING_FILE: ${{ secrets.SIGNING_SECRET_KEY_RING_FILE }} - SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} \ No newline at end of file + SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} + SDK_VERSION: ${{ github.event.release.tag_name }} \ No newline at end of file diff --git a/templates/android/library/build.gradle.twig b/templates/android/library/build.gradle.twig index 724971072..8557d14d2 100644 --- a/templates/android/library/build.gradle.twig +++ b/templates/android/library/build.gradle.twig @@ -7,7 +7,7 @@ plugins { ext { PUBLISH_GROUP_ID = '{{ sdk.namespace | caseDot }}' PUBLISH_ARTIFACT_ID = '{{ sdk.gitRepoName | caseDash }}' - PUBLISH_VERSION = '{{ sdk.version }}' + PUBLISH_VERSION = System.getenv('SDK_VERSION') POM_URL = 'https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}' POM_SCM_URL = 'https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}' POM_ISSUE_URL = 'https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}/issues' @@ -53,8 +53,8 @@ android { dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${KotlinCompilerVersion.VERSION}") - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3") - api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1") + api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0") api(platform("com.squareup.okhttp3:okhttp-bom:4.9.0")) api("com.squareup.okhttp3:okhttp") @@ -65,15 +65,16 @@ dependencies { implementation("net.gotev:cookie-store:1.3.5") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") implementation("androidx.lifecycle:lifecycle-common-java8:2.3.1") - implementation("androidx.appcompat:appcompat:1.2.0") - implementation("androidx.fragment:fragment-ktx:1.3.2") - implementation("androidx.activity:activity-ktx:1.2.2") + implementation("androidx.appcompat:appcompat:1.3.1") + implementation("androidx.fragment:fragment-ktx:1.3.6") + implementation("androidx.activity:activity-ktx:1.3.1") implementation("androidx.browser:browser:1.3.0") testImplementation 'junit:junit:4.+' - testImplementation "androidx.test.ext:junit-ktx:1.1.2" - testImplementation "androidx.test:core-ktx:1.3.0" + testImplementation "androidx.test.ext:junit-ktx:1.1.3" + testImplementation "androidx.test:core-ktx:1.4.0" testImplementation "org.robolectric:robolectric:4.5.1" + testApi("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1") } apply from: "${rootProject.projectDir}/scripts/publish-module.gradle" \ No newline at end of file diff --git a/templates/android/library/src/main/java/io/appwrite/Client.kt.twig b/templates/android/library/src/main/java/io/appwrite/Client.kt.twig index 57e85e8c0..8ec8dc393 100644 --- a/templates/android/library/src/main/java/io/appwrite/Client.kt.twig +++ b/templates/android/library/src/main/java/io/appwrite/Client.kt.twig @@ -5,8 +5,7 @@ import android.content.pm.PackageManager import com.google.gson.Gson import io.appwrite.appwrite.BuildConfig import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception -import {{ sdk.namespace | caseDot }}.extensions.JsonExtensions.fromJson -import {{ sdk.namespace | caseDot }}.models.Error +import {{ sdk.namespace | caseDot }}.extensions.fromJson import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,6 +35,7 @@ import kotlin.coroutines.resume class Client @JvmOverloads constructor( context: Context, var endPoint: String = "https://appwrite.io/v1", + var endPointRealtime: String? = null, private var selfSigned: Boolean = false ) : CoroutineScope { @@ -151,7 +151,7 @@ class Client @JvmOverloads constructor( } /** - * Set endpoint + * Set endpoint and realtime endpoint. * * @param endpoint * @@ -159,6 +159,23 @@ class Client @JvmOverloads constructor( */ fun setEndpoint(endPoint: String): Client { this.endPoint = endPoint + + if (this.endPointRealtime == null && endPoint.startsWith("http")) { + this.endPointRealtime = endPoint.replaceFirst("http", "ws") + } + + return this + } + + /** + * Set realtime endpoint + * + * @param endpoint + * + * @return this + */ + fun setEndpointRealtime(endPoint: String): Client { + this.endPointRealtime = endPoint return this } @@ -297,9 +314,9 @@ class Client @JvmOverloads constructor( val contentType: String = response.headers["content-type"] ?: "" val error = if (contentType.contains("application/json", ignoreCase = true)) { - bodyString.fromJson(Error::class.java) + bodyString.fromJson<{{ spec.title | caseUcfirst }}Exception>() } else { - Error(bodyString, response.code) + {{ spec.title | caseUcfirst }}Exception(bodyString, response.code) } it.cancel(AppwriteException( diff --git a/templates/android/library/src/main/java/io/appwrite/extensions/CollectionExtensions.kt.twig b/templates/android/library/src/main/java/io/appwrite/extensions/CollectionExtensions.kt.twig new file mode 100644 index 000000000..1cae0bea0 --- /dev/null +++ b/templates/android/library/src/main/java/io/appwrite/extensions/CollectionExtensions.kt.twig @@ -0,0 +1,12 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +suspend fun Collection.forEachAsync( + callback: suspend (T) -> Unit +) = withContext(IO) { + map { async { callback.invoke(it) } }.awaitAll() +} \ No newline at end of file diff --git a/templates/android/library/src/main/java/io/appwrite/extensions/JsonExtensions.kt.twig b/templates/android/library/src/main/java/io/appwrite/extensions/JsonExtensions.kt.twig index e05442224..48e536b3a 100644 --- a/templates/android/library/src/main/java/io/appwrite/extensions/JsonExtensions.kt.twig +++ b/templates/android/library/src/main/java/io/appwrite/extensions/JsonExtensions.kt.twig @@ -2,11 +2,33 @@ package {{ sdk.namespace | caseDot }}.extensions import com.google.gson.Gson -object JsonExtensions { +val gson = Gson() - fun Any.toJson(): String = - Gson().toJson(this) +fun Any.toJson(): String = + gson.toJson(this) - fun String.fromJson(clazz: Class): T = - Gson().fromJson(this, clazz) -} \ No newline at end of file +fun String.fromJson(clazz: Class): T = + gson.fromJson(this, clazz) + +inline fun String.fromJson(): T = + gson.fromJson(this, T::class.java) + +fun Any.jsonCast(to: Class): T = + toJson().fromJson(to) + +inline fun Any.jsonCast(): T = + toJson().fromJson(T::class.java) + +fun Any.tryJsonCast(to: Class): T? = try { + toJson().fromJson(to) +} catch (ex: Exception) { + ex.printStackTrace() + null +} + +inline fun Any.tryJsonCast(): T? = try { + toJson().fromJson(T::class.java) +} catch (ex: Exception) { + ex.printStackTrace() + null +} diff --git a/templates/android/library/src/main/java/io/appwrite/models/RealtimeModels.kt.twig b/templates/android/library/src/main/java/io/appwrite/models/RealtimeModels.kt.twig new file mode 100644 index 000000000..7c02715f5 --- /dev/null +++ b/templates/android/library/src/main/java/io/appwrite/models/RealtimeModels.kt.twig @@ -0,0 +1,31 @@ +package {{ sdk.namespace | caseDot }}.models + +import java.io.Closeable + +data class RealtimeSubscription( + private val close: () -> Unit +) : Closeable { + override fun close() = close.invoke() +} + +data class RealtimeCallback( + val payloadClass: Class<*>, + val callback: (RealtimeResponseEvent<*>) -> Unit +) + +open class RealtimeResponse( + val type: String, + val data: Any +) + +data class RealtimeResponseEvent( + val event: String, + val channels: Collection, + val timestamp: Long, + var payload: T +) + +enum class RealtimeCode(val value: Int) { + POLICY_VIOLATION(1008), + UNKNOWN_ERROR(-1) +} \ No newline at end of file diff --git a/templates/android/library/src/main/java/io/appwrite/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/appwrite/services/Realtime.kt.twig new file mode 100644 index 000000000..bee4aeff1 --- /dev/null +++ b/templates/android/library/src/main/java/io/appwrite/services/Realtime.kt.twig @@ -0,0 +1,174 @@ +package {{ sdk.namespace | caseDot }}.services + +import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception +import {{ sdk.namespace | caseDot }}.extensions.forEachAsync +import {{ sdk.namespace | caseDot }}.extensions.fromJson +import {{ sdk.namespace | caseDot }}.extensions.jsonCast +import {{ sdk.namespace | caseDot }}.models.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.ws.RealWebSocket +import java.util.* +import kotlin.coroutines.CoroutineContext + +class Realtime(client: Client) : Service(client), CoroutineScope { + + private val job = Job() + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + + private companion object { + private const val TYPE_ERROR = "error" + private const val TYPE_EVENT = "event" + + private const val DEBOUNCE_MILLIS = 1L + + private var socket: RealWebSocket? = null + private var channelCallbacks = mutableMapOf>() + private var errorCallbacks = mutableSetOf<({{ spec.title | caseUcfirst }}Exception) -> Unit>() + + private var subCallDepth = 0 + } + + private fun createSocket() { + val queryParamBuilder = StringBuilder() + .append("project=${client.config["project"]}") + + channelCallbacks.keys.forEach { + queryParamBuilder + .append("&channels[]=$it") + } + + val request = Request.Builder() + .url("${client.endPointRealtime}/realtime?$queryParamBuilder") + .build() + + if (socket != null) { + closeSocket() + } + + socket = RealWebSocket( + taskRunner = TaskRunner.INSTANCE, + originalRequest = request, + listener = {{ spec.title | caseUcfirst }}WebSocketListener(), + random = Random(), + pingIntervalMillis = client.http.pingIntervalMillis.toLong(), + extensions = null, + minimumDeflateSize = client.http.minWebSocketMessageToCompress + ) + + socket!!.connect(client.http) + } + + private fun closeSocket() { + socket?.close(RealtimeCode.POLICY_VIOLATION.value, null) + } + + fun subscribe( + vararg channels: String, + callback: (RealtimeResponseEvent) -> Unit, + ) = subscribe( + channels = channels, + Any::class.java, + callback + ) + + fun subscribe( + vararg channels: String, + payloadType: Class, + callback: (RealtimeResponseEvent) -> Unit, + ): RealtimeSubscription { + channels.forEach { + if (!channelCallbacks.containsKey(it)) { + channelCallbacks[it] = mutableListOf( + RealtimeCallback( + payloadType, + callback as (RealtimeResponseEvent<*>) -> Unit + ) + ) + return@forEach + } + channelCallbacks[it]?.add( + RealtimeCallback(payloadType, callback as (RealtimeResponseEvent<*>) -> Unit) + ) + } + + launch { + subCallDepth++ + delay(DEBOUNCE_MILLIS) + if (subCallDepth == 1) { + createSocket() + } + subCallDepth-- + } + + return RealtimeSubscription { unsubscribe(*channels) } + } + + fun unsubscribe(vararg channels: String) { + channels.forEach { + channelCallbacks[it] = mutableListOf() + } + if (channelCallbacks.all { it.value.isEmpty() }) { + errorCallbacks = mutableSetOf() + closeSocket() + } + } + + fun doOnError(callback: ({{ spec.title | caseUcfirst }}Exception) -> Unit) { + errorCallbacks.add(callback) + } + + private inner class {{ spec.title | caseUcfirst }}WebSocketListener : WebSocketListener() { + + override fun onMessage(webSocket: WebSocket, text: String) { + super.onMessage(webSocket, text) + + launch(IO) { + val message = text.fromJson() + when (message.type) { + TYPE_ERROR -> handleResponseError(message) + TYPE_EVENT -> handleResponseEvent(message) + } + } + } + + private fun handleResponseError(message: RealtimeResponse) { + val error = message.data.jsonCast<{{ spec.title | caseUcfirst }}Exception>() + errorCallbacks.forEach { it.invoke(error) } + } + + private suspend fun handleResponseEvent(message: RealtimeResponse) { + val event = message.data.jsonCast>() + event.channels.forEachAsync { channel -> + channelCallbacks[channel]?.forEachAsync { callbackWrapper -> + event.payload = event.payload.jsonCast(callbackWrapper.payloadClass) + callbackWrapper.callback.invoke(event) + } + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + super.onClosing(webSocket, code, reason) + if (code == RealtimeCode.POLICY_VIOLATION.value) { + return + } + launch { + delay(1000) + createSocket() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + super.onFailure(webSocket, t, response) + t.printStackTrace() + } + } +} \ No newline at end of file diff --git a/templates/android/library/src/main/java/io/appwrite/services/Service.kt.twig b/templates/android/library/src/main/java/io/appwrite/services/Service.kt.twig index 80ceeeffa..a6b5623fc 100644 --- a/templates/android/library/src/main/java/io/appwrite/services/Service.kt.twig +++ b/templates/android/library/src/main/java/io/appwrite/services/Service.kt.twig @@ -2,4 +2,4 @@ package {{ sdk.namespace | caseDot }}.services import {{ sdk.namespace | caseDot }}.Client -abstract class BaseService(private val client: Client) +abstract class Service(val client: Client) diff --git a/templates/android/library/src/main/java/io/appwrite/services/ServiceTemplate.kt.twig b/templates/android/library/src/main/java/io/appwrite/services/ServiceTemplate.kt.twig index 31989a7d3..d0fb41224 100644 --- a/templates/android/library/src/main/java/io/appwrite/services/ServiceTemplate.kt.twig +++ b/templates/android/library/src/main/java/io/appwrite/services/ServiceTemplate.kt.twig @@ -22,7 +22,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl {% endif %} import java.io.File -class {{ service.name | caseUcfirst }}(private val client: Client) : BaseService(client) { +class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { {% for method in service.methods %} /** diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig index ee3f1fea7..aa5218314 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig @@ -127,12 +127,12 @@ class Client @JvmOverloads constructor( } /** - * Set endpoint - * - * @param endpoint - * - * @return this - */ + * Set endpoint. + * + * @param endpoint + * + * @return this + */ fun setEndpoint(endPoint: String): Client { this.endPoint = endPoint return this diff --git a/tests/SDKTest.php b/tests/SDKTest.php index 9854d47ae..d22a7afdf 100644 --- a/tests/SDKTest.php +++ b/tests/SDKTest.php @@ -83,7 +83,8 @@ class SDKTest extends TestCase 'envs' => [ 'java-8' => 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/android alvrme/alpine-android:latest-jdk8 sh -c "./gradlew :library:testReleaseUnitTest -q && cat library/result.txt"', ], - 'supportException' => true, + 'supportException' => false, + 'supportRealtime' => true, ], 'kotlin' => [ diff --git a/tests/languages/android/ServiceTest.kt b/tests/languages/android/ServiceTest.kt index 8b79a4a03..d3b5d942d 100644 --- a/tests/languages/android/ServiceTest.kt +++ b/tests/languages/android/ServiceTest.kt @@ -4,11 +4,20 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.gson.Gson import io.appwrite.exceptions.AppwriteException +import io.appwrite.extensions.fromJson +import io.appwrite.extensions.toJson import io.appwrite.services.Bar import io.appwrite.services.Foo import io.appwrite.services.General -import okhttp3.Response +import io.appwrite.services.Realtime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.Response +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -18,31 +27,49 @@ import java.io.IOException import java.nio.file.Files import java.nio.file.Paths +data class TestPayload(val response: String) + @Config(manifest=Config.NONE) @RunWith(AndroidJUnit4::class) class ServiceTest { - val filename: String = "result.txt" + + private val filename: String = "result.txt" @Before - fun start() { + @ExperimentalCoroutinesApi + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) Files.deleteIfExists(Paths.get(filename)) writeToFile("Test Started") } + @After + @ExperimentalCoroutinesApi + fun tearDown() { + Dispatchers.resetMain() + } + @Test @Throws(IOException::class) fun test() { - val client = Client(ApplicationProvider.getApplicationContext()) + .setEndpointRealtime("wss://realtime.appwrite.org/v1") + .setProject("console") + .addHeader("Origin", "http://localhost") + .setSelfSigned(true) val foo = Foo(client) val bar = Bar(client) val general = General(client) - client.addHeader("Origin", "http://localhost") - client.setSelfSigned(true) + val realtime = Realtime(client) + var realtimeResponse = "Realtime failed!" + + realtime.subscribe("tests", payloadType = TestPayload::class.java) { + realtimeResponse = it.payload.response + } - var response: Response - // Foo Tests runBlocking { + var response: Response + // Foo Tests response = foo.get("string", 123, listOf("string in array")) printResponse(response) response = foo.post("string", 123, listOf("string in array")) @@ -75,21 +102,24 @@ class ServiceTest { try { general.error400() - } catch(e: AppwriteException) { + } catch (e: AppwriteException) { writeToFile(e.message) } try { general.error500() - } catch(e: AppwriteException) { + } catch (e: AppwriteException) { writeToFile(e.message) } try { general.error502() - } catch(e: AppwriteException) { + } catch (e: AppwriteException) { writeToFile(e.message) } + + delay(5000) + writeToFile(realtimeResponse) } } @@ -104,7 +134,7 @@ class ServiceTest { writeToFile(map["result"] as String) } - private fun writeToFile(string: String?){ + private fun writeToFile(string: String?) { val text = "${string ?: ""}\n" File("result.txt").appendText(text) }