Skip to content

Commit 235fb46

Browse files
authored
Merge pull request #238 from adamint/feature/native-targets
Kotlin/Native (MacOS, Windows, Linux) targets
2 parents c61896d + 06ee91c commit 235fb46

File tree

40 files changed

+574
-292
lines changed

40 files changed

+574
-292
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ repositories {
4040
implementation("com.adamratzman:spotify-api-kotlin-core:3.4.03")
4141
```
4242

43-
Note that images and profiles are not supported on the Kotlin/JS target.
44-
4543
### Android
4644
**If you declare any release types not named debug or release, you may see "Could not resolve com.adamratzman:spotify-api-kotlin-android:VERSION". You need to do the following for each release type not named debug or release:**
4745
```
@@ -78,6 +76,17 @@ If you have a question, you can:
7876
2. Join our [Discord server](https://discord.gg/G6vqP3S)
7977
3. Contact me using **Adam#9261** on [Discord](https://discordapp.com)
8078

79+
## Unsupported features on each platform:
80+
| Feature | JVM | Android | JS | Native (Mac/Windows/Linux) |
81+
|-----------------------------|--------------------|--------------------|--------------------|----------------------------|
82+
| Images (Playlist covers) | :heavy_check_mark: | :heavy_check_mark: | Unsupported | Unsupported |
83+
| getSpotifyPkceCodeChallenge | :heavy_check_mark: | :heavy_check_mark: | Unsupported | Unsupported |
84+
| Edit client playlist | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | Unsupported |
85+
| Remove playlist tracks | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | Unsupported |
86+
87+
Please feel free to open an issue/discussion on GitHub or Discord if you need access to one of these features
88+
or have an interest in implementing one, as direction can be provided.
89+
8190
## Creating a new api instance
8291
To decide which api you need (SpotifyAppApi, SpotifyClientApi, SpotifyImplicitGrantApi), you can refer
8392
to the sections below or the [Spotify authorization guide](https://developer.spotify.com/documentation/general/guides/authorization-guide/). In general:

build.gradle.kts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,30 @@ kotlin {
146146
}
147147
}
148148

149+
val hostOs = System.getProperty("os.name")
150+
val isMingwX64 = hostOs.startsWith("Windows")
151+
val nativeTarget = when {
152+
hostOs == "Mac OS X" -> macosX64("native")
153+
hostOs == "Linux" -> linuxX64("native")
154+
isMingwX64 -> mingwX64("native")
155+
else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
156+
}
157+
158+
149159
targets {
150160
sourceSets {
151-
val coroutineVersion = "1.4.2"
161+
val coroutineVersion = "1.4.2-native-mt"
152162
val serializationVersion = "1.0.1"
153163
val ktorVersion = "1.4.1"
164+
val kotlinxDatetimeVersion = "0.1.1"
154165

155166
val commonMain by getting {
156167
dependencies {
157168
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
158169
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
159170
implementation("io.ktor:ktor-client-core:$ktorVersion")
171+
implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDatetimeVersion")
172+
implementation("com.autodesk:coroutineworker:0.6.2")
160173
}
161174
}
162175
val commonTest by getting {
@@ -221,6 +234,17 @@ kotlin {
221234
}
222235
}
223236

237+
val nativeMain by getting {
238+
dependencies {
239+
implementation("io.ktor:ktor-client-curl:$ktorVersion")
240+
}
241+
}
242+
val nativeTest by getting {
243+
dependencies {
244+
245+
}
246+
}
247+
224248
all {
225249
languageSettings.useExperimentalAnnotation("kotlin.Experimental")
226250
}
@@ -260,8 +284,9 @@ publishing {
260284

261285
signing {
262286
if (project.hasProperty("signing.keyId")
263-
&& project.hasProperty("signing.password")
264-
&& project.hasProperty("signing.secretKeyRingFile")) {
287+
&& project.hasProperty("signing.password")
288+
&& project.hasProperty("signing.secretKeyRingFile")
289+
) {
265290
sign(publishing.publications)
266291
}
267292
}

src/androidMain/kotlin/com/adamratzman/spotify/http/Endpoints.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ internal actual fun String.encodeUrl() = URLEncoder.encode(this, "UTF-8")!!
1111
internal actual fun String.base64ByteEncode(): String {
1212
return if (VERSION.SDK_INT >= VERSION_CODES.O) {
1313
java.util.Base64.getUrlEncoder()
14-
.withoutPadding()
15-
.encodeToString(toByteArray())
14+
.withoutPadding()
15+
.encodeToString(toByteArray())
1616
} else {
1717
Base64.encodeToString(toByteArray(), Base64.DEFAULT)
1818
}
Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
11
/* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2021; Original author: Adam Ratzman */
22
package com.adamratzman.spotify.utils
33

4-
import android.annotation.SuppressLint
5-
import java.text.SimpleDateFormat
6-
import java.util.Date
7-
84
public actual fun getCurrentTimeMs(): Long = System.currentTimeMillis()
9-
10-
@SuppressLint("SimpleDateFormat")
11-
internal actual fun formatDate(format: String, date: Long): String {
12-
return SimpleDateFormat(format).format(Date(date))
13-
}

src/commonMain/kotlin/com.adamratzman.spotify/endpoints/public/BrowseApi.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,7 @@ public class BrowseApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) {
101101
endpointBuilder("/browse/featured-playlists").with("limit", limit).with("offset", offset).with(
102102
"market",
103103
market?.name
104-
).with("locale", locale).with("timestamp", timestamp?.let {
105-
formatDate("yyyy-MM-dd'T'HH:mm:ss", it)
106-
}).toString()
104+
).with("locale", locale).with("timestamp", timestamp?.let { formatDate(it) }).toString()
107105
).toObject(FeaturedPlaylists.serializer(), api, json)
108106

109107
/**
@@ -183,7 +181,7 @@ public class BrowseApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) {
183181
limit
184182
).with("offset", offset)
185183
.with("market", market?.name).toString()
186-
).toPagingObject(SimplePlaylist.serializer(), "playlists", endpoint = this, json = json)
184+
).toPagingObject((SimplePlaylist.serializer()), "playlists", endpoint = this, json = json)
187185

188186
/**
189187
* Create a playlist-style listening experience based on seed artists, tracks and genres.

src/commonMain/kotlin/com.adamratzman.spotify/http/HttpConnection.kt

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ public class HttpConnection constructor(
9595

9696
if (respCode == 502 && retryIf502) {
9797
api?.logger?.logError(
98-
false,
99-
"Received 502 (Invalid response) for URL $url and $this (${response.readText()})\nRetrying..",
100-
null
98+
false,
99+
"Received 502 (Invalid response) for URL $url and $this (${response.readText()})\nRetrying..",
100+
null
101101
)
102102
return@let execute(additionalHeaders, retryIf502 = false)
103103
} else if (respCode == 502 && !retryIf502) {
@@ -108,9 +108,9 @@ public class HttpConnection constructor(
108108
val ratelimit = response.headers["Retry-After"]!!.toLong() + 1L
109109
if (api?.spotifyApiOptions?.retryWhenRateLimited == true) {
110110
api.logger.logError(
111-
false,
112-
"The request ($url) was ratelimited for $ratelimit seconds at ${getCurrentTimeMs()}",
113-
null
111+
false,
112+
"The request ($url) was ratelimited for $ratelimit seconds at ${getCurrentTimeMs()}",
113+
null
114114
)
115115

116116
delay(ratelimit * 1000)
@@ -120,7 +120,7 @@ public class HttpConnection constructor(
120120

121121
val body = response.readText()
122122
if (respCode == 401 && body.contains("access token") &&
123-
api != null && api.spotifyApiOptions.automaticRefresh
123+
api != null && api.spotifyApiOptions.automaticRefresh
124124
) {
125125
api.refreshToken()
126126
val newAdditionalHeaders = additionalHeaders?.toMutableList() ?: mutableListOf()
@@ -129,26 +129,38 @@ public class HttpConnection constructor(
129129
}
130130

131131
return HttpResponse(
132-
responseCode = respCode,
133-
body = body,
134-
headers = response.headers.entries().map { (key, value) ->
135-
HttpHeader(
136-
key,
137-
value.getOrNull(0) ?: "null"
138-
)
139-
}
132+
responseCode = respCode,
133+
body = body,
134+
headers = response.headers.entries().map { (key, value) ->
135+
HttpHeader(
136+
key,
137+
value.getOrNull(0) ?: "null"
138+
)
139+
}
140140
)
141141
}
142142
} catch (e: CancellationException) {
143143
throw e
144144
} catch (e: ResponseException) {
145145
val errorBody = e.response.readText()
146146
try {
147-
val error = errorBody.toObject(ErrorResponse.serializer(), api, api?.spotifyApiOptions?.json ?: nonstrictJson).error
147+
val error = errorBody.toObject(
148+
ErrorResponse.serializer(),
149+
api,
150+
api?.spotifyApiOptions?.json ?: nonstrictJson
151+
).error
148152
throw BadRequestException(error.copy(reason = (error.reason ?: "") + " URL: $url"))
149153
} catch (ignored: ParseException) {
150-
val error = errorBody.toObject(AuthenticationError.serializer(), api, api?.spotifyApiOptions?.json ?: nonstrictJson)
151-
throw AuthenticationException(error)
154+
try {
155+
val error = errorBody.toObject(
156+
AuthenticationError.serializer(),
157+
api,
158+
api?.spotifyApiOptions?.json ?: nonstrictJson
159+
)
160+
throw AuthenticationException(error)
161+
} catch (ignored: ParseException) {
162+
throw e
163+
}
152164
}
153165
}
154166
}

src/commonMain/kotlin/com.adamratzman.spotify/models/SpotifyUris.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class SpotifyUriException(message: String) : SpotifyException.BadRequestE
2020

2121
private fun String.matchType(type: String, allowColon: Boolean): String? {
2222
val uriContent = "[^:]".takeUnless { allowColon } ?: "."
23-
val typeRegex = "^spotify:(?:.*:)*$type:($uriContent*)(?::.*)*$|^([^:]+)$".toRegex()
23+
val typeRegex = "^spotify:(?:.*:)?$type:($uriContent*)(?::.*)*$|^([^:]+)\$".toRegex()
2424
val match = typeRegex.matchEntire(this)?.groupValues ?: return null
2525
return match[1].takeIf { it.isNotBlank() || match[2].isEmpty() } ?: match[2].takeIf { it.isNotEmpty() }
2626
}
@@ -41,7 +41,10 @@ private fun String.remove(type: String, allowColon: Boolean): String {
4141

4242
private class SimpleUriSerializer<T : SpotifyUri>(val ctor: (String) -> T) : KSerializer<T> {
4343
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("SimpleUri", PrimitiveKind.STRING)
44-
override fun deserialize(decoder: Decoder): T = ctor(decoder.decodeString())
44+
override fun deserialize(decoder: Decoder): T {
45+
val str = decoder.decodeString()
46+
return ctor(str)
47+
}
4548
override fun serialize(encoder: Encoder, value: T) = encoder.encodeString(value.uri)
4649
}
4750

@@ -150,7 +153,9 @@ public sealed class CollectionUri(input: String, type: String, allowColon: Boole
150153
@Serializer(forClass = CollectionUri::class)
151154
public companion object : KSerializer<CollectionUri> {
152155
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CollectionUri", PrimitiveKind.STRING)
153-
override fun deserialize(decoder: Decoder): CollectionUri = CollectionUri(decoder.decodeString())
156+
override fun deserialize(decoder: Decoder): CollectionUri {
157+
return CollectionUri(decoder.decodeString())
158+
}
154159
override fun serialize(encoder: Encoder, value: CollectionUri): Unit = encoder.encodeString(value.uri)
155160

156161
public operator fun invoke(input: String): CollectionUri {
@@ -235,6 +240,7 @@ public class ArtistUri(input: String) : SpotifyUri(input, "artist") {
235240
*/
236241
@Serializable
237242
public class UserUri(input: String) : SpotifyUri(input, "user") {
243+
238244
@Serializer(forClass = UserUri::class)
239245
public companion object : KSerializer<UserUri> by SimpleUriSerializer(::UserUri)
240246
}

src/commonMain/kotlin/com.adamratzman.spotify/models/serialization/SerializationUtils.kt

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import kotlinx.serialization.json.Json
1818
import kotlinx.serialization.json.JsonElement
1919
import kotlinx.serialization.json.JsonObject
2020

21-
internal val nonstrictJson = Json {
22-
isLenient = true
23-
ignoreUnknownKeys = true
24-
allowSpecialFloatingPointValues = true
25-
useArrayPolymorphism = true
26-
}
21+
internal val nonstrictJson =
22+
Json {
23+
isLenient = true
24+
ignoreUnknownKeys = true
25+
allowSpecialFloatingPointValues = true
26+
useArrayPolymorphism = true
27+
}
2728

2829
internal inline fun <reified T : Any> String.toObjectNullable(
2930
serializer: KSerializer<T>,
@@ -78,20 +79,24 @@ internal fun <T : Any> String.toPagingObject(
7879
skipInnerNameFirstIfPossible: Boolean = true
7980
): NullablePagingObject<T> {
8081
if (innerObjectName != null || (arbitraryInnerNameAllowed && !skipInnerNameFirstIfPossible)) {
81-
val map = this.parseJson {
82-
val t = (String.serializer() to NullablePagingObject.serializer(tSerializer))
83-
json.decodeFromString(MapSerializer(t.first, t.second), this)
82+
val jsonObjectRoot = (json.parseToJsonElement(this) as JsonObject)
83+
val jsonElement =
84+
innerObjectName?.let { jsonObjectRoot[it] } ?: jsonObjectRoot.keys.firstOrNull()?.let { jsonObjectRoot[it] }
85+
?: throw SpotifyException.ParseException("Json element was null for class $tClazz (json $this)")
86+
val objectString = jsonElement.toString()
87+
88+
val map = objectString.parseJson {
89+
json.decodeFromString(NullablePagingObject.serializer(tSerializer), this)
8490
}
85-
return (map[innerObjectName] ?: if (arbitraryInnerNameAllowed) map.keys.firstOrNull()?.let { map[it] }
86-
?: error("") else error(""))
87-
.apply {
88-
this.endpoint = endpoint
89-
this.itemClazz = tClazz
90-
this.items.map { obj ->
91-
if (obj is NeedsApi) obj.api = endpoint.api
92-
if (obj is PagingObjectBase<*, *>) obj.endpoint = endpoint
93-
}
91+
92+
return map.apply {
93+
this.endpoint = endpoint
94+
this.itemClazz = tClazz
95+
this.items.map { obj ->
96+
if (obj is NeedsApi) obj.api = endpoint.api
97+
if (obj is PagingObjectBase<*, *>) obj.endpoint = endpoint
9498
}
99+
}
95100
}
96101

97102
return try {
@@ -261,7 +266,7 @@ internal fun <T> String.parseJson(producer: String.() -> T): T =
261266
producer(this)
262267
} catch (e: Exception) {
263268
throw SpotifyException.ParseException(
264-
"Unable to parse $this",
269+
"Unable to parse $this (${e.message})",
265270
e
266271
)
267272
}

src/commonMain/kotlin/com.adamratzman.spotify/utils/Platform.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ package com.adamratzman.spotify.utils
77
public enum class Platform {
88
JVM,
99
ANDROID,
10-
JS
10+
JS,
11+
NATIVE
1112
}
1213

1314
public expect val platform: Platform

src/commonMain/kotlin/com.adamratzman.spotify/utils/Utils.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package com.adamratzman.spotify.utils
33

44
import com.adamratzman.spotify.SpotifyException
55
import com.adamratzman.spotify.models.ResultEnum
6+
import kotlinx.datetime.Instant
7+
import kotlinx.datetime.TimeZone
8+
import kotlinx.datetime.toLocalDateTime
69
import kotlinx.serialization.json.JsonElement
710

811
public expect fun getCurrentTimeMs(): Long
@@ -22,4 +25,5 @@ internal suspend inline fun <T> catch(crossinline function: suspend () -> T): T?
2225
internal fun <T : ResultEnum> Array<T>.match(identifier: String) =
2326
firstOrNull { it.retrieveIdentifier().toString().equals(identifier, true) }
2427

25-
internal expect fun formatDate(format: String, date: Long): String
28+
internal fun formatDate(date: Long): String =
29+
Instant.fromEpochMilliseconds(date).toLocalDateTime(TimeZone.currentSystemDefault()).toString()

0 commit comments

Comments
 (0)