Skip to content

feat: Add KMM support #401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ kotlin {
}
}
}
iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets {
all {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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)!!
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String> {

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)
}
Original file line number Diff line number Diff line change
@@ -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()
}
25 changes: 25 additions & 0 deletions client/src/iosMain/kotlin/com/algolia/search/platform/NSData.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<uBytefVar>() + 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
}
10 changes: 10 additions & 0 deletions client/src/iosMain/kotlin/com/algolia/search/util/Closeable.kt
Original file line number Diff line number Diff line change
@@ -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.
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down