Skip to content

Deep links and app links

As of 2026-10-19 (vulture-ios 3176c4cc4, vulture-android 85fd9cc6, vulture-web 61d304e).

Vulture opens from outside in three ways:

  • the custom scheme vulture://open/…;
  • the web links https://vulture.aero/open/…, which are universal links on iOS and verified App Links on Android;
  • on Android only, the addresses of the Soaring Spot and Charron.line pages themselves.

All three lead to the same small set of /open/… paths. The paths are the contract. Each platform has its own parser for them, and the two parsers must accept the same paths in the same order.

The paths are checked in this order. The first prefix that matches wins (iOS route(url:in:), Android OpenLink.parse).

#PathOpensiOSAndroid
1/open/charronline/igc/<year>/<file>Downloads https://www.charronline.be/charronline/igc/<year>/<file> and opens it over the logbookopenCharronFlight, URL(charronFlightURL:)OpenLink.CharronFlight
2/open/soaringspot/flight/<id>Downloads https://www.soaringspot.com/en_gb/download-contest-flight/<id>?dl=1 and opens itopenSoaringSpotFlight, URL(flightURL:)OpenLink.SoaringSpotFlight
3/open/soaringspot/results/<comp>[/<class>/<day>]The competition, on that day’s scoreboard when both class and day are presentSoaringSpotDayLinkOpenLink.SoaringSpotResults
4/open/soaringspot[/<comp>], and any other path that starts with /open/soaringspotThe competition, or the Soaring Spot catalogue when the path names no competitionSoaringSpotCompetition(competitionURL:)OpenLink.SoaringSpot (competitionId may be null)
5/open/flight?id=<Core Data URI>A logbook flight, from the iOS Flight widgetopenFlight(with:in:)Ignored: the app only comes to the front

Sources:

  • vulture-ios/Modules/App/Sources/Application/SceneDelegate+Routing.swift
  • vulture-ios/Packages/SoaringSpot/Sources/SoaringSpot/Models/SoaringSpotDayLink.swift and SoaringSpotCompetition.swift
  • vulture-ios/Packages/SoaringSpot/Sources/SoaringSpot/Extensions/URL+SoaringSpot.swift
  • vulture-android/domain/services/src/main/kotlin/be/fousa/vulture/domain/services/link/OpenLink.kt
  • vulture-android/app/src/main/kotlin/be/fousa/vulture/services/AppLinks.kt

Tests that pin the paths:

  • iOS: vulture-ios/Packages/SoaringSpot/Tests/SoaringSpotTests/DeepLinkTests.swift and SoaringSpotDayLinkTests.swift
  • Android: vulture-android/domain/services/src/test/.../link/OpenLinkTest.kt and vulture-android/app/src/test/.../services/AppLinksTest.kt

Timing differs. iOS waits until the app is open before it routes a link (whenSubscribed), so a link never closes the launch paywall. Android routes at once, and it skips the first-launch paywall when the app was started by a link.

The iOS Flight widget (“On this day”) links to vulture:///open/flight?id=<id>. The id is flight.objectID.uriRepresentation(), a local Core Data object URI (vulture-ios/Packages/Persistence/Sources/Persistence/Widgets/WidgetStore.swift). It means something only to the store on the device that made it. It must never appear in a link that crosses devices or platforms. Android leaves it out on purpose: AppLinksTest asserts that it is not an App Link.

A portable flight link needs a portable id. See Flight identity. Spotlight and the App Intents FlightEntity also key flights by the Core Data URI. That is fine as long as they stay on the device.

