Skip to content

fix(onesignal): the deeplink handler has never been wired, and now is - #14

Merged
anilcancakir merged 6 commits into
masterfrom
feature/onesignal-click-stream
Sep 1, 2026
Merged

fix(onesignal): the deeplink handler has never been wired, and now is#14
anilcancakir merged 6 commits into
masterfrom
feature/onesignal-click-stream

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

This package advertises opening a deeplink from a push notification. It has never worked, on any release.

DeeplinkServiceProvider reached for the notification driver's onNotificationClicked and cast it to Stream<Map<String, dynamic>>. The declared type is Stream<PushNotificationEvent>, so the cast threw. The comment above it read "Assume driver has onNotificationClicked stream", and the assumption was the defect.

It could not have worked even with the right type. A consumer registers DeeplinkServiceProvider before NotificationServiceProvider, so at boot there is no driver to read a stream off, and the getter throws first.

Neither throw was ever seen, because both landed in a catch (e) whose body was two comment lines. A feature can be inert across releases when nothing is allowed to say so.

The fix

Not the cast. setup now takes the notification MANAGER and subscribes to its onPushClicked, which the manager owns from construction and republishes onto when a driver attaches later. Provider order stops mattering because there is nothing to be too early for, which is a better property than ordering advice a consumer has to remember. It is also the subject-guarded stream, so a push addressed to an identity this device no longer carries cannot drive a navigation.

The empty catch is gone. A failure now reports at error level.

The coupling stays optional

No dependency on magic_notifications was added and none will be. The coupling stays optional and structural, resolved through app.bound('notifications') and read without naming a type, because an app using deeplinks with no push at all is a normal app and must not be made to carry a notifications package.

That has one consequence worth stating plainly, since no resolver can express it: this release needs magic_notifications 0.1.0 or newer at RUNTIME, because onPushClicked does not exist before it. Paired with an older one, the handler reports at error level rather than routing. Loud is the point.

Verification

79 tests, dart analyze clean, dart format clean.

The version bump ships separately as chore(release): 0.0.3.

This package advertises opening a deeplink from a push notification. It has
never worked, on any release.

`DeeplinkServiceProvider` reached for the notification driver's
`onNotificationClicked` and cast it to `Stream<Map<String, dynamic>>`. The
declared type is `Stream<PushNotificationEvent>`, so the cast threw. The comment
above it read "Assume driver has onNotificationClicked stream", and the
assumption was the defect.

It could not have worked even with the right type. A consumer registers
`DeeplinkServiceProvider` before `NotificationServiceProvider`, so at boot there
is no driver to read a stream off, and the getter throws first.

Neither throw was ever seen, because both landed in a `catch (e)` whose body was
two comment lines. A feature can be inert across releases when nothing is
allowed to say so.

The fix is not the cast. `setup` now takes the notification MANAGER and
subscribes to its `onPushClicked`, which the manager owns from construction and
republishes onto when a driver attaches later. Provider order stops mattering
because there is nothing to be too early for, which is a better property than
ordering advice a consumer has to remember. It is also the subject-guarded
stream, so a push addressed to an identity this device no longer carries cannot
drive a navigation.

No dependency on `magic_notifications` was added and none will be. The coupling
stays optional and structural, resolved through `app.bound('notifications')` and
read without naming a type, because an app using deeplinks with no push at all
is a normal app and must not be made to carry a notifications package.

That has one consequence worth stating plainly, since no resolver can: this
release needs `magic_notifications` 0.1.0 or newer at RUNTIME, because
`onPushClicked` does not exist before it. Paired with an older one, the handler
reports at error level rather than routing. Loud is the point; the empty catch
is what made the last two years of this quiet.
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/src/handlers/onesignal_deeplink_handler.dart 89.65% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 1, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The diagnosis is right and the manager-stream subscription genuinely fixes the ordering problem, but dropping the try-catch moves a failure that used to be swallowed onto the app-boot path, and the payload type check repeats the same "assume the shape" pattern the PR is fixing.

