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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
641495132C405D0200C9A613 /* VerifiedAgeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641495122C405D0200C9A613 /* VerifiedAgeViewModel.swift */; };
641495152C405E1400C9A613 /* VerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641495142C405E1400C9A613 /* VerificationView.swift */; };
641495172C405E3600C9A613 /* VerificationLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641495162C405E3600C9A613 /* VerificationLoader.swift */; };
6499D22A2C4B2F4200825B30 /* Verification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6499D2292C4B2F4200825B30 /* Verification.swift */; };
7345AD032703D9470020AFB1 /* DaysUntilBirthday.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7345AD022703D9470020AFB1 /* DaysUntilBirthday.swift */; };
7345AD052703D9470020AFB1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7345AD042703D9470020AFB1 /* ContentView.swift */; };
7345AD072703D9480020AFB1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7345AD062703D9480020AFB1 /* Assets.xcassets */; };
Expand Down Expand Up @@ -59,6 +60,7 @@
641495122C405D0200C9A613 /* VerifiedAgeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerifiedAgeViewModel.swift; sourceTree = "<group>"; };
641495142C405E1400C9A613 /* VerificationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerificationView.swift; sourceTree = "<group>"; };
641495162C405E3600C9A613 /* VerificationLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerificationLoader.swift; sourceTree = "<group>"; };
6499D2292C4B2F4200825B30 /* Verification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Verification.swift; sourceTree = "<group>"; };
7345ACFF2703D9470020AFB1 /* DaysUntilBirthday (iOS).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DaysUntilBirthday (iOS).app"; sourceTree = BUILT_PRODUCTS_DIR; };
7345AD022703D9470020AFB1 /* DaysUntilBirthday.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaysUntilBirthday.swift; sourceTree = "<group>"; };
7345AD042703D9470020AFB1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -220,6 +222,7 @@
isa = PBXGroup;
children = (
739FCC47270E659A00C92042 /* Birthday.swift */,
6499D2292C4B2F4200825B30 /* Verification.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -383,6 +386,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6499D22A2C4B2F4200825B30 /* Verification.swift in Sources */,
739FCC48270E659A00C92042 /* Birthday.swift in Sources */,
739FCC46270E467600C92042 /* BirthdayView.swift in Sources */,
7345AD1B2703D9C30020AFB1 /* SignInView.swift in Sources */,
Expand Down
68 changes: 68 additions & 0 deletions Samples/Swift/DaysUntilBirthday/Shared/Models/Verification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

struct Verification: Decodable {
let signal: String

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.signal = try container.decode(String.self)
}

init(signal: String) {
self.signal = signal
}

static let noVerificationSignal: Verification = Verification(signal: "No signal found")
}

struct VerificationResponse: Decodable {
let verifications: [Verification]
let firstVerification: Verification

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.verifications = try container.decode([Verification].self, forKey:.ageVerificationResults)
guard let first = verifications.first else {
throw Error.noVerificationInResult
}
self.firstVerification = first
}
}

extension VerificationResponse {
enum CodingKeys: String, CodingKey {
case ageVerificationResults
}
}

extension VerificationResponse {
enum Error: Swift.Error {
case noVerificationInResult
}
}

/*
{
"name": "ageVerification",
"verificationId": "A verification id string",
"ageVerificationResults": [
"AGE_PENDING"
]
}
*/
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,97 @@
* limitations under the License.
*/

import Combine
import Foundation
import GoogleSignIn

#if os(iOS)