iOSAndroid
DeclaredCFBundleURLSchemes = [vulture] in vulture-ios/Modules/App/Resources/Info.plistVIEW filter with android:scheme="vulture" on MainActivity (vulture-android/app/src/main/AndroidManifest.xml)
Host normalisationURL.routableDeepLink (vulture-ios/Packages/Utilities/Sources/Utilities/URL+DeepLink.swift): the host is put back in front of the path, so vulture://open/x and vulture:///open/x route the sameOpenLink.parse does the same. The host must be empty or be read as the first path component
Who writes these linksThe Chrome extension writes vulture://open/…. The Flight widget writes vulture:///open/flight?id=…Nothing on Android
iOS appiOS App ClipAndroid
Claimapplinks:vulture.aero (vulture-ios/Modules/App/Resources/Entitlements/Release.entitlements)appclips:vulture.aero (vulture-ios/Modules/Clip/Resources/Entitlements/Release.entitlements)<intent-filter android:autoVerify="true">, https, host vulture.aero, pathPrefix="/open/"
Paths claimed/open/* and /auth/weglide (from the AASA)the /open/ links configured per experience in App Store Connect/open/* only
Verified by/.well-known/apple-app-site-associationthe same file, appclips section/.well-known/assetlinks.json

The base URL is Vulture.universalHost / Vulture.deepLinkURL = https://vulture.aero (vulture-ios/Packages/Preferences/Sources/Preferences/Vulture.swift).

/auth/weglide is the old WeGlide OAuth redirect (Vulture.swift, redirectURL). The app now authenticates to WeGlide with an API key, so the claim looks like a leftover. Android does not claim it, and vulture-web has no route for it.

Both files are built as JSON at request time. Neither is a static file: public/.well-known/ only holds pki-validation/.

FileSourceContent
/.well-known/apple-app-site-associationvulture-web/app/association.rbapplinks.details[0].appIDs: 743AM63YFU.be.fousa.vulture and 743AM63YFU.be.fousa.vulture.debug. Components /auth/weglide and /open/*. appclips.apps: 743AM63YFU.be.fousa.vulture.clip and 743AM63YFU.be.fousa.vulture.debug.clip
/.well-known/assetlinks.jsonvulture-web/app/asset_links.rbOne delegate_permission/common.handle_all_urls statement per package that has at least one fingerprint. RELEASE_FINGERPRINTS (be.fousa.vulture) has one SHA-256: Play signs with the upload key, so that one certificate covers Play installs and side-loaded release builds. DEBUG_FINGERPRINTS (be.fousa.vulture.debug) has one developer machine’s debug key. Specs: vulture-web/spec/app_spec.rb

The web fallback page (GET /open/*, vulture-web/views/open.haml) is described in vulture-web routes.

The App Clip (be.fousa.vulture.clip, vulture-ios/Modules/Clip/Sources/) shows Soaring Spot links to people who do not have the app. ClipRouter (Modules/Clip/Sources/Application/ClipRouter.swift) uses the same parsers as the app and handles a subset of the paths:

PathClip shows
/open/soaringspot/The competition catalogue (the clip’s root)
/open/soaringspot/<id>That competition
/open/soaringspot/results/<id>/<class>/<day>That competition’s day
anything elseThe catalogue, not an error

Two more pieces:

  • Test invocation URL: https://vulture.aero/open/soaringspot/results/jwgc2026/club/task-4-on-2026-08-06, passed as _XCAppClipURL in vulture-ios/XcodeGen/Modules/Clip.yml.
  • Smart App Banner: the site’s banner names the clip with app-id=1468252627, app-clip-bundle-id=be.fousa.vulture.clip (vulture-web/app/helpers/app_store.rb), so Safari can offer the clip on a covered URL.

Android has no equivalent. An Instant App is out of scope for v1 (roadmap).

On Apple platforms, browser extensions put an “Open in Vulture” link on the third-party pages. Chrome for Android runs no extensions, so Android reads the pages’ own addresses instead.

ExtensionPages (matches)Link it writes
Safari (vulture-ios/Modules/Safari/Sources/Web/)*://www.soaringspot.com/*/daily, */total, */tasks/*, and proef.php on lvzc.be, www.lvzc.be, charronline.be, www.charronline.behttps://vulture.aero/open/…. The base URL comes from the native SafariWebExtensionHandler.swift (Vulture.deepLinkURL)
Chrome (vulture-ios/Chrome/, manifest v3, version 1.0.1)The same pagesvulture://open/… (baseURL = "vulture:/" in each script), because Chrome on the Mac ignores universal links

What each script links to:

  • Soaring Spot daily page (soaringspot_daily.js): each flight pop-up to /open/soaringspot/flight/<id>, and the page to /open/soaringspot/results/<comp>/<class>/<day>.
  • Totals and task pages: the results link.
  • Charron.line proef.php (charron.js): /open followed by the page’s IGC link.

Android intent filters (vulture-android/app/src/main/AndroidManifest.xml)

Section titled “Android intent filters (vulture-android/app/src/main/AndroidManifest.xml)”
FilterHostsPathsVerified
Soaring Spotwww.soaringspot.com, soaringspot.com (http and https)/.*/results/.*/.*/daily, /.*/results/.*/.*/total, /.*/tasks/.*/.*, /.*/download-contest-flight/.*No (autoVerify="false")
Charron.linewww.lvzc.be, lvzc.be, www.charronline.be, charronline.be (http and https)/charronline/igc/.*No
Share targetShareLinkActivity, SEND text/plain, labelled “Open in Vulture”The first Soaring Spot or Charron.line link in the shared text (OpenLink.inText), passed on to MainActivityn/a

We do not own these hosts, so they cannot be verified. On Android 12 and later such a link opens in the browser until the pilot turns on Settings › Apps › Vulture › Open by default › Open supported links. On Android 10 and 11 a chooser offers Vulture.

What OpenLink reads from those addresses (OpenLink.website):

PageOpens
soaringspot.com/<lang>/<comp>/results/<class>/<day>/daily or …/totalThe competition on that day
soaringspot.com/<lang>/<comp>/tasks/<class>/<day>The same day
soaringspot.com/<lang>/<comp>/results, …/pilotsThe competition
soaringspot.com/<lang>/download-contest-flight/<id>The flight, downloaded
lvzc.be or charronline.be /charronline/igc/<year>/<file>The Charron.line flight, downloaded

Not read: a proef.php page (its IGC link is only in the HTML) and a bare competition page /<lang>/<comp>, whose address does not say what kind of page it is.

EntryiOS appiOS App ClipAndroid
https://vulture.aero/open/soaringspot…YesYes (catalogue, competition, day)Yes
https://vulture.aero/open/soaringspot/flight/<id>YesNo (root)Yes
https://vulture.aero/open/charronline/igc/…YesNo (root)Yes
https://vulture.aero/open/flight?id=Yes (local id)NoNo, ignored
https://vulture.aero/auth/weglideClaimed in the AASA, no routeNoNo
vulture://open/…YesNoDeclared, but dropped by AppLinks.of (see above)
soaringspot.com / charronline.be page addressesNo (the extensions rewrite them)NoYes, unverified filters and the share target
Home-screen quick actionsbe.fousa.vulture.shortcut.{createtask,openmap,startrecording,stoprecording} (Modules/App/Sources/Shell/Menus/Shortcut.swift)NoNone found
  1. Add the path on both parsers, and to the App Clip router if the clip should show it. Keep the prefix order above.
  2. Identify content portably, never by a local database id. See Flight identity.
  3. Any new top-level path outside /open/ needs a new AASA component and a new Android intent filter.
  4. Universal links and App Links only work when vulture-web serves the path. GET /open/* already does (see routes).