Major

lib/src/providers/deeplink_service_provider.dart:52 (correctness) — app.make('notifications') and the onPushClicked getter now run unguarded inside boot(). MagicApp.boot() awaits providers in a bare loop with no error handling (magic-0.0.9/lib/src/foundation/application.dart:378), and make() calls the binding factory (application.dart:279), so a notifications factory that throws, or an onPushClicked getter that throws anything other than NoSuchMethodError (a StateError from an un-initialised manager - exactly what this PR's own FakeNotificationManager.pushDriver does), now aborts app boot and every provider registered after this one. The old empty catch was wrong, but the fix is a catch+Log.error, not no catch. This also diverges from .claude/rules/flutter.md: "Optional dependencies: check app.bound('key') + dynamic cast + try-catch."

lib/src/handlers/onesignal_deeplink_handler.dart:82 (correctness, stated uncertainty) — extractData only accepts data is Map<String, dynamic>. If PushNotificationEvent.data is declared or arrives as Map<String, String> or Map<Object?, Object?> - both common for payloads crossing a platform channel - the check fails, the deep link is dropped, and the user gets an error log instead of a route. I could not verify the declared type: magic_notifications is deliberately not a dependency and is not in the pub cache here, so the only evidence either way is the fake in test/handlers/onesignal_deeplink_handler_test.dart:9, which the author wrote to declare Map<String, dynamic>. That is the same class of unverified shape assumption this PR exists to fix. if (data is Map) return data.map((k, v) => MapEntry(k.toString(), v)); costs nothing and removes the question.

Minor

lib/src/providers/deeplink_service_provider.dart:52 (maintainability) — the handler is constructed inline and no reference is kept, so dispose() can never be called. doc/basics/handlers.md tells consumers to "Call this in your service provider's teardown", and CLAUDE.md lists "Missing stream disposal" as a gotcha; neither is satisfiable while the instance is discarded.

lib/src/handlers/onesignal_deeplink_handler.dart:107 — the early return on an unresolvable stream happens before _subscription?.cancel() on line 109, so calling setup a second time with a manager that publishes no onPushClicked leaves the previous subscription live.

Tests

Good coverage of what changed: routing, notifications-registered-after-deeplinks ordering, notifications absent, manager without the stream, unreadable payload, and no bound logger. Not covered: a payload whose runtime map type is not Map<String, dynamic>, and a notifications binding or getter that throws (the boot-abort path above).

Checks I ran

  • flutter analyze --no-fatal-infos - "No issues found!" (24.3s)
  • flutter test - "All tests passed!", 79 tests, matching the description
  • Read magic-0.0.9 application.dart / service_provider.dart to confirm the ordering claim (boot runs after all registers - it holds) and that boot() has no error guard
  • dart format not run

Three findings from review on #14, all confirmed in source before fixing.

`app.make('notifications')` runs the binding factory and `onPushClicked` is a
getter, so either can throw. The handler answers `NoSuchMethodError` by name,
because that one means "this build of magic_notifications is too old", but a
`StateError` out of an uninitialised manager is a different thing and escaped.
magic's `Application.boot` awaits providers in a bare loop with no error handling
(`foundation/application.dart:375`), so that escape did not degrade the deep-link
feature: it stopped the app booting and took every provider registered after this
one with it, over a plugin that is optional by design.

The resolution is now guarded and reports at error level through the same seam
every other failure here uses. This is not the empty catch this branch removed:
that one had two comment lines for a body and is why the feature stayed inert
across two years of releases. This one names what failed, then lets boot
continue. `.claude/rules/flutter.md:16` asks for exactly this shape.

The handler was also constructed inline and its reference discarded, so the
`dispose` that `doc/basics/handlers.md` tells consumers to call from provider
teardown could never reach it. The provider now holds what it wired and exposes
`dispose()`.

And `setup`'s early return for an unresolvable stream sat before the cancel, so
re-wiring against a manager this handler cannot follow left it routing taps
through the previous one. The cancel moves ahead of the return.

The review's fourth finding, that `extractData` should accept any `Map` rather
than `Map<String, dynamic>`, is not taken. The reviewer said it could not verify
the declared type because `magic_notifications` is deliberately not a dependency;
it is `Map<String, dynamic>` (`push_driver.dart:7`), non-nullable, and both
drivers narrow to it before publishing (mobile passes `additionalData ?? {}`,
web's `_payloadOf` returns an already-narrowed map or a context-typed `const {}`).
A `Map<String, String>` satisfies the check anyway under Dart's covariance, and a
`Map<Object?, Object?>` cannot reach the field without a cast that throws at the
driver. Loosening it would defend against a state the type system prevents.

Four tests, each verified to go red against a mutant of the line it covers: a
throwing factory and a throwing getter both leave `app.isBooted` true and log,
`dispose()` stops routing, and a second `setup` against an unfollowable manager
drops the first subscription.
@anilcancakir

Copy link
Copy Markdown
Contributor Author

Thanks, three of the four are real and are fixed in 0e93850. I verified each against the source before acting.

Major 1, unguarded resolution aborting boot: confirmed and fixed. Application.boot is a bare loop (magic/lib/src/foundation/application.dart:375, no try/catch), and _resolveClickStream only answers NoSuchMethodError, so a StateError from an uninitialised manager or a throwing binding factory does take the app down. Guarded now, reporting at error level through the same seam. You are right that the fix is catch + Log.error rather than no catch, and it does not reintroduce what this branch removed: the old one had two comment lines for a body.

Minor 1, unreachable dispose: confirmed and fixed. The provider holds the handler it wired and exposes dispose().

Minor 2, cancel after the early return: confirmed and fixed. The cancel moves ahead of the return.

Major 2, the payload type check: not taken, and I think it is refuted rather than deferred. You flagged your own uncertainty here and said magic_notifications was not available to check; I have it in the workspace. PushNotificationEvent.data is declared Map<String, dynamic> and non-nullable (lib/src/drivers/push/push_driver.dart:7). Both publishers narrow to it before construction: mobile passes event.notification.additionalData ?? {} (onesignal_driver.dart:127), and web's _payloadOf returns either an is Map<String, dynamic>-narrowed value or a const {} whose type is inferred from the Map<String, dynamic> return context (onesignal_web_driver.dart:282-289). So the runtime type cannot be Map<Object?, Object?> without a cast that throws at the driver, and a Map<String, String> already satisfies is Map<String, dynamic> under Dart's covariance. Remapping would guard a state the type system prevents.

On the coverage gaps you named, both are now covered, and each test was verified to go red against a mutant of the exact line it covers: a throwing factory and a throwing getter each leave app.isBooted true and log at error level, dispose() stops routing, and a second setup against an unfollowable manager drops the first subscription.

83 tests, dart analyze clean, dart format clean.

@kodizm

kodizm Bot commented Sep 1, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental review of 0e93850 only. All three findings I raised are fixed as described, the refutation of the fourth holds, and the new commit introduces no defect I can find; one documentation gap left.

Since my last review: the notifications resolution in boot() is wrapped in try/catch + Log.error guarded by Magic.bound('log') (lib/src/providers/deeplink_service_provider.dart:82-94), the provider holds _pushClicks and exposes dispose() (:14, :22), and _subscription?.cancel() now runs before the clicks == null return (lib/src/handlers/onesignal_deeplink_handler.dart:111-114). Each has a test that names the exact behaviour. On the payload type check: your reading of PushNotificationEvent.data as declared non-nullable Map<String, dynamic> with both publishers narrowing before construction settles it — that finding is withdrawn, not deferred.

Minor

doc/architecture/service-provider.md:118 (maintainability) — the docs were not synced for this commit, which CLAUDE.md's post-change checklist asks for. The OneSignal snippet still shows the unguarded OneSignalDeeplinkHandler().setup(manager, app.make('notifications'));, the "two layers of defence" list below it does not mention the new third one, and the provider's new public dispose() appears nowhere in this page (grep -n dispose doc/architecture/service-provider.md returns nothing). CHANGELOG.md covers both, so this is drift in doc/ alone.

lib/src/providers/deeplink_service_provider.dart:22 (maintainability, low impact) — dispose() tears down only the push-click handler; the driver.onLink.listen(...) subscription from line 39 and the driver itself survive it. The doc comment scopes the claim honestly ("the push-click subscription this provider owns"), so nothing is wrong today, but a consumer following doc/basics/handlers.md's "call this in your service provider's teardown" will reasonably read a provider-level dispose() as tearing down the provider.

Tests

Both gaps I named are now covered and both assert the property that matters (app.isBooted still true, error-level log emitted): a throwing notifications factory, a throwing onPushClicked getter, dispose() stopping routing, and a second setup against an unfollowable manager dropping the first subscription. 83 tests, up from 79.

Checks I ran

  • flutter analyze --no-fatal-infos — "No issues found! (ran in 15.7s)"
  • flutter test — "All tests passed!", 83 tests, matching the reply
  • dart format --output=none --set-exit-if-changed . — "Formatted 26 files (0 changed)"
  • grep -n "dispose" doc/architecture/service-provider.md — no output, the basis for the first finding

…vider

Two findings from the second review pass on #14, both real.

`dispose()` tore down only the push-click handler while the driver's own link
subscription and the driver itself survived it. The doc comment scoped that
claim honestly, so nothing was wrong today, but `doc/basics/handlers.md` tells a
consumer to call teardown from their service provider, and a provider-level
`dispose()` that leaves the provider running is a name arguing against its own
behaviour. It now drops all three and is idempotent, because a consumer calling
it does not know which parts a given deployment wired.

`doc/architecture/service-provider.md` was not synced with the previous commit,
which this repo's post-change checklist asks for. The OneSignal snippet still
showed the unguarded call, the "two layers of defence" list was one short, and
`dispose()` appeared nowhere on the page. All three fixed, plus a short teardown
section the handlers doc can point at.

The teardown test asserts what is actually observable, that dispose completes on
a driver-wired provider and repeats safely, and a comment says plainly what it
does NOT cover: `AppLinksDriver.onLink` is the `app_links` package stream with
no injection seam, so a unit test cannot emit on it and the cancellation itself
is read from the source rather than exercised.

84 tests, analyze clean, format clean.
@kodizm

kodizm Bot commented Sep 1, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental review of 79ff312 only. Both minors from my last pass are addressed - the provider page now documents the guarded resolution, the third layer of defence and a Provider Teardown section, and dispose() is provider-level rather than push-only. One residual gap in the new dispose(), which the doc's own claim makes visible.

Minor

lib/src/providers/deeplink_service_provider.dart:38 (correctness, low impact) - dispose() drops the provider's _driver field but not the manager's. boot() calls manager.setDriver(driver) (:58) on the DeeplinkManager singleton, and nothing calls the forgetDriver() that exists for this (lib/src/deeplink_manager.dart:44), so after await provider.dispose() the singleton still returns the disposed driver from manager.driver and manager.getInitialLink() still calls through it. Concretely: dispose, then anything reaching Deeplink.getInitialLink() routes through a driver the provider believes it tore down. That's benign today only because AppLinksDriver.dispose() is a no-op comment (lib/src/drivers/app_links_driver.dart:44-47); it stops being benign the moment that method does something.

lib/src/providers/deeplink_service_provider.dart:68 (correctness, low impact) - the Future.delayed(Duration.zero, ...) initial-link fetch is fire-and-forget and holds no cancellation flag, so a dispose() in the same turn as boot() does not stop it: the callback still runs and still calls manager.handleUri(uri), routing a deep link after teardown. Narrow window, but dispose() is documented as dropping "everything boot() wired", and this is the one thing it can't reach.

Both are the same shape: the doc and CHANGELOG claim total teardown, and the manager-side driver plus the pending timer sit outside it. Either narrow the claim or add manager.forgetDriver() and a disposed flag the delayed callback checks.

Tests

The new test pins that dispose() completes on a driver-wired provider and is idempotent, and the comment above it is honest about what it does not cover (the link subscription actually ceasing, untestable without an injection seam in AppLinksDriver). Neither point above is covered - manager.driver after dispose is assertable without any seam.

Checks I ran

  • flutter analyze --no-fatal-infos - "No issues found! (ran in 15.2s)"
  • flutter test - "All tests passed!", 84 tests
  • dart format --output=none --set-exit-if-changed . - "Formatted 26 files (0 changed)"
  • Read deeplink_manager.dart and app_links_driver.dart for the two findings above; grep -rn "forgetDriver" lib/ returns only the declaration, no caller

Both findings from the third review pass on #14, and both are the same shape:
the doc I wrote last commit claimed total teardown while two things sat outside
it. The claim is the better half to keep, so the code moved to meet it.

`boot` calls `manager.setDriver(driver)` on the `DeeplinkManager` singleton, and
`dispose()` cleared only the provider's own field, so afterwards `manager.driver`
still answered with a driver this provider believed it had torn down and
`getInitialLink()` still called through it. `forgetDriver()` has existed on the
manager the whole time with no caller; it has one now. Benign today only because
`AppLinksDriver.dispose()` is an empty method, which is not a property to build
on.

The initial-link read is scheduled with `Future.delayed(Duration.zero)` and
there is no handle to cancel that with, so a teardown in the same turn as boot
left the callback to run and route a deep link afterwards. It checks a disposed
flag on both sides of its await now: before the read for the same-turn case,
after it for a teardown that lands while the read is in flight.

The manager-driver fix has a test, mutation-verified. The disposed flag does NOT,
and a comment in the test file says so rather than implying otherwise. One was
written and deleted: it stayed green against a build with both checks removed,
because `forgetDriver()` makes the callback's `getInitialLink()` raise into a
fire-and-forget future that swallows it, so the assertion held either way.
Isolating it needs a `getInitialLink()` that answers a URI, which needs a seam
`AppLinksDriver` does not have. The checks stay because relying on that throw is
the swallowed-error shape this release exists to remove; an early return is the
deliberate version of the same outcome.

Writing the first test also corrected an assumption: `DeeplinkManager.driver`
raises `DeeplinkException` when nothing is configured rather than answering
null, so the assertion is that reaching for it throws.

85 tests, analyze clean, format clean.
@anilcancakir

Copy link
Copy Markdown
Contributor Author

Both fixed in e31be35, and you framed the choice correctly: the claim was the better half to keep, so the code moved to meet it rather than the doc narrowing.

manager.forgetDriver(): confirmed and fixed. grep -rn "forgetDriver" lib/ returning only the declaration was the giveaway. It has a caller now, guarded on there being a driver to forget. Test included, mutation-verified.

The scheduled initial-link read: confirmed and fixed, with a disposed flag checked on both sides of the await, since a teardown can also land while getInitialLink() is in flight and not only before it.

On covering the second one, a report rather than a claim. I wrote the test you would expect, it passed, and then it also passed against a build with both checks removed. The reason is the first fix: forgetDriver() makes the callback's getInitialLink() raise "no driver configured" into a fire-and-forget future that swallows it, so handler.handled is empty either way. Isolating the flag needs a getInitialLink() that answers a URI, which needs a seam AppLinksDriver does not have. I deleted the test and left a comment in its place saying exactly that, rather than shipping a green assertion that proves nothing. The checks stay because relying on that throw is the swallowed-error shape this release exists to remove; an early return is the deliberate version of the same outcome.

Writing the first test also corrected an assumption of mine worth noting for the file: DeeplinkManager.driver raises DeeplinkException when nothing is configured rather than answering null, so the assertion is that reaching for it throws, which is the honest post-teardown state anyway.

85 tests, dart analyze clean, dart format clean.

@kodizm

kodizm Bot commented Sep 1, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental review of e31be35 only. Both minors from my last pass are fixed as described - dispose() now calls manager.forgetDriver() under the driver guard, and the scheduled initial-link read checks _disposed on both sides of its await. No new defect I can find; one narrow residual below.

The two _disposed checks are placed correctly, and better than the comment claims: if (_disposed) return and manager.getInitialLink()'s read of the driver getter sit in the same synchronous segment (deeplink_manager.dart:72 reaches driver before its first suspension), so a teardown cannot slip between them and turn the callback into an unhandled DeeplinkException in a fire-and-forget future. Your report on the deleted test stands as written: forgetDriver() does make that assertion pass against a mutant, and the comment in its place (test/providers/deeplink_service_provider_test.dart:288-302) is the right thing to ship instead.

Minor

lib/src/providers/deeplink_service_provider.dart:83 (correctness, low impact) - a dispose() that lands during boot() rather than after it still leaves a live subscription: _driver is set at :78, then await driver.initialize(...) (:80) suspends, and a teardown in that window clears _driver and forgets the manager's, after which boot resumes and assigns _links at :83 - a subscription created after teardown, which nothing will ever cancel. The _disposed flag already exists and already guards the callback below it; the same check before the listen would close it. Narrow enough that I would not block on it, but it is the same class as the one you just fixed and the doc still says "everything boot() wired".

Tests

The new test (:274) pins the finding it covers directly - manager.driver raising DeeplinkException after teardown - and asserting the throw rather than a null is the honest post-teardown state, since driver has always raised. The window above is not covered and would need the same seam the deleted test lacked.

Checks I ran

  • flutter analyze --no-fatal-infos - "No issues found! (ran in 16.5s)"
  • flutter test - "All tests passed!", 85 tests, matching the reply
  • dart format --output=none --set-exit-if-changed . - "Formatted 26 files (0 changed)"
  • Read deeplink_manager.dart to check whether getInitialLink() can suspend before reading driver - it cannot, which is what makes the second _disposed check sufficient

…subscription

Reported on #14. `_driver` is assigned, then `await driver.initialize(...)`
suspends; a teardown in that window cleared `_driver` and forgot the manager's,
and then boot resumed and assigned `_links` anyway, creating a subscription
AFTER the teardown meant to have caught it, which nothing would ever cancel.
Same class as the scheduled read below it, and the flag that already existed
answers it.

It returns out of `boot` rather than skipping the one block, deliberately:
attaching the push-click handler further down to a provider somebody has torn
down is the same defect one block later. The driver is disposed on the way out,
because `dispose()` ran before that assignment and never saw it.

Not covered by a test, for the reason the deleted one taught: `AppLinksDriver`
offers no seam to suspend `initialize()` from a test, so any assertion would
pass against the unguarded build too.

85 tests, analyze clean, format clean.
@anilcancakir

Copy link
Copy Markdown
Contributor Author

Fixed in d49106d. Confirmed exactly as you described: _driver is assigned, await driver.initialize(...) suspends, and a teardown in that window was followed by boot resuming and subscribing anyway.

It returns out of boot rather than skipping just that block, which is a decision worth naming: attaching the push-click handler further down to a provider somebody has torn down is the same defect one block later. The driver is disposed on the way out, since dispose() ran before that assignment and never saw it.

Not covered by a test, and I am saying that rather than shipping one: AppLinksDriver offers no way to suspend initialize() from a test, so any assertion would pass against the unguarded build too, which is the lesson the deleted test already taught on this file.

Thank you for checking the placement of the two _disposed checks against deeplink_manager.dart:72 and finding them better than my comment claimed. I had written them defensively without confirming the synchronous segment, so that is a real correction to what I believed, in the direction of the code being sounder than I could argue for.

85 tests, analyze clean, format clean.

@kodizm

kodizm Bot commented Sep 1, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental review of d49106d only. The guard closes the window I described - a teardown during await driver.initialize(...) no longer leaves a subscription behind - and the CHANGELOG entry was extended for it. One residual, and it is in the reasoning the comment gives rather than in the outcome.

Minor

lib/src/providers/deeplink_service_provider.dart:91 (maintainability / correctness, low impact) - the comment says "The driver is disposed here because dispose() ran before this assignment and never saw it", and that premise cannot hold. _driver = driver is at :78, the only suspension in the block is await driver.initialize(...) at :80, and boot() sets _disposed = false at :70 before any of it - so the only way to reach :93 with _disposed true is a teardown landing in that await, by which point dispose() has already seen _driver, called driver.dispose() and forgetDriver() (:55-65). The driver.dispose() at :94 is therefore always a second dispose of an already-disposed driver, never a first one. Benign today for the same reason as before, AppLinksDriver.dispose() is an empty body (lib/src/drivers/app_links_driver.dart:44-47), and it stops being benign under the same condition: the moment that method releases something, this releases it twice. Either drop the call and say the teardown already handled the driver, or keep it and make AppLinksDriver.dispose() idempotent - but the comment as written will send the next reader looking for a path that does not exist.

Nothing else in the diff. The return out of boot rather than a skipped block is the right call for the reason you gave, and skipping the notifications wiring below is a consequence I would want, not one to guard separately.

Tests

Not covered, and your reply says so plainly and correctly: AppLinksDriver.initialize() offers no seam to suspend from a test, so an assertion here would pass against the unguarded build too. The residual above is assertable without a seam though - a driver double-dispose counter would pin it - if the call stays.

Checks I ran

  • flutter analyze --no-fatal-infos - "No issues found! (ran in 38.1s)"
  • flutter test - "All tests passed!", 85 tests, matching the reply
  • dart format --output=none --set-exit-if-changed . - "Formatted 26 files (0 changed)"
  • Read deeplink_service_provider.dart in full and app_links_driver.dart to check the ordering claim above and whether the second dispose costs anything today

Reported on #14, and the finding is against my own reasoning rather than the
outcome. The comment claimed the driver had to be disposed there because
`dispose()` ran before the assignment and never saw it. That path does not
exist: `_driver = driver` sits before the only suspension in the block, so the
only way to reach the guard with the flag set is a teardown inside that await,
and by then `dispose()` has already seen `_driver`, disposed it and forgotten
the manager's.

So the call was always a second dispose, benign only while
`AppLinksDriver.dispose()` has an empty body and stopping the moment it
releases anything. Dropped, and the comment now says which path actually
reaches the line instead of inventing one.

85 tests, analyze clean, format clean.
@anilcancakir

Copy link
Copy Markdown
Contributor Author

Fixed in 6739c27, and this one is a finding against my reasoning rather than my outcome, which is the harder kind to catch.

You are right that the path the comment described does not exist. _driver = driver sits before the only suspension in the block, so the only way to arrive at the guard with the flag set is a teardown inside that await, and by then dispose() has already seen _driver, disposed it and forgotten the manager's. My driver.dispose() was therefore always a second dispose, benign only while AppLinksDriver.dispose() has an empty body, which is exactly the condition I said not to build on two commits ago and then built on.

Dropped rather than made idempotent: there is no first-dispose path here to protect, so a second dispose is not a case to support. The comment now names the path that actually reaches the line instead of inventing one.

85 tests, analyze clean, format clean.

@anilcancakir
anilcancakir merged commit a618df8 into master Sep 1, 2026
4 checks passed
anilcancakir added a commit that referenced this pull request Sep 1, 2026
…#15)

Cuts 0.0.3, carrying the handler repair merged in #14.

Five version sites, three of which were still on 0.0.1 because the 0.0.2 release moved only the pubspec and the changelog. A sweep for the current version would have missed the same three again, which is the argument for grepping the shape.

One changelog claim expired while the branch was open: it said onPushClicked arrives in a magic_notifications 0.1.0 that was not yet released. That went out earlier today, so the entry states the floor as a fact now.

85 tests, analyze clean, format clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant