From 8e868feec9a092efccf2fe2cce5da4fefd2e7f80 Mon Sep 17 00:00:00 2001 From: tabaina Date: Thu, 30 Apr 2026 18:15:22 +0300 Subject: [PATCH] supported get/set stored values by card_id commit_hash:d7fcc6410470b5d9b6830747c6e106da972bf713 --- .../ios/DivKit/Actions/DivActionHandler.swift | 8 +- .../ios/DivKit/Actions/DivActionIntent.swift | 11 +- .../Actions/SetStoredValueActionHandler.swift | 5 +- .../ios/DivKit/DivBlockModelingContext.swift | 3 +- .../CustomFunctions/CustomFunction.swift | 3 +- .../Expressions/ExpressionResolver.swift | 6 +- .../Functions/GetStoredValueFunctions.swift | 112 +++++++++++++++--- .../Expressions/FunctionsProvider.swift | 13 +- .../ios/DivKit/StoredValues/Clearable.swift | 2 + .../DivPersistentValuesStorage.swift | 34 +++++- .../DivKit/StoredValues/DivStoredValue.swift | 5 + .../DivActionSetStoredValue.swift | 24 +++- .../DivActionSetStoredValueTemplate.swift | 39 ++++-- .../Actions/DivActionIntentTests.swift | 52 +++++--- .../SetStoredValueActionHandlerTests.swift | 66 ++++++++++- .../DivPersistentValuesStorageTests.swift | 38 ++++++ .../ExpressionResolverAnyTests.swift | 3 +- .../Expressions/ExpressionResolverTests.swift | 55 ++++++++- .../Expressions/ExpressionTests.swift | 8 +- .../Expressions/FunctionSignaturesTests.swift | 3 +- expression-api/function_signatures_std.json | 30 +++-- 21 files changed, 433 insertions(+), 87 deletions(-) diff --git a/client/ios/DivKit/Actions/DivActionHandler.swift b/client/ios/DivKit/Actions/DivActionHandler.swift index 22bccb28d..34f4751f7 100644 --- a/client/ios/DivKit/Actions/DivActionHandler.swift +++ b/client/ios/DivKit/Actions/DivActionHandler.swift @@ -177,7 +177,8 @@ public final class DivActionHandler { let cardId = path.cardId let expressionResolver = ExpressionResolver( functionsProvider: FunctionsProvider( - persistentValuesStorage: persistentValuesStorage + persistentValuesStorage: persistentValuesStorage, + cardId: cardId ), customFunctionsStorageProvider: { [weak functionsStorage] in functionsStorage?.getStorage(path: path, contains: $0) @@ -364,8 +365,9 @@ public final class DivActionHandler { context.updateCard(.state(cardId)) case let .timer(timerId, action): timerActionHandler.handle(cardId: cardId, timerId: timerId, action: action) - case let .setStoredValue(storedValue): - persistentValuesStorage.set(value: storedValue) + case let .setStoredValue(storedValue, scope): + let scopeCardId: DivCardID? = scope == .card ? cardId : nil + persistentValuesStorage.set(value: storedValue, cardId: scopeCardId) } return } diff --git a/client/ios/DivKit/Actions/DivActionIntent.swift b/client/ios/DivKit/Actions/DivActionIntent.swift index e1ae1e583..8f1638a81 100644 --- a/client/ios/DivKit/Actions/DivActionIntent.swift +++ b/client/ios/DivKit/Actions/DivActionIntent.swift @@ -13,7 +13,7 @@ enum DivActionIntent: Hashable { case scroll(id: String, mode: ScrollMode) case timer(id: String, action: DivTimerAction) case video(id: String, action: DivVideoAction) - case setStoredValue(DivStoredValue) + case setStoredValue(DivStoredValue, DivStoredValueScope) static let scheme = "div-action" @@ -99,10 +99,10 @@ enum DivActionIntent: Hashable { } self = .video(id: id, action: action) case "set_stored_value": - guard let storedValue = url.storedValue else { + guard let (storedValue, scope) = url.storedValue else { return nil } - self = .setStoredValue(storedValue) + self = .setStoredValue(storedValue, scope) default: return nil } @@ -200,7 +200,7 @@ extension URL { queryParamValue(forName: "step").flatMap(Int.init) } - fileprivate var storedValue: DivStoredValue? { + fileprivate var storedValue: (DivStoredValue, DivStoredValueScope)? { guard let name = getParam(forName: "name"), let value = getParam(forName: "value"), let lifetime = getParam(forName: "lifetime").flatMap(Int.init), @@ -211,6 +211,7 @@ extension URL { DivKitLogger.error("Unsupported stored value type: \(typeStr)") return nil } + let scope = getParam(forName: "scope").flatMap(DivStoredValueScope.init) ?? .global let storedValue = DivStoredValue( name: name, value: value, @@ -221,7 +222,7 @@ extension URL { DivKitLogger.error("Incorrect value: \(value) for type: \(type)") return nil } - return storedValue + return (storedValue, scope) } fileprivate func getParam(forName name: String) -> String? { diff --git a/client/ios/DivKit/Actions/SetStoredValueActionHandler.swift b/client/ios/DivKit/Actions/SetStoredValueActionHandler.swift index 98f3ca309..1e880ab6a 100644 --- a/client/ios/DivKit/Actions/SetStoredValueActionHandler.swift +++ b/client/ios/DivKit/Actions/SetStoredValueActionHandler.swift @@ -14,13 +14,16 @@ final class SetStoredValueActionHandler { return } + let scope = action.resolveScope(expressionResolver) + let cardId: DivCardID? = scope == .card ? context.cardId : nil persistentValuesStorage.set( value: DivStoredValue( name: name, value: ExpressionValueConverter.stringify(value), type: type, lifetimeInSec: lifetime - ) + ), + cardId: cardId ) } } diff --git a/client/ios/DivKit/DivBlockModelingContext.swift b/client/ios/DivKit/DivBlockModelingContext.swift index 40a997b68..7b199b882 100644 --- a/client/ios/DivKit/DivBlockModelingContext.swift +++ b/client/ios/DivKit/DivBlockModelingContext.swift @@ -162,7 +162,8 @@ public struct DivBlockModelingContext { self.extensionHandlers = extensionHandlers self.layoutProviderHandler = layoutProviderHandler self.functionsProvider = FunctionsProvider( - persistentValuesStorage: persistentValuesStorage + persistentValuesStorage: persistentValuesStorage, + cardId: path.cardId ) self.idToPath = idToPath ?? IdToPath() self.animatorController = animatorController diff --git a/client/ios/DivKit/Expressions/CustomFunctions/CustomFunction.swift b/client/ios/DivKit/Expressions/CustomFunctions/CustomFunction.swift index 79f74ed0b..2229f7c87 100644 --- a/client/ios/DivKit/Expressions/CustomFunctions/CustomFunction.swift +++ b/client/ios/DivKit/Expressions/CustomFunctions/CustomFunction.swift @@ -40,7 +40,8 @@ final class CustomFunction: SimpleFunction { ) let resolver = ExpressionResolver( functionsProvider: FunctionsProvider( - persistentValuesStorage: DivPersistentValuesStorage() + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil ), customFunctionsStorageProvider: context.customFunctionsStorageProvider, variableValueProvider: { [weak self] name in diff --git a/client/ios/DivKit/Expressions/ExpressionResolver.swift b/client/ios/DivKit/Expressions/ExpressionResolver.swift index 338302be3..2e7ac9c7d 100644 --- a/client/ios/DivKit/Expressions/ExpressionResolver.swift +++ b/client/ios/DivKit/Expressions/ExpressionResolver.swift @@ -38,7 +38,8 @@ public final class ExpressionResolver { ) { self.init( functionsProvider: FunctionsProvider( - persistentValuesStorage: persistentValuesStorage + persistentValuesStorage: persistentValuesStorage, + cardId: nil ), customFunctionsStorageProvider: { _ in nil }, variableValueProvider: variableValueProvider, @@ -66,7 +67,8 @@ public final class ExpressionResolver { reporter: DivReporter ) { self.functionsProvider = FunctionsProvider( - persistentValuesStorage: persistentValuesStorage + persistentValuesStorage: persistentValuesStorage, + cardId: path.cardId ) self.customFunctionsStorageProvider = { functionsStorage?.getStorage(path: path, contains: $0) diff --git a/client/ios/DivKit/Expressions/Functions/GetStoredValueFunctions.swift b/client/ios/DivKit/Expressions/Functions/GetStoredValueFunctions.swift index 1f579e9ef..e4b847aa3 100644 --- a/client/ios/DivKit/Expressions/Functions/GetStoredValueFunctions.swift +++ b/client/ios/DivKit/Expressions/Functions/GetStoredValueFunctions.swift @@ -3,15 +3,22 @@ import VGSL extension [String: Function] { mutating func addGetStoredValueFunctions( - _ valueProvider: @escaping (String) -> Any? + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any? ) { addFunction("getStoredBooleanValue", makeFunction(Bool.self, valueProvider)) + addFunction("getStoredBooleanValue", makeTernaryWithScope(Bool.self, valueProvider)) addFunction("getStoredIntegerValue", makeFunction(Int.self, valueProvider)) + addFunction("getStoredIntegerValue", makeTernaryWithScope(Int.self, valueProvider)) addFunction("getStoredNumberValue", makeFunction(Double.self, valueProvider)) + addFunction("getStoredNumberValue", makeTernaryWithScope(Double.self, valueProvider)) addFunction("getStoredStringValue", makeFunction(String.self, valueProvider)) + addFunction("getStoredStringValue", makeTernaryWithScope(String.self, valueProvider)) addFunction("getStoredColorValue", makeColorFunction(valueProvider)) addFunction("getStoredUrlValue", makeUrlFunction(valueProvider)) - addFunction("getStoredArrayValue", makeFunctionWithoutFallback(DivArray.self, valueProvider)) + addFunction( + "getStoredArrayValue", + makeFunctionWithoutFallback(DivArray.self, valueProvider) + ) addFunction( "getStoredDictValue", makeFunctionWithoutFallback(DivDictionary.self, valueProvider) @@ -19,19 +26,50 @@ extension [String: Function] { } } +private func parseStoredValueScope(_ raw: String) throws -> DivStoredValueScope { + switch raw.lowercased() { + case "global": + return .global + case "card": + return .card + default: + throw ExpressionError("Unknown stored value scope '\(raw)'. Expected 'global' or 'card'.") + } +} + private func makeFunction( _: T.Type, - _ valueProvider: @escaping (String) -> Any? + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any? ) -> FunctionBinary { makeFunction(valueProvider) { $0 } } private func makeFunction( - _ valueProvider: @escaping (String) -> Any?, + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any?, transform: @escaping (U) throws -> T ) -> FunctionBinary { FunctionBinary { name, fallbackValue in - guard let value = valueProvider(name) as? T else { + guard let value = valueProvider(name, .global) as? T else { + return try transform(fallbackValue) + } + return value + } +} + +private func makeTernaryWithScope( + _: T.Type, + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any? +) -> FunctionTernary { + makeTernaryWithScope(valueProvider) { $0 } +} + +private func makeTernaryWithScope( + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any?, + transform: @escaping (U) throws -> T +) -> FunctionTernary { + FunctionTernary { name, scopeString, fallbackValue in + let scope = try parseStoredValueScope(scopeString) + guard let value = valueProvider(name, scope) as? T else { return try transform(fallbackValue) } return value @@ -40,19 +78,27 @@ private func makeFunction( private func makeFunctionWithoutFallback( _: T.Type, - _ valueProvider: @escaping (String) -> Any? -) -> FunctionUnary { - FunctionUnary { - if let value = valueProvider($0) as? T { + _ valueProvider: @escaping (String, DivStoredValueScope) -> Any? +) -> Function { + let unary: FunctionUnary = FunctionUnary { + if let value = valueProvider($0, .global) as? T { return value } throw ExpressionError("Missing value.") } + let binary: FunctionBinary = FunctionBinary { name, scopeString in + let scope = try parseStoredValueScope(scopeString) + if let value = valueProvider(name, scope) as? T { + return value + } + throw ExpressionError("Missing value.") + } + return OverloadedFunction(functions: [unary, binary]) } -private func makeColorFunction(_ valueProvider: @escaping (String) -> Any?) -> Function { - let fromColorFunction = makeFunction(Color.self, valueProvider) - let fromStringFunction: FunctionBinary = +private func makeColorFunction(_ valueProvider: @escaping (String, DivStoredValueScope) -> Any?) -> Function { + let fromColorBinary: FunctionBinary = makeFunction(Color.self, valueProvider) + let fromStringBinary: FunctionBinary = makeFunction(valueProvider) { guard let color = Color.color(withHexString: $0) else { throw ExpressionError( @@ -61,12 +107,28 @@ private func makeColorFunction(_ valueProvider: @escaping (String) -> Any?) -> F } return color } - return OverloadedFunction(functions: [fromColorFunction, fromStringFunction]) + let fromColorTernary: FunctionTernary = + makeTernaryWithScope(Color.self, valueProvider) + let fromStringTernary: FunctionTernary = + makeTernaryWithScope(valueProvider) { + guard let color = Color.color(withHexString: $0) else { + throw ExpressionError( + "Failed to get Color from (\($0)). Unable to convert value to Color." + ) + } + return color + } + return OverloadedFunction(functions: [ + fromColorBinary, + fromStringBinary, + fromColorTernary, + fromStringTernary, + ]) } -private func makeUrlFunction(_ valueProvider: @escaping (String) -> Any?) -> Function { - let fromUrlFunction = makeFunction(URL.self, valueProvider) - let fromStringFunction: FunctionBinary = +private func makeUrlFunction(_ valueProvider: @escaping (String, DivStoredValueScope) -> Any?) -> Function { + let fromUrlBinary: FunctionBinary = makeFunction(URL.self, valueProvider) + let fromStringBinary: FunctionBinary = makeFunction(valueProvider) { guard let url = URL(string: $0) else { throw ExpressionError( @@ -75,5 +137,21 @@ private func makeUrlFunction(_ valueProvider: @escaping (String) -> Any?) -> Fun } return url } - return OverloadedFunction(functions: [fromUrlFunction, fromStringFunction]) + let fromUrlTernary: FunctionTernary = + makeTernaryWithScope(URL.self, valueProvider) + let fromStringTernary: FunctionTernary = + makeTernaryWithScope(valueProvider) { + guard let url = URL(string: $0) else { + throw ExpressionError( + "Failed to get URL from (\($0)). Unable to convert value to URL." + ) + } + return url + } + return OverloadedFunction(functions: [ + fromUrlBinary, + fromStringBinary, + fromUrlTernary, + fromStringTernary, + ]) } diff --git a/client/ios/DivKit/Expressions/FunctionsProvider.swift b/client/ios/DivKit/Expressions/FunctionsProvider.swift index 399f72e13..671967321 100644 --- a/client/ios/DivKit/Expressions/FunctionsProvider.swift +++ b/client/ios/DivKit/Expressions/FunctionsProvider.swift @@ -12,9 +12,13 @@ final class FunctionsProvider { }() lazy var functions: [String: Function] = - lock.withLock { + lock.withLock { [weak self] in + guard let self else { return [:] } var functions = staticFunctions - functions.addGetStoredValueFunctions(persistentValuesStorage.get) + functions.addGetStoredValueFunctions({ name, scope in + let storageCardId: DivCardID? = (scope == .global) ? nil : self.cardId + return self.persistentValuesStorage.get(name: name, cardId: storageCardId) + }) return functions } @@ -58,12 +62,15 @@ final class FunctionsProvider { } private let persistentValuesStorage: DivPersistentValuesStorage + private let cardId: DivCardID? private let lock = AllocatedUnfairLock() init( - persistentValuesStorage: DivPersistentValuesStorage + persistentValuesStorage: DivPersistentValuesStorage, + cardId: DivCardID? ) { self.persistentValuesStorage = persistentValuesStorage + self.cardId = cardId } } diff --git a/client/ios/DivKit/StoredValues/Clearable.swift b/client/ios/DivKit/StoredValues/Clearable.swift index 40efac304..e5c6ba2d5 100644 --- a/client/ios/DivKit/StoredValues/Clearable.swift +++ b/client/ios/DivKit/StoredValues/Clearable.swift @@ -1,5 +1,7 @@ import Foundation +import LayoutKit public protocol Clearable { func clear() + func clear(cardId: DivCardID) } diff --git a/client/ios/DivKit/StoredValues/DivPersistentValuesStorage.swift b/client/ios/DivKit/StoredValues/DivPersistentValuesStorage.swift index a745880f1..332d6eb08 100644 --- a/client/ios/DivKit/StoredValues/DivPersistentValuesStorage.swift +++ b/client/ios/DivKit/StoredValues/DivPersistentValuesStorage.swift @@ -23,9 +23,10 @@ public final class DivPersistentValuesStorage { removeOutdatedStoredValues() } - func set(value: DivStoredValue) { + func set(value: DivStoredValue, cardId: DivCardID? = nil) { + let key = storageKey(name: value.name, cardId: cardId) var items = storage.value.items - items[value.name] = StoredValue( + items[key] = StoredValue( timestamp: timestampProvider.value, value: value.value, type: value.type, @@ -34,10 +35,11 @@ public final class DivPersistentValuesStorage { storage.value = StoredValues(items: items) } - func get(name: String) -> T? { + func get(name: String, cardId: DivCardID? = nil) -> T? { + let key = storageKey(name: name, cardId: cardId) let items = storage.value.items let currentTimestamp = timestampProvider.value - guard let storedValue = items[name] else { + guard let storedValue = items[key] else { return nil } let elapsedTimeInSec = (currentTimestamp - storedValue.timestamp) / 1000 @@ -57,6 +59,13 @@ public final class DivPersistentValuesStorage { return value } + func clearValues(forCardId cardId: DivCardID) { + let prefix = cardScopedKeyPrefix(cardId: cardId) + var items = storage.value.items + items = items.filter { !$0.key.hasPrefix(prefix) } + storage.value = StoredValues(items: items) + } + func reset() { storage.value = StoredValues(items: [:]) } @@ -74,10 +83,14 @@ public final class DivPersistentValuesStorage { } } -extension DivPersistentValuesStorage: Clearable { +extension DivPersistentValuesStorage: Clearable { public func clear() { reset() } + + public func clear(cardId: DivCardID) { + clearValues(forCardId: cardId) + } } private struct StoredValues: Equatable, Codable { @@ -150,3 +163,14 @@ extension DivStoredValue { variable != nil } } + +private func cardScopedKeyPrefix(cardId: DivCardID) -> String { + "\(cardKeyPrefix)\(cardId.rawValue)" +} +private func storageKey(name: String, cardId: DivCardID?) -> String { + guard let cardId else { // global scope + return name + } + return "\(cardScopedKeyPrefix(cardId: cardId))\(name)" +} +private let cardKeyPrefix = "card_" diff --git a/client/ios/DivKit/StoredValues/DivStoredValue.swift b/client/ios/DivKit/StoredValues/DivStoredValue.swift index ce309a3e8..d2f784216 100644 --- a/client/ios/DivKit/StoredValues/DivStoredValue.swift +++ b/client/ios/DivKit/StoredValues/DivStoredValue.swift @@ -1,5 +1,10 @@ import Foundation +public enum DivStoredValueScope: String, Hashable, Sendable, Decodable { + case global + case card +} + struct DivStoredValue: Hashable { enum ValueType: String, Codable { case string diff --git a/client/ios/DivKit/generated_sources/DivActionSetStoredValue.swift b/client/ios/DivKit/generated_sources/DivActionSetStoredValue.swift index f504be1e4..6a5fd3a38 100644 --- a/client/ios/DivKit/generated_sources/DivActionSetStoredValue.swift +++ b/client/ios/DivKit/generated_sources/DivActionSetStoredValue.swift @@ -9,6 +9,7 @@ public final class DivActionSetStoredValue: Sendable { public let lifetime: Expression public let name: Expression public let value: DivTypedValue + public let scope: Expression? public func resolveLifetime(_ resolver: ExpressionResolver) -> Int? { resolver.resolveNumeric(lifetime) @@ -18,22 +19,35 @@ public final class DivActionSetStoredValue: Sendable { resolver.resolveString(name) } + public func resolveScope(_ resolver: ExpressionResolver) -> DivStoredValueScope { + guard let scope else { + return .global + } + guard let raw = resolver.resolveString(scope) else { + return .global + } + return DivStoredValueScope(rawValue: raw.lowercased()) ?? .global + } + public convenience init(dictionary: [String: Any], context: ParsingContext) throws { self.init( lifetime: try dictionary.getExpressionField("lifetime", context: context), name: try dictionary.getExpressionField("name", context: context), - value: try dictionary.getField("value", transform: { (dict: [String: Any]) in try DivTypedValue(dictionary: dict, context: context) }, context: context) + value: try dictionary.getField("value", transform: { (dict: [String: Any]) in try DivTypedValue(dictionary: dict, context: context) }, context: context), + scope: try dictionary.getOptionalExpressionField("scope", context: context) ) } init( lifetime: Expression, name: Expression, - value: DivTypedValue + value: DivTypedValue, + scope: Expression? = nil ) { self.lifetime = lifetime self.name = name self.value = value + self.scope = scope } } @@ -43,7 +57,8 @@ extension DivActionSetStoredValue: Equatable { guard lhs.lifetime == rhs.lifetime, lhs.name == rhs.name, - lhs.value == rhs.value + lhs.value == rhs.value, + lhs.scope == rhs.scope else { return false } @@ -60,6 +75,9 @@ extension DivActionSetStoredValue: Serializable { result["lifetime"] = lifetime.toValidSerializationValue() result["name"] = name.toValidSerializationValue() result["value"] = value.toDictionary() + if let scope { + result["scope"] = scope.toValidSerializationValue() + } return result } } diff --git a/client/ios/DivKit/generated_sources/DivActionSetStoredValueTemplate.swift b/client/ios/DivKit/generated_sources/DivActionSetStoredValueTemplate.swift index d82f2ac69..d78925af4 100644 --- a/client/ios/DivKit/generated_sources/DivActionSetStoredValueTemplate.swift +++ b/client/ios/DivKit/generated_sources/DivActionSetStoredValueTemplate.swift @@ -10,13 +10,15 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { public let lifetime: Field>? public let name: Field>? public let value: Field? + public let scope: Field>? public convenience init(dictionary: [String: Any], templateToType: [TemplateName: String]) throws { self.init( parent: dictionary["type"] as? String, lifetime: dictionary.getOptionalExpressionField("lifetime"), name: dictionary.getOptionalExpressionField("name"), - value: dictionary.getOptionalField("value", templateToType: templateToType) + value: dictionary.getOptionalField("value", templateToType: templateToType), + scope: dictionary.getOptionalExpressionField("scope") ) } @@ -24,22 +26,26 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { parent: String?, lifetime: Field>? = nil, name: Field>? = nil, - value: Field? = nil + value: Field? = nil, + scope: Field>? = nil ) { self.parent = parent self.lifetime = lifetime self.name = name self.value = value + self.scope = scope } private static func resolveOnlyLinks(context: TemplatesContext, parent: DivActionSetStoredValueTemplate?) -> DeserializationResult { let lifetimeValue = { parent?.lifetime?.resolveValue(context: context) ?? .noValue }() let nameValue = { parent?.name?.resolveValue(context: context) ?? .noValue }() let valueValue = { parent?.value?.resolveValue(context: context, useOnlyLinks: true) ?? .noValue }() + let scopeValue = { parent?.scope?.resolveOptionalValue(context: context) ?? .noValue }() var errors = mergeErrors( lifetimeValue.errorsOrWarnings?.map { .nestedObjectError(field: "lifetime", error: $0) }, nameValue.errorsOrWarnings?.map { .nestedObjectError(field: "name", error: $0) }, - valueValue.errorsOrWarnings?.map { .nestedObjectError(field: "value", error: $0) } + valueValue.errorsOrWarnings?.map { .nestedObjectError(field: "value", error: $0) }, + scopeValue.errorsOrWarnings?.map { .nestedObjectError(field: "scope", error: $0) } ) if case .noValue = lifetimeValue { errors.append(.requiredFieldIsMissing(field: "lifetime")) @@ -60,7 +66,8 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { let result = DivActionSetStoredValue( lifetime: { lifetimeNonNil }(), name: { nameNonNil }(), - value: { valueNonNil }() + value: { valueNonNil }(), + scope: { scopeValue.value }() ) return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) } @@ -72,6 +79,7 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { var lifetimeValue: DeserializationResult> = { parent?.lifetime?.value() ?? .noValue }() var nameValue: DeserializationResult> = { parent?.name?.value() ?? .noValue }() var valueValue: DeserializationResult = .noValue + var scopeValue: DeserializationResult> = { parent?.scope?.value() ?? .noValue }() _ = { // Each field is parsed in its own lambda to keep the stack size managable // Otherwise the compiler will allocate stack for each intermediate variable @@ -92,6 +100,11 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { valueValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTypedValueTemplate.self).merged(with: valueValue) } }() + _ = { + if key == "scope" { + scopeValue = deserialize(__dictValue).merged(with: scopeValue) + } + }() _ = { if key == parent?.lifetime?.link { lifetimeValue = lifetimeValue.merged(with: { deserialize(__dictValue) }) @@ -107,15 +120,22 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { valueValue = valueValue.merged(with: { deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: DivTypedValueTemplate.self) }) } }() + _ = { + if key == parent?.scope?.link { + scopeValue = scopeValue.merged(with: { deserialize(__dictValue) }) + } + }() } }() if let parent = parent { _ = { valueValue = valueValue.merged(with: { parent.value?.resolveValue(context: context, useOnlyLinks: true) }) }() + _ = { scopeValue = scopeValue.merged(with: { parent.scope?.resolveOptionalValue(context: context) }) }() } var errors = mergeErrors( lifetimeValue.errorsOrWarnings?.map { .nestedObjectError(field: "lifetime", error: $0) }, nameValue.errorsOrWarnings?.map { .nestedObjectError(field: "name", error: $0) }, - valueValue.errorsOrWarnings?.map { .nestedObjectError(field: "value", error: $0) } + valueValue.errorsOrWarnings?.map { .nestedObjectError(field: "value", error: $0) }, + scopeValue.errorsOrWarnings?.map { .nestedObjectError(field: "scope", error: $0) } ) if case .noValue = lifetimeValue { errors.append(.requiredFieldIsMissing(field: "lifetime")) @@ -136,7 +156,8 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { let result = DivActionSetStoredValue( lifetime: { lifetimeNonNil }(), name: { nameNonNil }(), - value: { valueNonNil }() + value: { valueNonNil }(), + scope: { scopeValue.value }() ) return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) } @@ -152,7 +173,8 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { parent: nil, lifetime: lifetime ?? mergedParent.lifetime, name: name ?? mergedParent.name, - value: value ?? mergedParent.value + value: value ?? mergedParent.value, + scope: scope ?? mergedParent.scope ) } @@ -163,7 +185,8 @@ public final class DivActionSetStoredValueTemplate: TemplateValue, Sendable { parent: nil, lifetime: merged.lifetime, name: merged.name, - value: try merged.value?.resolveParent(templates: templates) + value: try merged.value?.resolveParent(templates: templates), + scope: merged.scope ) } } diff --git a/client/ios/DivKitTests/Actions/DivActionIntentTests.swift b/client/ios/DivKitTests/Actions/DivActionIntentTests.swift index 7bb691547..205e1dcaf 100644 --- a/client/ios/DivKitTests/Actions/DivActionIntentTests.swift +++ b/client/ios/DivKitTests/Actions/DivActionIntentTests.swift @@ -118,12 +118,10 @@ struct DivActionIntentTests { func setStoredValue_String() { #expect( intent("div-action://set_stored_value?name=var&value=value&type=string&lifetime=100") == - .setStoredValue(DivStoredValue( - name: "var", - value: "value", - type: .string, - lifetimeInSec: 100 - )) + .setStoredValue( + DivStoredValue(name: "var", value: "value", type: .string, lifetimeInSec: 100), + .global + ) ) } @@ -131,12 +129,10 @@ struct DivActionIntentTests { func setStoredValue_Boolean() { #expect( intent("div-action://set_stored_value?name=var&value=true&type=boolean&lifetime=100") == - .setStoredValue(DivStoredValue( - name: "var", - value: "true", - type: .boolean, - lifetimeInSec: 100 - )) + .setStoredValue( + DivStoredValue(name: "var", value: "true", type: .boolean, lifetimeInSec: 100), + .global + ) ) } @@ -144,12 +140,10 @@ struct DivActionIntentTests { func setStoredValue_Bool() { #expect( intent("div-action://set_stored_value?name=var&value=true&type=bool&lifetime=100") == - .setStoredValue(DivStoredValue( - name: "var", - value: "true", - type: .bool, - lifetimeInSec: 100 - )) + .setStoredValue( + DivStoredValue(name: "var", value: "true", type: .bool, lifetimeInSec: 100), + .global + ) ) } @@ -173,6 +167,28 @@ struct DivActionIntentTests { intent("div-action://set_stored_value?name=var&value=value&type=string") == nil ) } + + @Test + func setStoredValue_ScopeGlobal() { + #expect( + intent("div-action://set_stored_value?name=var&value=v&type=string&lifetime=100&scope=global") == + .setStoredValue( + DivStoredValue(name: "var", value: "v", type: .string, lifetimeInSec: 100), + .global + ) + ) + } + + @Test + func setStoredValue_ScopeCard() { + #expect( + intent("div-action://set_stored_value?name=var&value=v&type=string&lifetime=100&scope=card") == + .setStoredValue( + DivStoredValue(name: "var", value: "v", type: .string, lifetimeInSec: 100), + .card + ) + ) + } } private func intent(_ url: String) -> DivActionIntent? { diff --git a/client/ios/DivKitTests/Actions/SetStoredValueActionHandlerTests.swift b/client/ios/DivKitTests/Actions/SetStoredValueActionHandlerTests.swift index 9486d5484..d869b2577 100644 --- a/client/ios/DivKitTests/Actions/SetStoredValueActionHandlerTests.swift +++ b/client/ios/DivKitTests/Actions/SetStoredValueActionHandlerTests.swift @@ -14,7 +14,7 @@ final class SetStoredValueActionHandlerTests: XCTestCase { } override func tearDown() { - persistentValuesStorage.reset() + persistentValuesStorage.clear() } func test_SetStringValue() { @@ -134,13 +134,60 @@ final class SetStoredValueActionHandlerTests: XCTestCase { ) } - private func handle(_ action: DivActionSetStoredValue) { + func test_SetCardScopedValue() { + handle( + action( + name: "name", + value: .stringValue(StringValue(value: .value("card value"))), + scope: .card + ) + ) + + XCTAssertEqual( + persistentValuesStorage.get(name: "name", cardId: cardId), + "card value" + ) + XCTAssertNil(persistentValuesStorage.get(name: "name", cardId: nil)) + } + + func test_SetCardScopedValue_IsolatedPerPathCardId() { + let otherCard = DivCardID(rawValue: "other_stored_card") + let setName = "per_path_key" + handle( + action( + name: setName, + value: .stringValue(StringValue(value: .value("from_default"))), + scope: .card + ), + path: cardId.path + ) + handle( + action( + name: setName, + value: .stringValue(StringValue(value: .value("from_other"))), + scope: .card + ), + path: otherCard.path + ) + + XCTAssertEqual( + persistentValuesStorage.get(name: setName, cardId: cardId) as String?, + "from_default" + ) + XCTAssertEqual( + persistentValuesStorage.get(name: setName, cardId: otherCard) as String?, + "from_other" + ) + XCTAssertNil(persistentValuesStorage.get(name: setName, cardId: nil) as String?) + } + + private func handle(_ action: DivActionSetStoredValue, path: UIElementPath = cardId.path) { handler.handle( divAction( logId: "action_id", typed: .divActionSetStoredValue(action) ), - path: cardId.path, + path: path, source: .callback, sender: nil ) @@ -152,12 +199,21 @@ private let cardId = DivBlockModelingContext.testCardId private func action( name: String, value: DivTypedValue, - lifetime: Int = 1000 + lifetime: Int = 1000, + scope: DivStoredValueScope? = nil ) -> DivActionSetStoredValue { DivActionSetStoredValue( lifetime: .value(lifetime), name: .value(name), - value: value + value: value, + scope: scope.map { + switch $0 { + case .global: + return .value("global") + case .card: + return .value("card") + } + } ) } diff --git a/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift b/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift index b98a811a2..e28916360 100644 --- a/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift +++ b/client/ios/DivKitTests/DivPersistentValuesStorageTests.swift @@ -126,6 +126,44 @@ final class DivPersistentValuesStorageTests: XCTestCase { XCTAssertNil(stored) } + func test_cardScope_IsolatedByCardId() { + let cardA = DivCardID(rawValue: "card-a") + let cardB = DivCardID(rawValue: "card-b") + storage.set( + value: DivStoredValue(name: "k", value: "a", type: .string, lifetimeInSec: 86400), + cardId: cardA + ) + storage.set( + value: DivStoredValue(name: "k", value: "b", type: .string, lifetimeInSec: 86400), + cardId: cardB + ) + + XCTAssertEqual(storage.get(name: "k", cardId: cardA), "a") + XCTAssertEqual(storage.get(name: "k", cardId: cardB), "b") + XCTAssertNil(storage.get(name: "k", cardId: nil)) + } + + func test_globalScope_UsesPlainKey_BackwardsCompatible() { + storage.set( + value: DivStoredValue(name: "k", value: "g", type: .string, lifetimeInSec: 86400) + ) + XCTAssertEqual(storage.get(name: "k"), "g") + } + + func test_clearForCardId_RemovesOnlyCardScopedKeys() { + let cardId = DivCardID(rawValue: "c1") + storage.set(value: DivStoredValue(name: "g", value: "global", type: .string, lifetimeInSec: 86400)) + storage.set( + value: DivStoredValue(name: "x", value: "card", type: .string, lifetimeInSec: 86400), + cardId: cardId + ) + + storage.clear(cardId: cardId) + + XCTAssertEqual(storage.get(name: "g"), "global") + XCTAssertNil(storage.get(name: "x", cardId: cardId)) + } + private func makeStorage() -> DivPersistentValuesStorage { DivPersistentValuesStorage( timestampProvider: Variable { self.currentTimestamp } diff --git a/client/ios/DivKitTests/Expressions/ExpressionResolverAnyTests.swift b/client/ios/DivKitTests/Expressions/ExpressionResolverAnyTests.swift index 649c9d192..40ec69bd5 100644 --- a/client/ios/DivKitTests/Expressions/ExpressionResolverAnyTests.swift +++ b/client/ios/DivKitTests/Expressions/ExpressionResolverAnyTests.swift @@ -13,7 +13,8 @@ struct ExpressionResolverAnyTests { lazy var expressionResolver = ExpressionResolver( functionsProvider: FunctionsProvider( - persistentValuesStorage: DivPersistentValuesStorage() + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil ), customFunctionsStorageProvider: { _ in nil }, variableValueProvider: { [unowned self] in diff --git a/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift b/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift index d959c32c5..e88148dfe 100644 --- a/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift +++ b/client/ios/DivKitTests/Expressions/ExpressionResolverTests.swift @@ -26,7 +26,8 @@ struct ExpressionResolverTests { lazy var expressionResolver = ExpressionResolver( functionsProvider: FunctionsProvider( - persistentValuesStorage: DivPersistentValuesStorage() + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil ), customFunctionsStorageProvider: { _ in nil }, variableValueProvider: { [unowned self] in @@ -575,6 +576,58 @@ struct ExpressionResolverTests { } } +@Suite("GetStoredCardScope") +struct GetStoredCardScopeExpressionTests { + private let cardA = DivCardID(rawValue: "stored_scope_card_a") + private let cardB = DivCardID(rawValue: "stored_scope_card_b") + + @Test + func getStoredStringValue_cardScope_usesFunctionsProviderCardId() { + let storage = DivPersistentValuesStorage() + defer { storage.clear() } + storage.set( + value: DivStoredValue(name: "k", value: "alpha", type: .string, lifetimeInSec: 1000), + cardId: cardA + ) + storage.set( + value: DivStoredValue(name: "k", value: "beta", type: .string, lifetimeInSec: 1000), + cardId: cardB + ) + + let expr = "@{getStoredStringValue('k', 'card', 'fb')}" + #expect(resolver(storage: storage, cardId: cardA).resolveString(expression(expr)) == "alpha") + #expect(resolver(storage: storage, cardId: cardB).resolveString(expression(expr)) == "beta") + } + + @Test + func getStoredStringValue_globalScope_readsSameKeyForBothCardProviders() { + let storage = DivPersistentValuesStorage() + defer { storage.clear() } + storage.set( + value: DivStoredValue(name: "g", value: "global_only", type: .string, lifetimeInSec: 1000) + ) + + let ternaryGlobal = "@{getStoredStringValue('g', 'global', 'fb')}" + #expect(resolver(storage: storage, cardId: cardA).resolveString(expression(ternaryGlobal)) == "global_only") + #expect(resolver(storage: storage, cardId: cardB).resolveString(expression(ternaryGlobal)) == "global_only") + + let binaryImplicitGlobal = "@{getStoredStringValue('g', 'fb')}" + #expect(resolver(storage: storage, cardId: cardA).resolveString(expression(binaryImplicitGlobal)) == "global_only") + } + + private func resolver(storage: DivPersistentValuesStorage, cardId: DivCardID) -> ExpressionResolver { + ExpressionResolver( + functionsProvider: FunctionsProvider( + persistentValuesStorage: storage, + cardId: cardId + ), + customFunctionsStorageProvider: { _ in nil }, + variableValueProvider: { _ in nil }, + errorTracker: { _ in } + ) + } +} + private func expression(_ expression: String) -> ExpressionLink { ExpressionLink(rawValue: expression)! } diff --git a/client/ios/DivKitTests/Expressions/ExpressionTests.swift b/client/ios/DivKitTests/Expressions/ExpressionTests.swift index 87240d9f7..3d239f917 100644 --- a/client/ios/DivKitTests/Expressions/ExpressionTests.swift +++ b/client/ios/DivKitTests/Expressions/ExpressionTests.swift @@ -119,7 +119,10 @@ private struct ExpressionTestCase: Decodable { } self.variables = variables.extractDivVariableValues( ExpressionResolver( - functionsProvider: FunctionsProvider(persistentValuesStorage: DivPersistentValuesStorage()), + functionsProvider: FunctionsProvider( + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil + ), customFunctionsStorageProvider: { _ in nil }, variableValueProvider: { _ in nil }, errorTracker: { XCTFail($0.description) } @@ -161,7 +164,8 @@ private struct ExpressionTestCase: Decodable { errorTracker: ExpressionErrorTracker? = nil ) -> ExpressionResolver { let functionsProvider = FunctionsProvider( - persistentValuesStorage: DivPersistentValuesStorage() + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil ) for function in functions { functionsProvider.functions.addFunction(function.name, function) diff --git a/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift b/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift index 1eea555ae..7bc5a7219 100644 --- a/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift +++ b/client/ios/DivKitTests/Expressions/FunctionSignaturesTests.swift @@ -24,7 +24,8 @@ private func makeTestCases() -> [(String, SignatureTestCase)] { private func runTest(_ testCase: SignatureTestCase) { let functionsProvider = FunctionsProvider( - persistentValuesStorage: DivPersistentValuesStorage() + persistentValuesStorage: DivPersistentValuesStorage(), + cardId: nil ) let functionName = testCase.functionName let functions = testCase.isMethod ? FunctionsProvider.methods : functionsProvider.functions diff --git a/expression-api/function_signatures_std.json b/expression-api/function_signatures_std.json index c6acc0623..fdcd9cb7e 100644 --- a/expression-api/function_signatures_std.json +++ b/expression-api/function_signatures_std.json @@ -460,7 +460,8 @@ ], "return_type": "integer", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -517,7 +518,8 @@ ], "return_type": "number", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -574,7 +576,8 @@ ], "return_type": "string", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -631,7 +634,8 @@ ], "return_type": "url", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -688,7 +692,8 @@ ], "return_type": "url", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -745,7 +750,8 @@ ], "return_type": "color", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -802,7 +808,8 @@ ], "return_type": "color", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -859,7 +866,8 @@ ], "return_type": "boolean", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -903,7 +911,8 @@ ], "return_type": "array", "platforms": [ - "android" + "android", + "ios" ] }, { @@ -947,7 +956,8 @@ ], "return_type": "dict", "platforms": [ - "android" + "android", + "ios" ] } ]