@@ -342,5 +342,172 @@ class SentryTouchTrackerTests: XCTestCase {
342342 // Assert
343343 wait ( for: [ addExp, removeExp, readExp] , timeout: 5 )
344344 }
345+
346+ func testObjectIdentifierCollision_NewTouchGetsNewId( ) {
347+ // Arrange
348+ // This test simulates ObjectIdentifier collision when UITouch memory is reused.
349+ // When iOS reuses memory for a new UITouch at the same address as a previous touch,
350+ // the ObjectIdentifier will be the same, but it's actually a different touch gesture.
351+ let sut = getSut ( )
352+ let touch = MockUITouch ( phase: . began, location: CGPoint ( x: 100 , y: 100 ) )
353+
354+ // Act - First touch lifecycle (began -> moved -> ended)
355+ let event1 = MockUIEvent ( timestamp: 1 )
356+ event1. addTouch ( touch)
357+ sut. trackTouchFrom ( event: event1)
358+
359+ touch. phase = . moved
360+ touch. location = CGPoint ( x: 150 , y: 150 )
361+ let event2 = MockUIEvent ( timestamp: 2 )
362+ event2. addTouch ( touch)
363+ sut. trackTouchFrom ( event: event2)
364+
365+ touch. phase = . ended
366+ touch. location = CGPoint ( x: 200 , y: 200 )
367+ let event3 = MockUIEvent ( timestamp: 3 )
368+ event3. addTouch ( touch)
369+ sut. trackTouchFrom ( event: event3)
370+
371+ // Get events from first touch before they're overwritten
372+ let firstTouchEvents = sut. replayEvents ( from: referenceDate, until: referenceDate. addingTimeInterval ( 10 ) )
373+
374+ // In real-world usage, flushFinishedEvents would be called periodically
375+ // This removes finished touches from trackedTouches
376+ sut. flushFinishedEvents ( )
377+
378+ // Simulate memory reuse: same UITouch object starts a NEW touch gesture
379+ // In reality this would be a new UITouch at the same memory address,
380+ // but for testing we can use the same object with .began phase at a different location
381+ touch. phase = . began
382+ touch. location = CGPoint ( x: 50 , y: 50 ) // Different location - this is a NEW touch
383+ let event4 = MockUIEvent ( timestamp: 4 )
384+ event4. addTouch ( touch)
385+ sut. trackTouchFrom ( event: event4)
386+
387+ touch. phase = . ended
388+ touch. location = CGPoint ( x: 75 , y: 75 )
389+ let event5 = MockUIEvent ( timestamp: 5 )
390+ event5. addTouch ( touch)
391+ sut. trackTouchFrom ( event: event5)
392+
393+ // Get second touch events
394+ let secondTouchEvents = sut. replayEvents ( from: referenceDate, until: referenceDate. addingTimeInterval ( 10 ) )
395+
396+ // Assert - First touch (captured before flush)
397+ XCTAssertEqual ( firstTouchEvents. count, 3 , " First touch should have 3 events: start, move, end " )
398+
399+ let firstTouchStart = firstTouchEvents [ 0 ] . data
400+ let firstTouchPointerId = firstTouchStart ? [ " pointerId " ] as? Int
401+ XCTAssertEqual ( firstTouchStart ? [ " x " ] as? Float , 100 )
402+ XCTAssertEqual ( firstTouchStart ? [ " y " ] as? Float , 100 )
403+ XCTAssertEqual ( firstTouchStart ? [ " type " ] as? Int , TouchEventPhase . start. rawValue)
404+ XCTAssertEqual ( firstTouchPointerId, 1 , " First touch should have pointer ID 1 " )
405+
406+ let firstTouchMove = firstTouchEvents [ 1 ] . data
407+ XCTAssertEqual ( firstTouchMove ? [ " pointerId " ] as? Int , firstTouchPointerId)
408+
409+ let firstTouchEnd = firstTouchEvents [ 2 ] . data
410+ XCTAssertEqual ( firstTouchEnd ? [ " x " ] as? Float , 200 )
411+ XCTAssertEqual ( firstTouchEnd ? [ " y " ] as? Float , 200 )
412+ XCTAssertEqual ( firstTouchEnd ? [ " type " ] as? Int , TouchEventPhase . end. rawValue)
413+ XCTAssertEqual ( firstTouchEnd ? [ " pointerId " ] as? Int , firstTouchPointerId)
414+
415+ // Assert - Second touch (after collision)
416+ XCTAssertEqual ( secondTouchEvents. count, 2 , " Second touch should have 2 events: start, end " )
417+
418+ let secondTouchStart = secondTouchEvents [ 0 ] . data
419+ let secondTouchPointerId = secondTouchStart ? [ " pointerId " ] as? Int
420+ XCTAssertEqual ( secondTouchStart ? [ " x " ] as? Float , 50 )
421+ XCTAssertEqual ( secondTouchStart ? [ " y " ] as? Float , 50 )
422+ XCTAssertEqual ( secondTouchStart ? [ " type " ] as? Int , TouchEventPhase . start. rawValue)
423+ XCTAssertEqual ( secondTouchPointerId, 2 , " Second touch should have pointer ID 2 " )
424+
425+ let secondTouchEnd = secondTouchEvents [ 1 ] . data
426+ XCTAssertEqual ( secondTouchEnd ? [ " x " ] as? Float , 75 )
427+ XCTAssertEqual ( secondTouchEnd ? [ " y " ] as? Float , 75 )
428+ XCTAssertEqual ( secondTouchEnd ? [ " type " ] as? Int , TouchEventPhase . end. rawValue)
429+ XCTAssertEqual ( secondTouchEnd ? [ " pointerId " ] as? Int , secondTouchPointerId)
430+
431+ // Critical assertion: The two touches should have DIFFERENT pointer IDs
432+ XCTAssertNotEqual ( firstTouchPointerId, secondTouchPointerId,
433+ " Memory-reused touch should get a new pointer ID, not inherit the old one " )
434+ }
435+
436+ func testObjectIdentifierCollision_WithoutFlush_DoesNotOrphanEvents( ) {
437+ // Arrange
438+ // This test covers the case where a collision happens BEFORE flushFinishedEvents()
439+ // The old touch's events should still be accessible, not orphaned
440+ let sut = getSut ( )
441+ let touch = MockUITouch ( phase: . began, location: CGPoint ( x: 100 , y: 100 ) )
442+
443+ // Act - First touch begins but doesn't end
444+ let event1 = MockUIEvent ( timestamp: 1 )
445+ event1. addTouch ( touch)
446+ sut. trackTouchFrom ( event: event1)
447+
448+ touch. phase = . moved
449+ touch. location = CGPoint ( x: 150 , y: 150 )
450+ let event2 = MockUIEvent ( timestamp: 2 )
451+ event2. addTouch ( touch)
452+ sut. trackTouchFrom ( event: event2)
453+
454+ // Collision happens BEFORE first touch ends and BEFORE flush
455+ // Same ObjectIdentifier starts a NEW touch
456+ touch. phase = . began
457+ touch. location = CGPoint ( x: 50 , y: 50 )
458+ let event3 = MockUIEvent ( timestamp: 3 )
459+ event3. addTouch ( touch)
460+ sut. trackTouchFrom ( event: event3)
461+
462+ touch. phase = . ended
463+ touch. location = CGPoint ( x: 75 , y: 75 )
464+ let event4 = MockUIEvent ( timestamp: 4 )
465+ event4. addTouch ( touch)
466+ sut. trackTouchFrom ( event: event4)
467+
468+ // Assert
469+ let result = sut. replayEvents ( from: referenceDate, until: referenceDate. addingTimeInterval ( 10 ) )
470+
471+ // We should have events from BOTH touches, even though the first didn't end:
472+ // - Touch 1 start at (100, 100)
473+ // - Touch 1 move at (150, 150)
474+ // - Touch 2 start at (50, 50) <- collision without flush
475+ // - Touch 2 end at (75, 75)
476+ //
477+ // The first touch's events should NOT be orphaned/lost
478+
479+ XCTAssertEqual ( result. count, 4 , " Should have 4 events: both touches' events should be preserved " )
480+
481+ // First touch events (incomplete, no end)
482+ let firstTouchStart = result [ 0 ] . data
483+ let firstTouchPointerId = firstTouchStart ? [ " pointerId " ] as? Int
484+ XCTAssertEqual ( firstTouchStart ? [ " x " ] as? Float , 100 )
485+ XCTAssertEqual ( firstTouchStart ? [ " y " ] as? Float , 100 )
486+ XCTAssertEqual ( firstTouchStart ? [ " type " ] as? Int , TouchEventPhase . start. rawValue)
487+
488+ let firstTouchMove = result [ 1 ] . data
489+ XCTAssertEqual ( firstTouchMove ? [ " pointerId " ] as? Int , firstTouchPointerId)
490+ let positions = firstTouchMove ? [ " positions " ] as? [ [ String : Any ] ]
491+ XCTAssertEqual ( positions? . first ? [ " x " ] as? Float , 150 )
492+ XCTAssertEqual ( positions? . first ? [ " y " ] as? Float , 150 )
493+
494+ // Second touch events (after collision)
495+ let secondTouchStart = result [ 2 ] . data
496+ let secondTouchPointerId = secondTouchStart ? [ " pointerId " ] as? Int
497+ XCTAssertEqual ( secondTouchStart ? [ " x " ] as? Float , 50 )
498+ XCTAssertEqual ( secondTouchStart ? [ " y " ] as? Float , 50 )
499+ XCTAssertEqual ( secondTouchStart ? [ " type " ] as? Int , TouchEventPhase . start. rawValue)
500+
501+ let secondTouchEnd = result [ 3 ] . data
502+ XCTAssertEqual ( secondTouchEnd ? [ " x " ] as? Float , 75 )
503+ XCTAssertEqual ( secondTouchEnd ? [ " y " ] as? Float , 75 )
504+ XCTAssertEqual ( secondTouchEnd ? [ " type " ] as? Int , TouchEventPhase . end. rawValue)
505+ XCTAssertEqual ( secondTouchEnd ? [ " pointerId " ] as? Int , secondTouchPointerId)
506+
507+ // Critical assertion: Different IDs and NO orphaned events
508+ XCTAssertNotEqual ( firstTouchPointerId, secondTouchPointerId,
509+ " Touches should have different IDs " )
510+ XCTAssertNotNil ( firstTouchPointerId, " First touch events should not be orphaned " )
511+ }
345512}
346513#endif
0 commit comments