feat: add platform OAuth implementations (Android, iOS, Desktop)

- AndroidOAuthClient: ACTION_VIEW + OAuthCallbackActivity (myice://callback)
- IosOAuthClient: ASWebAuthenticationSession with presentation context provider
- DesktopOAuthClient: local HTTP server + system browser
- AndroidManifest: register OAuthCallbackActivity intent filter
- Align Kotlin to 2.3.20 (required by Compose MP 1.11.1 klibs) and
  compileSdk to 36 (required by activity-compose 1.13.0); fix missing
  Text import and stray brace in placeholder iOS/Android entry points
This commit is contained in:
2026-07-08 17:42:44 +02:00
parent 294aebd0b4
commit 5dc206ae72
9 changed files with 227 additions and 3 deletions
@@ -1,8 +1,8 @@
package ch.parano.myice
import androidx.compose.material3.Text
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController {
Text("MyIce")
}
}
@@ -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()
}
}