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 -1
View File
@@ -76,7 +76,7 @@ kotlin {
android {
namespace = "ch.parano.myice"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "ch.parano.myice"
@@ -15,6 +15,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".OAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myice" android:host="callback" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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?) {
@@ -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()
}
}
@@ -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<Uri>? = null
fun handleCallback(uri: Uri) {
deferred?.complete(uri)
deferred = null
}
}
@@ -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<String?>()
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 = """
<html><body>
<script>
var hash = window.location.hash.substring(1);
window.location.href = "/token?" + hash;
</script>
</body></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("<html><body><h1>Authentication complete. You can close this window.</h1></body></html>")
writer.flush()
socket.close()
break
}
socket.close()
}
} catch (e: Exception) {
deferred.complete(null)
} finally {
server.close()
}
}
Desktop.getDesktop().browse(URI(fullLoginUrl))
return deferred.await()
}
}
@@ -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()
}
}