/// An observable class for verifying via Google.
final class VerificationLoader: ObservableObject {
/// The `Verification` object of the current user holding the age verification signal.
/// - note: Changes to this property will be published to observers.
@Published private(set) var verification: Verification?

private var verifiedAgeViewModel: VerifiedAgeViewModel
private let baseUrlString = "https://verifywithgoogle.googleapis.com/v1/ageVerification"

private lazy var components: URLComponents? = {
var comps = URLComponents(string: baseUrlString)
return comps
}()

private lazy var request: URLRequest? = {
guard let components = components, let url = components.url else {
return nil
}
return URLRequest(url: url)
}()

/// Creates an instance of this loader.
/// - parameter verifiedAgeViewModel: The view model to use to set verification status on.
/// - parameter verifiedAgeViewModel: The view model to use to set age verification signal on.
init(verifiedViewAgeModel: VerifiedAgeViewModel) {
self.verifiedAgeViewModel = verifiedViewAgeModel
}

private func createSession(verifyResult: GIDVerifiedAccountDetailResult,
completion: @escaping (Result<URLSession, Error>) -> Void) {
guard let token = verifyResult.accessToken?.tokenString else {
completion(.failure(.couldNotCreateURLSession))
return
}
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"Authorization": "Bearer \(token)"
]
let session = URLSession(configuration: configuration)
completion(.success(session))
}

/// Creates a `Publisher` to fetch a user's `Verification` holding their age verification signal.
/// - parameter completion: A closure passing back the `AnyPublisher<Verification, Error>`
/// upon success.
func verificationPublisher(verifyResult: GIDVerifiedAccountDetailResult,
completion: @escaping (AnyPublisher<Verification, Error>) -> Void) {
createSession(verifyResult: verifyResult) { [weak self] result in
switch result {
case .success(let urlSession):
guard let request = self?.request else {
return completion(Fail(error:.couldNotCreateURLRequest).eraseToAnyPublisher())
}
let verificationPublisher = urlSession.dataTaskPublisher(for: request)
.tryMap { data, error -> Verification in
let decoder = JSONDecoder()
let verificationResponse = try decoder.decode(VerificationResponse.self, from: data)
return verificationResponse.firstVerification
}
.mapError { error -> Error in
guard let loaderError = error as? Error else {
return Error.couldNotFetchVerificationSignal(underlying: error)
}
return loaderError
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
completion(verificationPublisher)
case .failure(let error):
completion(Fail(error: error).eraseToAnyPublisher())
}
}
}

/// Verifies the user's age is over 18 based upon the selected account.
/// - note: Successful calls to this method will set the `verificationState` property of the
/// `verifiedAgeViewModel` instance passed to the initializer.
func verifyUserAgeOver18() {
let accountDetails: [GIDVerifiableAccountDetail] = [
GIDVerifiableAccountDetail(accountDetailType: .ageOver18)
]

guard let rootViewController = UIApplication.shared.windows.first?.rootViewController else {
print("There is no root view controller!")
return
}

let accountDetails: [GIDVerifiableAccountDetail] = [
GIDVerifiableAccountDetail(accountDetailType: .ageOver18)
]
let verifyAccountDetail = GIDVerifyAccountDetail()
verifyAccountDetail.verifyAccountDetails(accountDetails, presenting: rootViewController) {
verifyResult, error in
Expand All @@ -53,6 +116,37 @@ final class VerificationLoader: ObservableObject {
self.verifiedAgeViewModel.verificationState = .verified(verifyResult)
}
}

private var cancellable: AnyCancellable?

/// Fetches the age verification signal of the current user.
func fetchAgeVerificationSignal(verifyResult: GIDVerifiedAccountDetailResult,
completion: @escaping () -> Void) {
self.verificationPublisher(verifyResult: verifyResult) { publisher in
self.cancellable = publisher.sink { sinkCompletion in
switch sinkCompletion {
case .finished:
break
case .failure(let error):
self.verification = Verification.noVerificationSignal
print("Error retrieving age verification: \(error)")
completion()
}
} receiveValue: { verification in
self.verification = verification
completion()
}
}
}
}

extension VerificationLoader {
/// An error representing what went wrong in fetching a user's number of day until their birthday.
enum Error: Swift.Error {
case couldNotFetchVerificationSignal(underlying: Swift.Error)
case couldNotCreateURLRequest
case couldNotCreateURLSession
}
}

#endif