Skip to content
grovs-ioPublic

About

Open-source deep linking and first-party attribution SDK for iOS. Universal Links, deferred deep linking, and revenue tracking in Swift. MIT-licensed alternative to Branch.io, AppsFlyer, and Firebase Dynamic Links.

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Repository files navigation

Grovs

Latest release Swift versions Platforms CocoaPods MIT License GitHub stars

Deep linking, attribution, and smart links for iOS.
Part of the Grovs open-source mobile linking platform.

Quick Start · API Reference · Full Docs


The Grovs iOS SDK provides deep linking, universal linking, link generation, in-app messaging, event and screen tracking, revenue tracking, and attribution for iOS apps built with UIKit or SwiftUI.

Upgrading from 2.x? See the 3.0 upgrade guide.

Features

  • Deep linking & universal links — route users to the right in-app screen, even after install
  • Smart link generation — create trackable links with metadata, custom redirects, and UTM parameters
  • Events & screen tracking — custom events, automatic screen views for UIKit and SwiftUI
  • In-app messaging — display messages and announcements from the Grovs dashboard
  • Push notifications — receive push campaigns, report opens and follow their links
  • Revenue tracking — log StoreKit 2 and custom purchases with automatic attribution
  • User identity — attach user IDs and attributes for analytics and segmentation
  • Consent gating — collect nothing until the user opts in
  • Self-hosting support — point the SDK at your own backend

Requirements

  • iOS 13.0+
  • Swift 5.9+ (Xcode 15+)

Installation

Swift Package Manager

  1. In Xcode, go to File → Add Package Dependencies…
  2. Enter the repository URL: https://github.com/grovs-io/grovs-iOS.git
  3. Choose Up to Next Major Version starting at 3.1.0
  4. Add the Grovs library to your app target

CocoaPods

Add the pod to your Podfile:

pod 'Grovs', '~> 3.1'

Then run:

pod install

Quick Start

1. Initialize the SDK

Import the module and configure the SDK in your AppDelegate:

import Grovs

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Optional: verbose logging while integrating
    Grovs.setDebug(level: .info)

    Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, delegate: self) { success in
        if success {
            print("Grovs SDK is ready")
        }
    }

    // Optional: identify the user (after configure)
    Grovs.userIdentifier = "user_id_from_your_app"
    Grovs.userAttributes = ["name": "John Doe", "plan": "premium"]

    return true
}

Automatic screen tracking is on by default; pass autoTrackScreenViews: false to turn it off. For self-hosted backends, pass the baseURL parameter (domain only — the SDK appends the API path):

Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, baseURL: "https://your-domain.com", delegate: self)

2. Forward delegate calls

Apps using SceneDelegate

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    Grovs.handleSceneDelegate(openURLContexts: URLContexts)
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    Grovs.handleSceneDelegate(continue: userActivity)
}

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    Grovs.handleSceneDelegate(options: connectionOptions)
}

Apps using AppDelegate only

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    return Grovs.handleAppDelegate(continue: userActivity, restorationHandler: restorationHandler)
}

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    return Grovs.handleAppDelegate(open: url, options: options)
}

3. Handle deep links

Conform to the GrovsDelegate protocol to receive deep link callbacks. The delegate is held weakly, so assign an object that lives as long as your app:

class YourViewController: UIViewController, GrovsDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        Grovs.delegate = self
    }

    func grovsReceivedPayloadFromDeeplink(link: String?, payload: [String: Any]?, tracking: [String: Any]?) {
        // Route the user based on payload data
        if let screen = payload?["screen"] as? String {
            navigateTo(screen)
        }
    }
}

GrovsDelegate also has an optional grovsDidEncounterError(_:message:) for surfacing SDK errors to your own logging.

You can also retrieve past payloads:

// Get the most recent payload
Grovs.lastReceivedPayload { payload in
    print("Last payload: \(payload)")
}

// Get all payloads received since app launch
Grovs.allReceivedPayloadsSinceStartup { payloads in
    guard let payloads = payloads else { return }
    for payload in payloads {
        print("Payload: \(payload)")
    }
}

Clipboard-assisted deferred deep linking

When a link with copy-to-clipboard enabled is opened in a browser, the preview page copies it to the visitor's clipboard before sending them to the App Store. On the first launch with the SDK, it reads the link and attributes the install deterministically; the deferred link arrives through the same GrovsDelegate callback. This works with no integration. iOS may show its paste confirmation on that launch — it is skipped for projects with no clipboard-enabled link clicks in the last 48 hours and for devices whose clipboard holds no web URL. Denying it simply leaves the install unattributed.

If your project serves links from a custom domain, list it so the SDK recognizes your links (content on any other host is never read or sent):

Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, clipboardDomains: ["links.yourdomain.com"], delegate: self)

Link Generation

Create smart links with metadata, payload data, and tracking parameters:

Grovs.generateLink(
    title: "Check out this product",
    subtitle: "Limited time offer",
    imageURL: "https://example.com/image.jpg",
    data: ["productId": "12345", "screen": "product_detail"],
    tags: ["promotion", "share"],
    trackingCampaign: "spring_sale",
    trackingSource: "in_app",
    trackingMedium: "share_button"
) { url in
    guard let url = url else { return }
    print("Generated link: \(url)")
}

