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:
@@ -76,7 +76,7 @@ kotlin {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "ch.parano.myice"
|
namespace = "ch.parano.myice"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "ch.parano.myice"
|
applicationId = "ch.parano.myice"
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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
|
package ch.parano.myice
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.ui.window.ComposeUIViewController
|
import androidx.compose.ui.window.ComposeUIViewController
|
||||||
|
|
||||||
fun MainViewController() = ComposeUIViewController {
|
fun MainViewController() = ComposeUIViewController {
|
||||||
Text("MyIce")
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.3.20"
|
||||||
compose-multiplatform = "1.11.1"
|
compose-multiplatform = "1.11.1"
|
||||||
agp = "8.13.2"
|
agp = "8.13.2"
|
||||||
ktor = "3.5.1"
|
ktor = "3.5.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user