diff --git a/client/ios/DivKit.xcodeproj/project.pbxproj b/client/ios/DivKit.xcodeproj/project.pbxproj index 8c6049e10..1903701e9 100644 --- a/client/ios/DivKit.xcodeproj/project.pbxproj +++ b/client/ios/DivKit.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 76AD26822935F1B30088A360 /* RiveRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 76AD26812935F1B30088A360 /* RiveRuntime */; }; 76FD0506294208890002A1F9 /* RiveDivCustomData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FD0505294208890002A1F9 /* RiveDivCustomData.swift */; }; 76FD0508294208F80002A1F9 /* RiveContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FD0507294208F70002A1F9 /* RiveContainerView.swift */; }; + 891672372A5F6F9300121131 /* DivPersistentValuesStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891672362A5F6F9300121131 /* DivPersistentValuesStorageTests.swift */; }; 893D19642902AD8F005540DF /* TimeMeasure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893D19632902AD8F005540DF /* TimeMeasure.swift */; }; 896381852987C9D50064DDD4 /* RenderingTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896381842987C9D50064DDD4 /* RenderingTime.swift */; }; 89D02B1428C891890041EC18 /* RadioButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D02B1328C891890041EC18 /* RadioButtonsView.swift */; }; @@ -265,6 +266,7 @@ 767AF3E929364C3D0002B944 /* RiveAnimationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveAnimationFactory.swift; sourceTree = ""; }; 76FD0505294208890002A1F9 /* RiveDivCustomData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveDivCustomData.swift; sourceTree = ""; }; 76FD0507294208F70002A1F9 /* RiveContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveContainerView.swift; sourceTree = ""; }; + 891672362A5F6F9300121131 /* DivPersistentValuesStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivPersistentValuesStorageTests.swift; sourceTree = ""; }; 893D19632902AD8F005540DF /* TimeMeasure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeMeasure.swift; sourceTree = ""; }; 896381842987C9D50064DDD4 /* RenderingTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingTime.swift; sourceTree = ""; }; 89D02B1328C891890041EC18 /* RadioButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButtonsView.swift; sourceTree = ""; }; @@ -837,6 +839,7 @@ 8C7B1B6C2865C01C0036EF4C /* DivBuilders.swift */, 7035529D28D4881300E237CF /* DivDeserializationErrorsTests.swift */, 8C0DC7112865CA1100286F6B /* DivKitTests.swift */, + 891672362A5F6F9300121131 /* DivPersistentValuesStorageTests.swift */, 8C7B1B302865C01C0036EF4C /* DivSerializationTests.swift */, 8C1DEBCD2A2F8277009237F1 /* DivStateManagementTests.swift */, 8C7B1BF02865C01C0036EF4C /* DivStateManagerTests.swift */, @@ -1529,6 +1532,7 @@ 8C23F42629DDB3810069F3F7 /* EntityWithOptionalPropertyTemplate.swift in Sources */, 8C7B1BF32865C01C0036EF4C /* DivTabsExtensionsTests.swift in Sources */, 8C23F44429DDB3810069F3F7 /* OptionalStringEnumPropertyTests.swift in Sources */, + 891672372A5F6F9300121131 /* DivPersistentValuesStorageTests.swift in Sources */, 8C23F43F29DDB3810069F3F7 /* EntityProtocol.swift in Sources */, 52EEC66E293E64C90030FB11 /* DivBlockModelingContextErrorsTests.swift in Sources */, 8C23F42729DDB3810069F3F7 /* EntityWithOptionalStringEnumProperty.swift in Sources */, diff --git a/client/ios/DivKit/Actions/DivActionHandler.swift b/client/ios/DivKit/Actions/DivActionHandler.swift index 66d6f2e84..11ab64b98 100644 --- a/client/ios/DivKit/Actions/DivActionHandler.swift +++ b/client/ios/DivKit/Actions/DivActionHandler.swift @@ -13,6 +13,7 @@ public final class DivActionHandler { private let trackVisibility: TrackVisibility private let trackDisappear: TrackVisibility private let variablesStorage: DivVariablesStorage + private let persistentValuesStorage: DivPersistentValuesStorage init( divActionURLHandler: DivActionURLHandler, @@ -20,7 +21,8 @@ public final class DivActionHandler { logger: DivActionLogger, trackVisibility: @escaping TrackVisibility, trackDisappear: @escaping TrackVisibility, - variablesStorage: DivVariablesStorage + variablesStorage: DivVariablesStorage, + persistentValuesStorage: DivPersistentValuesStorage ) { self.divActionURLHandler = divActionURLHandler self.urlHandler = urlHandler @@ -28,6 +30,7 @@ public final class DivActionHandler { self.trackVisibility = trackVisibility self.trackDisappear = trackDisappear self.variablesStorage = variablesStorage + self.persistentValuesStorage = persistentValuesStorage } public convenience init( @@ -42,7 +45,8 @@ public final class DivActionHandler { trackVisibility: @escaping TrackVisibility = { _, _ in }, trackDisappear: @escaping TrackVisibility = { _, _ in }, performTimerAction: @escaping DivActionURLHandler.PerformTimerAction = { _, _, _ in }, - urlHandler: DivUrlHandler + urlHandler: DivUrlHandler, + persistentValuesStorage: DivPersistentValuesStorage ) { self.init( divActionURLHandler: DivActionURLHandler( @@ -53,13 +57,15 @@ public final class DivActionHandler { updateCard: updateCard, showTooltip: showTooltip, tooltipActionPerformer: tooltipActionPerformer, - performTimerAction: performTimerAction + performTimerAction: performTimerAction, + persistentValuesStorage: persistentValuesStorage ), urlHandler: urlHandler, logger: logger, trackVisibility: trackVisibility, trackDisappear: trackDisappear, - variablesStorage: variablesStorage + variablesStorage: variablesStorage, + persistentValuesStorage: persistentValuesStorage ) } @@ -95,7 +101,7 @@ public final class DivActionHandler { sender: AnyObject? ) { let variables = variablesStorage.makeVariables(for: cardId) - let expressionResolver = ExpressionResolver(variables: variables) + let expressionResolver = ExpressionResolver(variables: variables, persistentValuesStorage: persistentValuesStorage) if let url = action.resolveUrl(expressionResolver) { let isDivActionURLHandled = divActionURLHandler.handleURL( url, diff --git a/client/ios/DivKit/Actions/DivActionIntent.swift b/client/ios/DivKit/Actions/DivActionIntent.swift index cb1b262f0..ceb2b7595 100644 --- a/client/ios/DivKit/Actions/DivActionIntent.swift +++ b/client/ios/DivKit/Actions/DivActionIntent.swift @@ -14,6 +14,7 @@ enum DivActionIntent { case setPreviousItem(id: String, overflow: OverflowMode) case timer(id: String, action: DivTimerAction) case video(id: String, action: DivVideoAction) + case setStoredValue(storedValue: DivStoredValue) public static let scheme = "div-action" @@ -73,6 +74,11 @@ enum DivActionIntent { return nil } self = .video(id: id, action: action) + case "set_stored_value": + guard let storedValue = url.storedValue else { + return nil + } + self = .setStoredValue(storedValue: storedValue) default: return nil } @@ -165,4 +171,36 @@ extension URL { return .clamp } } + + fileprivate var storedValue: DivStoredValue? { + guard let name = getParam(forName: "name"), + let value = getParam(forName: "value"), + let lifetime = getParam(forName: "lifetime").flatMap(Int.init), + let typeStr = getParam(forName: "type") else { + return nil + } + guard let type = DivStoredValue.ValueType(rawValue: typeStr) else { + DivKitLogger.error("Unsupported stored value type: \(typeStr)") + return nil + } + let storedValue = DivStoredValue( + name: name, + value: value, + type: type, + lifetimeInSec: lifetime + ) + guard storedValue.isValueValid else { + DivKitLogger.error("Incorrect value: \(value) for type: \(type)") + return nil + } + return storedValue + } + + fileprivate func getParam(forName name: String) -> String? { + guard let param = queryParamValue(forName: name) else { + DivKitLogger.error("The required parameter \(name) is missing") + return nil + } + return param + } } diff --git a/client/ios/DivKit/Actions/DivActionURLHandler.swift b/client/ios/DivKit/Actions/DivActionURLHandler.swift index 6427a234a..1f458f52f 100644 --- a/client/ios/DivKit/Actions/DivActionURLHandler.swift +++ b/client/ios/DivKit/Actions/DivActionURLHandler.swift @@ -34,6 +34,7 @@ public final class DivActionURLHandler { private let showTooltip: ShowTooltipAction? private let tooltipActionPerformer: TooltipActionPerformer? private let performTimerAction: PerformTimerAction + private let persistentValuesStorage: DivPersistentValuesStorage public init( stateUpdater: DivStateUpdater, @@ -43,7 +44,8 @@ public final class DivActionURLHandler { updateCard: @escaping UpdateCardAction, showTooltip: ShowTooltipAction?, tooltipActionPerformer: TooltipActionPerformer?, - performTimerAction: @escaping PerformTimerAction = { _, _, _ in } + performTimerAction: @escaping PerformTimerAction = { _, _, _ in }, + persistentValuesStorage: DivPersistentValuesStorage ) { self.stateUpdater = stateUpdater self.blockStateStorage = blockStateStorage @@ -53,6 +55,7 @@ public final class DivActionURLHandler { self.showTooltip = showTooltip self.tooltipActionPerformer = tooltipActionPerformer self.performTimerAction = performTimerAction + self.persistentValuesStorage = persistentValuesStorage } public func canHandleURL(_ url: URL) -> Bool { @@ -119,6 +122,8 @@ public final class DivActionURLHandler { updateCard(.state(cardId)) case let .timer(timerId, action): performTimerAction(cardId, timerId, action) + case let .setStoredValue(storedValue): + persistentValuesStorage.set(value: storedValue) } return true diff --git a/client/ios/DivKit/DivBlockModelingContext.swift b/client/ios/DivKit/DivBlockModelingContext.swift index 4ac2c4f00..f47487cc4 100644 --- a/client/ios/DivKit/DivBlockModelingContext.swift +++ b/client/ios/DivKit/DivBlockModelingContext.swift @@ -29,6 +29,7 @@ public struct DivBlockModelingContext { public weak var parentScrollView: ScrollView? public let errorsStorage: DivErrorsStorage private let variableTracker: DivVariableTracker? + private let persistentValuesStorage: DivPersistentValuesStorage var overridenWidth: DivOverridenSize? var overridenHeight: DivOverridenSize? @@ -36,6 +37,7 @@ public struct DivBlockModelingContext { public var expressionResolver: ExpressionResolver { ExpressionResolver( variables: variables, + persistentValuesStorage: persistentValuesStorage, errorTracker: { [weak errorsStorage] error in errorsStorage?.add(DivBlockModelingRuntimeError(error, path: parentPath)) }, @@ -67,7 +69,8 @@ public struct DivBlockModelingContext { parentScrollView: ScrollView? = nil, errorsStorage: DivErrorsStorage = DivErrorsStorage(errors: []), layoutDirection: UserInterfaceLayoutDirection = UserInterfaceLayoutDirection.system, - variableTracker: DivVariableTracker? = nil + variableTracker: DivVariableTracker? = nil, + persistentValuesStorage: DivPersistentValuesStorage ) { self.cardId = cardId self.cardLogId = cardLogId @@ -89,6 +92,7 @@ public struct DivBlockModelingContext { self.errorsStorage = errorsStorage self.layoutDirection = layoutDirection self.variableTracker = variableTracker + self.persistentValuesStorage = persistentValuesStorage var extensionsHandlersDictionary = [String: DivExtensionHandler]() extensionHandlers.forEach { diff --git a/client/ios/DivKit/DivKitComponents.swift b/client/ios/DivKit/DivKitComponents.swift index ace7393d9..b3421d6b4 100644 --- a/client/ios/DivKit/DivKitComponents.swift +++ b/client/ios/DivKit/DivKitComponents.swift @@ -37,6 +37,7 @@ public final class DivKitComponents { private let variableTracker = DivVariableTracker() private let disposePool = AutodisposePool() private let updateCardPipe: SignalPipe<[DivActionURLHandler.UpdateReason]> + private let persistentValuesStorage = DivPersistentValuesStorage() public init( divCustomBlockFactory: DivCustomBlockFactory = EmptyDivCustomBlockFactory(), @@ -117,18 +118,21 @@ public final class DivKitComponents { trackVisibility: trackVisibility, trackDisappear: trackDisappear, performTimerAction: { weakTimerStorage?.perform($0, $1, $2) }, - urlHandler: urlHandler + urlHandler: urlHandler, + persistentValuesStorage: persistentValuesStorage ) triggersStorage = DivTriggersStorage( variablesStorage: variablesStorage, - actionHandler: actionHandler + actionHandler: actionHandler, + persistentValuesStorage: persistentValuesStorage ) timerStorage = DivTimerStorage( variablesStorage: variablesStorage, actionHandler: actionHandler, - updateCard: updateCard + updateCard: updateCard, + persistentValuesStorage: persistentValuesStorage ) weakActionHandler = actionHandler @@ -216,7 +220,8 @@ public final class DivKitComponents { debugParams: debugParams, parentScrollView: parentScrollView, layoutDirection: layoutDirection, - variableTracker: variableTracker + variableTracker: variableTracker, + persistentValuesStorage: persistentValuesStorage ) } diff --git a/client/ios/DivKit/DivPersistentValuesStorage.swift b/client/ios/DivKit/DivPersistentValuesStorage.swift new file mode 100644 index 000000000..60eb2c0ef --- /dev/null +++ b/client/ios/DivKit/DivPersistentValuesStorage.swift @@ -0,0 +1,127 @@ +import Foundation + +import BasePublic +import Foundation + + +public final class DivPersistentValuesStorage { + static let storageFileName = "divkit.values_storage" + private let timestampProvider: Variable + + public init( + timestampProvider: Variable = Variable { + Date().timeIntervalSince1970.milliseconds + } + ) { + self.timestampProvider = timestampProvider + removeOutdatedStoredValues() + } + + private let persistentStorage = Property( + fileName: storageFileName, + initialValue: StoredValues(items: [:]), + onError: { error in + DivKitLogger.error("Failed to create storage: \(error)") + } + ) + + func set(value: DivStoredValue) { + var items = persistentStorage.value.items + items[value.name] = StoredValue( + timestamp: timestampProvider.value, + value: value.value, + type: value.type, + lifetimeInSec: value.lifetimeInSec + ) + persistentStorage.value = StoredValues(items: items) + } + + func get(name: String) -> T? { + let items = persistentStorage.value.items + let currentTimestamp = timestampProvider.value + guard let storedValue = items[name], + currentTimestamp - storedValue.timestamp < storedValue.lifetimeInSec * 1000 else { + return nil + } + let divStoredValue = DivStoredValue( + name: name, + value: storedValue.value, + type: storedValue.type, + lifetimeInSec: storedValue.lifetimeInSec + ) + guard let value: T = divStoredValue.variable?.typedValue() else { + DivKitLogger.error("The type of the stored value \(name) is not \(T.self)") + return nil + } + return value + } + + private func removeOutdatedStoredValues() { + let currentTimestamp = timestampProvider.value + let items = persistentStorage.value.items + let newItems = items.filter { _, value in + currentTimestamp - value.timestamp < value.lifetimeInSec * 1000 + } + + if newItems.count != items.count { + persistentStorage.value = StoredValues(items: newItems) + } + } +} + +private struct StoredValues: Equatable, Codable { + let items: [String: StoredValue] +} + +private struct StoredValue: Equatable, Codable { + let timestamp: Milliseconds + let value: String + let type: DivStoredValue.ValueType + let lifetimeInSec: Int +} + +extension DivStoredValue { + var variable: DivVariableValue? { + switch type { + case .string: + return .string(value) + case .number: + if let value = Double(value) { + return .number(value) + } else { + return nil + } + case .integer: + if let value = Int(value) { + return .integer(value) + } else { + return nil + } + case .bool: + switch value.lowercased() { + case "0", "false": + return .bool(false) + case "1", "true": + return .bool(true) + default: + return nil + } + case .color: + if let value = Color.color(withHexString: value) { + return .color(value) + } else { + return nil + } + case .url: + if let value = URL(string: value) { + return .url(value) + } else { + return nil + } + } + } + + var isValueValid: Bool { + variable != nil + } +} diff --git a/client/ios/DivKit/DivStoredValue.swift b/client/ios/DivKit/DivStoredValue.swift new file mode 100644 index 000000000..09dfcc6c3 --- /dev/null +++ b/client/ios/DivKit/DivStoredValue.swift @@ -0,0 +1,16 @@ +import Foundation + +struct DivStoredValue { + enum ValueType: String, Codable { + case string + case number + case integer + case bool + case color + case url + } + let name: String + let value: String + let type: ValueType + let lifetimeInSec: Int +} diff --git a/client/ios/DivKit/Expressions/ExpressionResolver.swift b/client/ios/DivKit/Expressions/ExpressionResolver.swift index 49683c750..59374e585 100644 --- a/client/ios/DivKit/Expressions/ExpressionResolver.swift +++ b/client/ios/DivKit/Expressions/ExpressionResolver.swift @@ -13,9 +13,11 @@ public final class ExpressionResolver { let variables: DivVariables private let errorTracker: ExpressionErrorTracker let variableTracker: VariableTracker + private let persistentValuesStorage: DivPersistentValuesStorage public init( variables: DivVariables, + persistentValuesStorage: DivPersistentValuesStorage, errorTracker: ExpressionErrorTracker? = nil, variableTracker: @escaping VariableTracker = { _ in } ) { @@ -25,6 +27,7 @@ public final class ExpressionResolver { errorTracker?($0) } self.variableTracker = variableTracker + self.persistentValuesStorage = persistentValuesStorage } public func resolveString(expression: String) -> String { @@ -220,6 +223,10 @@ public final class ExpressionResolver { return validatedValue(value: result, validator: link.validator, rawValue: link.rawValue) } + func getStoredValue(_ name: String) -> T? { + persistentValuesStorage.get(name: name) + } + private func validatedValue( value: T?, validator: ExpressionValueValidator?, diff --git a/client/ios/DivKit/Expressions/Functions/ValueFunctions.swift b/client/ios/DivKit/Expressions/Functions/ValueFunctions.swift index f69092d57..2f4ab2b41 100644 --- a/client/ios/DivKit/Expressions/Functions/ValueFunctions.swift +++ b/client/ios/DivKit/Expressions/Functions/ValueFunctions.swift @@ -12,6 +12,13 @@ enum ValueFunctions: String, CaseIterable { case getColorValue case getBooleanValue + case getStoredIntegerValue + case getStoredNumberValue + case getStoredStringValue + case getStoredUrlValue + case getStoredColorValue + case getStoredBooleanValue + func getDeclaration(resolver: ExpressionResolver) -> [AnyCalcExpression.Symbol: AnyCalcExpression.SymbolEvaluator] { [ @@ -63,6 +70,45 @@ enum ValueFunctions: String, CaseIterable { case .getBooleanValue: let function: GetOrDefault = resolver.getValueFunction() return FunctionBinary(impl: function) + case .getStoredIntegerValue: + let function: GetOrDefault = resolver.getStoredValueFunction() + return FunctionBinary(impl: function) + case .getStoredNumberValue: + let function: GetOrDefault = resolver.getStoredValueFunction() + return FunctionBinary(impl: function) + case .getStoredStringValue: + let function: GetOrDefault = resolver.getStoredValueFunction() + return FunctionBinary(impl: function) + case .getStoredUrlValue: + let fromUrlFunction: GetOrDefault = resolver.getStoredValueFunction() + let fromStringFunction: GetOrDefaultWithTransform = resolver + .getStoredValueFunctionWithTransform { + guard let url = URL(string: $0) else { + throw AnyCalcExpression.Error.toURL($0) + } + return url + } + return OverloadedFunction(functions: [ + FunctionBinary(impl: fromUrlFunction), + FunctionBinary(impl: fromStringFunction), + ]) + + case .getStoredColorValue: + let fromColorFunction: GetOrDefault = resolver.getStoredValueFunction() + let fromStringFunction: GetOrDefaultWithTransform = resolver + .getStoredValueFunctionWithTransform { + guard let color = Color.color(withHexString: $0) else { + throw AnyCalcExpression.Error.toColor($0) + } + return color + } + return OverloadedFunction(functions: [ + FunctionBinary(impl: fromColorFunction), + FunctionBinary(impl: fromStringFunction), + ]) + case .getStoredBooleanValue: + let function: GetOrDefault = resolver.getStoredValueFunction() + return FunctionBinary(impl: function) } } } @@ -97,6 +143,26 @@ extension ExpressionResolver { return try typpedValue ?? transform(fallbackValue) } } + + fileprivate func getStoredValueFunction() -> GetOrDefault { + { name, fallbackValue in + guard let value: T = self.getStoredValue(name) else { + return fallbackValue + } + return value + } + } + + fileprivate func getStoredValueFunctionWithTransform( + transform: @escaping (U) throws -> T + ) -> GetOrDefaultWithTransform { + { name, fallbackValue in + guard let value: T = self.getStoredValue(name) else { + return try transform(fallbackValue) + } + return value + } + } } extension AnyCalcExpression.Error { diff --git a/client/ios/DivKit/Timers/DivTimerController.swift b/client/ios/DivKit/Timers/DivTimerController.swift index 4ef4b2a75..e3e3d1e14 100644 --- a/client/ios/DivKit/Timers/DivTimerController.swift +++ b/client/ios/DivKit/Timers/DivTimerController.swift @@ -19,6 +19,7 @@ final class DivTimerController { private let runActions: RunActions private let updateVariable: UpdateVariable private let updateCard: UpdateCard + private let persistentValuesStorage: DivPersistentValuesStorage private(set) var state: State = .stopped private var savedDuration: TimeInterval? @@ -35,7 +36,8 @@ final class DivTimerController { timeMeasuring: TimeMeasuring, runActions: @escaping RunActions, updateVariable: @escaping UpdateVariable, - updateCard: @escaping UpdateCard + updateCard: @escaping UpdateCard, + persistentValuesStorage: DivPersistentValuesStorage ) { self.divTimer = divTimer self.timerScheduler = timerScheduler @@ -43,6 +45,7 @@ final class DivTimerController { self.runActions = runActions self.updateVariable = updateVariable self.updateCard = updateCard + self.persistentValuesStorage = persistentValuesStorage } public func start(variables: DivVariables = [:]) { @@ -50,7 +53,7 @@ final class DivTimerController { DivKitLogger.error("Timer '\(divTimer.id)' can't start because it has state '\(state)'.") return } - let expressionResolver = ExpressionResolver(variables: variables) + let expressionResolver = ExpressionResolver(variables: variables, persistentValuesStorage: persistentValuesStorage) guard divTimer.parametersAreValid(expressionResolver) else { DivKitLogger.failure("Timer '\(divTimer.id)' is not valid.") return diff --git a/client/ios/DivKit/Timers/DivTimerStrorage.swift b/client/ios/DivKit/Timers/DivTimerStrorage.swift index 306d410f4..ea6f68a77 100644 --- a/client/ios/DivKit/Timers/DivTimerStrorage.swift +++ b/client/ios/DivKit/Timers/DivTimerStrorage.swift @@ -10,15 +10,18 @@ final class DivTimerStorage { private let timerScheduler = TimerScheduler() private var cardsTimers = [DivCardID: [DivTimer]]() private var timerControllers = [String: DivTimerController]() + private let persistentValuesStorage: DivPersistentValuesStorage public init( variablesStorage: DivVariablesStorage, actionHandler: DivActionHandler, - updateCard: @escaping DivActionURLHandler.UpdateCardAction + updateCard: @escaping DivActionURLHandler.UpdateCardAction, + persistentValuesStorage: DivPersistentValuesStorage ) { self.variablesStorage = variablesStorage self.actionHandler = actionHandler self.updateCard = updateCard + self.persistentValuesStorage = persistentValuesStorage } public func set( @@ -125,7 +128,8 @@ final class DivTimerStorage { }, updateCard: { [weak self] in self?.updateCard(.timer(cardId)) - } + }, + persistentValuesStorage: persistentValuesStorage ) } diff --git a/client/ios/DivKit/Variables/DivTriggersStorage.swift b/client/ios/DivKit/Variables/DivTriggersStorage.swift index 1fa0c0e83..b811ce11d 100644 --- a/client/ios/DivKit/Variables/DivTriggersStorage.swift +++ b/client/ios/DivKit/Variables/DivTriggersStorage.swift @@ -9,16 +9,19 @@ public final class DivTriggersStorage { private var cardsTriggers = DivCardsTriggers() private let variablesStorage: DivVariablesStorage private let actionHandler: DivActionHandler? + private let persistentValuesStorage: DivPersistentValuesStorage private let cardsTriggersLock = RWLock() private let autodisposePool = AutodisposePool() public init( variablesStorage: DivVariablesStorage, - actionHandler: DivActionHandler + actionHandler: DivActionHandler, + persistentValuesStorage: DivPersistentValuesStorage ) { self.variablesStorage = variablesStorage self.actionHandler = actionHandler + self.persistentValuesStorage = persistentValuesStorage variablesStorage.addObserver { [unowned self] event in let cardIdTriggersPairs = makeCardIdTriggersPairsForEvent(event) @@ -77,7 +80,8 @@ public final class DivTriggersStorage { if trigger.shouldPerformActions( for: changesVariablesNames, newVariables: newVariables, - oldVariables: oldVariables + oldVariables: oldVariables, + persistentValuesStorage: persistentValuesStorage ) { trigger.actions.forEach { actionHandler?.handle( @@ -119,9 +123,10 @@ extension DivTrigger { func shouldPerformActions( for changedVariablesNames: Set, newVariables: DivVariables, - oldVariables: DivVariables + oldVariables: DivVariables, + persistentValuesStorage: DivPersistentValuesStorage ) -> Bool { - let resolverWithNewVariables = ExpressionResolver(variables: newVariables) + let resolverWithNewVariables = ExpressionResolver(variables: newVariables, persistentValuesStorage: persistentValuesStorage) guard !condition.variablesNames.intersection(changedVariablesNames).isEmpty, resolveCondition(resolverWithNewVariables) ?? false @@ -131,7 +136,7 @@ extension DivTrigger { case .onVariable: return true case .onCondition: - let resolverWithOldVariables = ExpressionResolver(variables: oldVariables) + let resolverWithOldVariables = ExpressionResolver(variables: oldVariables, persistentValuesStorage: persistentValuesStorage) return !(resolveCondition(resolverWithOldVariables) ?? false) } } diff --git a/client/ios/DivKitExtensionsTests/ShimmerStyleTests.swift b/client/ios/DivKitExtensionsTests/ShimmerStyleTests.swift index aec9fd712..9ec704e11 100644 --- a/client/ios/DivKitExtensionsTests/ShimmerStyleTests.swift +++ b/client/ios/DivKitExtensionsTests/ShimmerStyleTests.swift @@ -5,7 +5,11 @@ import DivKit import BasePublic final class ShimmerStyleTests: XCTestCase { - private let expressionResolver = ExpressionResolver(variables: [:], errorTracker: nil) + private let expressionResolver = ExpressionResolver( + variables: [:], + persistentValuesStorage: DivPersistentValuesStorage(), + errorTracker: nil + ) func test_WhenDecodingEmptyShimmerStyle_DecodesWithDefaultValues() throws { XCTAssertEqual( diff --git a/client/ios/DivKitTests/DivBlockModelingContextErrorsTests.swift b/client/ios/DivKitTests/DivBlockModelingContextErrorsTests.swift index 90dd1f190..dc171c0d7 100644 --- a/client/ios/DivKitTests/DivBlockModelingContextErrorsTests.swift +++ b/client/ios/DivKitTests/DivBlockModelingContextErrorsTests.swift @@ -65,6 +65,7 @@ private func makeContext( stateManager: DivStateManager(), imageHolderFactory: ImageHolderFactory(make: { _, _ in FakeImageHolder() }), extensionHandlers: extensionHandlers, - stateInterceptors: stateInterceptors + stateInterceptors: stateInterceptors, + persistentValuesStorage: DivPersistentValuesStorage() ) } diff --git a/client/ios/DivKitTests/DivKitTests.swift b/client/ios/DivKitTests/DivKitTests.swift index 9762cf50f..d42f5d615 100644 --- a/client/ios/DivKitTests/DivKitTests.swift +++ b/client/ios/DivKitTests/DivKitTests.swift @@ -1,7 +1,7 @@ import XCTest +@testable import DivKit import CommonCorePublic -import DivKit import LayoutKit import NetworkingPublic import Serialization @@ -55,7 +55,8 @@ extension DivBlockModelingContext { cardLogId: DivKitTests.cardLogId, stateManager: DivStateManager(), blockStateStorage: blockStateStorage, - imageHolderFactory: ImageHolderFactory(make: { _, _ in FakeImageHolder() }) + imageHolderFactory: ImageHolderFactory(make: { _, _ in FakeImageHolder() }), + persistentValuesStorage: DivPersistentValuesStorage() ) } } diff --git a/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift b/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift new file mode 100644 index 000000000..40ee8da11 --- /dev/null +++ b/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift @@ -0,0 +1,82 @@ +@testable import DivKit + +import Foundation +import XCTest + +import BasePublic + +class DivPersistentValuesStorageTests: XCTestCase { + private var storage: DivPersistentValuesStorage! + private var currentTimestamp: Milliseconds = 0 + + override func setUp() { + let storageFileUrl = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + .appendingPathComponent(DivPersistentValuesStorage.storageFileName) + try? FileManager.default.removeItem(at: storageFileUrl) + + storage = makeStorage() + } + + func test_setValue_GetSameValue() { + let value = DivStoredValue(name: "var", value: "value", type: .string, lifetimeInSec: 1) + storage.set(value: value) + XCTAssertEqual( + storage.get(name: value.name), + value.value + ) + } + + func test_setColorValue_GetSameValue() { + let value = DivStoredValue(name: "var", value: "#FFFFFF", type: .color, lifetimeInSec: 1) + storage.set(value: value) + XCTAssertEqual( + storage.get(name: value.name), + Color.color(withHexString: value.value) + ) + } + + + func test_getSameValueInDay() { + let value = DivStoredValue(name: "var", value: "value", type: .string, lifetimeInSec: 86400) + storage.set(value: value) + shiftTime(days: 0.99) + XCTAssertEqual( + storage.get(name: value.name), + value.value + ) + } + + func test_expiresStoredValue() { + let value = DivStoredValue(name: "var", value: "value", type: .string, lifetimeInSec: 86400) + storage.set(value: value) + shiftTime(days: 1.01) + let stored: String? = storage.get(name: value.name) + XCTAssertNil(stored) + } + + func test_getDifferentType_GetNil() { + let value = DivStoredValue(name: "var", value: "value", type: .string, lifetimeInSec: 86400) + storage.set(value: value) + let stored: Int? = storage.get(name: value.name) + XCTAssertNil(stored) + } + + func test_getDoubleFromStoredInt_GetNil() { + let value = DivStoredValue(name: "var", value: "10", type: .integer, lifetimeInSec: 86400) + storage.set(value: value) + let stored: Double? = storage.get(name: value.name) + XCTAssertNil(stored) + } + + private func makeStorage() -> DivPersistentValuesStorage { + DivPersistentValuesStorage( + timestampProvider: Variable { self.currentTimestamp } + ) + } + + private func shiftTime(days: Float) { + currentTimestamp += Milliseconds(days * 86400000) + } +} diff --git a/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift b/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift index 7e847390c..07d644ad7 100644 --- a/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift +++ b/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift @@ -12,6 +12,7 @@ final class ExpressionResolverTests: XCTestCase { "enum_var": .string("first"), "url_var": .url(URL(string: "https://some.url")!), ], + persistentValuesStorage: DivPersistentValuesStorage(), variableTracker: { [unowned self] in self.usedVariables = self.usedVariables.union($0) } diff --git a/client/ios/DivKitTests/Expressions/ExpressionResolvingTests.swift b/client/ios/DivKitTests/Expressions/ExpressionResolvingTests.swift index d7bae538a..99da02454 100644 --- a/client/ios/DivKitTests/Expressions/ExpressionResolvingTests.swift +++ b/client/ios/DivKitTests/Expressions/ExpressionResolvingTests.swift @@ -301,6 +301,12 @@ final class ExpressionResolvingTests: XCTestCase { perform(on: testCases, type: type) } + func test_stored_values() throws { + let testCases = try makeTestCases(for: "functions_stored_values") + let type: ExpressionType = .stringBased(initializer: { $0 }) + perform(on: testCases, type: type) + } + private func perform(on testCases: TestCases, type: ExpressionType) { testCases.cases.filter { $0.platforms.contains(.ios) }.forEach { testCase = $0 @@ -409,7 +415,11 @@ extension ExpressionTestCase { rawValue: expression, errorTracker: errorTracker ).map { .link($0) } ?? .value(expression as! T) - let resolver = ExpressionResolver(variables: variables, errorTracker: errorTracker) + let resolver = ExpressionResolver( + variables: variables, + persistentValuesStorage: DivPersistentValuesStorage(), + errorTracker: errorTracker + ) switch type { case .singleItem: return resolver.resolveNumericValue(expression: expression) @@ -424,7 +434,10 @@ extension ExpressionTestCase { private func testResolveSingleItem(expectedValue: T) { let expression: Expression? = try? ExpressionLink(rawValue: expression) .map { .link($0) } - let resolver = ExpressionResolver(variables: variables) + let resolver = ExpressionResolver( + variables: variables, + persistentValuesStorage: DivPersistentValuesStorage() + ) let result = resolver.resolveNumericValue(expression: expression) XCTAssertEqual(result, expectedValue, "test: \(name)") } @@ -435,7 +448,10 @@ extension ExpressionTestCase { ) { let expression: Expression? = try? ExpressionLink(rawValue: expression) .map { .link($0) } ?? .value(expression as! T) - let resolver = ExpressionResolver(variables: variables) + let resolver = ExpressionResolver( + variables: variables, + persistentValuesStorage: DivPersistentValuesStorage() + ) let result = resolver.resolveStringBasedValue( expression: expression, initializer: initializer diff --git a/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift b/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift index 47c451e1b..f543739a1 100644 --- a/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift +++ b/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift @@ -32,7 +32,10 @@ private let functions: [String: Function] = { DictFunctions.allCases.forEach { result[$0.rawValue] = $0.function } IntervalFunctions.allCases.forEach { result[$0.rawValue] = $0.function } ValueFunctions.allCases.forEach { - result[$0.rawValue] = $0.getFunction(resolver: ExpressionResolver(variables: [:])) + result[$0.rawValue] = $0.getFunction(resolver: ExpressionResolver( + variables: [:], + persistentValuesStorage: DivPersistentValuesStorage() + )) } return result }() diff --git a/client/ios/DivKitTests/Timers/DivTimerControllerTests.swift b/client/ios/DivKitTests/Timers/DivTimerControllerTests.swift index 6a4338467..5fd9ad9ca 100644 --- a/client/ios/DivKitTests/Timers/DivTimerControllerTests.swift +++ b/client/ios/DivKitTests/Timers/DivTimerControllerTests.swift @@ -354,7 +354,8 @@ final class DivTimerControllerTests: XCTestCase { variableValue = value } }, - updateCard: {} + updateCard: {}, + persistentValuesStorage: DivPersistentValuesStorage() ) } } diff --git a/client/ios/DivKitTests/Variables/DivTriggerTests.swift b/client/ios/DivKitTests/Variables/DivTriggerTests.swift index 3f7aa0bed..a0b669500 100644 --- a/client/ios/DivKitTests/Variables/DivTriggerTests.swift +++ b/client/ios/DivKitTests/Variables/DivTriggerTests.swift @@ -13,12 +13,14 @@ final class DivTriggerTests: XCTestCase { showTooltip: { _ in }, urlHandler: DivUrlHandlerDelegate { [unowned self] _, _ in self.triggersCount += 1 - } + }, + persistentValuesStorage: DivPersistentValuesStorage() ) private lazy var triggerStorage = DivTriggersStorage( variablesStorage: variablesStorage, - actionHandler: actionHandler + actionHandler: actionHandler, + persistentValuesStorage: DivPersistentValuesStorage() ) private var triggersCount = 0 diff --git a/test_data/expression_test_data/function_signatures_std.json b/test_data/expression_test_data/function_signatures_std.json index 582daea98..c9ab58bb2 100644 --- a/test_data/expression_test_data/function_signatures_std.json +++ b/test_data/expression_test_data/function_signatures_std.json @@ -737,6 +737,158 @@ "web", "ios" ] + }, + { + "name": "getStoredIntegerValue(string, integer) integer", + "function_name": "getStoredIntegerValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "integer", + "doc": "Fallback value." + } + ], + "result_type": "integer", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredNumberValue(string, number) number", + "function_name": "getStoredNumberValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "number", + "doc": "Fallback value." + } + ], + "result_type": "number", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredStringValue(string, string) string", + "function_name": "getStoredStringValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "string", + "doc": "Fallback value." + } + ], + "result_type": "string", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredUrlValue(string, url) url", + "function_name": "getStoredUrlValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "url", + "doc": "Fallback value." + } + ], + "result_type": "url", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredUrlValue(string, string) url", + "function_name": "getStoredUrlValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "string", + "doc": "Fallback value." + } + ], + "result_type": "url", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredColorValue(string, color) color", + "function_name": "getStoredColorValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "color", + "doc": "Fallback value." + } + ], + "result_type": "color", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredColorValue(string, string) color", + "function_name": "getStoredColorValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "string", + "doc": "Fallback value." + } + ], + "result_type": "color", + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredBooleanValue(string, boolean) boolean", + "function_name": "getStoredBooleanValue", + "doc": "Returns the stored value by its name. If the value doesn't exist or has incorrect type, the default value would be returned.", + "arguments": [ + { + "type": "string", + "doc": "Stored value name." + }, + { + "type": "boolean", + "doc": "Fallback value." + } + ], + "result_type": "boolean", + "platforms": [ + "ios" + ] } ] } diff --git a/test_data/expression_test_data/functions_stored_values.json b/test_data/expression_test_data/functions_stored_values.json new file mode 100644 index 000000000..799922eb2 --- /dev/null +++ b/test_data/expression_test_data/functions_stored_values.json @@ -0,0 +1,106 @@ +{ + "cases": [ + { + "name": "getStoredIntegerValue(notStoredIntVar, fallback) => intVal", + "expression": "@{getStoredIntegerValue('abc', 456)}", + "expected": { + "type": "integer", + "value": 456 + }, + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredNumberValue(notStoredNumberVar, fallback) => numberVal", + "expression": "@{getStoredNumberValue('abc', 456.0)}", + "expected": { + "type": "number", + "value": 456.0 + }, + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredBooleanValue(notStoredBooleanVar, fallback) => booleanVal", + "expression": "@{getStoredBooleanValue('abc', true)}", + "expected": { + "type": "boolean", + "value": true + }, + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredStringValue(notStoredStringVar, fallback) => stringVal", + "expression": "@{getStoredStringValue('abc', 'a')}", + "expected": { + "type": "string", + "value": "a" + }, + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredColorValue(notStoredColorVar, fallbackVar) => colorVal", + "expression": "@{getStoredColorValue('abc', fallback)}", + "expected": { + "type": "color", + "value": "#FFFFFFFF" + }, + "variables": [ + { + "type": "color", + "name": "fallback", + "value": "#FFFFFFFF" + } + ], + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredColorValue(notStoredColorVar, fallback) => colorVal", + "expression": "@{getStoredColorValue('abc', '#FFFFFFFF')}", + "expected": { + "type": "color", + "value": "#FFFFFFFF" + }, + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredUrlValue(notStoredUrlVar, fallbackVar) => urlVal", + "expression": "@{getStoredUrlValue('abc', fallback)}", + "expected": { + "type": "url", + "value": "https://fallback" + }, + "variables": [ + { + "type": "url", + "name": "fallback", + "value": "https://fallback" + } + ], + "platforms": [ + "ios" + ] + }, + { + "name": "getStoredUrlValue(notStoredUrlVar, fallback) => urlVal", + "expression": "@{getStoredUrlValue('abc', 'https://fallback')}", + "expected": { + "type": "url", + "value": "https://fallback" + }, + "platforms": [ + "ios" + ] + } + ] +} diff --git a/test_data/regression_test_data/index.json b/test_data/regression_test_data/index.json index e5e47d13a..35d77b626 100644 --- a/test_data/regression_test_data/index.json +++ b/test_data/regression_test_data/index.json @@ -1877,6 +1877,23 @@ "The tooltip is closed by tapping on the cross button and disappears when the Disappear Action is called" ], "file": "tooltips.json" + }, + { + "title": "Persistent values storage", + "platforms": [ + "ios" + ], + "steps": [ + "Press 'Store value for 10 seconds'", + "Reopen this json in less than 10 seconds", + "Reopen this json in after 10 seconds" + ], + "expected_results": [ + "In the first open there should be the text 'Nothing stored'", + "In the second open there should be the text 'Stored'", + "In the last open there should be again the text 'Nothing stored'" + ], + "file": "stored_value.json" } ] } diff --git a/test_data/regression_test_data/stored_value.json b/test_data/regression_test_data/stored_value.json new file mode 100644 index 000000000..2b755c1b7 --- /dev/null +++ b/test_data/regression_test_data/stored_value.json @@ -0,0 +1,63 @@ +{ + "templates": {}, + "card": { + "log_id": "card", + "states": [ + { + "state_id": 0, + "div": { + "type": "container", + "items": [ + { + "type": "text", + "font_size": 20, + "line_height": 24, + "text_alignment_horizontal": "center", + "font_weight": "bold", + "margins": { + "top": 16, + "left": 16, + "right": 16, + "bottom": 16 + }, + "text": "@{getStoredStringValue('storedString', 'Nothing stored')}" + }, + { + "type": "text", + "text": "Store value for 10 seconds", + "height": { + "type": "fixed", + "value": 48 + }, + "margins": { + "top": 16, + "left": 16, + "right": 16, + "bottom": 16 + }, + "border": { + "corner_radius": 16 + }, + "background": [ + { + "type": "solid", + "color": "#00B341" + } + ], + "font_size": 20, + "font_weight": "medium", + "text_alignment_vertical": "center", + "text_alignment_horizontal": "center", + "actions": [ + { + "log_id": "store value", + "url": "div-action://set_stored_value?name=storedString&value=Stored&type=string&lifetime=10" + } + ] + } + ] + } + } + ] + } +} \ No newline at end of file