To override the project's copy-to-clipboard setting for a single link, pass copyToClipboardiOS / copyToClipboardAndroid — nil inherits the project default.

Custom redirects

Override where a link sends users on each platform:

let redirects = CustomRedirects(
    ios: CustomLinkRedirect(link: "https://example.com/ios-promo"),
    android: CustomLinkRedirect(link: "https://example.com/android-promo"),
    desktop: CustomLinkRedirect(link: "https://example.com/desktop-promo", openAppIfInstalled: false)
)

Grovs.generateLink(title: "Special offer", data: ["promoId": "summer25"], customRedirects: redirects) { url in
    guard let url = url else { return }
    print("Generated link: \(url)")
}

Share sheet

Present a share sheet after generating a link:

Grovs.generateLink(title: "Share this", data: ["itemId": "abc"]) { url in
    guard let url = url else { return }
    let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
    self.present(activityVC, animated: true)
}

Events & Screen Tracking

Track custom events with optional properties and tags:

Grovs.track("purchase", properties: ["item": "sku-42", "price": 19.99], tags: ["checkout"])
Grovs.trackScreenView("Checkout", properties: ["section": "payment"])
Grovs.setGlobalTags(["beta"])   // attached to every later event; nil clears

Events are written to disk, sent in batches, and retried on failure. Every custom event carries a screen_name property naming the most recently viewed screen.

Screen tracking

With autoTrackScreenViews enabled (the default), the SDK tracks UIKit view controllers by class name and SwiftUI screens by view type. SwiftUI screens whose name can't be resolved unambiguously (an AnyView root, for example) are dropped rather than reported under a meaningless name — annotate those:

struct CheckoutView: View {
    var body: some View {
        VStack { ... }
            .grovsScreen()            // auto-named from the file: "Checkout"
            // or .grovsScreen("Checkout Page")
    }
}

.grovsScreen() also suppresses automatic tracking for that screen, so it is never counted twice. For UIKit, conform to GrovsScreenTracking to name or skip a controller, or set a global resolver:

Grovs.screenNameProvider = { viewController in
    switch viewController {
    case is LegalViewController: return .suppress
    case is ProductViewController: return .name("Product")
    default: return .automatic
    }
}

Map class names to friendlier dashboard names with Grovs.setScreenAliases(["CheckoutViewController": "Checkout Page"]).

Property values

Property values may be strings, numbers, booleans, NSNull, or nested arrays and dictionaries of those. Date, URL, and UUID are coerced to strings; values that can't be represented in JSON are dropped for that key only; properties over 8 KB are dropped together and the event is still sent. Event names must be non-empty and not a reserved system name (install, open, screen_view, …). Tags are capped at 20 per event.

Messages

Push notifications

Receive push campaigns sent from the Grovs dashboard or the server API, report opens and follow their links.

1. Add capabilities — In Xcode, add the Push Notifications capability.

2. Upload your APNs key — In Apple Developer → Keys, create a key with APNs enabled. Upload the .p8 file, Key ID, and Team ID in your Grovs dashboard under Settings → Push Notifications.

The Grovs test environment sends through the APNs sandbox, production through production APNs. Builds run from Xcode get sandbox tokens, so configure them with useTestEnvironment: true; TestFlight and App Store builds with false. A mismatch is rejected by APNs (BadDeviceToken) and the token is dropped.

3. Request permission, register, and handle taps:

import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Configure Grovs first (see Setup); set the delegate before returning so a launching tap is delivered.
        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Grovs.setPushToken(deviceToken: deviceToken)
    }

    // Reports the open and delivers the push to grovsReceivedPayloadFromDeeplink.
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        Grovs.handleNotificationResponse(response)
        completionHandler()
    }

    // Optional: show pushes that arrive while the app is open.
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        if #available(iOS 14.0, *) {
            completionHandler([.banner, .list, .sound])
        } else {
            completionHandler([.alert, .sound])
        }
    }
}

A tapped Grovs push arrives in grovsReceivedPayloadFromDeeplink like a deep link, and in allReceivedPayloadsSinceStartup. The payload is the push's userInfo without aps: your campaign data keys plus grovs_campaign_id, grovs_link and grovs_image. When the push has a link, the link is resolved and its payload is merged in (the push's keys win).

Grovs.isGrovsNotification(userInfo:) tells Grovs pushes apart from your other providers'. Taps and a token set before configure are held until then; a held tap is dropped if you configure with enabled: false.

4. Images (optional) — Add a Notification Service Extension target, link the GrovsNotificationService product (SPM) or pod, and make its principal class inherit from it:

target 'NotificationService' do
  pod 'GrovsNotificationService', '~> 3.1'
end
import GrovsNotificationService

class NotificationService: GrovsNotificationServiceExtension {}

If you already have an extension, call GrovsNotificationContent.attachImage(to:session:completion:) from it instead. Pass it the content your other code has already changed; the image is added to its attachments. Keep the task it returns and cancel it when time runs out; the completion then runs once, with the content minus the image, so don't deliver separately:

class NotificationService: UNNotificationServiceExtension {
    private var download: URLSessionTask?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        let content = request.content // or the content your other code has already changed
        download = GrovsNotificationContent.attachImage(to: content, completion: contentHandler)
    }

    override func serviceExtensionTimeWillExpire() {
        download?.cancel()
    }
}

Use JPEG, PNG or GIF images; notifications show no other format, and the push arrives without one. The extension ships through SPM and CocoaPods only, not in the Grovs.xcframework download.

Remote pushes reach the iOS Simulator on Apple silicon Macs (Xcode 14+) with sandbox tokens; xcrun simctl push delivers local test payloads.

Revenue Tracking

Revenue tracking is currently in beta.

Setup

  1. Enable revenue tracking in the Grovs dashboard under Settings → Revenue Tracking
  2. Configure App Store Server Notifications in App Store Connect — set the production and sandbox URLs shown in the Grovs dashboard under Developers → iOS Setup → Revenue

App Store purchases (StoreKit 2)

import StoreKit

let result = try await Product.purchase(...)

if case .success(let verification) = result,
   case .verified(let transaction) = verification {

    Grovs.logInAppPurchase(transactionID: transaction.id) { success in
        if success {
            Task { await transaction.finish() }
        }
    }
}

Requires iOS 15+. The SDK automatically extracts price, currency, and product info. Duplicates are filtered.

Custom purchases

Grovs.logCustomPurchase(
    type: .buy,
    priceInCents: 999,       // $9.99
    currency: "USD",
    productID: "premium_monthly"
) { success in
    // Revenue event recorded
}

Use .cancel and .refund transaction types for cancellations and refunds. For App Store purchases, these are detected automatically via App Store Server Notifications.

Privacy & Consent

The SDK collects nothing while it is disabled — no authentication, no device resolution, no event or purchase delivery. To start a launch disabled, pass your stored consent to configure; calling setSDK(enabled: false) afterwards is too late, because configuring starts the first round of collection:

Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, enabled: hasUserConsent, delegate: self)

// Later, as consent changes. Enabling authenticates and sends what was held.
Grovs.setSDK(enabled: true)

The flag is not persisted — pass your stored value to configure every launch. A disabled configure reports false to its completion without authenticating; treat that as "not started", not as a failure.

The SDK ships a PrivacyInfo.xcprivacy privacy manifest, reads no advertising identifier, and does not require App Tracking Transparency. See Privacy & Consent for what is collected and what to declare in App Store Connect.

API Reference

Properties

Property Type Description
delegate GrovsDelegate? Receives deep link payloads and SDK errors (held weakly)
userIdentifier String? User ID shown in dashboard and reports
userAttributes [String: Any]? User attributes for analytics
pushToken String? APNs device token (hex) for push notifications; setPushToken(deviceToken:) sets it from Data
screenNameProvider ((UIViewController) -> GrovsScreenName)? Resolver for automatically tracked screen names

Key Methods

Method Description
configure(...) Initialize the SDK — API key, environment, baseURL, autoTrackScreenViews, clipboardDomains, enabled, delegate
setSDK(enabled:) Enable or disable all collection
setDebug(level:) Set logging level (.info, .warn, .error)
generateLink(...) Generate a smart link
lastReceivedPayload(...) Get the last deep link payload
allReceivedPayloadsSinceStartup(...) Get all payloads since launch
linkDetails(path:...) Get details for a link path
track(_:properties:tags:) Track a custom event
trackScreenView(_:properties:) Track a screen view
setGlobalTags(_:) Tags attached to every later event
setScreenAliases(_:) Friendly dashboard names for view controller classes
displayMessagesViewController(...) Deprecated: the messages inbox was removed; only calls its completion
numberOfUnreadMessages(...) Deprecated: the messages inbox was removed; always yields 0
handleNotificationResponse(_:) Report a push campaign tap and deliver it as a deep link
handleNotificationOpen(userInfo:) The same, for hosts that only have the payload of a tapped push; never call it on delivery (didReceiveRemoteNotification), which would count every push as opened
setPushToken(deviceToken:) Pass the APNs token from didRegisterForRemoteNotificationsWithDeviceToken
isGrovsNotification(userInfo:) Whether a push came from Grovs
logInAppPurchase(transactionID:...) Log a StoreKit 2 purchase
logCustomPurchase(...) Log a custom purchase — type, price in cents, currency, product ID, optional date

Full API reference: docs.grovs.io/docs/sdk/ios/api-reference

Example App

A demo project is available at grovs-io/grovs-ios-example-app.

Setup Guides

Migration Guides

Documentation

Full documentation at docs.grovs.io.

Support

For technical support and inquiries, contact support@grovs.io.

License

See LICENSE for details.

About

Open-source deep linking and first-party attribution SDK for iOS. Universal Links, deferred deep linking, and revenue tracking in Swift. MIT-licensed alternative to Branch.io, AppsFlyer, and Firebase Dynamic Links.

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages