Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,299 @@
//
// AgeNet.swift
//
// This file was automatically generated and should not be edited.
//
import CoreML
/// Model Prediction Input Type
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
class AgeNetInput : MLFeatureProvider {
/// input as color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high
var input: CVPixelBuffer
var featureNames: Set<String> { ["input"] }
func featureValue(for featureName: String) -> MLFeatureValue? {
if featureName == "input" {
return MLFeatureValue(pixelBuffer: input)
}
return nil
}
init(input: CVPixelBuffer) {
self.input = input
}
convenience init(inputWith input: CGImage) throws {
self.init(input: try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!)
}
convenience init(inputAt input: URL) throws {
self.init(input: try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!)
}
func setInput(with input: CGImage) throws {
self.input = try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!
}
func setInput(with input: URL) throws {
self.input = try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!
}
}
/// Model Prediction Output Type
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
class AgeNetOutput : MLFeatureProvider {
/// Source provided by CoreML
private let provider : MLFeatureProvider
/// Identity as 1 by 1 matrix of floats
var Identity: MLMultiArray {
provider.featureValue(for: "Identity")!.multiArrayValue!
}
/// Identity as 1 by 1 matrix of floats
var IdentityShapedArray: MLShapedArray<Float> {
MLShapedArray<Float>(Identity)
}
var featureNames: Set<String> {
provider.featureNames
}
func featureValue(for featureName: String) -> MLFeatureValue? {
provider.featureValue(for: featureName)
}
init(Identity: MLMultiArray) {
self.provider = try! MLDictionaryFeatureProvider(dictionary: ["Identity" : MLFeatureValue(multiArray: Identity)])
}
init(features: MLFeatureProvider) {
self.provider = features
}
}
/// Class for model loading and prediction
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
class AgeNet {
let model: MLModel
/// URL of model assuming it was installed in the same bundle as this class
class var urlOfModelInThisBundle : URL {
let bundle = Bundle(for: self)
return bundle.url(forResource: "AgeNet", withExtension:"mlmodelc")!
}
/**
Construct AgeNet instance with an existing MLModel object.
Usually the application does not use this initializer unless it makes a subclass of AgeNet.
Such application may want to use `MLModel(contentsOfURL:configuration:)` and `AgeNet.urlOfModelInThisBundle` to create a MLModel object to pass-in.
- parameters:
- model: MLModel object
*/
init(model: MLModel) {
self.model = model
}
/**
Construct a model with configuration
- parameters:
- configuration: the desired model configuration
- throws: an NSError object that describes the problem
*/
convenience init(configuration: MLModelConfiguration = MLModelConfiguration()) throws {
try self.init(contentsOf: type(of:self).urlOfModelInThisBundle, configuration: configuration)
}
/**
Construct AgeNet instance with explicit path to mlmodelc file
- parameters:
- modelURL: the file url of the model
- throws: an NSError object that describes the problem
*/
convenience init(contentsOf modelURL: URL) throws {
try self.init(model: MLModel(contentsOf: modelURL))
}
/**
Construct a model with URL of the .mlmodelc directory and configuration
- parameters:
- modelURL: the file url of the model
- configuration: the desired model configuration
- throws: an NSError object that describes the problem
*/
convenience init(contentsOf modelURL: URL, configuration: MLModelConfiguration) throws {
try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration))
}
/**
Construct AgeNet instance asynchronously with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- configuration: the desired model configuration
- handler: the completion handler to be called when the model loading completes successfully or unsuccessfully
*/
class func load(configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result<AgeNet, Error>) -> Void) {
load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration, completionHandler: handler)
}
/**
Construct AgeNet instance asynchronously with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- configuration: the desired model configuration
*/
class func load(configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNet {
try await load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration)
}
/**
Construct AgeNet instance asynchronously with URL of the .mlmodelc directory with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- modelURL: the URL to the model
- configuration: the desired model configuration
- handler: the completion handler to be called when the model loading completes successfully or unsuccessfully
*/
class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result<AgeNet, Error>) -> Void) {
MLModel.load(contentsOf: modelURL, configuration: configuration) { result in
switch result {
case .failure(let error):
handler(.failure(error))
case .success(let model):
handler(.success(AgeNet(model: model)))
}
}
}
/**
Construct AgeNet instance asynchronously with URL of the .mlmodelc directory with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- modelURL: the URL to the model
- configuration: the desired model configuration
*/
class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNet {
let model = try await MLModel.load(contentsOf: modelURL, configuration: configuration)
return AgeNet(model: model)
}
/**
Make a prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetInput
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetOutput
*/
func prediction(input: AgeNetInput) throws -> AgeNetOutput {
try prediction(input: input, options: MLPredictionOptions())
}
/**
Make a prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetInput
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetOutput
*/
func prediction(input: AgeNetInput, options: MLPredictionOptions) throws -> AgeNetOutput {
let outFeatures = try model.prediction(from: input, options: options)
return AgeNetOutput(features: outFeatures)
}
/**
Make an asynchronous prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetInput
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetOutput
*/
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
func prediction(input: AgeNetInput, options: MLPredictionOptions = MLPredictionOptions()) async throws -> AgeNetOutput {
let outFeatures = try await model.prediction(from: input, options: options)
return AgeNetOutput(features: outFeatures)
}
/**
Make a prediction using the convenience interface
It uses the default function if the model has multiple functions.
- parameters:
- input: color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetOutput
*/
func prediction(input: CVPixelBuffer) throws -> AgeNetOutput {
let input_ = AgeNetInput(input: input)
return try prediction(input: input_)
}
/**
Make a batch prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- inputs: the inputs to the prediction as [AgeNetInput]
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as [AgeNetOutput]
*/
func predictions(inputs: [AgeNetInput], options: MLPredictionOptions = MLPredictionOptions()) throws -> [AgeNetOutput] {
let batchIn = MLArrayBatchProvider(array: inputs)
let batchOut = try model.predictions(from: batchIn, options: options)
var results : [AgeNetOutput] = []
results.reserveCapacity(inputs.count)
for i in 0..<batchOut.count {
let outProvider = batchOut.features(at: i)
let result = AgeNetOutput(features: outProvider)
results.append(result)
}
return results
}
}
@@ -0,0 +1,312 @@
//
// AgeNetLegacy.swift
//
// This file was automatically generated and should not be edited.
//
import CoreML
/// Model Prediction Input Type
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *)
class AgeNetLegacyInput : MLFeatureProvider {
/// input as color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high
var input: CVPixelBuffer
var featureNames: Set<String> { ["input"] }
func featureValue(for featureName: String) -> MLFeatureValue? {
if featureName == "input" {
return MLFeatureValue(pixelBuffer: input)
}
return nil
}
init(input: CVPixelBuffer) {
self.input = input
}
convenience init(inputWith input: CGImage) throws {
self.init(input: try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!)
}
convenience init(inputAt input: URL) throws {
self.init(input: try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!)
}
func setInput(with input: CGImage) throws {
self.input = try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!
}
func setInput(with input: URL) throws {
self.input = try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!
}
}
/// Model Prediction Output Type
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *)
class AgeNetLegacyOutput : MLFeatureProvider {
/// Source provided by CoreML
private let provider : MLFeatureProvider
/// Identity as multidimensional array of floats
var Identity: MLMultiArray {
provider.featureValue(for: "Identity")!.multiArrayValue!
}
/// Identity as multidimensional array of floats
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
var IdentityShapedArray: MLShapedArray<Float> {
MLShapedArray<Float>(Identity)
}
var featureNames: Set<String> {
provider.featureNames
}
func featureValue(for featureName: String) -> MLFeatureValue? {
provider.featureValue(for: featureName)
}
init(Identity: MLMultiArray) {
self.provider = try! MLDictionaryFeatureProvider(dictionary: ["Identity" : MLFeatureValue(multiArray: Identity)])
}
init(features: MLFeatureProvider) {
self.provider = features
}
}
/// Class for model loading and prediction
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *)
class AgeNetLegacy {
let model: MLModel
/// URL of model assuming it was installed in the same bundle as this class
class var urlOfModelInThisBundle : URL {
let bundle = Bundle(for: self)
return bundle.url(forResource: "AgeNetLegacy", withExtension:"mlmodelc")!
}
/**
Construct AgeNetLegacy instance with an existing MLModel object.
Usually the application does not use this initializer unless it makes a subclass of AgeNetLegacy.
Such application may want to use `MLModel(contentsOfURL:configuration:)` and `AgeNetLegacy.urlOfModelInThisBundle` to create a MLModel object to pass-in.
- parameters:
- model: MLModel object
*/
init(model: MLModel) {
self.model = model
}
/**
Construct AgeNetLegacy instance by automatically loading the model from the app's bundle.
*/
@available(*, deprecated, message: "Use init(configuration:) instead and handle errors appropriately.")
convenience init() {
try! self.init(contentsOf: type(of:self).urlOfModelInThisBundle)
}
/**
Construct a model with configuration
- parameters:
- configuration: the desired model configuration
- throws: an NSError object that describes the problem
*/
convenience init(configuration: MLModelConfiguration) throws {
try self.init(contentsOf: type(of:self).urlOfModelInThisBundle, configuration: configuration)
}
/**
Construct AgeNetLegacy instance with explicit path to mlmodelc file
- parameters:
- modelURL: the file url of the model
- throws: an NSError object that describes the problem
*/
convenience init(contentsOf modelURL: URL) throws {
try self.init(model: MLModel(contentsOf: modelURL))
}
/**
Construct a model with URL of the .mlmodelc directory and configuration
- parameters:
- modelURL: the file url of the model
- configuration: the desired model configuration
- throws: an NSError object that describes the problem
*/
convenience init(contentsOf modelURL: URL, configuration: MLModelConfiguration) throws {
try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration))
}
/**
Construct AgeNetLegacy instance asynchronously with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- configuration: the desired model configuration
- handler: the completion handler to be called when the model loading completes successfully or unsuccessfully
*/
@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *)
class func load(configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result<AgeNetLegacy, Error>) -> Void) {
load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration, completionHandler: handler)
}
/**
Construct AgeNetLegacy instance asynchronously with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- configuration: the desired model configuration
*/
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
class func load(configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNetLegacy {
try await load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration)
}
/**
Construct AgeNetLegacy instance asynchronously with URL of the .mlmodelc directory with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- modelURL: the URL to the model
- configuration: the desired model configuration
- handler: the completion handler to be called when the model loading completes successfully or unsuccessfully
*/
@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *)
class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result<AgeNetLegacy, Error>) -> Void) {
MLModel.load(contentsOf: modelURL, configuration: configuration) { result in
switch result {
case .failure(let error):
handler(.failure(error))
case .success(let model):
handler(.success(AgeNetLegacy(model: model)))
}
}
}
/**
Construct AgeNetLegacy instance asynchronously with URL of the .mlmodelc directory with optional configuration.
Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread.
- parameters:
- modelURL: the URL to the model
- configuration: the desired model configuration
*/
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNetLegacy {
let model = try await MLModel.load(contentsOf: modelURL, configuration: configuration)
return AgeNetLegacy(model: model)
}
/**
Make a prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetLegacyInput
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetLegacyOutput
*/
func prediction(input: AgeNetLegacyInput) throws -> AgeNetLegacyOutput {
try prediction(input: input, options: MLPredictionOptions())
}
/**
Make a prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetLegacyInput
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetLegacyOutput
*/
func prediction(input: AgeNetLegacyInput, options: MLPredictionOptions) throws -> AgeNetLegacyOutput {
let outFeatures = try model.prediction(from: input, options: options)
return AgeNetLegacyOutput(features: outFeatures)
}
/**
Make an asynchronous prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- input: the input to the prediction as AgeNetLegacyInput
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetLegacyOutput
*/
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
func prediction(input: AgeNetLegacyInput, options: MLPredictionOptions = MLPredictionOptions()) async throws -> AgeNetLegacyOutput {
let outFeatures = try await model.prediction(from: input, options: options)
return AgeNetLegacyOutput(features: outFeatures)
}
/**
Make a prediction using the convenience interface
It uses the default function if the model has multiple functions.
- parameters:
- input: color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high
- throws: an NSError object that describes the problem
- returns: the result of the prediction as AgeNetLegacyOutput
*/
func prediction(input: CVPixelBuffer) throws -> AgeNetLegacyOutput {
let input_ = AgeNetLegacyInput(input: input)
return try prediction(input: input_)
}
/**
Make a batch prediction using the structured interface
It uses the default function if the model has multiple functions.
- parameters:
- inputs: the inputs to the prediction as [AgeNetLegacyInput]
- options: prediction options
- throws: an NSError object that describes the problem
- returns: the result of the prediction as [AgeNetLegacyOutput]
*/
func predictions(inputs: [AgeNetLegacyInput], options: MLPredictionOptions = MLPredictionOptions()) throws -> [AgeNetLegacyOutput] {
let batchIn = MLArrayBatchProvider(array: inputs)
let batchOut = try model.predictions(from: batchIn, options: options)
var results : [AgeNetLegacyOutput] = []
results.reserveCapacity(inputs.count)
for i in 0..<batchOut.count {
let outProvider = batchOut.features(at: i)
let result = AgeNetLegacyOutput(features: outProvider)
results.append(result)
}
return results
}
}
@@ -0,0 +1,124 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import FileMediaResourceStatus
import ZipArchive
private let queue = Queue()
public enum AgeVerificationAvailability {
case available(String, Bool)
case progress(Float)
case unavailable
}
private var forceCoreMLVariant: Bool {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}
private func modelPath() -> String {
return NSTemporaryDirectory() + "AgeNet.mlmodelc"
}
private let modelPeer = "agecomputation"
private func legacyModelPath() -> String {
return NSTemporaryDirectory() + "AgeNetLegacy.mlmodelc"
}
private let legacyModelPeer = "agelegacycomputation"
public func ageVerificationAvailability(context: AccountContext) -> Signal<AgeVerificationAvailability, NoError> {
let compiledModelPath: String
let modelPeerName: String
let isLegacy: Bool
if #available(iOS 15.0, *) {
compiledModelPath = modelPath()
modelPeerName = modelPeer
isLegacy = false
} else {
compiledModelPath = legacyModelPath()
modelPeerName = legacyModelPeer
isLegacy = true
}
if FileManager.default.fileExists(atPath: compiledModelPath) {
return .single(.available(compiledModelPath, isLegacy))
}
return context.engine.peers.resolvePeerByName(name: modelPeerName, referrer: nil)
|> mapToSignal { result -> Signal<AgeVerificationAvailability, NoError> in
guard case let .result(maybePeer) = result else {
return .complete()
}
guard let peer = maybePeer else {
return .single(.unavailable)
}
return context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: peer.id, threadId: nil), index: .lowerBound, anchorIndex: .lowerBound, count: 5, fixedCombinedReadStates: nil)
|> mapToSignal { view -> Signal<(TelegramMediaFile, EngineMessage)?, NoError> in
if !view.0.isLoading {
if let message = view.0.entries.last?.message, let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile {
return .single((file, EngineMessage(message)))
} else {
return .single(nil)
}
} else {
return .complete()
}
}
|> take(1)
|> mapToSignal { maybeFileAndMessage -> Signal<AgeVerificationAvailability, NoError> in
if let (file, message) = maybeFileAndMessage {
let fetchedData = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .other, userContentType: .file, reference: FileMediaReference.message(message: MessageReference(message._asMessage()), media: file).resourceReference(file.resource))
enum FetchStatus {
case completed(String)
case progress(Float)
case failed
}
let fetchStatus = Signal<FetchStatus, NoError> { subscriber in
let fetchedDisposable = fetchedData.start()
let resourceDataDisposable = context.account.postbox.mediaBox.resourceData(file.resource, attemptSynchronously: false).start(next: { next in
if next.complete {
SSZipArchive.unzipFile(atPath: next.path, toDestination: NSTemporaryDirectory())
subscriber.putNext(.completed(compiledModelPath))
subscriber.putCompletion()
}
}, error: subscriber.putError, completed: subscriber.putCompletion)
let progressDisposable = messageFileMediaResourceStatus(context: context, file: file, message: message, isRecentActions: false).start(next: { status in
switch status.fetchStatus {
case let .Remote(progress), let .Fetching(_, progress), let .Paused(progress):
subscriber.putNext(.progress(progress))
default:
break
}
})
return ActionDisposable {
fetchedDisposable.dispose()
resourceDataDisposable.dispose()
progressDisposable.dispose()
}
}
return fetchStatus
|> mapToSignal { status -> Signal<AgeVerificationAvailability, NoError> in
switch status {
case .completed:
return .single(.available(compiledModelPath, isLegacy))
case let .progress(progress):
return .single(.progress(progress))
case .failed:
return .single(.unavailable)
}
}
} else {
return .single(.unavailable)
}
}
}
}
@@ -0,0 +1,459 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BalancedTextComponent
import MultilineTextComponent
import BundleIconComponent
import ButtonComponent
import GlassBarButtonComponent
import AccountContext
import PresentationDataUtils
import TelegramUIPreferences
import UndoUI
import DeviceAccess
public func requireAgeVerification(context: AccountContext) -> Bool {
if let value = context.currentAppConfiguration.with({ $0 }).data?["need_age_video_verification"] as? Bool, value {
return true
}
return false
}
public func requireAgeVerification(context: AccountContext, peer: EnginePeer) -> Signal<Bool, NoError> {
if requireAgeVerification(context: context), peer._asPeer().hasSensitiveContent(platform: "ios") {
return context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.ContentSettings())
|> map { contentSettings in
if !contentSettings.ignoreContentRestrictionReasons.contains("sensitive") {
return true
}
return false
}
}
return .single(false)
}
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let dismiss: () -> Void
init(
context: AccountContext,
dismiss: @escaping () -> Void
) {
self.context = context
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
final class State: ComponentState {
}
func makeState() -> State {
return State()
}
static var body: Body {
let icon = Child(ZStack<Empty>.self)
let closeButton = Child(GlassBarButtonComponent.self)
let title = Child(Text.self)
let text = Child(BalancedTextComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme
let strings = presentationData.strings
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let icon = icon.update(
component: ZStack([
AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(RoundedRectangle(color: theme.list.itemCheckColors.fillColor, cornerRadius: 45.0, size: CGSize(width: 90.0, height: 90.0)))
),
AnyComponentWithIdentity(
id: AnyHashable(1),
component: AnyComponent(BundleIconComponent(
name: "Settings/FaceVerification",
tintColor: theme.list.itemCheckColors.foregroundColor
))
)
]),
availableSize: CGSize(width: 90.0, height: 90.0),
transition: .immediate
)
context.add(icon
.position(CGPoint(x: context.availableSize.width / 2.0, y: icon.size.height / 2.0 + 31.0))
)
let closeButton = closeButton.update(
component: GlassBarButtonComponent(
size: CGSize(width: 40.0, height: 40.0),
backgroundColor: theme.rootController.navigationBar.glassBarButtonBackgroundColor,
isDark: theme.overallDarkAppearance,
state: .generic,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.rootController.navigationBar.glassBarButtonForegroundColor
)
)),
action: { _ in
component.dismiss()
}
),
availableSize: CGSize(width: 40.0, height: 40.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: 16.0 + closeButton.size.width / 2.0, y: 16.0 + closeButton.size.height / 2.0))
)
let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0
contentSize.height += 124.0
let title = title.update(
component: Text(text: strings.AgeVerification_Title, font: Font.bold(24.0), color: theme.list.itemPrimaryTextColor),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 13.0
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = theme.actionSheet.primaryTextColor
let linkColor = theme.actionSheet.controlAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
var textString = environment.strings.AgeVerification_Text
if let code = component.context.currentAppConfiguration.with({ $0 }).data?["verify_age_country"] as? String {
let key = "AgeVerification.Text.\(code)"
if let string = environment.strings.primaryComponent.dict[key] {
textString = string
}
}
let text = text.update(
component: BalancedTextComponent(
text: .markdown(
text: textString,
attributes: markdownAttributes
),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
)
contentSize.height += text.size.height
contentSize.height += 23.0
let controller = environment.controller() as? AgeVerificationScreen
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 10.0,
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(NSMutableAttributedString(string: strings.AgeVerification_Verify, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center))))
),
isEnabled: true,
displaysProgress: false,
action: { [weak controller] in
controller?.complete(result: true)
}
),
availableSize: CGSize(width: context.availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0),
transition: .immediate
)
context.add(button
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += buttonInsets.bottom
return contentSize
}
}
}
private final class AgeVerificationSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
init(
context: AccountContext
) {
self.context = context
}
static func ==(lhs: AgeVerificationSheetComponent, rhs: AgeVerificationSheetComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() as? AgeVerificationScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
})
}
)),
style: .glass,
backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor),
followContentSizeChanges: true,
clipsContent: true,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() as? AgeVerificationScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() as? AgeVerificationScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
public final class AgeVerificationScreen: ViewControllerComponentContainer {
private let context: AccountContext
private let completion: (Bool, Signal<AgeVerificationAvailability, NoError>) -> Void
private let promise = Promise<AgeVerificationAvailability>()
public init(
context: AccountContext,
completion: @escaping (Bool, Signal<AgeVerificationAvailability, NoError>) -> Void
) {
self.context = context
self.completion = completion
self.promise.set(ageVerificationAvailability(context: context))
super.init(
context: context,
component: AgeVerificationSheetComponent(
context: context
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var didComplete = false
fileprivate func complete(result: Bool) {
guard !self.didComplete else {
return
}
if result {
let context = self.context
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
DeviceAccess.authorizeAccess(to: .camera(.ageVerification), presentationData: presentationData, present: { c, a in
c.presentationArguments = a
context.sharedContext.mainWindow?.present(c, on: .root)
}, openSettings: {
context.sharedContext.applicationBindings.openSettings()
}, { [weak self] granted in
guard let self, granted else {
return
}
self.didComplete = true
self.completion(true, self.promise.get())
self.dismissAnimated()
})
} else {
self.didComplete = true
self.completion(false, self.promise.get())
}
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
public func presentAgeVerification(context: AccountContext, parentController: ViewController, completion: @escaping () -> Void) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let _ = (contentSettingsConfiguration(network: context.account.network)
|> deliverOnMainQueue).start(next: { [weak parentController] settings in
if !settings.canAdjustSensitiveContent {
let alertController = textAlertController(
context: context,
title: presentationData.strings.AgeVerification_Unavailable_Title,
text: presentationData.strings.AgeVerification_Unavailable_Text,
actions: []
)
parentController?.present(alertController, in: .window(.root))
return
}
let miniappPromise = Promise<EnginePeer?>(nil)
var useVerifyAgeBot = false
if let value = context.currentAppConfiguration.with({ $0 }).data?["force_verify_age_bot"] as? Bool, value {
useVerifyAgeBot = value
}
if useVerifyAgeBot, let verifyAgeBotUsername = context.currentAppConfiguration.with({ $0 }).data?["verify_age_bot_username"] as? String {
miniappPromise.set(context.engine.peers.resolvePeerByName(name: verifyAgeBotUsername, referrer: nil)
|> mapToSignal { result in
if case let .result(peer) = result {
return .single(peer)
}
return .complete()
})
}
let infoScreen = AgeVerificationScreen(context: context, completion: { [weak parentController] check, availability in
if check {
var requiredAge = 18
if let value = context.currentAppConfiguration.with({ $0 }).data?["verify_age_min"] as? Double {
requiredAge = Int(value)
}
let success = { [weak parentController] in
completion()
let navigationController = parentController?.navigationController
Queue.mainQueue().after(2.0) {
let controller = UndoOverlayController(presentationData: presentationData, content: .actionSucceeded(title: presentationData.strings.AgeVerification_Success_Title, text: presentationData.strings.AgeVerification_Success_Text, cancel: nil, destructive: false), action: { _ in return true })
(navigationController?.viewControllers.last as? ViewController)?.present(controller, in: .current)
}
}
let failure = { [weak parentController] in
let controller = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_banned", scale: 0.066, colors: [:], title: presentationData.strings.AgeVerification_Fail_Title, text: presentationData.strings.AgeVerification_Fail_Text, customUndoText: nil, timeout: nil), action: { _ in return true })
parentController?.present(controller, in: .current)
}
let _ = (miniappPromise.get()
|> take(1)
|> deliverOnMainQueue).start(next: { peer in
if let peer, let parentController {
context.sharedContext.openWebApp(
context: context,
parentController: parentController,
updatedPresentationData: nil,
botPeer: peer,
chatPeer: nil,
threadId: nil,
buttonText: "",
url: "",
simple: true,
source: .generic,
skipTermsOfService: true,
payload: nil,
verifyAgeCompletion: { age in
if age >= requiredAge {
success()
} else {
failure()
}
}
)
} else {
let scanScreen = FaceScanScreen(context: context, availability: availability, completion: { age in
if age >= requiredAge {
success()
} else {
failure()
}
})
parentController?.push(scanScreen)
}
})
}
})
parentController?.push(infoScreen)
})
}
@@ -0,0 +1,791 @@
import Foundation
import UIKit
import AccountContext
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Camera
import CoreImage
import TelegramPresentationData
import TelegramCore
import Markdown
import TextFormat
import PresentationDataUtils
import ComponentFlow
import ViewControllerComponent
import Vision
import AVFoundation
import AppBundle
import ZipArchive
import PlainButtonComponent
import MultilineTextComponent
final class FaceScanScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let availability: Signal<AgeVerificationAvailability, NoError>
init(
context: AccountContext,
availability: Signal<AgeVerificationAvailability, NoError>
) {
self.context = context
self.availability = availability
}
static func ==(lhs: FaceScanScreenComponent, rhs: FaceScanScreenComponent) -> Bool {
return true
}
final class View: UIView, AVCaptureVideoDataOutputSampleBufferDelegate {
private let captureSession = AVCaptureSession()
private lazy var previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)
private let videoDataOutput = AVCaptureVideoDataOutput()
private let videoDataOutputQueue = DispatchQueue(label: "VideoDataOutput", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
private var faceDetectionRequest: VNDetectFaceRectanglesRequest!
private let overlayView = UIView()
private let cutoutLayer = CAShapeLayer()
private let frameView = FrameView()
private let numberOfStripes = 16
private var completedAngles: Set<Int> = []
private let instruction = ComponentView<Empty>()
private let cancel = ComponentView<Empty>()
private var currentFaceImage: CIImage?
private enum State {
case waitingForFace
case positioning
case readyToStart
case tracking
case completed
}
private var processState: State = .waitingForFace
private var transitioningToViewFinder = false
private var lastFaceYaw: NSNumber?
private var lastFacePitch: NSNumber?
private var lastFaceRoll: NSNumber?
private var centerYaw: Double = 0
private var centerPitch: Double = 0
private var centerRoll: Double = 0
private let motionThreshold: Double = 0.07
private var faceDetectionTimer: Foundation.Timer?
private var segmentTimer: Foundation.Timer?
private var currentSegment: Int?
private let segmentDwellTime: TimeInterval = 0.05
private let positioningTime: TimeInterval = 1.0
private var horizontalGuideLines: [CAShapeLayer] = []
private var verticalGuideLines: [CAShapeLayer] = []
private let maxGuideLines = 30
private let guideLineFadeDuration: TimeInterval = 0.3
private var lastGuideLineUpdate: Date = Date()
private let guideLineUpdateInterval: TimeInterval = 0.01
private var lastGuideLineYaw: Double = 0
private var lastGuideLinePitch: Double = 0
private let angleThreshold: Double = 0.01
private var ageModel: VNCoreMLModel?
private var ages: [Double] = []
private var availabilityDisposable: Disposable?
private var isUpdating: Bool = false
private var component: FaceScanScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .black
//self.previewLayer.backgroundColor = UIColor.red.cgColor
self.previewLayer.videoGravity = .resizeAspectFill
self.layer.addSublayer(previewLayer)
self.overlayView.backgroundColor = UIColor.black
self.addSubview(overlayView)
self.cutoutLayer.fillRule = .evenOdd
self.overlayView.layer.mask = self.cutoutLayer
self.addSubview(self.frameView)
}
required init?(coder: NSCoder) {
preconditionFailure()
}
deinit {
self.availabilityDisposable?.dispose()
}
private func setupModel(availability: Signal<AgeVerificationAvailability, NoError>) {
self.availabilityDisposable = (availability
|> deliverOnMainQueue).start(next: { [weak self] availability in
guard let self else {
return
}
if case let .available(path, isLegacy) = availability {
if isLegacy {
if let model = try? AgeNetLegacy(contentsOf: URL(fileURLWithPath: path)).model {
self.ageModel = try? VNCoreMLModel(for: model)
}
} else if #available(iOS 15.0, *) {
if let model = try? AgeNet(contentsOf: URL(fileURLWithPath: path)).model {
self.ageModel = try? VNCoreMLModel(for: model)
}
}
}
})
}
private func extractFaceImage(from pixelBuffer: CVPixelBuffer, faceObservation: VNFaceObservation) -> CIImage? {
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
let imageSize = ciImage.extent.size
let visionRect = faceObservation.boundingBox
let boundingBox = CGRect(x: 1.0 - visionRect.maxY, y: 1.0 - visionRect.maxX, width: visionRect.height, height: visionRect.width)
let faceRect = CGRect(
x: boundingBox.minX * imageSize.width,
y: boundingBox.minY * imageSize.height,
width: boundingBox.width * imageSize.width,
height: boundingBox.height * imageSize.height
)
let padding: CGFloat = 0.1
let paddingX = faceRect.width * padding
let paddingY = faceRect.height * padding
let paddedRect = CGRect(
x: max(0, faceRect.minX - paddingX),
y: max(0, faceRect.minY - paddingY),
width: min(imageSize.width - max(0, faceRect.minX - paddingX), faceRect.width + 2 * paddingX),
height: min(imageSize.height - max(0, faceRect.minY - paddingY), faceRect.height + 2 * paddingY)
)
let croppedImage = ciImage.cropped(to: paddedRect)
let rotatedImage = croppedImage.oriented(.leftMirrored)
return rotatedImage
}
private func setupCamera() {
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
return
}
do {
let input = try AVCaptureDeviceInput(device: device)
self.captureSession.beginConfiguration()
self.captureSession.sessionPreset = .high
if self.captureSession.canAddInput(input) {
self.captureSession.addInput(input)
}
if self.captureSession.canAddOutput(self.videoDataOutput) {
self.captureSession.addOutput(self.videoDataOutput)
self.videoDataOutput.alwaysDiscardsLateVideoFrames = true
self.videoDataOutput.setSampleBufferDelegate(self, queue: self.videoDataOutputQueue)
}
self.captureSession.commitConfiguration()
} catch {
print("Failed to setup camera: \(error)")
}
Queue.concurrentDefaultQueue().async {
self.captureSession.startRunning()
}
}
private func setupVision() {
self.faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in
guard error == nil else { return }
Queue.mainQueue().async {
self?.handleFaceDetection(request)
}
}
}
private func handleFaceDetection(_ request: VNRequest) {
guard #available(iOS 15.0, *) else {
return
}
guard let observations = request.results as? [VNFaceObservation], let face = observations.first else {
if self.processState == .tracking || self.processState == .readyToStart {
self.resetTracking()
}
self.currentFaceImage = nil
return
}
guard let yaw = face.yaw,
let pitch = face.pitch,
let roll = face.roll else { return }
let faceCenter = CGPoint(x: face.boundingBox.midX, y: face.boundingBox.midY)
let targetCenter = CGPoint(x: 0.5, y: 0.5)
let distance = sqrt(pow(faceCenter.x - targetCenter.x, 2) + pow(faceCenter.y - targetCenter.y, 2))
if distance < 0.24 {
switch processState {
case .waitingForFace:
self.processState = .positioning
self.faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: self.positioningTime, repeats: false) { [weak self] _ in
self?.processState = .readyToStart
self?.state?.updated(transition: .spring(duration: 0.3))
}
case .positioning:
break
case .readyToStart:
self.centerYaw = yaw.doubleValue
self.centerPitch = pitch.doubleValue
self.centerRoll = roll.doubleValue
self.processState = .tracking
case .tracking:
self.trackHeadOrientation(yaw: yaw, pitch: pitch, roll: roll)
case .completed:
break
}
} else if self.processState == .tracking {
self.resetTracking()
}
}
private func trackHeadOrientation(yaw: NSNumber, pitch: NSNumber, roll: NSNumber) {
let relativeYaw = yaw.doubleValue - self.centerYaw
let relativePitch = pitch.doubleValue - self.centerPitch
//self.updateTrailingGuideLines(yaw: relativeYaw, pitch: relativePitch)
if let lastYaw = self.lastFaceYaw, let lastPitch = self.lastFacePitch {
let yawChange = abs(yaw.doubleValue - lastYaw.doubleValue)
let pitchChange = abs(pitch.doubleValue - lastPitch.doubleValue)
if yawChange < 0.02 && pitchChange < 0.02 {
return
}
}
let mirroredYaw = -relativeYaw
let flippedPitch = relativePitch
let angle = atan2(mirroredYaw, flippedPitch) + .pi
let normalizedAngle = angle / (2 * .pi)
let segmentIndex = Int(normalizedAngle * Double(self.numberOfStripes)) % self.numberOfStripes
let rotationMagnitude = sqrt(relativeYaw * relativeYaw + relativePitch * relativePitch)
if rotationMagnitude > self.motionThreshold {
if self.currentSegment != segmentIndex {
self.currentSegment = segmentIndex
self.segmentTimer?.invalidate()
self.segmentTimer = Timer.scheduledTimer(withTimeInterval: self.segmentDwellTime, repeats: false) { _ in
self.fillSegment(segmentIndex)
}
}
}
self.lastFaceYaw = yaw
self.lastFacePitch = pitch
self.lastFaceRoll = roll
}
private func fillSegment(_ segmentIndex: Int) {
guard !self.completedAngles.contains(segmentIndex) else {
return
}
self.completedAngles.insert(segmentIndex)
if self.completedAngles.count >= self.numberOfStripes {
Queue.mainQueue().after(0.3, {
self.processState = .completed
self.state?.updated(transition: .spring(duration: 0.3))
Queue.mainQueue().after(1.0) {
if !self.ages.isEmpty {
let averageAge = self.ages.reduce(0, +) / Double(self.ages.count)
if let environment = self.environment, let controller = environment.controller() as? FaceScanScreen {
controller.completion(Int(averageAge))
controller.dismiss(animated: true)
}
} else {
self.completedAngles.removeAll()
self.processState = .tracking
self.state?.updated(transition: .spring(duration: 0.3))
}
}
})
}
self.state?.updated(transition: .spring(duration: 0.3))
}
private func resetTracking() {
self.faceDetectionTimer?.invalidate()
self.segmentTimer?.invalidate()
if self.processState != .waitingForFace && self.processState != .positioning {
self.transitioningToViewFinder = true
}
self.processState = .waitingForFace
self.completedAngles.removeAll()
self.currentSegment = nil
self.lastFaceYaw = nil
self.lastFacePitch = nil
self.lastFaceRoll = nil
self.currentFaceImage = nil
self.state?.updated(transition: .spring(duration: 0.3))
}
private var tick = 0
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .leftMirrored)
do {
try requestHandler.perform([self.faceDetectionRequest])
if let observations = self.faceDetectionRequest.results, let faceObservation = observations.first {
tick += 1
if tick < 33 {
return
}
tick = 0
self.currentFaceImage = self.extractFaceImage(from: pixelBuffer, faceObservation: faceObservation)
self.processFace()
}
} catch {
print("Failed to perform request: \(error)")
}
}
public func processFace() {
guard let faceImage = self.currentFaceImage else {
return
}
guard let model = self.ageModel else {
return
}
let request = VNCoreMLRequest(model: model) { [weak self] request, error in
if let results = request.results as? [VNCoreMLFeatureValueObservation], let ageObservation = results.last {
let age = ageObservation.featureValue.multiArrayValue?[0].doubleValue ?? 0
Queue.mainQueue().async {
self?.ages.append(age)
}
}
}
let handler = VNImageRequestHandler(ciImage: faceImage)
DispatchQueue.global(qos: .userInteractive).async {
do {
try handler.perform([request])
} catch {
print(error)
}
}
}
func update(component: FaceScanScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
if self.component == nil {
self.setupModel(availability: component.availability)
self.setupCamera()
self.setupVision()
}
self.component = component
self.state = state
let environment = environment[EnvironmentType.self].value
self.environment = environment
let theme = environment.theme
let strings = environment.strings
self.overlayView.frame = CGRect(origin: .zero, size: availableSize)
self.cutoutLayer.frame = CGRect(origin: .zero, size: availableSize)
let path = CGMutablePath(rect: overlayView.bounds, transform: nil)
let radius: CGFloat = 130.0
let widthRadius = ceil(radius * 1.05)
let heightRadius = widthRadius //radius //floor(widthRadius * 1.17778)
let center = CGPoint(x: availableSize.width / 2, y: environment.statusBarHeight + 10.0 + widthRadius * 1.3)
var previewScale = 0.85
if self.processState == .tracking || self.processState == .readyToStart || self.processState == .completed || self.transitioningToViewFinder {
let circlePath = CGPath(roundedRect: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2), cornerWidth: radius, cornerHeight: radius, transform: nil)
path.addPath(circlePath)
previewScale = 0.75
} else {
let rectanglePath = CGPath(roundedRect: CGRect(x: center.x - widthRadius, y: center.y - heightRadius, width: widthRadius * 2, height: heightRadius * 2), cornerWidth: 20, cornerHeight: 20, transform: nil)
path.addPath(rectanglePath)
}
transition.setShapeLayerPath(layer: self.cutoutLayer, path: path)
self.previewLayer.bounds = CGRect(origin: .zero, size: availableSize)
self.previewLayer.position = CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0 - 200)
transition.setTransform(layer: self.previewLayer, transform: CATransform3DMakeScale(previewScale, previewScale, 1.0))
let frameViewSize = CGSize(width: 330.0, height: 330.0)
let frameViewFrame = CGRect(x: (availableSize.width - frameViewSize.width) / 2.0, y: center.y - frameViewSize.height * 0.5, width: frameViewSize.width, height: frameViewSize.height)
self.frameView.frame = frameViewFrame
self.frameView.update(size: frameViewFrame.size)
var instructionString = environment.strings.FaceScan_Instruction_Position
switch self.processState {
case .waitingForFace, .positioning:
self.frameView.update(state: .viewFinder, intermediateCompletion: { [weak self] in
if let self {
self.transitioningToViewFinder = false
self.state?.updated(transition: .spring(duration: 0.3))
}
}, transition: .spring(duration: 0.3))
instructionString = environment.strings.FaceScan_Instruction_Position
case .readyToStart:
self.frameView.update(state: .segments(Set()), transition: .spring(duration: 0.3))
instructionString = environment.strings.FaceScan_Instruction_Rotate
case .tracking:
self.frameView.update(state: .segments(self.completedAngles), transition: .spring(duration: 0.3))
instructionString = environment.strings.FaceScan_Instruction_Rotate
case .completed:
self.frameView.update(state: .success, transition: .spring(duration: 0.3))
instructionString = ""
}
let instructionSize = self.instruction.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: instructionString, font: Font.semibold(20.0), textColor: .white)),
horizontalAlignment: .center,
maximumNumberOfLines: 3,
lineSpacing: 0.1
)
),
environment: {},
containerSize: availableSize
)
let instructionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - instructionSize.width) / 2.0), y: 484.0), size: instructionSize)
if let instructionView = self.instruction.view {
if instructionView.superview == nil {
self.addSubview(instructionView)
}
instructionView.frame = instructionFrame
}
let cancelSize = self.cancel.update(
transition: .immediate,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.list.itemAccentColor))
)
),
action: { [weak self] in
guard let self, let environment = self.environment, let controller = environment.controller() as? FaceScanScreen else {
return
}
controller.dismiss(animated: true)
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
let cancelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - cancelSize.width) / 2.0), y: availableSize.height - cancelSize.height - environment.safeInsets.bottom - 22.0), size: cancelSize)
if let cancelView = self.cancel.view {
if cancelView.superview == nil {
self.addSubview(cancelView)
}
cancelView.frame = cancelFrame
transition.setAlpha(view: cancelView, alpha: self.processState == .completed ? 0.0 : 1.0)
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class FaceScanScreen: ViewControllerComponentContainer {
private let context: AccountContext
fileprivate let completion: (Int) -> Void
public init(
context: AccountContext,
availability: Signal<AgeVerificationAvailability, NoError>,
completion: @escaping (Int) -> Void
) {
self.context = context
self.completion = completion
super.init(context: context, component: FaceScanScreenComponent(
context: context,
availability: availability
), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil)
self.title = ""
self.statusBar.statusBarStyle = .White
self.navigationPresentation = .standaloneFlatModal
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.animateIn()
}
private func animateIn() {
guard let layout = self.validLayout else {
return
}
self.view.clipsToBounds = true
self.view.layer.cornerRadius = layout.deviceMetrics.screenCornerRadius
self.view.layer.animatePosition(from: CGPoint(x: self.view.layer.position.x, y: self.view.layer.position.y + self.view.layer.bounds.size.height), to: self.view.layer.position, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in
self.view.clipsToBounds = false
})
}
private func animateOut(completion: (() -> Void)? = nil) {
guard let layout = self.validLayout else {
return
}
self.view.clipsToBounds = true
self.view.layer.cornerRadius = layout.deviceMetrics.screenCornerRadius
self.view.layer.animatePosition(from: self.view.layer.position, to: CGPoint(x: self.view.layer.position.x, y: self.view.layer.position.y + self.view.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in
completion?()
})
}
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
if flag {
self.animateOut(completion: {
super.dismiss(animated: false, completion: completion)
})
} else {
super.dismiss(animated: flag, completion: completion)
}
}
}
extension FaceScanScreenComponent.View {
private func updateTrailingGuideLines(yaw: Double, pitch: Double) {
let now = Date()
guard now.timeIntervalSince(lastGuideLineUpdate) >= guideLineUpdateInterval else {
return
}
self.lastGuideLineUpdate = now
let radius: CGFloat = 128.0
let center = CGPoint(x: self.bounds.width / 2, y: self.frameView.center.y)
let maxRotation: Double = 0.5
let normalizedYaw = min(max(yaw / maxRotation, -1.0), 1.0) * 1.5
let normalizedPitch = min(max(pitch / maxRotation, -1.0), 1.0) * 1.5
let yawChange = abs(yaw - lastGuideLineYaw)
let pitchChange = abs(pitch - lastGuideLinePitch)
let rotationMagnitude = sqrt(yaw * yaw + pitch * pitch)
if rotationMagnitude > 0.01 && (yawChange > angleThreshold || pitchChange > angleThreshold) {
if abs(pitch) > 0.01 {
createHorizontalGuideLine(center: center, radius: radius, curvature: normalizedPitch)
}
if abs(yaw) > 0.01 {
createVerticalGuideLine(center: center, radius: radius, curvature: normalizedYaw)
}
lastGuideLineYaw = yaw
lastGuideLinePitch = pitch
}
cleanupOldGuideLines()
}
private func createHorizontalGuideLine(center: CGPoint, radius: CGFloat, curvature: Double) {
let lineLayer = CAShapeLayer()
let path = createCurvedHorizontalPath(center: center, radius: radius, curvature: curvature)
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor(rgb: 0xb4f5ff).cgColor
lineLayer.lineWidth = 3.0
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .round
lineLayer.opacity = 0.3
lineLayer.shadowColor = UIColor(rgb: 0xb4f5ff).cgColor
lineLayer.shadowRadius = 2
lineLayer.shadowOpacity = 0.5
lineLayer.shadowOffset = .zero
self.layer.addSublayer(lineLayer)
horizontalGuideLines.append(lineLayer)
animateLinefadeOut(lineLayer)
DispatchQueue.main.asyncAfter(deadline: .now() + guideLineFadeDuration + 0.1) { [weak self] in
if let index = self?.horizontalGuideLines.firstIndex(of: lineLayer) {
self?.horizontalGuideLines.remove(at: index)
}
lineLayer.removeFromSuperlayer()
}
}
private func createVerticalGuideLine(center: CGPoint, radius: CGFloat, curvature: Double) {
let lineLayer = CAShapeLayer()
let path = createCurvedVerticalPath(center: center, radius: radius, curvature: curvature)
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor(rgb: 0xb4f5ff).cgColor
lineLayer.lineWidth = 3.0
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .round
lineLayer.opacity = 0.3
lineLayer.shadowColor = UIColor(rgb: 0xb4f5ff).cgColor
lineLayer.shadowRadius = 2
lineLayer.shadowOpacity = 0.5
lineLayer.shadowOffset = .zero
self.layer.addSublayer(lineLayer)
verticalGuideLines.append(lineLayer)
animateLinefadeOut(lineLayer)
DispatchQueue.main.asyncAfter(deadline: .now() + guideLineFadeDuration + 0.1) { [weak self] in
if let index = self?.verticalGuideLines.firstIndex(of: lineLayer) {
self?.verticalGuideLines.remove(at: index)
}
lineLayer.removeFromSuperlayer()
}
}
private func createCurvedHorizontalPath(center: CGPoint, radius: CGFloat, curvature: Double) -> UIBezierPath {
let path = UIBezierPath()
let startPoint = CGPoint(x: center.x - radius, y: center.y)
let endPoint = CGPoint(x: center.x + radius, y: center.y)
if abs(curvature) < 0.05 {
path.move(to: startPoint)
path.addLine(to: endPoint)
} else {
let curveOffset = CGFloat(curvature) * radius * 0.6
let controlPoint = CGPoint(x: center.x, y: center.y + curveOffset)
path.move(to: startPoint)
path.addQuadCurve(to: endPoint, controlPoint: controlPoint)
}
return path
}
private func createCurvedVerticalPath(center: CGPoint, radius: CGFloat, curvature: Double) -> UIBezierPath {
let path = UIBezierPath()
let startPoint = CGPoint(x: center.x, y: center.y - radius)
let endPoint = CGPoint(x: center.x, y: center.y + radius)
if abs(curvature) < 0.05 {
path.move(to: startPoint)
path.addLine(to: endPoint)
} else {
let curveOffset = CGFloat(curvature) * radius * 0.6
let controlPoint = CGPoint(x: center.x + curveOffset, y: center.y)
path.move(to: startPoint)
path.addQuadCurve(to: endPoint, controlPoint: controlPoint)
}
return path
}
private func animateLinefadeOut(_ layer: CAShapeLayer) {
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 0.5
opacityAnimation.toValue = 0.0
opacityAnimation.duration = guideLineFadeDuration
opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
opacityAnimation.fillMode = .forwards
opacityAnimation.isRemovedOnCompletion = false
let shadowAnimation = CABasicAnimation(keyPath: "shadowOpacity")
shadowAnimation.fromValue = 0.5
shadowAnimation.toValue = 0.0
shadowAnimation.duration = guideLineFadeDuration
shadowAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
shadowAnimation.fillMode = .forwards
shadowAnimation.isRemovedOnCompletion = false
layer.add(opacityAnimation, forKey: "fadeOut")
layer.add(shadowAnimation, forKey: "shadowFadeOut")
}
private func cleanupOldGuideLines() {
while horizontalGuideLines.count > maxGuideLines {
let oldestLine = horizontalGuideLines.removeFirst()
oldestLine.removeFromSuperlayer()
}
while verticalGuideLines.count > maxGuideLines {
let oldestLine = verticalGuideLines.removeFirst()
oldestLine.removeFromSuperlayer()
}
}
private func clearAllGuideLines() {
for line in horizontalGuideLines {
line.removeFromSuperlayer()
}
self.horizontalGuideLines.removeAll()
for line in verticalGuideLines {
line.removeFromSuperlayer()
}
self.verticalGuideLines.removeAll()
}
}
@@ -0,0 +1,412 @@
import UIKit
import Display
import ComponentFlow
final class FrameView: UIView {
enum State: Equatable {
case viewFinder
case segments(Set<Int>)
case success
case failure
}
private let viewFinderLayer = ViewFinderLayer()
private let transitionLayer = TransitionLayer()
private let segmentsLayer = SegmentsLayer()
private var currentState: State = .viewFinder
private var scheduledState: State?
private var isTransitioning = false
private var currentLayout: CGSize?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .clear
self.transitionLayer.isHidden = true
self.segmentsLayer.isHidden = true
self.layer.addSublayer(self.viewFinderLayer)
self.layer.addSublayer(self.transitionLayer)
self.layer.addSublayer(self.segmentsLayer)
}
required init?(coder: NSCoder) {
preconditionFailure()
}
func update(state: State, intermediateCompletion: (() -> Void)? = nil, transition: ComponentTransition) {
guard !self.isTransitioning else {
self.scheduledState = state
return
}
let previousState = self.currentState
self.currentState = state
switch state {
case .viewFinder:
switch previousState {
case .viewFinder:
break
case .segments:
self.isTransitioning = true
self.segmentsLayer.animateOut(transition: transition) {
self.segmentsLayer.isHidden = true
self.transitionLayer.isHidden = false
self.transitionLayer.animateOut(transition: transition) {
self.transitionLayer.isHidden = true
self.viewFinderLayer.isHidden = false
intermediateCompletion?()
self.viewFinderLayer.animateIn(transition: transition) {
self.isTransitioning = false
self.maybeApplyScheduledState()
}
}
}
case .success:
break
case .failure:
break
}
case let .segments(segments):
switch previousState {
case .viewFinder:
self.isTransitioning = true
self.viewFinderLayer.animateOut(transition: transition) {
self.viewFinderLayer.isHidden = true
self.transitionLayer.isHidden = false
self.transitionLayer.animateIn(transition: transition) {
self.transitionLayer.isHidden = true
self.segmentsLayer.isHidden = false
self.segmentsLayer.animateIn (transition: transition) {
self.isTransitioning = false
self.maybeApplyScheduledState()
}
}
}
case .segments:
self.segmentsLayer.update(segments: segments, transition: transition)
case .success:
break
case .failure:
break
}
case .success:
self.isTransitioning = true
self.segmentsLayer.animateOut(transition: transition) {
self.segmentsLayer.isHidden = true
self.transitionLayer.isHidden = false
self.transitionLayer.update(color: UIColor(rgb: 0x65c466))
self.transitionLayer.animateOut(transition: transition) {
self.isTransitioning = false
self.maybeApplyScheduledState()
}
}
case .failure:
break
}
}
func maybeApplyScheduledState() {
if !self.isTransitioning, let state = self.scheduledState {
self.scheduledState = nil
self.update(state: state, transition: .spring(duration: 0.3))
}
}
func update(size: CGSize) {
guard self.currentLayout != size else {
return
}
self.currentLayout = size
let bounds = CGRect(origin: .zero, size: size)
//let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
//let viewFinderWidth = bounds.width - 34.0
//let viewFinderSize = CGSize(width: viewFinderWidth, height: floor(viewFinderWidth * 1.17778))
let viewFinderFrame = bounds.insetBy(dx: 29.0, dy: 29.0) //viewFinderSize.centered(around: center)
self.viewFinderLayer.update(size: viewFinderFrame.size, closed: self.currentState != .viewFinder, transition: .immediate)
self.viewFinderLayer.frame = viewFinderFrame
let transitionFrame = bounds.insetBy(dx: 29.0, dy: 29.0) //viewFinderSize.centered(around: center)
self.transitionLayer.update(size: transitionFrame.size)
self.transitionLayer.frame = transitionFrame
let segmentsFrame = bounds.insetBy(dx: 15.0, dy: 15.0)
self.segmentsLayer.update(size: segmentsFrame.size)
self.segmentsLayer.frame = segmentsFrame
}
}
private let numberOfSegments = 64
private let lineWidth: CGFloat = 4.0
final class ViewFinderLayer: SimpleLayer {
private let viewFinderTopLeftLine = SimpleShapeLayer()
private let viewFinderTopRightLine = SimpleShapeLayer()
private let viewFinderBottomLeftLine = SimpleShapeLayer()
private let viewFinderBottomRightLine = SimpleShapeLayer()
private var viewFinderLines: [SimpleShapeLayer] {
return [
self.viewFinderTopLeftLine,
self.viewFinderTopRightLine,
self.viewFinderBottomLeftLine,
self.viewFinderBottomRightLine
]
}
override init() {
super.init()
for line in self.viewFinderLines {
line.strokeColor = UIColor.white.cgColor
line.fillColor = UIColor.clear.cgColor
line.lineWidth = lineWidth
line.lineCap = .round
self.addSublayer(line)
}
}
required init?(coder: NSCoder) {
preconditionFailure()
}
private var validLayout: CGSize?
func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
guard let size = self.validLayout else {
return
}
self.update(size: size, closed: true, transition: transition, completion: completion)
}
func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) {
guard let size = self.validLayout else {
return
}
self.update(size: size, closed: false, transition: transition, completion: completion)
}
func update(size: CGSize, closed: Bool, transition: ComponentTransition, completion: (() -> Void)? = nil) {
self.validLayout = size
let cornerRadius = closed ? size.width / 2.0 : 18.0
let lineLength = size.width / 2.0 - cornerRadius
let targetLineLength = 34.0
let fraction = targetLineLength / lineLength
let strokeFraction = (1.0 - fraction) / 2.0
let strokeStart = closed ? 0.0 : strokeFraction
let strokeEnd = closed ? 1.0 : 1.0 - strokeFraction
let topLeftPath = CGMutablePath()
topLeftPath.move(to: CGPoint(x: 0.0, y: size.height / 2.0))
topLeftPath.addArc(center: CGPoint(x: cornerRadius, y: cornerRadius), radius: cornerRadius, startAngle: -.pi, endAngle: -.pi / 2.0, clockwise: false)
topLeftPath.addLine(to: CGPoint(x: size.width / 2.0, y: 0.0))
transition.setShapeLayerPath(layer: self.viewFinderTopLeftLine, path: topLeftPath, completion: { _ in
completion?()
})
transition.setShapeLayerStrokeStart(layer: self.viewFinderTopLeftLine, strokeStart: strokeStart)
transition.setShapeLayerStrokeEnd(layer: self.viewFinderTopLeftLine, strokeEnd: strokeEnd)
let topRightPath = CGMutablePath()
topRightPath.move(to: CGPoint(x: size.width / 2.0, y: 0.0))
topRightPath.addArc(center: CGPoint(x: size.width - cornerRadius, y: cornerRadius), radius: cornerRadius, startAngle: -.pi / 2.0, endAngle: 0.0, clockwise: false)
topRightPath.addLine(to: CGPoint(x: size.width, y: size.height / 2.0))
transition.setShapeLayerPath(layer: self.viewFinderTopRightLine, path: topRightPath)
transition.setShapeLayerStrokeStart(layer: self.viewFinderTopRightLine, strokeStart: strokeStart)
transition.setShapeLayerStrokeEnd(layer: self.viewFinderTopRightLine, strokeEnd: strokeEnd)
let bottomRightPath = CGMutablePath()
bottomRightPath.move(to: CGPoint(x: size.width, y: size.height / 2.0))
bottomRightPath.addArc(center: CGPoint(x: size.width - cornerRadius, y: size.height - cornerRadius), radius: cornerRadius, startAngle: 0.0, endAngle: .pi / 2.0, clockwise: false)
bottomRightPath.addLine(to: CGPoint(x: size.width / 2.0, y: size.height))
transition.setShapeLayerPath(layer: self.viewFinderBottomRightLine, path: bottomRightPath)
transition.setShapeLayerStrokeStart(layer: self.viewFinderBottomRightLine, strokeStart: strokeStart)
transition.setShapeLayerStrokeEnd(layer: self.viewFinderBottomRightLine, strokeEnd: strokeEnd)
let bottomLeftPath = CGMutablePath()
bottomLeftPath.move(to: CGPoint(x: size.width / 2.0, y: size.height))
bottomLeftPath.addArc(center: CGPoint(x: cornerRadius, y: size.height - cornerRadius), radius: cornerRadius, startAngle: .pi / 2.0, endAngle: .pi, clockwise: false)
bottomLeftPath.addLine(to: CGPoint(x: 0.0, y: size.height / 2.0))
transition.setShapeLayerPath(layer: self.viewFinderBottomLeftLine, path: bottomLeftPath)
transition.setShapeLayerStrokeStart(layer: self.viewFinderBottomLeftLine, strokeStart: strokeStart)
transition.setShapeLayerStrokeEnd(layer: self.viewFinderBottomLeftLine, strokeEnd: strokeEnd)
for line in self.viewFinderLines {
line.frame = CGRect(origin: .zero, size: size)
}
}
}
final class TransitionLayer: SimpleLayer {
private var segmentLayers: [SimpleShapeLayer] = []
func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) {
var i = 0
for layer in self.segmentLayers {
transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.499)
transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.501, completion: i == 0 ? { _ in completion() } : nil)
i += 1
}
}
func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
var i = 0
for layer in self.segmentLayers {
transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0)
transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 1.0, completion: i == 0 ? { _ in completion() } : nil)
i += 1
}
}
func setupIfNeeded(size: CGSize) {
guard self.segmentLayers.isEmpty else {
return
}
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
let radius: CGFloat = size.width / 2.0
let gapInDegrees: CGFloat = 0.0
let gapInRadians: CGFloat = gapInDegrees * .pi / 180.0
let totalGapAngle = CGFloat(numberOfSegments) * gapInRadians
let totalSegmentAngle = 2 * .pi - totalGapAngle
let segmentAngle = totalSegmentAngle / CGFloat(numberOfSegments)
for i in 0 ..< numberOfSegments {
let startAngle = -segmentAngle * 0.5 + (CGFloat(i) * (segmentAngle + gapInRadians)) - .pi / 2
let endAngle = startAngle + segmentAngle
let path = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
let stripeLayer = SimpleShapeLayer()
stripeLayer.path = path.cgPath
stripeLayer.strokeColor = UIColor(rgb: 0xaaaaaa).cgColor
stripeLayer.lineWidth = lineWidth
stripeLayer.fillColor = UIColor.clear.cgColor
stripeLayer.lineCap = .round
self.addSublayer(stripeLayer)
self.segmentLayers.append(stripeLayer)
}
}
func update(color: UIColor) {
for layer in self.segmentLayers {
layer.strokeColor = color.cgColor
}
}
func update(size: CGSize) {
self.setupIfNeeded(size: size)
}
}
final class SegmentsLayer: SimpleLayer {
private var segmentLayers: [SimpleShapeLayer] = []
func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) {
var i = 0
for layer in self.segmentLayers {
transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0)
transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.32, completion: i == 0 ? { _ in completion() } : nil)
i += 1
}
}
func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
var i = 0
for layer in self.segmentLayers {
transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0)
transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.001, completion: i == 0 ? { _ in completion() } : nil)
i += 1
}
}
func setupIfNeeded(size: CGSize) {
guard self.segmentLayers.isEmpty else {
return
}
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
let innerRadius: CGFloat = size.width / 2.0 - 13.0
let outerRadius: CGFloat = size.width / 2.0 + 13.0
let gapInDegrees: CGFloat = 2.0
let gapInRadians: CGFloat = gapInDegrees * .pi / 180.0
let totalGapAngle = CGFloat(numberOfSegments) * gapInRadians
let totalSegmentAngle = 2 * .pi - totalGapAngle
let segmentAngle = totalSegmentAngle / CGFloat(numberOfSegments)
for i in 0 ..< numberOfSegments {
let angle = (CGFloat(i) * (segmentAngle + gapInRadians)) - .pi / 2
let startPoint = CGPoint(
x: center.x + innerRadius * cos(angle),
y: center.y + innerRadius * sin(angle)
)
let endPoint = CGPoint(
x: center.x + outerRadius * cos(angle),
y: center.y + outerRadius * sin(angle)
)
let path = UIBezierPath()
path.move(to: startPoint)
path.addLine(to: endPoint)
let stripeLayer = SimpleShapeLayer()
stripeLayer.path = path.cgPath
stripeLayer.strokeColor = UIColor(rgb: 0xaaaaaa).cgColor
stripeLayer.lineWidth = lineWidth
stripeLayer.fillColor = UIColor.clear.cgColor
stripeLayer.lineCap = .round
stripeLayer.strokeStart = 0.0
stripeLayer.strokeEnd = 0.001
self.addSublayer(stripeLayer)
self.segmentLayers.append(stripeLayer)
}
}
func update(segments: Set<Int>, transition: ComponentTransition) {
var mappedSegments = Set<Int>()
for value in segments {
for i in 0 ..< 4 {
mappedSegments.insert(value * 4 + i)
}
}
for i in 0 ..< numberOfSegments {
let stripeLayer = self.segmentLayers[i]
if mappedSegments.contains(i) {
transition.setShapeLayerStrokeEnd(layer: stripeLayer, strokeEnd: 1.0)
transition.setShapeLayerStrokeColor(layer: stripeLayer, color: UIColor(rgb: 0x00ca48))
} else {
transition.setShapeLayerStrokeEnd(layer: stripeLayer, strokeEnd: 0.32)
transition.setShapeLayerStrokeColor(layer: stripeLayer, color: UIColor(rgb: 0xaaaaaa))
}
}
}
func update(size: CGSize) {
self.setupIfNeeded(size: size)
}
}