Skip to content

Commit e495908

Browse files
authored
feat(sample): Add Nav3 lifecycle scenarios to sample app (JAVA-785) (#6309)
Part 5 of our Nav3 support for multi-Activity and multi-Fragment applications Add multi-Activity and multi-Fragment Nav3 scenarios with shared transaction history so lifecycle handoffs can be exercised in the Android sample app.
1 parent 9523053 commit e495908

19 files changed

Lines changed: 1233 additions & 317 deletions

File tree

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package io.sentry.compose.navigation3
33
import androidx.compose.runtime.Immutable
44
import org.jetbrains.annotations.ApiStatus
55

6-
// Keep the default low: every captured entry may require argument extraction and recursive
6+
// Keep the default low, as every captured entry may require argument extraction and recursive
77
// sanitization when navigation changes are observed.
88
private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 5
99

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt‎

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import io.sentry.SpanStatus
1919
* **Exception semantics**
2020
*
2121
* Methods in this class with an `IScope` parameter can throw arbitrary exceptions†, which propagate
22-
* to the caller. This class restores its own bookkeeping first, so the coordinator remains usable
23-
* if the exception is caught.
22+
* to the caller. Each method updates its own bookkeeping before calling into the scope, so the
23+
* coordinator remains usable if the exception is caught.
2424
*
2525
* † E.g., `IScope` can throw arbitrary exceptions when scope mutators notify registered observers –
2626
* including the host app – in an unguarded loop, or when `finish()` is called on a retained nav
@@ -172,35 +172,38 @@ internal class NavLeaseCoordinator {
172172
* left to time out, its published data is left in place, and the lease is released rather than
173173
* handed off.
174174
*
175-
* Coordinator references are released even if scope cleanup or lease handoff throws. See
176-
* "Exception semantics" in the class KDoc. The remaining scope cleanup is then skipped.
175+
* Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. Coordinator
176+
* references to `owner` are released before any scope call, so they're released even if scope
177+
* cleanup or lease handoff throws. The remaining scope cleanup is then skipped.
177178
*/
178179
fun unregisterCandidate(owner: Any, scope: IScope?) {
179180
val candidate = findCandidate(owner) ?: return
181+
val transaction = candidate.transaction
182+
val wasPublisher = publishedDataCandidate === candidate
183+
val wasLeaseHolder = leaseHolder === candidate
180184

181-
try {
182-
finishTransaction(candidate, scope)
183-
candidates.remove(candidate)
185+
candidates.remove(candidate)
186+
candidate.transaction = null
187+
if (wasPublisher) {
188+
publishedDataCandidate = null
189+
}
190+
if (wasLeaseHolder) {
191+
leaseHolder = null
192+
}
184193

185-
if (publishedDataCandidate === candidate) {
186-
publishedDataCandidate = null
187-
scope?.let { candidate.onClearPublishedData(it) }
188-
}
194+
// No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as
195+
// the transaction can't be sent and it will time out anyway. Not finishing it has the added
196+
// advantage of keeping SDK work inside `configureScope`'s error handling.
197+
if (scope == null) {
198+
return
199+
}
189200

190-
if (leaseHolder === candidate) {
191-
handOffLease(mostRecentlyResumedCandidate(), scope)
192-
}
193-
} finally {
194-
// Scope observers and transaction callbacks can throw. Never retain a departing owner
195-
// just because cleanup failed, and preserve any successor selected during handoff.
196-
candidates.remove(candidate)
197-
candidate.transaction = null
198-
if (publishedDataCandidate === candidate) {
199-
publishedDataCandidate = null
200-
}
201-
if (leaseHolder === candidate) {
202-
leaseHolder = null
203-
}
201+
finishTransaction(transaction, scope)
202+
if (wasPublisher) {
203+
candidate.onClearPublishedData(scope)
204+
}
205+
if (wasLeaseHolder) {
206+
transferLeaseTo(mostRecentlyResumedCandidate(), scope)
204207
}
205208
}
206209

@@ -334,8 +337,8 @@ internal class NavLeaseCoordinator {
334337

335338
val transaction = candidate.transaction
336339
if (transaction?.isFinished == true) {
337-
clearTransactionFromScope(candidate, scope)
338340
candidate.transaction = null
341+
clearTransactionFromScope(transaction, scope)
339342
} else if (transaction != null) {
340343
// The navigation lease doesn't give this owner control over an unrelated transaction
341344
// already bound to the shared scope. Restore the owner's retained nav transaction only if
@@ -348,47 +351,40 @@ internal class NavLeaseCoordinator {
348351
}
349352
}
350353

354+
val previousPublisher = publishedDataCandidate
351355
leaseHolder = candidate
352356
if (candidate != null) {
357+
// Record the candidate as the publisher before calling into the scope: it owns the scope's
358+
// nav data from the moment it takes the lease. If clearing the previous owner's data then
359+
// throws, the next handoff still clears the candidate's data rather than never clearing it.
360+
//
353361
// Clear the previous publisher's nav data before publishing the candidate's, so the
354362
// previous owner's values don't linger where the candidate has nothing staged to replace
355363
// them.
356-
val previousPublisher = publishedDataCandidate
357-
try {
358-
if (previousPublisher != null && previousPublisher !== candidate) {
359-
previousPublisher.onClearPublishedData(scope)
360-
}
361-
publishedDataCandidate = candidate
362-
candidate.onPublishData(scope)
363-
} finally {
364-
// Scope observers can throw. A candidate owns the scope's nav data from the moment it
365-
// takes the lease, so record it as the publisher even if clearing the previous owner's
366-
// data threw. Leaving the previous owner recorded here would strand both owners' values
367-
// b/c the next handoff would clear data the candidate may have already replaced, and would
368-
// never clear the candidate's own.
369-
publishedDataCandidate = candidate
364+
publishedDataCandidate = candidate
365+
if (previousPublisher != null && previousPublisher !== candidate) {
366+
previousPublisher.onClearPublishedData(scope)
370367
}
368+
candidate.onPublishData(scope)
371369
}
372370
}
373371

374-
/** Finishes and unbinds [candidate]'s nav transaction, then forgets it. */
375-
private fun finishTransaction(candidate: Candidate, scope: IScope?) {
376-
// No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as
377-
// the transaction can't be sent and it will time out anyway. Not finishing it has the added
378-
// advantage of keeping SDK work inside `configureScope`'s error handling.
379-
if (scope != null) {
380-
val transaction = candidate.transaction
381-
if (transaction != null && !transaction.isFinished) {
382-
transaction.finish(transaction.status ?: SpanStatus.OK)
383-
}
384-
clearTransactionFromScope(candidate, scope)
372+
/**
373+
* Finishes [transaction] if it's unfinished, and unbinds it from [scope].
374+
*
375+
* Callers must forget the transaction first.
376+
*/
377+
private fun finishTransaction(transaction: ITransaction?, scope: IScope) {
378+
if (transaction == null) {
379+
return
385380
}
386-
387-
candidate.transaction = null
381+
if (!transaction.isFinished) {
382+
transaction.finish(transaction.status ?: SpanStatus.OK)
383+
}
384+
clearTransactionFromScope(transaction, scope)
388385
}
389386

390-
private fun clearTransactionFromScope(candidate: Candidate, scope: IScope) {
391-
val transaction = candidate.transaction ?: return
387+
private fun clearTransactionFromScope(transaction: ITransaction, scope: IScope) {
392388
scope.withTransaction { current ->
393389
if (current === transaction) {
394390
scope.clearTransaction()

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import io.sentry.IScope
44
import io.sentry.PropagationContext
55

66
/**
7-
* Holds navigation state staged for publication: a screen name and back stack nav context.
7+
* Holds navigation state staged for publication.
88
*
99
* Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e.,
1010
* breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are

‎sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt‎

Lines changed: 3 additions & 198 deletions
Original file line numberDiff line numberDiff line change
@@ -269,25 +269,9 @@ class NavLeaseCoordinatorTest {
269269
}
270270

271271
/**
272-
* An owner that leaves must not leave its nav data behind on the shared scope.
273-
*
274-
* The coordinator records which owner last published nav data. Each handoff uses that record to
275-
* clear the previous owner's screen and back stack, then publishes the incoming owner's. If the
276-
* clear attempt throws, the coordinator must still record the incoming owner as the one that last
277-
* published.
278-
*
279-
* This test runs two handoffs over three owners. The first owner publishes; the second takes the
280-
* lease; and then the first owner's clear attempt throws. The third then takes the lease. The
281-
* test verifies that third handoff clears the second owner's data, and not the first's. That lets
282-
* us ensure the second problem listed below doesn't happen.
283-
*
284-
* In the setup above, if the coordinator didn't record the second owner's data after the first
285-
* owner's clear attempt threw, two things would happen when handing off to the third owner:
286-
*
287-
* 1. Harmless: The handoff logic would try to clear the first owner's data again. That'd remove
288-
* nothing because the scope no longer holds the values the first owner published.
289-
* 2. Problematic: The handoff would never clear the second owner's data. Instead, the second
290-
* owner's data would stay on the scope, so later events would report stale nav data.
272+
* The coordinator records the incoming owner as the publisher before calling into the scope, so a
273+
* clear callback that throws mid-handoff doesn't strand the incoming owner's nav data: the next
274+
* handoff still clears it.
291275
*/
292276
@Test
293277
fun `if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() {
@@ -323,71 +307,6 @@ class NavLeaseCoordinatorTest {
323307
assertThat(fixture.cleared).containsExactly(secondOwner)
324308
}
325309

326-
/**
327-
* The same guarantee as
328-
* [if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data],
329-
* but for a failure while publishing instead of while clearing.
330-
*
331-
* This case already worked before the fix, because the coordinator records the incoming owner
332-
* before it publishes. The test exists to catch a future change that moves the record after the
333-
* publish.
334-
*/
335-
@Test
336-
fun `if a publish attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() {
337-
val fixture = Fixture.create()
338-
val navLeaseCoordinator = fixture.navLeaseCoordinator
339-
val failure = IllegalStateException("publish failed")
340-
val firstOwner = previous
341-
val secondOwner = incoming
342-
val thirdOwner = owner
343-
fixture.acquireLease(firstOwner)
344-
navLeaseCoordinator.registerCandidate(
345-
secondOwner,
346-
Lifecycle.State.RESUMED,
347-
onPublishData = { throw failure },
348-
onClearPublishedData = { fixture.cleared += secondOwner },
349-
)
350-
351-
val thrown =
352-
assertThrows(IllegalStateException::class.java) {
353-
navLeaseCoordinator.onBackStackUpdate(secondOwner, fixture.scope)
354-
}
355-
356-
assertThat(thrown).isSameInstanceAs(failure)
357-
assertThat(navLeaseCoordinator.hasLease(secondOwner)).isTrue()
358-
359-
fixture.acquireLease(thirdOwner)
360-
361-
assertThat(fixture.cleared).containsExactly(firstOwner, secondOwner).inOrder()
362-
}
363-
364-
@Test
365-
fun `unregistering an owner that threw while clearing its nav data preserves the new leaseholder's nav data`() {
366-
val fixture = Fixture.create()
367-
val navLeaseCoordinator = fixture.navLeaseCoordinator
368-
val failure = IllegalStateException("clear failed")
369-
var failClear = true
370-
fixture.acquireLease(
371-
previous,
372-
onClearPublishedData = {
373-
if (failClear) {
374-
failClear = false
375-
throw failure
376-
}
377-
fixture.cleared += previous
378-
},
379-
)
380-
fixture.register(incoming, Lifecycle.State.RESUMED)
381-
assertThrows(IllegalStateException::class.java) {
382-
navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope)
383-
}
384-
385-
navLeaseCoordinator.unregisterCandidate(previous, fixture.scope)
386-
387-
assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue()
388-
assertThat(fixture.cleared).isEmpty()
389-
}
390-
391310
@Test
392311
fun `later back stack updates do not republish nav data or reclaim the nav lease`() {
393312
val fixture = Fixture.create()
@@ -914,120 +833,6 @@ class NavLeaseCoordinatorTest {
914833
assertThat(fixture.cleared).isEmpty()
915834
}
916835

917-
@Test
918-
fun `unregistering the leaseholder forgets the owner when unbinding its transaction throws`() {
919-
val fixture = Fixture.create()
920-
val navLeaseCoordinator = fixture.navLeaseCoordinator
921-
val failure = IllegalStateException("unbind failed")
922-
val failingScope = mock<IScope>()
923-
fixture.acquireLease(owner, transaction = transaction)
924-
doThrow(failure).whenever(failingScope).withTransaction(any())
925-
926-
val thrown =
927-
assertThrows(IllegalStateException::class.java) {
928-
navLeaseCoordinator.unregisterCandidate(owner, failingScope)
929-
}
930-
931-
assertThat(thrown).isSameInstanceAs(failure)
932-
assertThat(navLeaseCoordinator.hasLease(owner)).isFalse()
933-
assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse()
934-
935-
fixture.published.clear()
936-
navLeaseCoordinator.resumeCandidate(owner, fixture.scope)
937-
fixture.acquireLease(incoming)
938-
939-
assertThat(fixture.published).containsExactly(incoming)
940-
assertThat(fixture.cleared).isEmpty()
941-
}
942-
943-
@Test
944-
fun `unregistering the leaseholder forgets the owner when clearing its published nav data throws`() {
945-
val fixture = Fixture.create()
946-
val navLeaseCoordinator = fixture.navLeaseCoordinator
947-
val failure = IllegalStateException("clear failed")
948-
fixture.acquireLease(owner, onClearPublishedData = { throw failure })
949-
950-
val thrown =
951-
assertThrows(IllegalStateException::class.java) {
952-
navLeaseCoordinator.unregisterCandidate(owner, fixture.scope)
953-
}
954-
955-
assertThat(thrown).isSameInstanceAs(failure)
956-
assertThat(navLeaseCoordinator.hasLease(owner)).isFalse()
957-
958-
fixture.published.clear()
959-
navLeaseCoordinator.resumeCandidate(owner, fixture.scope)
960-
fixture.acquireLease(incoming)
961-
962-
assertThat(fixture.published).containsExactly(incoming)
963-
assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue()
964-
}
965-
966-
@Test
967-
fun `unregistering the leaseholder forgets the owner when restoring a fallback transaction throws`() {
968-
val fixture = Fixture.create()
969-
val navLeaseCoordinator = fixture.navLeaseCoordinator
970-
val failure = IllegalStateException("fallback restoration failed")
971-
val failingScope = mock<IScope>()
972-
fixture.acquireLease(previous, transaction = transaction)
973-
fixture.acquireLease(owner)
974-
doThrow(failure).whenever(failingScope).withTransaction(any())
975-
976-
val thrown =
977-
assertThrows(IllegalStateException::class.java) {
978-
navLeaseCoordinator.unregisterCandidate(owner, failingScope)
979-
}
980-
981-
assertThat(thrown).isSameInstanceAs(failure)
982-
assertThat(navLeaseCoordinator.hasLease(owner)).isFalse()
983-
assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue()
984-
985-
fixture.published.clear()
986-
fixture.cleared.clear()
987-
navLeaseCoordinator.resumeCandidate(owner, fixture.scope)
988-
fixture.acquireLease(incoming)
989-
990-
assertThat(fixture.published).containsExactly(incoming)
991-
assertThat(fixture.cleared).doesNotContain(owner)
992-
}
993-
994-
@Test
995-
fun `unregistering the leaseholder preserves the fallback lease when publishing fallback nav data throws`() {
996-
val fixture = Fixture.create()
997-
val navLeaseCoordinator = fixture.navLeaseCoordinator
998-
val failure = IllegalStateException("fallback publication failed")
999-
var failPublication = false
1000-
navLeaseCoordinator.registerCandidate(
1001-
previous,
1002-
Lifecycle.State.RESUMED,
1003-
onPublishData = {
1004-
if (failPublication) {
1005-
throw failure
1006-
}
1007-
},
1008-
onClearPublishedData = {},
1009-
)
1010-
navLeaseCoordinator.onBackStackUpdate(previous, fixture.scope)
1011-
fixture.acquireLease(owner, transaction = transaction)
1012-
failPublication = true
1013-
1014-
val thrown =
1015-
assertThrows(IllegalStateException::class.java) {
1016-
navLeaseCoordinator.unregisterCandidate(owner, fixture.scope)
1017-
}
1018-
1019-
assertThat(thrown).isSameInstanceAs(failure)
1020-
assertThat(navLeaseCoordinator.hasLease(owner)).isFalse()
1021-
assertThat(navLeaseCoordinator.hasLease(previous)).isTrue()
1022-
assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse()
1023-
1024-
fixture.published.clear()
1025-
navLeaseCoordinator.resumeCandidate(owner, fixture.scope)
1026-
1027-
assertThat(fixture.published).isEmpty()
1028-
assertThat(navLeaseCoordinator.hasLease(previous)).isTrue()
1029-
}
1030-
1031836
private class Fixture {
1032837
val navLeaseCoordinator = NavLeaseCoordinator()
1033838
val scope = Scope(SentryOptions())

0 commit comments

Comments
 (0)