Android SDK

Native Android deep linking, deterministic deferred deep linking, attribution, identity, events, and campaign link generation.

Requirements

Using Flutter?

Install flutter_deeplinkly instead. It wraps this SDK, so both integrations run the same native Android code.

Install

Add the SDK to your app module.

app/build.gradle
dependencies {
    implementation 'com.deeplinkly:deeplinkly-android:1.3.0'
}

Configure the manifest

Use verified HTTPS App Links in production and retain a custom scheme for browser fallback and development.

AndroidManifest.xml
<activity android:name=".MainActivity" android:launchMode="singleTop">
  <!-- Production App Links -->
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="links.yourapp.com" />
  </intent-filter>

  <!-- Browser fallback / development custom scheme -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="yourapp" android:host="deeplink" />
  </intent-filter>
</activity>

<application ...>
  <meta-data android:name="com.deeplinkly.sdk.api_key"
             android:value="your_api_key_here" />

  <!-- Optional. Comma-separate multiple hosts. -->
  <meta-data android:name="com.deeplinkly.sdk.link_domains"
             android:value="links.yourapp.com" />
</application>

Replace links.yourapp.com, yourapp, and the API key with your project values. launchMode="singleTop" makes an already-running activity receive the link through onNewIntent.

App Links are the production path

Without the verified HTTPS filter, every link detours through a browser. In-app browsers that block intent:// fallbacks may never reach your installed app. autoVerify applies only to HTTP and HTTPS links.

Which links the SDK claims

Redirected links carry a click_id, which the SDK handles regardless of scheme. Direct App Links contain only the host and first path segment, so the SDK uses link_domains to decide which HTTPS hosts it may resolve.

  • Custom-scheme links without a click_id are ignored. Routes such as yourapp://settings/notifications remain yours.
  • HTTP(S) links are resolved by code. With link_domains set, only the listed hosts are resolved; without it, every HTTPS link your app handles is eligible.

Set link_domains for mixed-purpose domains

If your app also claims a marketing site, set this allowlist or a URL such as https://www.yourapp.com/pricing could be resolved as the Deeplinkly code pricing.

Initialize

Initialize once from your Application class before handling links or calling SDK APIs.

App.kt
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Deeplinkly.init(this)
    }
}

Initialization is deliberately explicit: the SDK does not merge anandroidx.startup initializer or a content provider into your manifest. init is idempotent, so repeated calls are harmless.

Inspecting SDK state

state
Deeplinkly.isEnabled          // true after init when the API key was found
Deeplinkly.version            // the native SDK version, for example "1.3.0"

isEnabled is false before init and remains false when the manifest API key is missing or unreadable. In that state, reporting and deep-link handling are no-ops, but getDeeplinklyId() remains available because it is generated locally.

Deferred deep linking

The original destination survives a Play Store install without a permission or user gesture.

Deferred linking works through the Google Play Install Referrer. When a visitor taps a Deeplinkly link, installs the app from Play, and opens it for the first time, the listener receives the resolved link withsource = "install_referrer".

Test through Google Play

Install Referrer data is unavailable to sideloaded builds. Use a Play internal testing track for a real deferred-link test.

Identity and install attribution

identity
Deeplinkly.getInstallAttribution()   // first-touch attribution, write-once
Deeplinkly.getDeeplinklyId()         // stable install id
Deeplinkly.setUserId("user_123")     // your id, reported as custom_user_id

User data

The fields a conversion is matched on once it reaches Meta's Conversions API or Google's enhanced conversions.

setUserData
Deeplinkly.setUserData(
    userId = "user_123",
    email = "ada@example.com",
    phoneNumber = "+441234567890",
    firstName = "Ada",
    lastName = "Lovelace",
    city = "London",
    country = "GB",
)   // false if any field was malformed, in which case nothing was stored

Every field is optional and each call merges, so you can supply an email at sign-up and an address at checkout. A malformed field rejects the whole call — nothing is stored — so you never have to guess which of the values took.

Supply only what your own privacy policy and consent flow allow; the SDK cannot know what you told your users. These fields survive a REDUCED downgrade, because attribution levels gate what the SDK observes about a device and an email someone typed into your app is not an observation. At NONE nothing is sent.

  • dateOfBirth: YYYY-MM-DD.
  • gender: "m" or "f" — the only two values Meta's ge accepts. Anything else is refused rather than coerced.
  • country: ISO-3166-1 alpha-2, for example "US".
  • Every field has a maximum length, enforced before anything is stored.

Your own identifiers

customData carries identifiers Deeplinkly does not name — typically product-analytics ids such as a Mixpanel distinct id or a CleverTap id. Attach a new identifier anytime; no app release required.

customData
Deeplinkly.setUserData(
    userId = "user_123",
    customData = mapOf(
        "mixpanel_distinct_id" to "d-8837",
        "clevertap_id" to "ct-4412",
    ),
)

Up to 10 entries, keys up to 64 characters and values up to 256. Anything larger rejects the whole call, exactly as one bad typed field does.

Erasing it

clearUserData
Deeplinkly.clearUserData()   // erases everything setUserData and setUserId recorded
Deeplinkly.setUserId(null)   // clears only the id

This is not merely “stop sending”: the next enrichment reports each previously-set field as empty, which the service reads as null this column. The erasure is re-sent until it is delivered, so calling it on a device that is offline still takes effect once it is not.

Custom events

Event values retain their JSON types and are validated before a request is sent.

