Sign In With Apple
*로그인 서비스를 제공하는 경우, iOS 앱에서 애플 로그인은 필수.
애플 로그인만 있어도 되고, 애플 로그인 + a의 형태로 되어야함.
개발자 계정이 있어야 하고, Apple ID iCloud를 활용하는 방식임.
1. Authentication Sign-in method에서 Apple 추가
2. Certificates, Identifiers & Profiles(인증서, 식별자, 프로필 설정)
개발자 프로그램에 가입한 계정의 경우, 앱 타겟의 Signing & Capabilities에서 손쉽게 인증 관련 설정을 할 수 있다.
3. 로그인 버튼 만들기
https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple
import AuthenticationServices
SignInWithAppleButton { request in
} onCompletion: { result in
}
.frame(height: 55)
이 스유 방식으로 하면, HIG의 조건에 맞게 세팅을 하는 것이 어렵기 때문에(컬러 스킴 등) UIKit의 로그인버튼을 활용해서 만들어 보자.
UIViewRepresentable 활용
https://toughie-ios.tistory.com/261
struct SignInWithAppleButtonViewRepresentable: UIViewRepresentable {
let buttonType: ASAuthorizationAppleIDButton.ButtonType
let buttonStyle: ASAuthorizationAppleIDButton.Style
func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
ASAuthorizationAppleIDButton(authorizationButtonType: buttonType, authorizationButtonStyle: buttonStyle)
}
func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) {
}
}
이 버튼뷰를 SwiftUI 버튼의 label로 활용한다.
Button {
//로그인 관련 액션
} label: {
SignInWithAppleButtonViewRepresentable(buttonType: .default, buttonStyle: .whiteOutline)
.allowsHitTesting(false)
.frame(height: 55)
}
4. signInApple 메서드를 만들어 보자.
먼저 보안을 위해 임의의 문자열인 nonce를 만들어 줘야 된다고 하니 함수로 하나 만들어 준다.
private func randomNonceString(length: Int = 32) -> String {
precondition(length > 0)
var randomBytes = [UInt8](repeating: 0, count: length)
let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes)
if errorCode != errSecSuccess {
fatalError(
"Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)"
)
}
let charset: [Character] =
Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
let nonce = randomBytes.map { byte in
// Pick a random character from the set, wrapping around if needed.
charset[Int(byte) % charset.count]
}
return String(nonce)
}
그리고 nonce의 SHA256 해쉬를 전송해야 한다고 하니.. 그것도 추가한다.
*여기서 CryptoKit을 import 해줘야함.
@available(iOS 13, *)
private func sha256(_ input: String) -> String {
let inputData = Data(input.utf8)
let hashedData = SHA256.hash(data: inputData)
let hashString = hashedData.compactMap {
String(format: "%02x", $0)
}.joined()
return hashString
}
이제 애플 로그인을 시작하는 메서드를 추가해준다.
사실 위에서 설명한 Nonce와 sha가 이 메서드 내부에 구현되어 있음.
@available(iOS 13, *)
func startSignInWithAppleFlow() {
let nonce = randomNonceString()
currentNonce = nonce
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(nonce)
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
이제 위 메서드에서 활용할 Delegate 설정
코드가 많고 복잡해서 정리된 코드를 가지고 하나씩 살펴보도록 함..!
1. 파이어베이스 인증을 위한 매니저
2. 뷰모델
3. 애플 로그인을 위한 헬퍼
3. 로그인 화면을 위한 뷰
크게 이렇게 나눠서 볼 것..!
async await이 활용됨.
유저 모델
먼저 파이어베이스에서 유저의 정보를 받아오기 위한 모델을 만든다.
import Foundation
import FirebaseAuth
struct AuthDataResultModel {
let uid: String
let email: String?
let photoUrl: String?
init(user: User) {
self.uid = user.uid
self.email = user.email
self.photoUrl = user.photoURL?.absoluteString
}
}
FirebaseAuth 프레임워크는 사용자 인증 서비스를 사용할 수 있게 해주는 라이브러리다.
(사용자 인증 정보를 관리하고 처리하기 위함)
FirebaseAuth의 User객체를 받아 초기화되는데, 이 User에는 다양한 사용자 정보가 있다.
여기서는 uid, email, photoUrl(프로필 이미지 url)만 사용한다고 가정한 경우이다.
인증 매니저
앱 전반에서 인증 관련 작업을 처리할 것이기 때문에 싱글톤 패턴을 적용함.
(일관성 유지 및 효율성을 위해) 더 좋은 방법이 있다면 알려주세요..!
final class AuthenticationManager {
static let shared = AuthenticationManager()
private init() { }
로그인 메서드
func signIn(credential: AuthCredential) async throws -> AuthDataResultModel{
let authDataResult = try await Auth.auth().signIn(with: credential)
return AuthDataResultModel(user: authDataResult.user)
}
Credential(인증 증명)을 사용해서 사용자를 로그인하고, 로그인한 사용자 어보를 모델 객체로 변환해서 반환하는 메서드.
Auth.auth().signIn(with: credential)으로 Firebase 서버와 통신 후 인증을 처리함. 해당 결과를 기다렸다가
결과에서 유저 데이터를 뽑아 원하는 모델로 변환해서 반환
현재 로그인 된 사용자 정보 가져오는 메서드
func getAuthenticatedUser() throws -> AuthDataResultModel {
guard let user = Auth.auth().currentUser else {
throw URLError(.badServerResponse)
}
return AuthDataResultModel(user: user)
}
FirebaseAuth를 사용해서 현재 인증된 사용자의 정보를 가져오는 메서드.
Auth.auth().currentUser는 현재 앱 인증 상태와 관련된 정보를 가지고 있는 User 객체를 반환함.
User객체는 현재 사용자에 대한 '로컬 정보'로 서버 통신 없이 앱 내부에서 접근할 수 있음.
* 서버로 실시간 업데이트 되는 정보가 아님
로그아웃 메서드
func signOut() throws {
try Auth.auth().signOut()
}
현재 인증된 사용자 로그아웃.
AuthProvider
AuthProvider는 파이어베이스 인증에서 제공하는 인증 제공자를 말한다.
이메일/비밀번호, 애플, 구글, 페이스북 등등!
한 유저는 여러 로그인 방식으로 로그인을 할 수 있기 때문에 배열 형태로 관리된다.
여기서 프로바이더는 magic String 지양을 위해 열거형으로 관리한다.
enum AuthProviderOption: String {
case email = "password"
case google = "google.com"
case apple = "apple.com"
}
프로바이더 정보를 얻기 위한 메서드
func getProviders() throws -> [AuthProviderOption] {
guard let providerData = Auth.auth().currentUser?.providerData else {
throw URLError(.badURL)
}
var providers: [AuthProviderOption] = []
for provider in providerData {
if let option = AuthProviderOption(rawValue: provider.providerID) {
providers.append(option)
} else {
assertionFailure("provider option not found: \(provider.providerID)")
}
}
return providers
}
Auth.auth().currentUser?.providerData를 통해 현재 인증된 사용자의 프로바이더 데이터를 가져온다.
⭐️애플 로그인 메서드
@discardableResult
func signInWithApple(tokens: SignInWithAppleResult) async throws -> AuthDataResultModel {
let credential = OAuthProvider.credential(withProviderID: AuthProviderOption.apple.rawValue, idToken: tokens.token, rawNonce: tokens.nonce)
return try await signIn(credential: credential)
}
@discardableResult 어노테이션 - 함수의 반환값이 사용되지 않아도 됨을 표시. (노란 경고 안 뜨게 할 수 있음)
애플 로그인 결과와 관련된 정보를 가지고 있는 SignInWIthAppleResult 객체를 매개변수로 받음(토큰)
OAuthProvider.credential(withProviderID:idToken:rawNonce:)를 사용해서 애플 로그인에 필요한 인증서 생성(credential)
* providerID는 apple.com
여기서 보면 인증서를 만들 대 토큰과 nonce가 필요함을 알 수 있음.
그리고 나서 해당 인증서를 가지고 파이어베이스Auth를 통해 로그인 진행
애플 로그인 관련된 메서드를 모아둔 Helper 파일을 따로 만들어 코드를 관리한다.
보안을 위한 nonce, sha를 만들고 이를 기반으로 request를 만들어 애플 서버에 요청하고..그런 과정들을 위한 코드가 있다.
구글 로그인에 비해 더 복잡하지만..뭐 보안 때문이니까 어쩔 수 없다 ㅋㅋ
먼저 필요한 프레임워크들을 import 해준다.
import UIKit
import SwiftUI
import AuthenticationServices
import CryptoKit
애플 로그인 결과 모델
struct SignInWithAppleResult {
let token: String
let nonce: String
let userName: String?
let email: String?
}
애플 로그인 결과에 관련된 정보를 구조체로 관리한다.
로그인 토큰, 로그인 요청 시 생성한 nonce 그리고 유저의 이름과 이메일을 받아온다.
애플 로그인 버튼
struct SignInWithAppleButtonViewRepresentable: UIViewRepresentable {
let buttonType: ASAuthorizationAppleIDButton.ButtonType
let buttonStyle: ASAuthorizationAppleIDButton.Style
func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
ASAuthorizationAppleIDButton(authorizationButtonType: buttonType, authorizationButtonStyle: buttonStyle)
}
func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) {
}
}
UIViewRepresentable 프로토콜을 채택해서 UIKit의 애플 로그인 버튼을 표시하기 위한 SwiftUIView 생성
SignInAppleHelper (클래스)
@MainActor
final class SignInAppleHelper: NSObject {
...
@MainActor
Call to main actor-isolated instance method 'topViewController(controller:)' in a synchronous nonisolated context.
Add '@MainActor' to make instance method 'startSignInWithAppleFlow(completion:)' part of global actor 'MainActor'
여기서 애플 로그인 모달 프레젠테이션을 위해서 topViewController가 존재하고,
애플 로그인 플로우를 시작하는 메서드가 메인스레드에서 동작해야 하기 때문에, @MainActor를 붙여준다.
또한 Delegate 패턴을 위해 NSObject 프로토콜도 채택해 준다.
애플 로그인 모달과 관련해서 필요한 코드가 있고, 해당 내용이 조금 복잡해서 먼저 간단히 정리하고 들어간다.
TopViewController
최상위 뷰 컨트롤러가 뭘까?
뷰 컨트롤러 계층 구조에서 최상위에 위치한 뷰 컨트롤러를 의미한다.
현재 사용자에게 표시되는 가장 앞에 있는 뷰 컨트롤러!
만약 네비게이션 스택을 사용한다면? 유저가 화면을 넘기면 스택에 뷰 컨트롤러가 계속 push될 것이고
이 경우 최상위 뷰컨트롤러는 네비게이션 컨트롤러 스택의 가장 위에 있는 뷰 컨트롤러.
탭 바를 사용한다면? 각 탭은 각자 독립적인 뷰 컨트롤러를 관리하기 때문에
사용자가 탭을 전환하면 해당 탭의 뷰 컨트롤러가 최상위 뷰컨트롤러가 되는 것이다.
이런 뷰컨트롤러 계층 구조에서 최상위에 위치한 뷰 컨트롤러를 찾기위한 메서드가 필요하다.
이를 통해 앱에서 현재 사용자에게 보여지는 뷰 컨트롤러를 식별할 수 있기 때문.
애플 로그인 버튼을 누르면 애플 로그인 모달이 올라오니까 여기서 최상위 뷰 컨트롤러를 사용하기 때문에 필요함.
자세한 부분은 실제 코드 부분에서 다시 살펴보기로!
import Foundation
import UIKit
final class Utilities {
static let shared = Utilities()
private init() { }
//Main actor-isolated class property 'shared' can not be referenced from a non-isolated context
@MainActor
func topViewController(controller: UIViewController? = nil) -> UIViewController? {
// controller 매개변수가 주어지지 않은 경우 앱의 루트 뷰 컨트롤러를 가져옴.
let controller = controller ?? UIApplication.shared.keyWindow?.rootViewController
// 네비게이션 컨트롤러인 경우
if let navigationController = controller as? UINavigationController {
return topViewController(controller: navigationController.visibleViewController)
}
//탭바 컨트롤러인 경우
if let tabController = controller as? UITabBarController {
if let selected = tabController.selectedViewController {
return topViewController(controller: selected)
}
}
// 현재 프레젠트된 뷰 컨트롤러를 가져옴.
if let presented = controller?.presentedViewController {
return topViewController(controller: presented)
}
return controller
}
}
뷰컨트롤러 종류에 따라 재귀적으로 최상위 뷰 컨트롤러를 찾음.
UIViewContoroller의 최상위 뷰 컨트롤러를 반환하는 메서드가 담긴 Utilities 클래스를 만들어 줬다.
보안 관련된 코드를 보자.
randomNonceString
로그인 요청 시 nonce값이 필요하다. 주어진 길이의 난수 문자열을 생성하는 메서드.
private func randomNonceString(length: Int = 32) -> String {
precondition(length > 0)
var randomBytes = [UInt8](repeating: 0, count: length)
let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes)
if errorCode != errSecSuccess {
fatalError(
"Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)"
)
}
let charset: [Character] =
Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
let nonce = randomBytes.map { byte in
// Pick a random character from the set, wrapping around if needed.
charset[Int(byte) % charset.count]
}
return String(nonce)
}
sha256
주어진 문자열의 SHA256 해시 값을 반환하는 메서드
로그인 요청 시 사용하는 nonce값을 해시화 하기 위해서 필요함
private func sha256(_ input: String) -> String {
let inputData = Data(input.utf8)
let hashedData = SHA256.hash(data: inputData)
let hashString = hashedData.compactMap {
String(format: "%02x", $0)
}.joined()
return hashString
}
애플 로그인 컨트롤러 델리게이트 관련 구현
extension SignInAppleHelper: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard
let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential,
let appleIDToken = appleIDCredential.identityToken,
let idTokenString = String(data: appleIDToken, encoding: .utf8),
let nonce = currentNonce else {
completionHandler?(.failure(URLError(.badServerResponse)))
return
}
let userName = appleIDCredential.fullName?.givenName
let email = appleIDCredential.email
let tokens = SignInWithAppleResult(token: idTokenString, nonce: nonce, userName: userName, email: email)
//클래스 내부에 클로저 따로 선언되어 있음. 이것은 애플 플로우 메서드 부분에서 다루겠음.
completionHandler?(.success(tokens))
}
//애플 로그인 실패했을 때 (모달 띄우고 그냥 닫았을 경우 등)
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
// Handle error.
print("Sign in with Apple errored: \(error)")
}
}
ASAuthorizationController의 delegate를 SignInAppleHelper가 수행하도록
ASAuthorizationControllerDelegate 프로토콜을 채택해줌.
요구조건 1
authorizationController(controller:didCompleteWithAuthorization:)
애플 로그인이 성공했을 때 호출되는 메서드.
authorization 파라미터에는 인증 결과에 대한 정보가 전달됨.
전달 받은 authorization에서 필요한 정보들을 추출하고(Credential, IDToken, idTokenString, nonce, userName, email 등)
이를 SignInWithAppleResult로 묶어 콜백함수로(completionHandler) 값을 전달함.
요구조건 2
authorizationController(controller:didCompleteWithError:)
애플 로그인이 실패했을 때 호출되는 메서드.
에러 핸들링을 여기서 하면 된다.
UIViewController 확장
extension UIViewController: ASAuthorizationControllerPresentationContextProviding {
public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
guard let window = self.view.window else {
preconditionFailure("Unable to access view's window.")
}
return window
}
}
ASAuthorizationControllerPresentationContextProviding 프로토콜 준수
presentationAnchor(for:)메서드는 애플 로그인 컨트롤러에 대한 프레젠테이션 컨텍스트를 제공하기 위한 메서드이다.
현재 뷰컨트롤러의 view.window를 반환해서 컨트롤러의 프레젠테이션 컨텍스트로 사용함.
(뷰에는 윈도우가 존재할 것이기 때문에 강제 언래핑을 할 수도 있겠지만.. !는 위험하기 때문에 위와 같이 코드를 수정해봄.)
프레젠테이션 컨텍스트란?
프레젠테이션 컨텍스트는 뷰 컨트롤러가 다른 뷰 컨트롤러를 모달로 표시하기 위해 사용하는 *컨텍스트
*컨텍스트 - 뷰 컨트롤러가 동작하는 환경. (화면에 표시되는 뷰 관리, 사용자 입력 및 이벤트 처리, 데이터 업데이트 등 작업을 위한 환경)
위 코드에서 반환되는 window는 해당 뷰컨트롤러의 루트뷰가 속한 윈도우를 나타냄.
Apple 로그인 컨트롤러는 이 윈도우를 사용해서 모달 프레젠테이션을 구현함.
즉 presentationAnchor 메서드는
애플 로그인 컨트롤러가 어느 윈도우 위에 모달로 표시되어야 하는지 결정하기 위해 해당 뷰컨트롤러의 윈도우를 반환하는 메서드인것.
-> 위 확장은 Apple 로그인 컨트롤러가 올바른 위치에서 모달로 표시되도록 하기 위함!!
애플 로그인 플로우 시작
컴플리션 핸들러 활용 방식 -> async await 메서드로 변형
func startSignInWithAppleFlow(completion: @escaping (Result<SignInWithAppleResult, Error>) -> Void) {
guard let topVC = Utilities.shared.topViewController() else {
completion(.failure(URLError(.badURL)))
return
}
let nonce = randomNonceString()
currentNonce = nonce
completionHandler = completion
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(nonce)
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = topVC
authorizationController.performRequests()
}
1. topViewController를 가져옴. -> 애플 로그인 뷰 컨트롤러를 나타내는 컨텍스트 설정
2. randomNonceString()을 통해 난수 문자열을 생성하고, 클래스 내부 프로퍼티로 따로 관리함.
-> 로그인 요청과 응답 사이에서 nonce 값을 일치시키기 위해서
startSignInWithAppleFlow 메서드가 종료되면 지역 변수인 nonce의 생명주기가 끝나기 때문에
다른 메서드(async await 메서드)에서 계속 사용하기 위해서임.
3. 클래스 내부에 completionHandler 클로저가 따로 선언되어 있는데, 이는 클래스의 다른 메서드에서
completionHandler를 참조하고 호출하기 위한 장치 (async await 메서드에서 활용할 예정)
completion은 애플 로그인 플로우가 완료되거나 실패했을 때 호출됨 (로그인 결과를 전달)
4. ASAuthorizationAppleIDProvider를 활용해 로그인 요청(request) 생성
5. request를 통해 ASAuthorizationController를 초기화 하고,
이 컨트롤러의 delegate를 현재 클래스(SignInAppleHelper)로 설정함.
* 이를 위해서 extension에서 ASAuthorizationControllerDelegate 프로토콜을 준수했음.
로그인 요청의 성공/실패와 관련된 이벤트를 처리함
6. 프레젠테이션 컨텍스트 설정
애플 로그인 인터페이스가 표시될 컨텍스트를 제공하기 위해
authorizationController.presentationContextProvider를 최상위 뷰 컨트롤러로 설정해줌.
7. authorizationController.performRequests()를 통해 애플 로그인 요청을 실행.
async / await 활용으로 메서드 정리
코드의 가독성 향상, 예외처리를 용이하기 하게 위해!
func startSignInWithAppleFlow() async throws -> SignInWithAppleResult {
try await withCheckedThrowingContinuation { continuation in
self.startSignInWithAppleFlow { result in
switch result {
case .success(let signInAppleResult):
continuation.resume(returning: signInAppleResult)
return
case .failure(let error):
continuation.resume(throwing: error)
return
}
}
}
}
startSignInWithAppleFlow 메서드를 비동기 메서드로 선언.
호출자에게 SignInWithAppleResult 타입을 반환하며, 예외를 던질 수 있음.
withCheckedThrowingContinuation은 Continuation을 통해 비동기 작업의 완료를 처리하는 기능을 제공함.
(async 컨텍스트에서 작업 수행)
기존의 콜백 기반 비동기 작업을 async await 패턴으로 변환하기 위해 사용한 것.
여기서 startSignInWithAppleFlow(컴플리션 핸들러 방식)을 호출하고 result는
Result<SignInWithAppleResult, Error> 타입의 값임.
만약 result가 .success인 경우 (로그인 성공)
continuation.resum(retruning:)을 호출해서 비동기 작업을 완료하고 signInAppleResult 값을 반환
만약 result가 .failure인 경우 (로그인 실패)
continuation.resum(throwing:)을 호출해서 비동기 작업을 완료하고 에러를 던짐.
-> 위 메서드를 통해 Apple 로그인 플로우를 시작하고 비동기적으로 결과를 반환함.
메서드 호출자는 await 키워드를 통해 비동기 작업의 완료를 기다리고, 성공할 경우 SignInWithAppleResult를 받음.
애플 로그인 -> 파이어베이스 인증
지금 파이어베이스 인증 관련 매니저 클래스가 있고, 위에서 작업한 애플 로그인 관련 헬퍼 클래스가 있다.
우리는 결국 애플로그인을 통해 파이어베이스 로그인을 하려는 거니까 이 작업을 위한 메서드를 뷰모델에서 만들어 본다.
@MainActor
final class AuthenticaitonViewModel: ObservableObject {
func signInApple() async throws {
let helper = SignInAppleHelper()
let tokens = try await helper.startSignInWithAppleFlow()
try await AuthenticationManager.shared.signInWithApple(tokens: tokens)
}
}
애플 로그인 모달과 관련된 작업을 수행하기에 메인 액터에서 실행.
(애플 로그인 모달과 관련된 UI업데이트 및 처리가 메인 스레드에서 이루어지고 안정성을 보장할 수 있음)
여기서 토큰은 SIgnInWithAppleResult 타입이고 이 토큰을 가지고 credential을 만들어서
Auth.auth().signIn(with: crdential)을 통해 파이어베이스 로그인을 완료하는 로직.
애플 로그인 버튼 뷰에 구현
Button {
//Task를 통해 비동기 작업 수행
Task {
do {
try await vm.signInApple()
// 로그인 성공 시 풀스크린을 닫는 경우라 있는 코드.
// 뷰에 따라 달라질 것
showSignInView = false
} catch {
print(error.localizedDescription)
}
}
} label: {
SignInWithAppleButtonViewRepresentable(buttonType: .default, buttonStyle: .whiteOutline)
//버튼 탭 방지용(탭 이벤트는 위 action 클로저에서 처리함)
.allowsHitTesting(false)
}
.frame(height: 55)
양이 너무 많아서 복잡하고 어려웠다.
정말 모든 동작방식을 1부터 100까지 이해하는 것은 아직은 무리지 않나 싶다.
그래도 최대한 코드를 분리해서 살펴보고, 애플 로그인 플로우를 이해하려고 힘썼다.
로그인 테스트를 해보니 이렇게 파이어베이스에도 잘 로그인 되는 것을 확인했다 :)
'iOS Developer > Firebase' 카테고리의 다른 글
0.Firebase 초기 설정 / App Delegate (0) | 2023.07.02 |
---|