Skip to content

Commit e904b04

Browse files
Create helper class to pass Codable pure Swift types through a remote call.
PiperOrigin-RevId: 371180976
1 parent 73009d7 commit e904b04

7 files changed

Lines changed: 363 additions & 4 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import Foundation
2+
3+
/// CodableVariable wraps a `Codable` instance to make it compatible with @objc methods.
4+
///
5+
/// `Codable` documentation: https://developer.apple.com/documentation/swift/codable
6+
///
7+
/// The Swift compiler doesn't allow developers to declare an @objc method for eDO as below:
8+
///
9+
/// ```
10+
/// @objc public FooClass : NSObject {
11+
/// @objc public func callBar(value: Int?) // Compile Error!
12+
/// }
13+
/// ```
14+
///
15+
/// The same issue also applies to other auto-synthesized Codable types, including classes, structs
16+
/// and enums (Swift 5.5+). This is because Optional<Int> is a pure Swift type that cannot be
17+
/// represented in Objective-C land. As a workaround, developers can declare a new method in the
18+
/// class extension as below:
19+
///
20+
/// ```
21+
/// extension FooClass {
22+
/// @objc public func callBar(codedValue: CodableVariable) throws {
23+
/// self.callBar(value: try codedValue.unwrap(Int?.self))
24+
/// }
25+
/// }
26+
/// ```
27+
///
28+
/// The new method is compatible with @objc and forwards the coded variables to the original method.
29+
/// So developers can use the new method in the remote call:
30+
///
31+
/// ```
32+
/// var value : Int?
33+
/// // Do something...
34+
/// try remoteFooInstance.callBar(codedValue: try CodableVariable.wrap(value))
35+
/// ```
36+
@objc(EDOCodableVariable)
37+
public class CodableVariable: NSObject, NSSecureCoding, Codable {
38+
39+
/// Error thrown by `CodableVariable` during the decoding.
40+
public enum DecodingError: Error, LocalizedError {
41+
/// The type of encoded data doesn't match the caller's expecting type.
42+
/// - expectedType: The type expected by the decoding method caller.
43+
/// - actualType: The type of the encoded data.
44+
case typeUnmatched(expectedType: String, actualType: String)
45+
46+
public var errorDescription: String? {
47+
switch self {
48+
case let .typeUnmatched(expectedType, actualType):
49+
return "Expecting to decode \(expectedType) but the codable variable is \(actualType)."
50+
}
51+
}
52+
}
53+
54+
internal static let typeKey = "EDOTypeKey"
55+
internal let data: Data
56+
internal let type: String
57+
58+
/// Creates `CodableVariable` instance with a `Codable` instance.
59+
///
60+
/// - Parameter parameter: The Codable instance to be wrapped.
61+
/// - Returns: A `CodableVariable` instance.
62+
/// - Throws: Errors propagated from JSONEncoder when encoding `parameter`.
63+
public static func wrap<T: Encodable>(_ parameter: T) throws -> CodableVariable {
64+
let encoder = JSONEncoder()
65+
return CodableVariable(data: try encoder.encode(parameter), type: String(describing: T.self))
66+
}
67+
68+
internal init(data: Data, type: String) {
69+
self.data = data
70+
self.type = type
71+
}
72+
73+
/// Decodes the Codable instance.
74+
///
75+
/// - Parameter type: The expected type of the decoded instance.
76+
/// - Returns: The decoded instance of `type`.
77+
/// - Throws: `CodableVariable.DecodingError` if decoding fails.
78+
public func unwrap<T: Decodable>(_ type: T.Type) throws -> T {
79+
guard self.type == String(describing: type) else {
80+
throw DecodingError.typeUnmatched(
81+
expectedType: self.type,
82+
actualType: String(describing: type))
83+
}
84+
let decoder = JSONDecoder()
85+
return try decoder.decode(T.self, from: data)
86+
}
87+
88+
// MARK - NSSecureCoding
89+
90+
public required init?(coder: NSCoder) {
91+
guard let data = coder.decodeData(),
92+
let type = coder.decodeObject(forKey: CodableVariable.typeKey) as? String
93+
else {
94+
return nil
95+
}
96+
self.data = data
97+
self.type = type
98+
}
99+
100+
public func encode(with coder: NSCoder) {
101+
coder.encode(data)
102+
coder.encode(type, forKey: CodableVariable.typeKey)
103+
}
104+
105+
@objc public var edo_isEDOValueType: Bool { return true }
106+
107+
@objc public static var supportsSecureCoding: Bool { return true }
108+
}
109+
110+
/// Extends Encodable to easily produce `CodableVariable` from the instance.
111+
extension Encodable {
112+
/// Produces a `CodableVariable` instance from `self`.
113+
public var eDOCodableVariable: CodableVariable {
114+
// try! is used here because`CodableVariable.wrap` only throws programmer errors when
115+
// JSONEncoder fails to encode a `Encodable` type.
116+
return try! CodableVariable.wrap(self)
117+
}
118+
}

Service/Tests/FunctionalTests/EDOSwiftUITest.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ class EDOSwiftUITest: XCTestCase {
4444
service.invalidate()
4545
}
4646