purchase event
Deeplinkly.logEvent(
    "purchase",
    mapOf("order_id" to "ord_42", "amount" to 49.99, "currency" to "USD"),
) { accepted -> /* optional */ }
  • Event name: maximum 64 characters.
  • At most 25 custom parameters. Reserved _dl_* keys do not count and cannot be supplied by the app.
  • Parameter key: maximum 64 characters.
  • String value: maximum 256 characters.
  • List and Map values become compact JSON; the 256-character limit applies to the encoded value.
  • Numbers and booleans retain their types end to end; 49.99is not converted to a string.

Purchases

A typed wrapper over logEvent, so revenue is spelled the same way by every caller.

logPurchase
Deeplinkly.logPurchase(
    value = 49.99,
    currency = "USD",
    orderId = "ord_42",
    quantity = 1,
    productId = "sku_9",
) { accepted -> /* optional */ }

Not a separate pipeline: it sends the event named purchase with value and currency set, and everything true of logEvent — the retry queue, the parameter limits, the device block — is true of this too.

logEvent is untyped, so left to themselves one app sends revenue and another sends "USD 49.99". value and currency are what Meta's Conversions API and Google's enhanced conversions both key off, so this typed wrapper feeds both without a conversion forwarder having to guess.

Rejected, sending nothing, if the value is negative or not finite (a refund is a different event, not a negative purchase), the currency is not three letters, the quantity is negative, or parameters contains any of the keys this method sets.

Pass orderId

It is what Google deduplicates conversions on, and how you reconcile a forwarded conversion against your own records.

Privacy and attribution levels

Restrict reporting without breaking link resolution or delivery.

runtime controls
Deeplinkly.setTrackingEnabled(false)                       // off entirely
Deeplinkly.setAttributionLevel(AttributionLevel.REDUCED)   // middle ground
LevelWhat is sent
FULLEverything. This is the default.
REDUCEDDrops screen geometry, model, CPU, local IP, WebView user agent, advertising ID, and Android ID. Keeps coarse campaign-reporting context.
MINIMALOnly the install id, app build, and the link being reported on.
NONENo enrichment. Links still resolve and deliver.

Each level is a strict subset of the level above it. Deep link delivery works at every level, including NONE. Resolving a link never sends device description signals at any level.

To apply a restriction before any app code runs, set the manifest default:

AndroidManifest.xml
<meta-data android:name="com.deeplinkly.sdk.attribution_level"
           android:value="reduced" />

setTrackingEnabled(false) always wins and behaves as NONE. The SDK does not do probabilistic fingerprint matching; matches are deterministic, using the click id or install referrer.

Hashing identifiers on the device

Off by default. With it on, the email, phone number and names given to setUserData are SHA-256 hashed before they are sent, so plaintext never reaches Deeplinkly.

PII hashing
Deeplinkly.setPIIHashingEnabled(true)   // SHA-256 on device before sending
Deeplinkly.isPIIHashingEnabled()       // off unless you turned it on

Only those four are hashed. Gender, country and date of birth are not: their value ranges are small enough that a digest is reversed by enumerating them, so hashing them would be protection in appearance only.

It costs attribution quality

A digest is computed once, under one normalisation, and advertising destinations disagree about phone formatting — so a conversion forwarded to a destination whose rules differ will not match, and the service can no longer re-derive per destination because the value it would need is gone. Turn it on when a compliance requirement says plaintext must not reach a processor, not by default.

Hashing happens at send time rather than in the store, so the switch is reversible.

Advertising ID is opt-in

The SDK compiles against Google's Advertising ID library but does not bundle it, because that library would merge the AD_ID permission into every host app. Add it yourself only if your policy and consent flow allow advertising-id collection:

app/build.gradle
dependencies {
    implementation 'com.google.android.gms:play-services-ads-identifier:18.2.0'
}

Without this optional dependency, deep linking and deterministic attribution continue to work. ACCESS_NETWORK_STATE is also not declared; if your app already has it, the SDK reports connection type, otherwise that field is omitted.

Advanced lifecycle control

Normal native integrations do not need to call either of these methods.

lifecycle
Deeplinkly.onForeground()
Deeplinkly.shutdown()

onForeground() optionally reports an app-open and asks the persistent link queue to process immediately. The SDK already observes activity transitions, and duplicate foreground calls are rate-limited, so this is mainly useful to a framework bridge that owns a more precise foreground signal.

shutdown() detaches the deep-link listener, stops queue processing, and cancels the SDK's background scope. It is intended for final host/framework teardown, not ordinary activity destruction. The SDK cannot be initialized again in the same process after shutdown, so application integrations should normally leave the process-wide SDK running.

Debugging and testing

development builds
Deeplinkly.setDebugMode(true) // verbose logcat under the "Deeplinkly" tag
adb
# Simulate a deep link
adb shell am start -a android.intent.action.VIEW \
  -d "https://links.yourapp.com/abc123"

# Simulate an install referrer
adb shell am broadcast -a com.android.vending.INSTALL_REFERRER \
  --es "referrer" "click_id=test123"
  • Test cold start with the process killed and warm start with the app already running or backgrounded.
  • If App Links do not open the app, verify the hosted file, content, signing fingerprint, and pm get-app-links result.
  • If warm links disappear, confirm every receiving activity forwards onNewIntent and uses singleTop.
  • If an event is rejected, check its name, parameter count, reserved keys, key length, and encoded value length.

Source repository

The native SDK, sample app, tests, and canonical README are open source.

View android_deeplinkly on GitHub →