Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ kotlin {
implementation(libs.glance.material3)
implementation(libs.compose.runtime)
}
iosMain.dependencies {
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chukchukhaksa.mobile.common.kmp

actual class AppleSignInClient actual constructor() {
actual suspend fun signIn(): AppleSignInResult {
throw UnsupportedOperationException("Apple Sign In is not supported on Android")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.chukchukhaksa.mobile.common.kmp

data class AppleSignInResult(
val identityToken: String,
val authorizationCode: String,
val userId: String,
val email: String?,
val fullName: String?,
)

expect class AppleSignInClient() {
suspend fun signIn(): AppleSignInResult
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chukchukhaksa.mobile.di

import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase
import com.chukchukhaksa.mobile.domain.config.usecase.CheckNeedForceUpdateUseCase
import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase
import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableUseCase
Expand Down Expand Up @@ -40,4 +41,7 @@ val domainModule = module {
factory { UpdateOpenLectureIfNeedUseCase(get())}

factory { CheckNeedForceUpdateUseCase(get())}

// Auth use cases
factory { AppleLoginUseCase(get()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.chukchukhaksa.mobile.domain.auth.usecase

import com.chukchukhaksa.mobile.common.kmp.AppleSignInResult
import com.chukchukhaksa.mobile.common.kmp.AppleSignInClient
import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled

class AppleLoginUseCase(
private val appleSignInClient: AppleSignInClient,
) {
suspend operator fun invoke(): Result<AppleSignInResult> = runCatchingIgnoreCancelled {
appleSignInClient.signIn()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ package com.chukchukhaksa.mobile.presentation.timetable.timetable.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
Expand All @@ -22,9 +29,15 @@ import com.chukchukhaksa.mobile.common.designsystem.component.button.CchBasicBut
import com.chukchukhaksa.mobile.common.designsystem.theme.CchTheme
import com.chukchukhaksa.mobile.common.designsystem.theme.Gray600
import com.chukchukhaksa.mobile.common.designsystem.theme.Purple600
import com.chukchukhaksa.mobile.common.kmp.Platform
import com.chukchukhaksa.mobile.common.kmp.getPlatform
import com.chukchukhaksa.mobile.common.ui.cchClickable
import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase
import io.github.aakira.napier.Napier
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.koinInject

private const val CCHAKSA_URL = "https://www.cchaksa.com/"
private const val CCHAKSA_ASK_URL = "https://heliotrope-flea-959.notion.site/24d1a2480da1805d9d9bcaf608fb629b"
Expand Down Expand Up @@ -65,6 +78,11 @@ fun WebViewGuideScreen() {
textStyle = CchTheme.typography.bodyMdStrong,
onClick = { uriHandler.openUri(CCHAKSA_URL) },
)

if (getPlatform() == Platform.IOS) {
Spacer(modifier = Modifier.height(16.dp))
AppleLoginButton()
}
}

Text(
Expand All @@ -81,3 +99,35 @@ fun WebViewGuideScreen() {
}
}
}

@Composable
private fun AppleLoginButton(
appleLoginUseCase: AppleLoginUseCase = koinInject(),
) {
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(false) }

CchBasicButton(
modifier = Modifier.padding(horizontal = 38.dp),
text = if (isLoading) "로그인 중..." else "Apple로 로그인",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string 값 하드코딩된 상태여도 괜찮을까요??

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 디자인 나오기 전이라서 임시로 넣어놓은거라 괜찮을거같아유

enable = !isLoading,
textStyle = CchTheme.typography.bodyMdStrong,
onClick = {
Napier.d(tag = "AppleLogin") { ">>> 버튼 클릭됨" }
scope.launch {
Napier.d(tag = "AppleLogin") { ">>> 코루틴 시작" }
isLoading = true
appleLoginUseCase()
.onSuccess { result ->
Napier.d(tag = "AppleLogin") { ">>> 성공 - identityToken: ${result.identityToken.take(5)}..." }
Napier.d(tag = "AppleLogin") { ">>> authorizationCode: ${result.authorizationCode.take(5)}..." }
}
.onFailure { error ->
Napier.e(tag = "AppleLogin", throwable = error) { ">>> 실패: ${error.message}" }
}
isLoading = false
Napier.d(tag = "AppleLogin") { ">>> 코루틴 종료" }
}
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package com.chukchukhaksa.mobile.common.kmp

import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.AuthenticationServices.ASAuthorization
import platform.AuthenticationServices.ASAuthorizationAppleIDCredential
import platform.AuthenticationServices.ASAuthorizationAppleIDProvider
import platform.AuthenticationServices.ASAuthorizationController
import platform.AuthenticationServices.ASAuthorizationControllerDelegateProtocol
import platform.AuthenticationServices.ASAuthorizationControllerPresentationContextProvidingProtocol
import platform.AuthenticationServices.ASAuthorizationScopeEmail
import platform.AuthenticationServices.ASAuthorizationScopeFullName
import platform.AuthenticationServices.ASPresentationAnchor
import platform.Foundation.NSError
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.UIKit.UIApplication
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowScene
import platform.darwin.NSObject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

actual class AppleSignInClient actual constructor() {

// Strong reference 유지 (delegate/controller가 ARC로 해제되지 않도록)
private var currentDelegate: AppleSignInDelegate? = null
private var currentController: ASAuthorizationController? = null

actual suspend fun signIn(): AppleSignInResult = suspendCancellableCoroutine { continuation ->
val delegate = AppleSignInDelegate(continuation) {
currentDelegate = null
currentController = null
}
currentDelegate = delegate

val provider = ASAuthorizationAppleIDProvider()
val request = provider.createRequest()
request.requestedScopes = listOf(
ASAuthorizationScopeEmail,
ASAuthorizationScopeFullName,
)

val controller = ASAuthorizationController(
authorizationRequests = listOf(request),
)
controller.delegate = delegate
controller.presentationContextProvider = delegate
currentController = controller

continuation.invokeOnCancellation {
currentDelegate = null
currentController = null
}

controller.performRequests()
}
}

private class AppleSignInDelegate(
private val continuation: CancellableContinuation<AppleSignInResult>,
private val onComplete: () -> Unit,
) : NSObject(),
ASAuthorizationControllerDelegateProtocol,
ASAuthorizationControllerPresentationContextProvidingProtocol {

@OptIn(BetaInteropApi::class)
override fun authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization: ASAuthorization,
) {
val credential = didCompleteWithAuthorization.credential as? ASAuthorizationAppleIDCredential
?: run {
onComplete()
continuation.resumeWithException(
IllegalStateException("Invalid credential type"),
)
return
}

val identityTokenData = credential.identityToken
val authorizationCodeData = credential.authorizationCode

if (identityTokenData == null || authorizationCodeData == null) {
onComplete()
continuation.resumeWithException(
IllegalStateException("Missing identityToken or authorizationCode"),
)
return
}

val identityToken = NSString.create(
data = identityTokenData,
encoding = NSUTF8StringEncoding,
)?.toString() ?: run {
onComplete()
continuation.resumeWithException(
IllegalStateException("Failed to decode identityToken"),
)
return
}

val authorizationCode = NSString.create(
data = authorizationCodeData,
encoding = NSUTF8StringEncoding,
)?.toString() ?: run {
onComplete()
continuation.resumeWithException(
IllegalStateException("Failed to decode authorizationCode"),
)
return
}

val fullName = credential.fullName?.let { nameComponents ->
listOfNotNull(
nameComponents.familyName,
nameComponents.givenName,
).joinToString(" ").ifBlank { null }
}

onComplete()
continuation.resume(
AppleSignInResult(
identityToken = identityToken,
authorizationCode = authorizationCode,
userId = credential.user,
email = credential.email,
fullName = fullName,
),
)
}

override fun authorizationController(
controller: ASAuthorizationController,
didCompleteWithError: NSError,
) {
onComplete()
continuation.resumeWithException(
Exception("Apple Sign In failed: ${didCompleteWithError.localizedDescription}"),
)
}

@OptIn(ExperimentalForeignApi::class)
override fun presentationAnchorForAuthorizationController(
controller: ASAuthorizationController,
): ASPresentationAnchor {
val windowScene = UIApplication.sharedApplication.connectedScenes
.filterIsInstance<UIWindowScene>()
.firstOrNull()

return windowScene?.windows
?.filterIsInstance<UIWindow>()
?.firstOrNull { it.isKeyWindow() }
?: UIWindow()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chukchukhaksa.mobile.di

import com.chukchukhaksa.mobile.common.kmp.AppleSignInClient
import com.chukchukhaksa.mobile.local.database.openlecture.database.OpenLectureDatabaseFactory
import com.chukchukhaksa.mobile.local.database.openmajor.database.OpenMajorDatabaseFactory
import com.chukchukhaksa.mobile.local.database.timetable.database.TimetableDatabaseFactory
Expand All @@ -12,4 +13,5 @@ actual val platformModule
single { OpenMajorDatabaseFactory() }
single { OpenLectureDatabaseFactory() }
single { ChukChukHaksaDataStoreFactory() }
factory { AppleSignInClient() }
}
2 changes: 2 additions & 0 deletions iosApp/iosApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
Expand Down Expand Up @@ -376,6 +377,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
Expand Down
10 changes: 10 additions & 0 deletions iosApp/iosApp/iosApp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
Loading