urbanairship / ios-library

Urban Airship iOS SDK
http://urbanairship.com
Apache License 2.0
478 stars 265 forks source link

Live Activity date parsing via Airship SDK seems to be miscalculated #411

Closed justin closed 1 month ago

justin commented 1 month ago

Preliminary Info

What Airship dependencies are you using?

We are using 18.6.0

What are the versions of any relevant development tools you are using?

Report

What unexpected behavior are you seeing?

Scenario: We are implementing Live Activities that are started and updated via push notifications.

When using Live Activities I cannot figure out how the ActivityAttributes.ContentState is being parsed by Airship's SDK. It seems like it's trying to parse the JSON response into milliseconds instead of seconds, but I can't confirm or reject that thesis.

I am trying to make a progress countdown from Date.now to a date value defined in the ActivityAttributes.ContentState and passed as epoch time.

It seems like the expiration field is trying to parse as a Date from milliseconds instead of just seconds because the time difference between that value and Date.now is so strikingly different.

What is the expected behavior?

I'm expecting to see around a 1 minute countdown, not 11,000 days or so.

Untitled

What are the steps to reproduce the unexpected behavior?

This is a truncated version of our JSON with non-relevant fields omitted.

{
    "notification": {
        "ios": {
            "live_activity": {
              "timestamp": 1721840495,
                "event": "start",
                "name": "draft-102",
                "content_state": {
                    "expiration": 1721840555,
                },
            }
        }
    },
}

The relevant fields from the Swift type that is parsing this.

struct DraftWidgetAttributes: ActivityAttributes {
  typealias ContentState = NextPickState

  struct NextPickState: Codable, Hashable {
    var expiration: Date

    var pickDateRange: ClosedRange<Date> {
      Date.now...expiration
    }
  }
#endif

Do you have logging for the issue?

I cannot see anything of relevance, but am happy to toggle and send anything. Running the device through Proxyman to see what payloads are sent or received doesn't show anything related to my payloads, but I did notice how every Airship related value being sent seems to be in milliseconds.

rlepinski commented 1 month ago

Everything in the content state is not parsed by us, we just we just deliver it to the OS and the OS will automatically parse it for you using the default encodable/decodable behaviors. You will want to look at the default behavior of how a Date is decoded using JSONDecoder. I believe it should be seconds, the docs on this are pretty bad though - https://developer.apple.com/documentation/foundation/jsondecoder/2895216-datedecodingstrategy

I am not sure if you can set a custom date decoding strategy, the one you want here is https://developer.apple.com/documentation/foundation/jsondecoder/datedecodingstrategy/secondssince1970

I am pretty sure you can decode this yourself though, but it means you have to decode everything in the struct. It might be better to decode to an UInt and converting it to a date in a property wrapper.

rlepinski commented 1 month ago

I dug a bit on this to figure it out, this test is passing:

    struct TestStruct: Codable {
        let expiration: Date

        enum CodingKeys: String, CodingKey {
            case expiration
        }
    }

    func testDateDecoding() throws {
        let payload = """
        {
          "expiration": 1721840555
        }
        """
        let decoded = try JSONDecoder().decode(TestStruct.self, from: payload.data(using: .utf8)!)
        XCTAssertEqual(Date(timeIntervalSinceReferenceDate: 1721840555), decoded.expiration)
    }

Its timeIntervalSinceReferenceDate, which is Returns a Date initialized relative to 00:00:00 UTC on 1 January 2001 by a given number of seconds.

I would recommend using an iso 8601 timestamp instead

rlepinski commented 1 month ago

This works if you want to use the values you have without having to figure out if you can give a widget a custom decoding strategy:

    struct TestStruct: Codable {
        let expirationSeconds: TimeInterval
        var expirationDate: Date {
            Date(timeIntervalSince1970: expirationSeconds)
        }

        enum CodingKeys: String, CodingKey {
            case expirationSeconds = "expiration"
        }
    }

    func testDateDecoding() throws {
        let payload = """
        {
          "expiration": 1721840555
        }
        """
        let decoded = try JSONDecoder().decode(TestStruct.self, from: payload.data(using: .utf8)!)
        XCTAssertEqual(Date(timeIntervalSince1970: 1721840555), decoded.expirationDate)
    }
rlepinski commented 1 month ago

And here is the custom decoding handled in the struct:

    struct TestStruct: Codable {
        let expiration: Date

        enum CodingKeys: String, CodingKey {
            case expiration
        }

        init(from decoder: any Decoder) throws {
            let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
            self.expiration = Date(
                timeIntervalSince1970: try container.decode(TimeInterval.self, forKey: .expiration)
            )
        }

        func encode(to encoder: any Encoder) throws {
            var container = encoder.container(keyedBy: AirshipDateFormatterTest.TestStruct.CodingKeys.self)
            try container.encode(self.expiration.timeIntervalSince1970, forKey: .expiration)
        }
    }

    func testDateDecoding() throws {
        let payload = """
        {
          "expiration": 1721840555
        }
        """
        let decoded = try JSONDecoder().decode(TestStruct.self, from: payload.data(using: .utf8)!)
        XCTAssertEqual(Date(timeIntervalSince1970: 1721840555), decoded.expiration)
    }
justin commented 1 month ago

Thank you, @rlepinski! I'll give it a whirl.

rlepinski commented 1 month ago

I have one more for you, you can just add your own codable date structure that handles seconds from epoch:

    struct Expiration: Codable {
        let date: Date

        init(from decoder: any Decoder) throws {
            let container = try decoder.singleValueContainer()
            self.date = Date(
                timeIntervalSince1970: try container.decode(TimeInterval.self)
            )
        }

        func encode(to encoder: any Encoder) throws {
            var container = encoder.singleValueContainer()
            try container.encode(self.date.timeIntervalSince1970)
        }
    }

    struct TestStruct: Codable {
        let expiration: Expiration

        enum CodingKeys: String, CodingKey {
            case expiration
        }
    }

    func testDateDecoding() throws {
        let payload = """
        {
          "expiration": 1721840555
        }
        """
        let decoded = try JSONDecoder().decode(TestStruct.self, from: payload.data(using: .utf8)!)
        XCTAssertEqual(Date(timeIntervalSince1970: 1721840555), decoded.expiration.date)
    }

This contains the custom encoding/decoding to a single struct so you dont have to decode/encode everything in your content state