@straitlink/capacitor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Strait authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Package.swift ADDED
@@ -0,0 +1,26 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ // @straitlink/capacitor, iOS side: a thin Capacitor plugin over StraitSDK (Swift Package Manager).
5
+ let package = Package(
6
+ name: "StraitlinkCapacitor",
7
+ platforms: [.iOS(.v15)],
8
+ products: [
9
+ .library(name: "StraitlinkCapacitor", targets: ["StraitPlugin"]),
10
+ ],
11
+ dependencies: [
12
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
13
+ .package(url: "https://github.com/strait-link/strait-sdk-swift.git", from: "0.9.3"),
14
+ ],
15
+ targets: [
16
+ .target(
17
+ name: "StraitPlugin",
18
+ dependencies: [
19
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
20
+ .product(name: "Cordova", package: "capacitor-swift-pm"),
21
+ .product(name: "StraitSDK", package: "strait-sdk-swift"),
22
+ ],
23
+ path: "ios/Sources/StraitPlugin"
24
+ ),
25
+ ]
26
+ )
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ <!-- Header: the same in every Strait SDK README (design system v5). -->
2
+ <p align="center">
3
+ <a href="https://straitlink.in">
4
+ <picture>
5
+ <source media="(prefers-color-scheme: dark)" srcset=".github/assets/strait-lockup-dark.svg">
6
+ <img src=".github/assets/strait-lockup.svg" alt="Strait" width="160" height="53">
7
+ </picture>
8
+ </a>
9
+ </p>
2
10
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
11
+ <h1 align="center">Strait SDK for Capacitor and Ionic</h1>
12
+
13
+ <p align="center"><strong>Straight to the screen. On the record.</strong><br>
14
+ A tap opens the exact screen, and every link open and install is recorded in your Strait dashboard.</p>
15
+
16
+ <p align="center">
17
+ <a href="https://www.npmjs.com/package/@straitlink/capacitor"><img alt="npm" src="https://img.shields.io/npm/v/@straitlink/capacitor?style=flat-square&labelColor=0F0D0A&color=423B33"></a>
18
+ <a href="https://straitlink.in/platform-status/"><img alt="SDK: New" src="https://img.shields.io/badge/SDK-new-423B33?style=flat-square&labelColor=0F0D0A"></a>
19
+ <a href="https://github.com/strait-link/strait-sdk-capacitor/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/strait-link/strait-sdk-capacitor/ci.yml?branch=main&label=CI&style=flat-square&labelColor=0F0D0A"></a>
20
+ <a href="LICENSE"><img alt="Licence: MIT" src="https://img.shields.io/badge/licence-MIT-423B33?style=flat-square&labelColor=0F0D0A"></a>
21
+ </p>
22
+
23
+ <p align="center">
24
+ <a href="https://straitlink.in/docs/sdks/capacitor/">Docs</a> ·
25
+ <a href="https://straitlink.in/platform-status/">Platform status</a> ·
26
+ <a href="CHANGELOG.md">Changelog</a> ·
27
+ <a href="#docs-and-support">Talk to the Strait team</a>
28
+ </p>
29
+
30
+ `@straitlink/capacitor` gives a Capacitor 8 app (Ionic, Vue, React, Angular or plain JS) direct
31
+ links (App Links, Universal Links, custom schemes), deferred links that survive the install, and
32
+ link-open analytics. It wraps the Strait [Android](https://github.com/strait-link/strait-sdk-android)
33
+ and [iOS](https://github.com/strait-link/strait-sdk-swift) SDKs, and its API matches
34
+ [`@straitlink/react-native`](https://github.com/strait-link/strait-sdk-react-native).
35
+
36
+ ## Quick start (60 seconds)
37
+
38
+ You need: a Capacitor 8 app with `android/` and/or `ios/` added, and from
39
+ **Dashboard → Get started** your publishable key (`st_pub_live_…`) and link domain
40
+ (`https://<your-handle>.strait.link`).
41
+
42
+ **1. Install and sync**
43
+
44
+ ```sh
45
+ npm install @straitlink/capacitor
46
+ npx cap sync
47
+ ```
48
+
49
+ **2. Start it when your app starts** (for example in `main.ts`)
50
+
51
+ ```ts
52
+ import { createStrait } from '@straitlink/capacitor';
53
+
54
+ const strait = createStrait({
55
+ publishableKey: 'st_pub_live_…', // safe in apps; never the secret key
56
+ endpoint: 'https://<your-handle>.strait.link',
57
+ });
58
+ strait.onLink((e) => {
59
+ console.log('Strait link:', e.kind, e.route, e.matched, e.path);
60
+ if (e.matched && e.path) router.push({ path: e.path, query: e.params }); // your router
61
+ });
62
+ strait.start();
63
+ ```
64
+
65
+ **3. Let the app claim your link domain**
66
+
67
+ - Android, `android/app/src/main/AndroidManifest.xml`, inside the main `<activity>`:
68
+
69
+ ```xml
70
+ <intent-filter android:autoVerify="true">
71
+ <action android:name="android.intent.action.VIEW" />
72
+ <category android:name="android.intent.category.DEFAULT" />
73
+ <category android:name="android.intent.category.BROWSABLE" />
74
+ <data android:scheme="https" android:host="<your-handle>.strait.link" />
75
+ </intent-filter>
76
+ ```
77
+
78
+ - iOS, Xcode → App target → Signing & Capabilities → **+ Associated Domains** →
79
+ `applinks:<your-handle>.strait.link`.
80
+ - In **Dashboard → Settings → App**: your Android package name + signing SHA-256, and your
81
+ Apple Team ID + bundle ID. Strait then serves `assetlinks.json` and the
82
+ `apple-app-site-association` file for you.
83
+
84
+ **You should see:** run the app on a phone or emulator, then open one of your links:
85
+
86
+ ```sh
87
+ adb shell am start -a android.intent.action.VIEW -d "https://<your-handle>.strait.link/<code>"
88
+ xcrun simctl openurl booted "https://<your-handle>.strait.link/<code>"
89
+ ```
90
+
91
+ The app opens on the link's screen and the console prints
92
+ `Strait link: direct app_link true /p/42` (your path). The open shows up in the dashboard's
93
+ Analytics within a minute. On a fresh install the first launch prints a `deferred` event instead.
94
+
95
+ ## Platform features
96
+
97
+ | Feature | Status | Notes |
98
+ |---|---|---|
99
+ | Android: direct links and deferred links | ● Live | Deferred = exact match through the Play Install Referrer (read for you). |
100
+ | iPhone links (Universal Links, custom schemes) | ● Live | Through Capacitor's AppDelegate / SceneDelegate proxy: no extra code. |
101
+ | iPhone: install matching (deferred links) | Opt-in | Your choice in Dashboard → Settings → iPhone installs, read at runtime. See the [iOS SDK](https://github.com/strait-link/strait-sdk-swift#iphone-you-choose-the-deferred-link-method). |
102
+ | iOS with CocoaPods | – Not yet | Swift Package Manager only (the Capacitor 8 default for new apps). |
103
+ | Web | – | Every call resolves a safe default; use [`@straitlink/web`](https://www.npmjs.com/package/@straitlink/web) for websites. |
104
+ | Published on npm | ● Live | `npm install @straitlink/capacitor` |
105
+
106
+ ● Live · Opt-in: off until you switch it on · – Not yet. The same words as the
107
+ [platform status](https://straitlink.in/platform-status/) page.
108
+
109
+ ## API
110
+
111
+ | Call | What it does |
112
+ |---|---|
113
+ | `createStrait({ publishableKey, endpoint, linkHosts? })` | One client per app. Throws only for a missing or secret key. `linkHosts` adds custom short-link hosts or old `<x>.page.link` hosts. |
114
+ | `start()` | Reads the link that launched the app, listens for new ones, runs the deferred check once per install, sends saved open reports. Call it once, after `onLink`. |
115
+ | `onLink(cb)` | Every link event, including ones from before you subscribed. Returns an unsubscribe. |
116
+ | `onLinkStart(cb)` | A link just arrived and is being resolved: show a loading state. |
117
+ | `checkDeferred()` | Asks for the deferred link again (settings or debug screens). Doesn't add an install. |
118
+ | `trackEvent(name, { value, currency, linkId, clickId })` | Conversion or revenue event, placed on the tap of the last attributed open (7 days). Resolves `true` when accepted. |
119
+ | `pendingOpenReports()` / `flushOpenReports()` | Open reports saved while offline (sent again on start and resume). |
120
+ | `deviceClass()` | `phone` or `tablet` (iPad / 600 dp and up), as sent with open reports. |
121
+ | `stop()` | Stops delivering events to this object. |
122
+
123
+ `LinkEvent`: `id, kind (direct|deferred), route (app_link|custom_scheme|install_referrer|fingerprint|clipboard),
124
+ appState (closed|background|foreground), matched, reason, rawUrl, url, path, params, linkId, referralCode, ms, at`.
125
+ After `createStrait`, nothing throws: a failure resolves `matched: false` with a `reason`, `false`, `0` or nothing.
126
+
127
+ ## How it works
128
+
129
+ The plugin is a thin layer. All link logic (resolving short links, the once-per-install deferred
130
+ check, open reports and their retry queue, app-state labels) runs in the native SDKs, which share
131
+ golden test vectors with the Strait engine:
132
+
133
+ - **Android:** `strait-sdk-android` **v0.8.1** from JitPack. The plugin adds JitPack to your Gradle
134
+ repositories, limited to the `com.github.strait-link` group, so nothing else is ever fetched from
135
+ it. It reads the Play Install Referrer (`com.android.installreferrer:installreferrer:2.2`), the launch
136
+ intent and `onNewIntent`, and keeps state in SharedPreferences (`strait`).
137
+ - **iOS:** `StraitSDK` **0.9.3+** through Swift Package Manager. Links come from Capacitor's
138
+ `capacitorOpenURL` / `capacitorOpenUniversalLink` notifications; state lives in `UserDefaults`.
139
+
140
+ What gets recorded, the privacy details and the match methods are the same as the native SDKs:
141
+ see [Android](https://github.com/strait-link/strait-sdk-android#what-strait-records-automatically-no-extra-code)
142
+ and [iOS](https://github.com/strait-link/strait-sdk-swift#what-strait-records-automatically-no-extra-code).
143
+
144
+ ### Testing a deferred link on an Android emulator
145
+
146
+ A sideloaded app never gets a Play Install Referrer. **Debug builds only** accept a stand-in on the
147
+ first launch after install (release builds ignore it):
148
+
149
+ ```sh
150
+ adb shell am start -n <your.package>/.MainActivity \
151
+ --es strait.debugReferrer "'strait_link=lnk_…&strait_click=…'"
152
+ ```
153
+
154
+ Take both values from the Play Store URL your link redirects an Android phone to (its `referrer=`).
155
+
156
+ ## Sample app
157
+
158
+ [`examples/capacitor-app`](examples/capacitor-app): a plain-JS Capacitor app with two screens
159
+ (Home and Product), a "Check deferred link" button and a "Log a purchase" button. CI builds it for
160
+ Android and the iOS Simulator on every pull request.
161
+
162
+ ```sh
163
+ cd examples/capacitor-app && npm install
164
+ STRAIT_PUBLISHABLE_KEY=st_pub_live_… STRAIT_ENDPOINT=https://<your-handle>.strait.link npm run sync
165
+ npx cap run android # or: npx cap run ios
166
+ ```
167
+
168
+ ## Docs and support
169
+
170
+ - **Docs:** [straitlink.in/docs/sdks/capacitor/](https://straitlink.in/docs/sdks/capacitor/) · [platform status](https://straitlink.in/platform-status/) · [troubleshooting](https://straitlink.in/docs/troubleshooting/)
171
+ - **Talk to the Strait team:** [support@straitlink.in](mailto:support@straitlink.in) (replies within 1 working day, IST) or call +91 81218 61890.
172
+ - **Bugs and feature requests:** [open an issue](https://github.com/strait-link/strait-sdk-capacitor/issues) on this repo.
173
+ - **Security:** never in a public issue. Write to security@straitlink.in (see [SECURITY.md](SECURITY.md)).
@@ -0,0 +1,56 @@
1
+ // @straitlink/capacitor, Android side: a thin Capacitor plugin over strait-sdk-android (JitPack).
2
+ ext {
3
+ straitSdkVersion = 'v0.8.1'
4
+ }
5
+
6
+ buildscript {
7
+ repositories {
8
+ google()
9
+ mavenCentral()
10
+ }
11
+ dependencies {
12
+ classpath 'com.android.tools.build:gradle:8.13.0'
13
+ }
14
+ }
15
+
16
+ apply plugin: 'com.android.library'
17
+
18
+ // strait-sdk-android is built by JitPack from its version tags. Gradle resolves this plugin's
19
+ // dependencies with the app's repositories, so add JitPack to every project, limited to the
20
+ // Strait group (nothing else is ever fetched from JitPack).
21
+ rootProject.allprojects {
22
+ repositories {
23
+ maven {
24
+ url 'https://jitpack.io'
25
+ content { includeGroup 'com.github.strait-link' }
26
+ }
27
+ }
28
+ }
29
+
30
+ android {
31
+ namespace = 'dev.strait.capacitor'
32
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
33
+ defaultConfig {
34
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
35
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
36
+ }
37
+ compileOptions {
38
+ sourceCompatibility JavaVersion.VERSION_21
39
+ targetCompatibility JavaVersion.VERSION_21
40
+ }
41
+ lint {
42
+ abortOnError = false
43
+ }
44
+ }
45
+
46
+ repositories {
47
+ google()
48
+ mavenCentral()
49
+ }
50
+
51
+ dependencies {
52
+ implementation project(':capacitor-android')
53
+ implementation "androidx.appcompat:appcompat:${project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'}"
54
+ implementation "com.github.strait-link:strait-sdk-android:${straitSdkVersion}"
55
+ implementation 'com.android.installreferrer:installreferrer:2.2'
56
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ </manifest>
@@ -0,0 +1,280 @@
1
+ package dev.strait.capacitor;
2
+
3
+ import android.content.Context;
4
+ import android.content.Intent;
5
+ import android.content.SharedPreferences;
6
+ import android.content.pm.ApplicationInfo;
7
+ import android.util.DisplayMetrics;
8
+
9
+ import com.android.installreferrer.api.InstallReferrerClient;
10
+ import com.android.installreferrer.api.InstallReferrerStateListener;
11
+ import com.getcapacitor.JSArray;
12
+ import com.getcapacitor.JSObject;
13
+ import com.getcapacitor.Plugin;
14
+ import com.getcapacitor.PluginCall;
15
+ import com.getcapacitor.PluginMethod;
16
+ import com.getcapacitor.annotation.CapacitorPlugin;
17
+
18
+ import java.util.ArrayList;
19
+ import java.util.List;
20
+ import java.util.Locale;
21
+ import java.util.Map;
22
+ import java.util.TimeZone;
23
+ import java.util.concurrent.CountDownLatch;
24
+ import java.util.concurrent.ExecutorService;
25
+ import java.util.concurrent.Executors;
26
+ import java.util.concurrent.TimeUnit;
27
+
28
+ import dev.strait.sdk.AppLifecycleState;
29
+ import dev.strait.sdk.CoreKt;
30
+ import dev.strait.sdk.DeviceFields;
31
+ import dev.strait.sdk.KeyValueStore;
32
+ import dev.strait.sdk.LinkEvent;
33
+ import dev.strait.sdk.LinkStart;
34
+ import dev.strait.sdk.StraitClient;
35
+ import dev.strait.sdk.StraitConfig;
36
+ import dev.strait.sdk.UrlConnectionTransport;
37
+ import kotlin.Unit;
38
+
39
+ /**
40
+ * Capacitor bridge over strait-sdk-android's StraitClient. All link logic (resolve, deferred
41
+ * check once per install, open reports, retry queue) lives in the native SDK; this class only
42
+ * wires Android pieces (SharedPreferences, Play Install Referrer, display metrics, intents,
43
+ * lifecycle) and forwards events to JS as "link" / "linkStart".
44
+ */
45
+ @CapacitorPlugin(name = "Strait")
46
+ public class StraitPlugin extends Plugin {
47
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
48
+ private StraitClient client;
49
+ /** A link that arrived (onNewIntent) before JS called start(). */
50
+ private String pendingUrl;
51
+
52
+ @PluginMethod
53
+ public synchronized void start(PluginCall call) {
54
+ if (client != null) { // a WebView reload calls start() again: keep the one client
55
+ call.resolve();
56
+ return;
57
+ }
58
+ String key = call.getString("publishableKey", "");
59
+ String endpoint = call.getString("endpoint", "");
60
+ if (key == null || !key.startsWith("st_pub_") || endpoint == null || endpoint.isEmpty()) {
61
+ call.reject("Strait: start needs publishableKey (st_pub_...) and endpoint");
62
+ return;
63
+ }
64
+ List<String> hosts = new ArrayList<>();
65
+ JSArray arr = call.getArray("linkHosts", new JSArray());
66
+ for (int i = 0; i < arr.length(); i++) {
67
+ String h = arr.optString(i, null);
68
+ if (h != null && !h.isEmpty()) hosts.add(h);
69
+ }
70
+ Context ctx = getContext().getApplicationContext();
71
+ SharedPreferences prefs = ctx.getSharedPreferences("strait", Context.MODE_PRIVATE);
72
+ KeyValueStore storage = new KeyValueStore() {
73
+ @Override public String get(String k) { return prefs.getString(k, null); }
74
+ @Override public void set(String k, String v) { prefs.edit().putString(k, v).apply(); }
75
+ };
76
+ Intent launch = getActivity() != null ? getActivity().getIntent() : null;
77
+ String debugReferrer = debugReferrer(ctx, launch);
78
+ client = new StraitClient(new StraitConfig(
79
+ key,
80
+ endpoint,
81
+ hosts,
82
+ storage,
83
+ () -> debugReferrer != null ? debugReferrer : readInstallReferrer(ctx),
84
+ "android",
85
+ () -> device(ctx),
86
+ new UrlConnectionTransport(10_000, 10_000),
87
+ executor,
88
+ System::currentTimeMillis,
89
+ deviceClass(ctx)
90
+ ));
91
+ client.onLinkStart(s -> { notifyListeners("linkStart", toJs(s), true); return Unit.INSTANCE; });
92
+ client.onLink(e -> { notifyListeners("link", toJs(e), true); return Unit.INSTANCE; });
93
+ client.onAppState(AppLifecycleState.ACTIVE, System.currentTimeMillis());
94
+
95
+ String initial = pendingUrl != null ? pendingUrl : launchUrl(launch);
96
+ pendingUrl = null;
97
+ client.start(initial);
98
+ executor.execute(call::resolve); // after start's work on the same single thread
99
+ }
100
+
101
+ @PluginMethod
102
+ public void checkDeferred(PluginCall call) {
103
+ StraitClient c = ready(call);
104
+ if (c == null) return;
105
+ executor.execute(() -> call.resolve(toJs(c.checkDeferred())));
106
+ }
107
+
108
+ @PluginMethod
109
+ public void trackEvent(PluginCall call) {
110
+ StraitClient c = ready(call);
111
+ if (c == null) return;
112
+ String name = call.getString("name", "");
113
+ if (name == null || name.isEmpty()) {
114
+ call.reject("Strait: trackEvent needs a name");
115
+ return;
116
+ }
117
+ Double value = call.getDouble("value");
118
+ String currency = call.getString("currency");
119
+ String linkId = call.getString("linkId");
120
+ String clickId = call.getString("clickId");
121
+ executor.execute(() -> {
122
+ JSObject r = new JSObject();
123
+ r.put("accepted", c.trackEvent(name, value, currency, linkId, clickId));
124
+ call.resolve(r);
125
+ });
126
+ }
127
+
128
+ @PluginMethod
129
+ public void pendingOpenReports(PluginCall call) {
130
+ StraitClient c = ready(call);
131
+ if (c == null) return;
132
+ JSObject r = new JSObject();
133
+ r.put("count", c.pendingOpenReports());
134
+ call.resolve(r);
135
+ }
136
+
137
+ @PluginMethod
138
+ public void flushOpenReports(PluginCall call) {
139
+ StraitClient c = ready(call);
140
+ if (c == null) return;
141
+ executor.execute(() -> { c.flushOpenReports(); call.resolve(); });
142
+ }
143
+
144
+ @PluginMethod
145
+ public void deviceClass(PluginCall call) {
146
+ JSObject r = new JSObject();
147
+ String dc = deviceClass(getContext());
148
+ if (dc != null) r.put("deviceClass", dc);
149
+ call.resolve(r);
150
+ }
151
+
152
+ @Override
153
+ protected void handleOnNewIntent(Intent intent) {
154
+ super.handleOnNewIntent(intent);
155
+ String url = launchUrl(intent);
156
+ if (url == null) return;
157
+ synchronized (this) {
158
+ if (client == null) pendingUrl = url;
159
+ else client.handleUrl(url);
160
+ }
161
+ }
162
+
163
+ @Override
164
+ protected void handleOnResume() {
165
+ super.handleOnResume();
166
+ if (client != null) client.onAppState(AppLifecycleState.ACTIVE, System.currentTimeMillis());
167
+ }
168
+
169
+ @Override
170
+ protected void handleOnPause() {
171
+ super.handleOnPause();
172
+ if (client != null) client.onAppState(AppLifecycleState.INACTIVE, System.currentTimeMillis());
173
+ }
174
+
175
+ @Override
176
+ protected void handleOnStop() {
177
+ super.handleOnStop();
178
+ if (client != null) client.onAppState(AppLifecycleState.BACKGROUND, System.currentTimeMillis());
179
+ }
180
+
181
+ private StraitClient ready(PluginCall call) {
182
+ if (client == null) call.reject("Strait: call start() first");
183
+ return client;
184
+ }
185
+
186
+ /** The link in a VIEW intent, but not when Android replays an old intent from the recents screen. */
187
+ private static String launchUrl(Intent intent) {
188
+ if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) return null;
189
+ if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return null;
190
+ String s = intent.getDataString();
191
+ return s == null || s.isEmpty() ? null : s;
192
+ }
193
+
194
+ /**
195
+ * Debug builds only: a stand-in for the Play Install Referrer, so the deferred path can be tested
196
+ * on an emulator (a sideloaded app never gets a Play referrer). Release builds ignore it.
197
+ * adb shell am start -n <package>/.MainActivity --es strait.debugReferrer "strait_link=lnk_…&strait_click=…"
198
+ */
199
+ private static String debugReferrer(Context ctx, Intent intent) {
200
+ if (intent == null || (ctx.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0) return null;
201
+ String r = intent.getStringExtra("strait.debugReferrer");
202
+ return r == null || r.isEmpty() ? null : r;
203
+ }
204
+
205
+ private static DeviceFields device(Context ctx) {
206
+ DisplayMetrics m = ctx.getResources().getDisplayMetrics();
207
+ return new DeviceFields(
208
+ CoreKt.portraitScreenWidth(m.widthPixels / (double) m.density, m.heightPixels / (double) m.density),
209
+ m.density,
210
+ Locale.getDefault().toLanguageTag(),
211
+ TimeZone.getDefault().getID()
212
+ );
213
+ }
214
+
215
+ /** Contract B14: the sw600dp rule (600 dp or more = tablet). */
216
+ private static String deviceClass(Context ctx) {
217
+ return CoreKt.deviceClassForSize(ctx.getResources().getConfiguration().smallestScreenWidthDp);
218
+ }
219
+
220
+ /** Blocking read of the Play Install Referrer (runs on the SDK's executor), or null. */
221
+ private static String readInstallReferrer(Context ctx) {
222
+ CountDownLatch latch = new CountDownLatch(1);
223
+ String[] result = {null};
224
+ InstallReferrerClient rc = InstallReferrerClient.newBuilder(ctx).build();
225
+ try {
226
+ rc.startConnection(new InstallReferrerStateListener() {
227
+ @Override public void onInstallReferrerSetupFinished(int code) {
228
+ if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
229
+ try { result[0] = rc.getInstallReferrer().getInstallReferrer(); } catch (Exception ignored) { }
230
+ }
231
+ latch.countDown();
232
+ }
233
+ @Override public void onInstallReferrerServiceDisconnected() { latch.countDown(); }
234
+ });
235
+ latch.await(5, TimeUnit.SECONDS);
236
+ } catch (Exception ignored) {
237
+ } finally {
238
+ try { rc.endConnection(); } catch (Exception ignored) { }
239
+ }
240
+ return result[0];
241
+ }
242
+
243
+ static JSObject toJs(LinkEvent e) {
244
+ JSObject o = new JSObject();
245
+ o.put("id", e.getId());
246
+ o.put("kind", e.getKind());
247
+ o.put("route", e.getRoute());
248
+ o.put("appState", e.getAppState());
249
+ o.put("matched", e.getMatched());
250
+ putIf(o, "reason", e.getReason());
251
+ putIf(o, "rawUrl", e.getRawUrl());
252
+ putIf(o, "url", e.getUrl());
253
+ putIf(o, "path", e.getPath());
254
+ putIf(o, "linkId", e.getLinkId());
255
+ putIf(o, "referralCode", e.getReferralCode());
256
+ Map<String, String> params = e.getParams();
257
+ if (params != null) {
258
+ JSObject p = new JSObject();
259
+ for (Map.Entry<String, String> kv : params.entrySet()) p.put(kv.getKey(), kv.getValue());
260
+ o.put("params", p);
261
+ }
262
+ o.put("ms", e.getMs());
263
+ o.put("at", e.getAt());
264
+ return o;
265
+ }
266
+
267
+ static JSObject toJs(LinkStart s) {
268
+ JSObject o = new JSObject();
269
+ o.put("id", s.getId());
270
+ o.put("kind", s.getKind());
271
+ o.put("appState", s.getAppState());
272
+ putIf(o, "rawUrl", s.getRawUrl());
273
+ o.put("at", s.getAt());
274
+ return o;
275
+ }
276
+
277
+ private static void putIf(JSObject o, String k, String v) {
278
+ if (v != null) o.put(k, v);
279
+ }
280
+ }
@@ -0,0 +1,9 @@
1
+ import { type CreateStraitConfig, type Strait, type StraitNativePlugin } from './strait.js';
2
+ /** The native plugin (registered as "Strait"). Most apps use `createStrait` instead. */
3
+ export declare const StraitNative: StraitNativePlugin;
4
+ /**
5
+ * Create one client when the app starts, subscribe with `onLink`, then call `start()`.
6
+ * Android and iOS only; on the web every call resolves a safe default (use @straitlink/web there).
7
+ */
8
+ export declare function createStrait(config: CreateStraitConfig, native?: StraitNativePlugin): Strait;
9
+ export type { CreateStraitConfig, DeviceClass, LinkEvent, LinkStart, Strait, StraitNativePlugin, TrackEventExtra, } from './strait.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import { registerPlugin } from '@capacitor/core';
2
+ import { createStrait as createWith } from './strait.js';
3
+ /** The native plugin (registered as "Strait"). Most apps use `createStrait` instead. */
4
+ export const StraitNative = registerPlugin('Strait');
5
+ /**
6
+ * Create one client when the app starts, subscribe with `onLink`, then call `start()`.
7
+ * Android and iOS only; on the web every call resolves a safe default (use @straitlink/web there).
8
+ */
9
+ export function createStrait(config, native = StraitNative) {
10
+ return createWith(config, native);
11
+ }
@@ -0,0 +1,94 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ /** One link the app received (B9): the same shape as @straitlink/react-native. */
3
+ export interface LinkEvent {
4
+ /** Unique per open; also the id Strait records this open under. */
5
+ id: string;
6
+ /** direct = the app was opened by a link; deferred = link tapped before install. */
7
+ kind: 'direct' | 'deferred';
8
+ /** app_link · custom_scheme · install_referrer · fingerprint · clipboard. */
9
+ route: 'app_link' | 'custom_scheme' | 'install_referrer' | 'fingerprint' | 'clipboard';
10
+ /** What the app was doing when the link arrived. */
11
+ appState: 'closed' | 'background' | 'foreground';
12
+ matched: boolean;
13
+ /** Why it didn't match: not_found, expired, password_protected, network, no_match, unavailable, … */
14
+ reason?: string;
15
+ /** The URL the OS gave the app (direct links). */
16
+ rawUrl?: string;
17
+ /** The destination to navigate to. */
18
+ url?: string;
19
+ path?: string;
20
+ params?: Record<string, string>;
21
+ linkId?: string;
22
+ /** Deferred links only: the referral code the tap carried (preview, B21). */
23
+ referralCode?: string;
24
+ /** Time spent resolving, ms. */
25
+ ms: number;
26
+ at: number;
27
+ }
28
+ /** A link just arrived and is being resolved: show a loading state until the LinkEvent with the same id. */
29
+ export interface LinkStart {
30
+ id: string;
31
+ kind: 'direct' | 'deferred';
32
+ appState: 'closed' | 'background' | 'foreground';
33
+ rawUrl?: string;
34
+ at: number;
35
+ }
36
+ export type DeviceClass = 'phone' | 'tablet';
37
+ export interface CreateStraitConfig {
38
+ /** Your workspace's publishable key (`st_pub_live_…`), Dashboard → Get started. Never the secret key. */
39
+ publishableKey: string;
40
+ /** Your workspace's link domain, e.g. https://<your-handle>.strait.link */
41
+ endpoint: string;
42
+ /** Extra hosts that serve your short links (custom domains, old `<x>.page.link`). */
43
+ linkHosts?: string[];
44
+ }
45
+ export interface TrackEventExtra {
46
+ value?: number;
47
+ currency?: string;
48
+ linkId?: string;
49
+ clickId?: string;
50
+ }
51
+ /** The native side (Android: StraitPlugin.java over strait-sdk-android; iOS: StraitPlugin.swift over StraitSDK). */
52
+ export interface StraitNativePlugin {
53
+ start(options: {
54
+ publishableKey: string;
55
+ endpoint: string;
56
+ linkHosts: string[];
57
+ }): Promise<void>;
58
+ checkDeferred(): Promise<LinkEvent>;
59
+ trackEvent(options: {
60
+ name: string;
61
+ } & TrackEventExtra): Promise<{
62
+ accepted: boolean;
63
+ }>;
64
+ pendingOpenReports(): Promise<{
65
+ count: number;
66
+ }>;
67
+ flushOpenReports(): Promise<void>;
68
+ deviceClass(): Promise<{
69
+ deviceClass?: DeviceClass;
70
+ }>;
71
+ addListener(event: 'link', cb: (e: LinkEvent) => void): Promise<PluginListenerHandle>;
72
+ addListener(event: 'linkStart', cb: (s: LinkStart) => void): Promise<PluginListenerHandle>;
73
+ }
74
+ export interface Strait {
75
+ /** Reads the launch link, listens for new ones, runs the deferred check once per install. */
76
+ start(): Promise<void>;
77
+ /** Every link event, including ones that happened before you subscribed. Returns an unsubscribe. */
78
+ onLink(cb: (event: LinkEvent) => void): () => void;
79
+ /** A link just arrived and is being resolved (for a loading state). Returns an unsubscribe. */
80
+ onLinkStart(cb: (start: LinkStart) => void): () => void;
81
+ /** Re-run the deferred check now (debugging); doesn't touch the once-per-install flag. */
82
+ checkDeferred(): Promise<LinkEvent>;
83
+ /** Conversion / revenue event, on the tap of the last attributed open (≤7 days, B15). True when accepted. */
84
+ trackEvent(name: string, extra?: TrackEventExtra): Promise<boolean>;
85
+ /** Open reports saved while offline, waiting to be sent (debugging). */
86
+ pendingOpenReports(): Promise<number>;
87
+ /** Send saved open reports now (also happens on start and on resume). */
88
+ flushOpenReports(): Promise<void>;
89
+ /** What the app runs on, as sent with open reports (B14): iPad / sw600dp = tablet. */
90
+ deviceClass(): Promise<DeviceClass | undefined>;
91
+ /** Stop delivering events to this object. */
92
+ stop(): void;
93
+ }
94
+ export declare function createStrait(config: CreateStraitConfig, native: StraitNativePlugin): Strait;
package/dist/strait.js ADDED
@@ -0,0 +1,69 @@
1
+ export function createStrait(config, native) {
2
+ const key = config.publishableKey ?? '';
3
+ if (!key.startsWith('st_pub_')) {
4
+ throw new TypeError('Strait: use your publishable key (st_pub_…, Dashboard → Get started). A secret key must never ship inside an app.');
5
+ }
6
+ const events = [];
7
+ const linkCbs = new Set();
8
+ const startCbs = new Set();
9
+ const each = (cbs, x) => {
10
+ for (const cb of [...cbs]) {
11
+ try {
12
+ cb(x);
13
+ }
14
+ catch {
15
+ // an app's handler must not stop the others
16
+ }
17
+ }
18
+ };
19
+ // Subscribe before start(), so the link that launched the app is never missed.
20
+ const handles = [
21
+ native.addListener('link', (e) => {
22
+ events.push(e);
23
+ each(linkCbs, e);
24
+ }),
25
+ native.addListener('linkStart', (s) => each(startCbs, s)),
26
+ ];
27
+ let started;
28
+ const safe = async (fn, fallback) => {
29
+ try {
30
+ return await fn();
31
+ }
32
+ catch {
33
+ return fallback;
34
+ }
35
+ };
36
+ return {
37
+ start() {
38
+ started ??= safe(async () => {
39
+ await Promise.all(handles);
40
+ await native.start({ publishableKey: key, endpoint: config.endpoint, linkHosts: config.linkHosts ?? [] });
41
+ }, undefined);
42
+ return started;
43
+ },
44
+ onLink(cb) {
45
+ linkCbs.add(cb);
46
+ for (const e of [...events])
47
+ each(new Set([cb]), e);
48
+ return () => linkCbs.delete(cb);
49
+ },
50
+ onLinkStart(cb) {
51
+ startCbs.add(cb);
52
+ return () => startCbs.delete(cb);
53
+ },
54
+ checkDeferred: () => safe(() => native.checkDeferred(), {
55
+ id: '', kind: 'deferred', route: 'fingerprint', appState: 'foreground',
56
+ matched: false, reason: 'unavailable', ms: 0, at: Date.now(),
57
+ }),
58
+ trackEvent: (name, extra = {}) => safe(async () => (await native.trackEvent({ name, ...extra })).accepted === true, false),
59
+ pendingOpenReports: () => safe(async () => (await native.pendingOpenReports()).count ?? 0, 0),
60
+ flushOpenReports: () => safe(() => native.flushOpenReports(), undefined),
61
+ deviceClass: () => safe(async () => (await native.deviceClass()).deviceClass, undefined),
62
+ stop() {
63
+ linkCbs.clear();
64
+ startCbs.clear();
65
+ for (const h of handles)
66
+ void h.then((x) => x.remove()).catch(() => { });
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,129 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import StraitSDK
4
+
5
+ /// Capacitor bridge over StraitSDK's `StraitLinks`. All link logic (resolve, deferred check once
6
+ /// per install, open reports, retry queue, app-state labels) lives in the native SDK; this class
7
+ /// only feeds it the links Capacitor's AppDelegate proxy receives and forwards events to JS as
8
+ /// "link" / "linkStart".
9
+ @objc(StraitPlugin)
10
+ public class StraitPlugin: CAPPlugin, CAPBridgedPlugin {
11
+ public let identifier = "StraitPlugin"
12
+ public let jsName = "Strait"
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "checkDeferred", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "trackEvent", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "pendingOpenReports", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "flushOpenReports", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "deviceClass", returnType: CAPPluginReturnPromise),
20
+ ]
21
+
22
+ private var links: StraitLinks?
23
+ private var subscriptions: [StraitSubscription] = []
24
+ /// A link that arrived before JS called start().
25
+ private var pendingURL: URL?
26
+
27
+ override public func load() {
28
+ for name in [Notification.Name.capacitorOpenURL, .capacitorOpenUniversalLink] {
29
+ NotificationCenter.default.addObserver(self, selector: #selector(onOpen(_:)), name: name, object: nil)
30
+ }
31
+ }
32
+
33
+ @objc private func onOpen(_ note: Notification) {
34
+ guard let url = (note.object as? [String: Any])?["url"] as? URL else { return }
35
+ DispatchQueue.main.async { [self] in
36
+ if let links = links { links.handle(url: url) } else { pendingURL = url }
37
+ }
38
+ }
39
+
40
+ @objc func start(_ call: CAPPluginCall) {
41
+ DispatchQueue.main.async { [self] in
42
+ if links != nil { call.resolve(); return } // a WebView reload calls start() again
43
+ let key = call.getString("publishableKey") ?? ""
44
+ let endpoint = call.getString("endpoint") ?? ""
45
+ guard key.hasPrefix("st_pub_"), !endpoint.isEmpty else {
46
+ call.reject("Strait: start needs publishableKey (st_pub_...) and endpoint")
47
+ return
48
+ }
49
+ let hosts = (call.getArray("linkHosts") as? [String]) ?? []
50
+ let l = StraitLinks(StraitLinksConfig(publishableKey: key, endpoint: endpoint, linkHosts: hosts))
51
+ links = l
52
+ subscriptions = [
53
+ l.onLinkStart { [weak self] s in self?.notifyListeners("linkStart", data: Self.js(s), retainUntilConsumed: true) },
54
+ l.onLink { [weak self] e in self?.notifyListeners("link", data: Self.js(e), retainUntilConsumed: true) },
55
+ ]
56
+ // The launch link: delivered before start() (cold start), or remembered by Capacitor.
57
+ let initial = pendingURL ?? ApplicationDelegateProxy.shared.lastURL
58
+ pendingURL = nil
59
+ l.start(initialURL: initial) { call.resolve() }
60
+ }
61
+ }
62
+
63
+ @objc func checkDeferred(_ call: CAPPluginCall) {
64
+ guard let l = ready(call) else { return }
65
+ l.checkDeferred { e in call.resolve(Self.js(e)) }
66
+ }
67
+
68
+ @objc func trackEvent(_ call: CAPPluginCall) {
69
+ guard let l = ready(call) else { return }
70
+ guard let name = call.getString("name"), !name.isEmpty else {
71
+ call.reject("Strait: trackEvent needs a name")
72
+ return
73
+ }
74
+ l.trackEvent(
75
+ name,
76
+ value: call.getDouble("value"),
77
+ currency: call.getString("currency"),
78
+ linkId: call.getString("linkId"),
79
+ clickId: call.getString("clickId")
80
+ ) { ok in call.resolve(["accepted": ok]) }
81
+ }
82
+
83
+ @objc func pendingOpenReports(_ call: CAPPluginCall) {
84
+ guard let l = ready(call) else { return }
85
+ l.pendingOpenReports { n in call.resolve(["count": n]) }
86
+ }
87
+
88
+ @objc func flushOpenReports(_ call: CAPPluginCall) {
89
+ guard let l = ready(call) else { return }
90
+ l.flushOpenReports { call.resolve() }
91
+ }
92
+
93
+ @objc func deviceClass(_ call: CAPPluginCall) {
94
+ DispatchQueue.main.async {
95
+ var r: [String: Any] = [:]
96
+ if let dc = currentDeviceClass() { r["deviceClass"] = dc }
97
+ call.resolve(r)
98
+ }
99
+ }
100
+
101
+ private func ready(_ call: CAPPluginCall) -> StraitLinks? {
102
+ guard let l = links else {
103
+ call.reject("Strait: call start() first")
104
+ return nil
105
+ }
106
+ return l
107
+ }
108
+
109
+ static func js(_ e: LinkEvent) -> [String: Any] {
110
+ var o: [String: Any] = [
111
+ "id": e.id, "kind": e.kind.rawValue, "route": e.route.rawValue, "appState": e.appState.rawValue,
112
+ "matched": e.matched, "ms": e.ms, "at": e.at,
113
+ ]
114
+ if let v = e.reason { o["reason"] = v }
115
+ if let v = e.rawUrl { o["rawUrl"] = v }
116
+ if let v = e.url { o["url"] = v }
117
+ if let v = e.path { o["path"] = v }
118
+ if let v = e.params { o["params"] = v }
119
+ if let v = e.linkId { o["linkId"] = v }
120
+ if let v = e.referralCode { o["referralCode"] = v }
121
+ return o
122
+ }
123
+
124
+ static func js(_ s: LinkStart) -> [String: Any] {
125
+ var o: [String: Any] = ["id": s.id, "kind": s.kind.rawValue, "appState": s.appState.rawValue, "at": s.at]
126
+ if let v = s.rawUrl { o["rawUrl"] = v }
127
+ return o
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@straitlink/capacitor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Deep linking for Capacitor and Ionic apps: direct links (App Links, Universal Links, custom schemes), deferred links that survive the install and link-open analytics. Wraps the Strait Android and iOS SDKs.",
5
+ "keywords": [
6
+ "capacitor",
7
+ "capacitor-plugin",
8
+ "ionic",
9
+ "deep-linking",
10
+ "deferred-deep-linking",
11
+ "universal-links",
12
+ "app-links",
13
+ "dynamic-links"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "The Strait authors",
17
+ "homepage": "https://straitlink.in/docs/sdks/capacitor/",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/strait-link/strait-sdk-capacitor.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/strait-link/strait-sdk-capacitor/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "dist/index.js",
27
+ "module": "dist/index.js",
28
+ "types": "dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "android/src/main/",
40
+ "android/build.gradle",
41
+ "ios/Sources",
42
+ "Package.swift"
43
+ ],
44
+ "capacitor": {
45
+ "ios": {
46
+ "src": "ios"
47
+ },
48
+ "android": {
49
+ "src": "android"
50
+ }
51
+ },
52
+ "sideEffects": false,
53
+ "peerDependencies": {
54
+ "@capacitor/core": ">=8.0.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "provenance": true
59
+ },
60
+ "scripts": {
61
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
62
+ "prepublishOnly": "npm run build",
63
+ "test": "vitest run",
64
+ "typecheck": "tsc --noEmit"
65
+ },
66
+ "devDependencies": {
67
+ "@capacitor/core": "8.5.3",
68
+ "typescript": "^5.7.0",
69
+ "vitest": "^4.1.12"
70
+ }
71
+ }