diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index e066838..95f04ba 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -76,7 +76,7 @@ kotlin {
android {
namespace = "ch.parano.myice"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
applicationId = "ch.parano.myice"
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index ec1b400..a3ae022 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -15,6 +15,17 @@
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt b/composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt
index 1cfac60..45a4aaa 100644
--- a/composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt
@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.material3.Text
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt b/composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt
new file mode 100644
index 0000000..a4d123e
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt
@@ -0,0 +1,17 @@
+package ch.parano.myice
+
+import android.app.Activity
+import android.net.Uri
+import android.os.Bundle
+import ch.parano.myice.auth.OAuthCallbackHolder
+
+class OAuthCallbackActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val uri: Uri? = intent?.data
+ if (uri != null) {
+ OAuthCallbackHolder.handleCallback(uri)
+ }
+ finish()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt b/composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt
new file mode 100644
index 0000000..7746ef6
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt
@@ -0,0 +1,39 @@
+package ch.parano.myice.auth
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import kotlinx.coroutines.CompletableDeferred
+
+class AndroidOAuthClient(private val context: Context) : OAuthClient {
+
+ override fun redirectUri(): String = "myice://callback"
+
+ override suspend fun authenticate(loginUrl: String): String? {
+ OAuthCallbackHolder.deferred = CompletableDeferred()
+
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ val callbackUri = OAuthCallbackHolder.deferred?.await() ?: return null
+
+ val fragment = callbackUri.fragment ?: return null
+ val params = fragment.split("&").associate { pair ->
+ val idx = pair.indexOf("=")
+ if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
+ else pair to ""
+ }
+ return params["access_token"]
+ }
+}
+
+object OAuthCallbackHolder {
+ var deferred: CompletableDeferred? = null
+
+ fun handleCallback(uri: Uri) {
+ deferred?.complete(uri)
+ deferred = null
+ }
+}
diff --git a/composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt b/composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt
new file mode 100644
index 0000000..b4d8fd9
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt
@@ -0,0 +1,89 @@
+package ch.parano.myice.auth
+
+import kotlinx.coroutines.CompletableDeferred
+import java.awt.Desktop
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.PrintWriter
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.URI
+import java.net.URLDecoder
+import java.net.URLEncoder
+import kotlin.concurrent.thread
+
+class DesktopOAuthClient : OAuthClient {
+
+ private var serverPort: Int = 0
+
+ override fun redirectUri(): String = "http://localhost:$serverPort/callback"
+
+ override suspend fun authenticate(loginUrl: String): String? {
+ val server = ServerSocket()
+ server.bind(InetSocketAddress(0))
+ serverPort = server.localPort
+
+ val redirectUri = "http://localhost:$serverPort/callback"
+ val fullLoginUrl = loginUrl + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8")
+
+ val deferred = CompletableDeferred()
+
+ thread {
+ try {
+ while (true) {
+ val socket = server.accept()
+ val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
+ val writer = PrintWriter(socket.getOutputStream())
+
+ val requestLine = reader.readLine() ?: continue
+ val path = requestLine.split(" ").getOrNull(1) ?: continue
+
+ if (path.startsWith("/callback")) {
+ val html = """
+
+
+
+ """.trimIndent()
+ writer.println("HTTP/1.1 200 OK")
+ writer.println("Content-Type: text/html")
+ writer.println("Connection: close")
+ writer.println()
+ writer.println(html)
+ writer.flush()
+ } else if (path.startsWith("/token")) {
+ val query = path.substringAfter("?")
+ val params = query.split("&").associate { pair ->
+ val idx = pair.indexOf("=")
+ if (idx >= 0) {
+ pair.substring(0, idx) to URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
+ } else pair to ""
+ }
+ val token = params["access_token"]
+ deferred.complete(token)
+
+ writer.println("HTTP/1.1 200 OK")
+ writer.println("Content-Type: text/html")
+ writer.println("Connection: close")
+ writer.println()
+ writer.println("Authentication complete. You can close this window.
")
+ writer.flush()
+ socket.close()
+ break
+ }
+ socket.close()
+ }
+ } catch (e: Exception) {
+ deferred.complete(null)
+ } finally {
+ server.close()
+ }
+ }
+
+ Desktop.getDesktop().browse(URI(fullLoginUrl))
+
+ return deferred.await()
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt b/composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt
index 15ec92e..db4a9c3 100644
--- a/composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt
+++ b/composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt
@@ -1,8 +1,8 @@
package ch.parano.myice
+import androidx.compose.material3.Text
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController {
Text("MyIce")
- }
}
diff --git a/composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt b/composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt
new file mode 100644
index 0000000..19a98a5
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt
@@ -0,0 +1,67 @@
+package ch.parano.myice.auth
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol
+import platform.AuthenticationServices.ASWebAuthenticationSession
+import platform.Foundation.NSError
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+import platform.UIKit.UIWindow
+import platform.UIKit.UIWindowScene
+import platform.darwin.NSObject
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+@OptIn(ExperimentalForeignApi::class)
+class IosOAuthClient : OAuthClient {
+
+ override fun redirectUri(): String = "myice://callback"
+
+ override suspend fun authenticate(loginUrl: String): String? = suspendCoroutine { continuation ->
+ val nsUrl = NSURL.URLWithString(loginUrl) ?: run {
+ continuation.resume(null)
+ return@suspendCoroutine
+ }
+
+ val session = ASWebAuthenticationSession(
+ nsUrl,
+ "myice",
+ completionHandler = { callbackURL: NSURL?, error: NSError? ->
+ if (error != null || callbackURL == null) {
+ continuation.resume(null)
+ } else {
+ val urlStr = callbackURL.absoluteString ?: ""
+ val fragmentIndex = urlStr.indexOf("#")
+ if (fragmentIndex < 0) {
+ continuation.resume(null)
+ } else {
+ val fragment = urlStr.substring(fragmentIndex + 1)
+ val params = fragment.split("&").associate { pair ->
+ val idx = pair.indexOf("=")
+ if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
+ else pair to ""
+ }
+ continuation.resume(params["access_token"])
+ }
+ }
+ }
+ )
+
+ session.presentationContextProvider = object : NSObject(),
+ ASWebAuthenticationPresentationContextProvidingProtocol {
+ override fun presentationAnchorForWebAuthenticationSession(
+ session: ASWebAuthenticationSession
+ ): UIWindow {
+ return keyWindow()
+ }
+ }
+
+ session.start()
+ }
+
+ private fun keyWindow(): UIWindow {
+ val scenes = UIApplication.sharedApplication.connectedScenes
+ val windowScene = scenes.firstOrNull { it is UIWindowScene } as? UIWindowScene
+ return (windowScene?.windows?.firstOrNull() as? UIWindow) ?: UIWindow()
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d031aa2..d5ecc0c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-kotlin = "2.2.20"
+kotlin = "2.3.20"
compose-multiplatform = "1.11.1"
agp = "8.13.2"
ktor = "3.5.1"