From 11224d74b802190272f7ae0f9a0369e475794211 Mon Sep 17 00:00:00 2001 From: Emanuele Papa Date: Wed, 19 Jul 2023 14:30:09 +0200 Subject: [PATCH] feat: Add KMM support --- client/build.gradle.kts | 24 +++++ .../algolia/search/helper/internal/Date.kt | 42 +++++++++ .../search/helper/internal/Encoding.kt | 13 +++ .../algolia/search/helper/internal/Hashing.kt | 31 +++++++ .../com/algolia/search/model/ClientDate.kt | 21 +++++ .../com/algolia/search/model/internal/Time.kt | 10 +++ .../com/algolia/search/platform/NSData.kt | 25 ++++++ .../com/algolia/search/platform/NSString.kt | 11 +++ .../algolia/search/platform/NSTimeInterval.kt | 15 ++++ .../algolia/search/transport/internal/Gzip.kt | 88 +++++++++++++++++++ .../com/algolia/search/util/Closeable.kt | 10 +++ gradle/libs.versions.toml | 1 + 12 files changed, 291 insertions(+) create mode 100644 client/src/iosMain/kotlin/com/algolia/search/helper/internal/Date.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/helper/internal/Encoding.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/helper/internal/Hashing.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/model/ClientDate.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/model/internal/Time.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/platform/NSData.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/platform/NSString.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/platform/NSTimeInterval.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/transport/internal/Gzip.kt create mode 100644 client/src/iosMain/kotlin/com/algolia/search/util/Closeable.kt diff --git a/client/build.gradle.kts b/client/build.gradle.kts index d9c890130..07ad43aa5 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -22,6 +22,9 @@ kotlin { } } } + iosX64() + iosArm64() + iosSimulatorArm64() sourceSets { all { @@ -55,6 +58,27 @@ kotlin { implementation(libs.ktor.client.okhttp) } } + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + val iosMain by creating { + dependsOn(commonMain) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + dependencies { + implementation(libs.ktor.client.darwin) + } + } + val iosX64Test by getting + val iosArm64Test by getting + val iosSimulatorArm64Test by getting + val iosTest by creating { + dependsOn(commonTest) + iosX64Test.dependsOn(this) + iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) + } } } diff --git a/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Date.kt b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Date.kt new file mode 100644 index 000000000..b7bdcc10e --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Date.kt @@ -0,0 +1,42 @@ +package com.algolia.search.helper.internal + +import com.algolia.search.platform.fractional +import com.algolia.search.platform.toMillis +import kotlinx.cinterop.UnsafeNumber +import platform.Foundation.NSDate +import platform.Foundation.NSISO8601DateFormatWithFractionalSeconds +import platform.Foundation.NSISO8601DateFormatWithInternetDateTime +import platform.Foundation.NSISO8601DateFormatter +import platform.Foundation.create +import platform.Foundation.timeIntervalSince1970 + +@ThreadLocal +internal actual object DateISO8601 { + + private val dateISO8601 = NSISO8601DateFormatter() + + @OptIn(UnsafeNumber::class) + internal val dateISO8601Millis = NSISO8601DateFormatter().apply { + formatOptions = NSISO8601DateFormatWithInternetDateTime xor NSISO8601DateFormatWithFractionalSeconds + } + + actual fun format(timestamp: Long, inMilliseconds: Boolean): String { + val date = NSDate.create(timeIntervalSince1970 = timestamp.fractional()) + val formatter = if (inMilliseconds) dateISO8601Millis else dateISO8601 + return formatter.stringFromDate(date) + } + + actual fun parse(date: String, inMilliseconds: Boolean): Long { + val formatter = if (inMilliseconds) dateISO8601Millis else dateISO8601 + return formatter.dateFromString(date)?.timeIntervalSince1970?.toMillis() + ?: throw IllegalArgumentException("unable to parse $date") + } + + fun parseToNSDate(date: String): NSDate { + return when (date.length) { + 20 -> dateISO8601.dateFromString(date) + 24 -> dateISO8601Millis.dateFromString(date) + else -> NSDate() + } ?: throw IllegalArgumentException("unable to parse $date") + } +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Encoding.kt b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Encoding.kt new file mode 100644 index 000000000..ea87eab6f --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Encoding.kt @@ -0,0 +1,13 @@ +package com.algolia.search.helper.internal + +import com.algolia.search.platform.asNSString +import platform.Foundation.NSCharacterSet +import platform.Foundation.URLHostAllowedCharacterSet +import platform.Foundation.stringByAddingPercentEncodingWithAllowedCharacters + +/** + * Encodes [this] using UTF-8 + */ +internal actual fun String.encodeUTF8(): String { + return asNSString().stringByAddingPercentEncodingWithAllowedCharacters(allowedCharacters = NSCharacterSet.URLHostAllowedCharacterSet)!! +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Hashing.kt b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Hashing.kt new file mode 100644 index 000000000..ea574faa8 --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/helper/internal/Hashing.kt @@ -0,0 +1,31 @@ +package com.algolia.search.helper.internal + +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CCHmac +import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH +import platform.CoreCrypto.kCCHmacAlgSHA256 + +@OptIn(ExperimentalUnsignedTypes::class, UnsafeNumber::class) +internal actual fun String.sha256(key: String): String { + val input = encodeToByteArray() + val keyData = key.encodeToByteArray() + val digest = UByteArray(CC_SHA256_DIGEST_LENGTH) + keyData.usePinned { keyPinned -> + input.usePinned { inputPinned -> + digest.usePinned { digestPinned -> + CCHmac( + kCCHmacAlgSHA256, + keyPinned.addressOf(0), + keyData.size.convert(), + inputPinned.addressOf(0), + input.size.convert(), + digestPinned.addressOf(0) + ) + } + } + } + return digest.toByteArray().toHex(true) +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/model/ClientDate.kt b/client/src/iosMain/kotlin/com/algolia/search/model/ClientDate.kt new file mode 100644 index 000000000..6a3634a33 --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/model/ClientDate.kt @@ -0,0 +1,21 @@ +package com.algolia.search.model + +import com.algolia.search.helper.internal.DateISO8601 +import com.algolia.search.model.internal.Raw +import com.algolia.search.serialize.KSerializerClientDate +import kotlinx.serialization.Serializable +import platform.Foundation.NSDate + +/** + * JVM implementation converting a [String] or a [Long] into a [Date] format. Relies on ISO8601. + */ +@Serializable(KSerializerClientDate::class) +public actual data class ClientDate internal actual constructor(override val raw: String) : Raw { + + internal actual constructor(timestamp: Long) : this(DateISO8601.format(timestamp, false)) + + /** + * In the eventuality of the Date format being wrong, we create an empty [NSDate] object instead of throwing an exception. + */ + val date: NSDate = DateISO8601.parseToNSDate(raw) +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/model/internal/Time.kt b/client/src/iosMain/kotlin/com/algolia/search/model/internal/Time.kt new file mode 100644 index 000000000..ef96f118c --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/model/internal/Time.kt @@ -0,0 +1,10 @@ +package com.algolia.search.model.internal + +import com.algolia.search.platform.toMillis +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +internal actual object Time { + + actual fun getCurrentTimeMillis(): Long = NSDate().timeIntervalSince1970.toMillis() +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/platform/NSData.kt b/client/src/iosMain/kotlin/com/algolia/search/platform/NSData.kt new file mode 100644 index 000000000..3f776903d --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/platform/NSData.kt @@ -0,0 +1,25 @@ +package com.algolia.search.platform + +import kotlinx.cinterop.UnsafeNumber +import platform.Foundation.NSData +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding + +/** + * Converts [NSData] to [String] using UTF-8 encoding. + */ +@OptIn(UnsafeNumber::class) +internal fun NSData.asString(): String? { + return NSString.create(this, NSUTF8StringEncoding) as String? +} + +/** + * Converts [String] to [NSData] using UTF-8 encoding. + */ +@OptIn(UnsafeNumber::class) +@Suppress("CAST_NEVER_SUCCEEDS") +public fun String.asNSData(): NSData? { + return (this as NSString).dataUsingEncoding(NSUTF8StringEncoding) +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/platform/NSString.kt b/client/src/iosMain/kotlin/com/algolia/search/platform/NSString.kt new file mode 100644 index 000000000..76c6cd5a8 --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/platform/NSString.kt @@ -0,0 +1,11 @@ +package com.algolia.search.platform + +import platform.Foundation.NSString + +/** + * Map Kotlin [String] as Objective-C [NSString]. + * + * [documentation](https://kotlinlang.org/docs/native-objc-interop.html#mappings) + */ +@Suppress("CAST_NEVER_SUCCEEDS") +internal fun String.asNSString() = this as NSString diff --git a/client/src/iosMain/kotlin/com/algolia/search/platform/NSTimeInterval.kt b/client/src/iosMain/kotlin/com/algolia/search/platform/NSTimeInterval.kt new file mode 100644 index 000000000..277e3e18f --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/platform/NSTimeInterval.kt @@ -0,0 +1,15 @@ +package com.algolia.search.platform + +import platform.Foundation.NSTimeInterval + +/** + * Converts [NSTimeInterval] to milliseconds, since it always specified as seconds. + * + * [documentation](https://developer.apple.com/documentation/foundation/nstimeinterval) + */ +internal inline fun NSTimeInterval.toMillis() = (this * 1000).toLong() + +/** + * Converts to timestamp with fractional seconds. + */ +internal inline fun Long.fractional() = this / 1000.0 diff --git a/client/src/iosMain/kotlin/com/algolia/search/transport/internal/Gzip.kt b/client/src/iosMain/kotlin/com/algolia/search/transport/internal/Gzip.kt new file mode 100644 index 000000000..73ccd3999 --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/transport/internal/Gzip.kt @@ -0,0 +1,88 @@ +package com.algolia.search.transport.internal + +import com.algolia.search.model.filter.internal.Converter +import com.algolia.search.platform.asNSData +import io.ktor.utils.io.core.toByteArray +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.plus +import kotlinx.cinterop.ptr +import kotlinx.cinterop.refTo +import kotlinx.cinterop.reinterpret +import platform.Foundation.NSData +import platform.Foundation.NSMutableData +import platform.Foundation.create +import platform.Foundation.dataWithLength +import platform.Foundation.increaseLengthBy +import platform.posix.memcpy +import platform.zlib.MAX_MEM_LEVEL +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_OK +import platform.zlib.deflate +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.uBytefVar +import platform.zlib.z_stream + +internal actual object Gzip : (String) -> ByteArray { + + override fun invoke(input: String): ByteArray { + if (input.isEmpty()) return byteArrayOf() + return input.asNSData()?.compress()?.toByteArray() ?: throw IllegalStateException("unable to compress data") + } +} + +internal const val CHUNK = 16384u // 16K chunks for expansion + +@OptIn(UnsafeNumber::class) +private fun NSData.compress(): NSData? { + return memScoped { + val stream: z_stream = alloc() + val compressed: NSMutableData + try { + stream.zalloc = null + stream.zfree = null + stream.opaque = null + stream.avail_in = length.convert() + stream.next_in = bytes!!.reinterpret() + stream.total_out = 0u + stream.avail_out = 0u + + if (deflateInit2( + strm = stream.ptr, + level = Z_DEFAULT_COMPRESSION, + method = Z_DEFLATED, + windowBits = 31, // (15+16) for gzip + memLevel = MAX_MEM_LEVEL, + strategy = Z_DEFAULT_STRATEGY + ) != Z_OK + ) return null + + compressed = NSMutableData.dataWithLength(CHUNK.convert())!! + do { + if (stream.total_out >= compressed.length) compressed.increaseLengthBy(CHUNK.convert()) + stream.next_out = compressed.mutableBytes!!.reinterpret() + stream.total_out.toInt() + val avail = compressed.length - stream.total_out + stream.avail_out = avail.convert() + deflate(stream.ptr, Z_FINISH) + } while (stream.avail_out == 0u) + } finally { + deflateEnd(stream.ptr) + } + + NSData.create(data = compressed) + } +} + +@OptIn(UnsafeNumber::class) +private fun NSData.toByteArray(): ByteArray = memScoped { + val size = length.toInt() + val nsData = ByteArray(size) + memcpy(nsData.refTo(0), bytes, size.convert()) + return nsData +} diff --git a/client/src/iosMain/kotlin/com/algolia/search/util/Closeable.kt b/client/src/iosMain/kotlin/com/algolia/search/util/Closeable.kt new file mode 100644 index 000000000..602b2c88f --- /dev/null +++ b/client/src/iosMain/kotlin/com/algolia/search/util/Closeable.kt @@ -0,0 +1,10 @@ +package com.algolia.search.util + +public actual interface Closeable { + public actual fun close() +} + +@PublishedApi +internal actual fun Throwable.addSuppressedInternal(other: Throwable) { + // no-op. +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69006640c..bae39b5ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ ktor-client-serialization-json = { group = "io.ktor", name = "ktor-serialization ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.ref = "ktor" } ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = "ktor" } ktor-client-jetty = { group = "io.ktor", name = "ktor-client-jetty", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }