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"