47-
func testRemoteInvocationWithParameter() {
47+
/// Verifies eDO can make remote calls through Swift @objc methods.
48+
func testRemoteInvocationWithParameter() throws {
4849
launchAppWithPort(port: 1234, value: 10)
4950
let service = EDOHostService(port: 2234, rootObject: self, queue: DispatchQueue.main)
5051
let hostPort = EDOHostPort(port: 1234, name: nil, deviceSerialNumber: nil)
@@ -53,6 +54,9 @@ class EDOSwiftUITest: XCTestCase {
5354
let data = ["a": 1, "b": 2] as NSDictionary
5455
XCTAssertEqual(swiftClass.returnWithDictionarySum(data: data.passByValue()), 3)
5556
XCTAssertEqual(swiftClass.returnWithDictionarySum(data: data), 3)
57+
let testingStruct = EDOTestSwiftStruct(intValues: [1, 2, 3, 4, 5])
58+
let codedResult = try swiftClass.sumFrom(codedStruct: testingStruct.eDOCodableVariable)
59+
XCTAssertEqual(try codedResult.unwrap([Int].self).first, 15)
5660
service.invalidate()
5761
}
5862

Service/Tests/TestsBundle/EDOTestSwiftClass.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//
1616

1717
import Foundation
18+
import SwiftUtil
1819
@objc
1920
public class EDOTestSwiftClass: NSObject, EDOTestSwiftProtocol {
2021
public func returnString() -> NSString {
@@ -36,6 +37,15 @@ public class EDOTestSwiftClass: NSObject, EDOTestSwiftProtocol {
3637
public func returnSwiftArray() -> [AnyObject] {
3738
return [NSObject.init(), NSObject.init()]
3839
}
40+
41+
public func sumFrom(structValue: EDOTestSwiftStruct) -> [Int] {
42+
return [structValue.intValues.reduce(0) { $0 + $1 }]
43+
}
44+
45+
public func sumFrom(codedStruct: CodableVariable) throws -> CodableVariable {
46+
let structValue = try codedStruct.unwrap(EDOTestSwiftStruct.self)
47+
return self.sumFrom(structValue: structValue).eDOCodableVariable
48+
}
3949
}
4050

4151
extension EDOTestDummy: EDOTestDummyExtension {

Service/Tests/TestsBundle/EDOTestSwiftProtocol.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,23 @@
1515
//
1616

1717
import Foundation
18+
import SwiftUtil
19+
20+
public struct EDOTestSwiftStruct: Codable {
21+
public var intValues: [Int]
22+
23+
public init(intValues: [Int]) {
24+
self.intValues = intValues
25+
}
26+
}
1827

1928
@objc
2029
public protocol EDOTestSwiftProtocol {
2130
func returnString() -> NSString
2231
func returnWithBlock(block: @escaping (NSString) -> EDOTestSwiftProtocol) -> NSString
2332
func returnWithDictionarySum(data: NSDictionary) -> Int
2433
func returnSwiftArray() -> [AnyObject]
34+
func sumFrom(codedStruct: CodableVariable) throws -> CodableVariable
2535
}
2636

2737
@objc
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import XCTest
2+
import SwiftUtil
3+
4+
struct EDOTestingStruct: Codable {
5+
var intValue: Int
6+
var stringValue: String
7+
var floatValue: Float
8+
}
9+
10+
final class CodableVariableTests: XCTestCase {
11+
12+
/// Verifies CodableVariable can wrap/unwrap structs that conforms Codable.
13+
func testSerializeStruct() throws {
14+
let structValue = EDOTestingStruct(intValue: 0, stringValue: "foo", floatValue: -1.0)
15+
let serialized = structValue.eDOCodableVariable
16+
let deserialized = try serialized.unwrap(EDOTestingStruct.self)
17+
XCTAssertEqual(deserialized.intValue, structValue.intValue)
18+
XCTAssertEqual(deserialized.stringValue, structValue.stringValue)
19+
XCTAssertEqual(deserialized.floatValue, structValue.floatValue)
20+
}
21+
22+
/// Verifies CodableVariable can wrap/unwrap optional primitive types.
23+
func testSerializeOptionalPrimitive() throws {
24+
guard #available(iOS 13.0, *) else {
25+
throw XCTSkip("Optional is available for encoding after iOS 13")
26+
}
27+
let optionalValue: Int? = nil
28+
let serialized = optionalValue.eDOCodableVariable
29+
let deserialized = try serialized.unwrap(Int?.self)
30+
XCTAssertNil(deserialized)
31+
}
32+
33+
/// Verifies CodableVariable throws exceptions when decoding expects a wrong type.
34+
func testThrowErrorWhenTypeMismatch() {
35+
let structValue = EDOTestingStruct(intValue: 0, stringValue: "foo", floatValue: -1.0)
36+
let serialized = structValue.eDOCodableVariable
37+
var thrownError: Error?
38+
XCTAssertThrowsError(try serialized.unwrap(Int?.self)) {
39+
thrownError = $0
40+
}
41+
let expectedError =
42+
CodableVariable.DecodingError.typeUnmatched(
43+
expectedType: "EDOTestingStruct",
44+
actualType: "Optional<Int>")
45+
XCTAssertEqual(thrownError?.localizedDescription, expectedError.localizedDescription)
46+
}
47+
48+
}

eDistantObject.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Pod::Spec.new do |s|
3030
# ${PODS_TARGET_SRCROOT} is needed for Pod lint which locates the local eDistantObject codebase.
3131
s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "${PODS_ROOT}/eDistantObject ${PODS_TARGET_SRCROOT}" }
3232
s.source_files = "Channel/Sources/*.{m,h}", "Device/Sources/*.{m,h}",
33-
"Measure/Sources/*.{m,h}", "Service/Sources/*.{m,h}"
33+
"Measure/Sources/*.{m,h}", "Service/Sources/*.{m,h,swift}"
3434

3535
s.ios.deployment_target = "10.0"
3636
s.osx.deployment_target = "10.10"

0 commit comments

Comments
 (0)