diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e425263..834d3c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,47 +5,97 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: + inputs: + run_macos_native: + description: 'Run macOS-only MAUI iOS and full KMP checks' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + HUSKY: 0 + NX_DAEMON: false + NX_NO_CLOUD: true + NX_DISABLE_DB: true jobs: js: name: JS/TS packages - runs-on: ubuntu-latest - env: - HUSKY: 0 - NX_DAEMON: false - NX_NO_CLOUD: true - NX_DISABLE_DB: true + runs-on: [self-hosted] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - - run: npm install --ignore-scripts + - run: npm ci --ignore-scripts - run: npm run build + - run: npm run lint - run: npm test flutter: name: Flutter package - runs-on: ubuntu-latest + runs-on: [self-hosted] steps: - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - uses: android-actions/setup-android@v3 + with: + packages: tools platform-tools build-tools;35.0.0 platforms;android-35 - uses: subosito/flutter-action@v2 with: flutter-version: '3.x' channel: 'stable' + - name: Trust Flutter SDK checkout + run: git config --global --add safe.directory "$FLUTTER_ROOT" - run: cd packages/sdk-flutter && flutter pub get - run: cd packages/sdk-flutter && flutter analyze - maui: - name: .NET MAUI SDK + maui-android: + name: .NET MAUI Android SDK + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - uses: android-actions/setup-android@v3 + with: + packages: tools platform-tools build-tools;35.0.0 platforms;android-35 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Install MAUI workloads + run: dotnet workload install maui-android + - name: Restore + run: dotnet restore packages/sdk-maui/ScreebMaui.csproj -p:TargetFramework=net9.0-android + - name: Build Android + run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android --no-restore + - name: Run unit tests + run: dotnet test packages/sdk-maui/tests/ScreebUtilsTests.csproj + + maui-ios: + name: .NET MAUI iOS SDK runs-on: macos-latest + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_macos_native }} steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '9.0.x' - name: Install MAUI workloads - run: dotnet workload install android ios + run: dotnet workload install ios - name: Download Screeb iOS XCFramework env: GH_TOKEN: ${{ github.token }} @@ -56,9 +106,63 @@ jobs: cp -r /tmp/screeb_ios/Screeb.xcframework packages/sdk-maui/native/ios/ - name: Restore run: dotnet restore packages/sdk-maui/ScreebMaui.csproj - - name: Build Android - run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android --no-restore - name: Build iOS run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-ios --no-restore - - name: Run unit tests - run: dotnet test packages/sdk-maui/tests/ScreebUtilsTests.csproj + + kmp-android: + name: Kotlin Multiplatform Android SDK + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v4 + - name: Check native Android artifact + id: native-android + run: | + version=$(sed -n 's/^SCREEB_ANDROID_SDK_VERSION=//p' packages/sdk-kmp/gradle.properties | head -n1) + url="https://repo.maven.apache.org/maven2/app/screeb/sdk/survey/${version}/survey-${version}.pom" + echo "version=${version}" >> "$GITHUB_OUTPUT" + if curl -fsI "$url" >/dev/null; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::app.screeb.sdk:survey:${version} is not published yet; skipping KMP Android build." + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-java@v4 + if: steps.native-android.outputs.available == 'true' + with: + java-version: '17' + distribution: 'temurin' + - uses: android-actions/setup-android@v3 + if: steps.native-android.outputs.available == 'true' + with: + packages: tools platform-tools build-tools;35.0.0 platforms;android-35 + - name: Make gradlew executable + if: steps.native-android.outputs.available == 'true' + run: chmod +x packages/sdk-kmp/gradlew + - name: Build Android target + if: steps.native-android.outputs.available == 'true' + run: cd packages/sdk-kmp && ./gradlew assembleRelease testDebugUnitTest --no-daemon + + kmp-full: + name: Kotlin Multiplatform full SDK + runs-on: macos-latest + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_macos_native }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Download Screeb iOS XCFramework + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SCREEB_IOS_SDK_VERSION=$(grep '^SCREEB_IOS_SDK_VERSION=' packages/sdk-kmp/gradle.properties | cut -d= -f2) + mkdir -p packages/sdk-kmp/native/ios + gh release download "v${SCREEB_IOS_SDK_VERSION}" --repo ScreebApp/sdk-ios-public --pattern 'Screeb.zip' --output /tmp/Screeb.zip --clobber + unzip -q /tmp/Screeb.zip -d /tmp/screeb_ios + cp -r /tmp/screeb_ios/Screeb.xcframework packages/sdk-kmp/native/ios/ + - name: Make gradlew executable + run: chmod +x packages/sdk-kmp/gradlew + - name: Build SDK + run: cd packages/sdk-kmp && ./gradlew clean build --no-daemon diff --git a/.github/workflows/publish-kmp.yml b/.github/workflows/publish-kmp.yml new file mode 100644 index 0000000..7e19661 --- /dev/null +++ b/.github/workflows/publish-kmp.yml @@ -0,0 +1,48 @@ +name: Publish Screeb KMP SDK + +on: + push: + tags: + - 'sdk-kmp/v*' + +jobs: + publish: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - run: npm run versions:check + + - name: Download Screeb iOS XCFramework + run: | + set -euo pipefail + SCREEB_IOS_SDK_VERSION=$(grep '^SCREEB_IOS_SDK_VERSION=' packages/sdk-kmp/gradle.properties | cut -d= -f2) + SCREEB_IOS_URL=$(curl -sL "https://api.github.com/repos/ScreebApp/sdk-ios-public/releases/tags/v${SCREEB_IOS_SDK_VERSION}" \ + | python3 -c "import sys,json; print(next(a['browser_download_url'] for a in json.load(sys.stdin)['assets'] if a['name']=='Screeb.zip'))") + curl -sL "$SCREEB_IOS_URL" -o /tmp/Screeb.zip + unzip -q /tmp/Screeb.zip -d /tmp/screeb_ios + mkdir -p packages/sdk-kmp/native/ios + cp -r /tmp/screeb_ios/Screeb.xcframework packages/sdk-kmp/native/ios/ + test -d packages/sdk-kmp/native/ios/Screeb.xcframework || (echo "XCFramework not found after download" && exit 1) + + - name: Make gradlew executable + run: chmod +x packages/sdk-kmp/gradlew + + - name: Publish to Maven Central + working-directory: packages/sdk-kmp + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + GPG_KEY: ${{ secrets.GPG_KEY }} + GPG_PASSWORD: ${{ secrets.GPG_PASSWORD }} + run: ./gradlew publishAllPublicationsToOSSRHRepository --no-daemon diff --git a/docs/screeb-team-release.md b/docs/screeb-team-release.md new file mode 100644 index 0000000..7da64a7 --- /dev/null +++ b/docs/screeb-team-release.md @@ -0,0 +1,162 @@ +# Screeb team release workflow + +Internal notes for updating wrapper versions and testing local native SDK changes before publishing. + +## Version source of truth + +Native and wrapper versions live in `sdk-versions.json`. + +```bash +npm run versions:sync +npm run versions:check +``` + +Edit `sdk-versions.json`, then run `versions:sync` to update generated version references. Android and iOS native SDK versions are independent: use `native.android` for Maven Central and `native.ios` for CocoaPods/SPM. + +## Local native SDK testing + +Keep the repositories next to each other for the default local flow: + +```text +Screeb/ + sdk/ + sdk-android/ + sdk-ios/ +``` + +Then build the wrapper or example with: + +```bash +SCREEB_USE_LOCAL_SDK=true +``` + +Optional overrides: + +```bash +SCREEB_ANDROID_SDK_PATH=/absolute/path/to/sdk-android +SCREEB_IOS_SDK_PATH=/absolute/path/to/sdk-ios +``` + +No native SDK release is required for this flow. + +## Useful local commands + +```bash +# Android native example +SCREEB_USE_LOCAL_SDK=true ./gradlew :app:assembleDebug --no-daemon \ + -p examples/example-android + +# React Native Android +cd examples/example-reactnative/android +SCREEB_USE_LOCAL_SDK=true ./gradlew :app:assembleDebug --no-daemon + +# Flutter Android +cd examples/example-flutter/android +SCREEB_USE_LOCAL_SDK=true ./gradlew :app:assembleDebug --no-daemon + +# Expo Android +cd examples/example-expo +SCREEB_USE_LOCAL_SDK=true npx expo prebuild --platform android --clean --no-install +cd android +SCREEB_USE_LOCAL_SDK=true ./gradlew :app:assembleDebug --no-daemon + +# KMP +SCREEB_USE_LOCAL_SDK=true ./gradlew build --no-daemon \ + -p packages/sdk-kmp + +# MAUI package +SCREEB_USE_LOCAL_SDK=true dotnet build packages/sdk-maui/ScreebMaui.csproj \ + -f net9.0-android +SCREEB_USE_LOCAL_SDK=true dotnet build packages/sdk-maui/ScreebMaui.csproj \ + -f net9.0-ios +``` + +## How the local switch works + +Android: + +- Android and KMP Gradle builds use a local Gradle composite for `../sdk-android`. +- Flutter, React Native and Expo automatically publish `../sdk-android` to Maven local during native project configuration. This keeps their normal Maven dependency path while avoiding a manual release. +- MAUI builds the local Android AAR from `../sdk-android` and binds that AAR directly. + +iOS: + +- Flutter, React Native and Expo use the local `../sdk-ios` pod when `SCREEB_USE_LOCAL_SDK=true`. +- KMP and MAUI build a temporary `Screeb.xcframework` from `../sdk-ios` through `scripts/build-local-ios-xcframework.mjs`. +- Generated local artifacts stay under ignored build folders. + +## SDK size report + +To build wrapper artifacts and inspect local sizes: + +```bash +npm run size:sdks +``` + +This is informational only. Use `npm run size:sdks -- --no-build` to read existing local artifacts without rebuilding. + +Current iOS native release reference: + +- full `Screeb.xcframework`: 1.36 MB +- iOS app embed impact: about 449.4 KB, because release apps embed only the device slice; the simulator slice is build-time only + +## Public documentation references + +Public API reference pages live in the docs repository: + +```text +../screeb/docs/public/docs//reference.md +``` + +Regenerate them from the SDK source files after any public API, hook payload, wrapper, or documentation-link change: + +```bash +npm run docs:reference:update +npm run docs:reference:check +npm run docs:reference:coverage +``` + +The generator is `scripts/update-public-docs-reference.mjs`. Keep extraction source-driven when possible; only edit the SDK-specific descriptions, groups, links, or known capability rules in that script. + +Before publishing docs, validate the public docs app: + +```bash +cd ../screeb/docs/public +pnpm typecheck +pnpm build +``` + +## Release validation + +Run the release matrix before publishing native SDKs or wrappers: + +```bash +npm run verify:release +``` + +To inspect the matrix without running it: + +```bash +npm run verify:release -- --list +``` + +To run one area only: + +```bash +npm run verify:release -- --scope=android +npm run verify:release -- --scope=flutter,react-native +``` + +The matrix uses `SCREEB_USE_LOCAL_SDK=true` by default and expects the `sdk`, `sdk-android`, and `sdk-ios` repositories to be siblings. +Set `SCREEB_IOS_TEST_DESTINATION` to override the simulator used by the iOS SDK test step. + +## Release checklist + +1. Bump the native SDK versions in `../sdk-android` and/or `../sdk-ios`. +2. Edit `sdk-versions.json`. +3. Run `npm run versions:sync`. +4. Run `npm run versions:check`. +5. Run `npm run verify:release`. +6. Run `npm run size:sdks`. +7. Release the native SDKs. +8. Release the public wrappers with the updated native dependency versions. diff --git a/examples/example-android/.gitignore b/examples/example-android/.gitignore new file mode 100644 index 0000000..c5bfecd --- /dev/null +++ b/examples/example-android/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +local.properties +build/ +app/build/ diff --git a/examples/example-android/README.md b/examples/example-android/README.md index c62f611..d07d842 100644 --- a/examples/example-android/README.md +++ b/examples/example-android/README.md @@ -1,57 +1,53 @@ -# example-android +# Screeb Android Example -Minimal Android example showing Screeb SDK integration. +Complete native Android example for the Screeb Android SDK. -> The Android SDK is closed source. See [developers.screeb.app/sdk-android/install](https://developers.screeb.app/sdk-android/install) for the full documentation. +Full documentation: [developers.screeb.app/sdk-android/install](https://developers.screeb.app/sdk-android/install) + +## What This Example Covers + +- SDK initialization with visitor properties +- Deep link handling for the Screeb editor +- Identity and visitor property updates +- Event and screen tracking +- Programmatic survey/message start +- Session replay start/stop +- SDK debug command +- Optional camera/microphone permissions for media questions ## Requirements -- Android SDK 19+ (Android 4.4+) +- Android Studio +- Android SDK 35 +- JDK 17 -## Setup +## Run -`build.gradle` (project level): +From this directory: -```gradle -allprojects { - repositories { - mavenCentral() - } -} +```bash +./gradlew :app:installDebug ``` -`app/build.gradle`: +To verify the SDK consumer ProGuard rules in a minified app build: -```gradle -dependencies { - implementation 'app.screeb.sdk:survey:x.x.x' -} +```bash +./gradlew :app:assembleRelease ``` -## Permissions +## Deep Links -`AndroidManifest.xml`: +The manifest registers: ```xml - - - - - - + ``` -## Deep links (In-App Message editor) +The example uses the same demo channel ID in the manifest and in `Screeb.initSdk`. -Add to your main Activity in `AndroidManifest.xml`: - -```xml - - - - - - -``` +## Files -## Usage +- [settings.gradle](settings.gradle): plugin and repository configuration +- [app/build.gradle](app/build.gradle): app module and Screeb dependency +- [AndroidManifest.xml](app/src/main/AndroidManifest.xml): permissions and deep links +- [MainActivity.kt](app/src/main/kotlin/app/screeb/example/MainActivity.kt): complete SDK usage sample diff --git a/examples/example-android/app/build.gradle b/examples/example-android/app/build.gradle new file mode 100644 index 0000000..4c8423c --- /dev/null +++ b/examples/example-android/app/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'app.screeb.example' + compileSdk 35 + + defaultConfig { + applicationId 'app.screeb.example' + minSdk 23 + targetSdk 35 + versionCode 1 + versionName '1.0' + + manifestPlaceholders = [ + screebChannelId: '0e2b609a-8dce-4695-a80f-966fbfa87a88', + ] + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation 'app.screeb.sdk:survey:4.0.0' +} diff --git a/examples/example-android/app/proguard-rules.pro b/examples/example-android/app/proguard-rules.pro new file mode 100644 index 0000000..7964410 --- /dev/null +++ b/examples/example-android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Example app rules. +# +# Screeb's published AAR provides its own consumer ProGuard rules, so the app +# does not need Screeb-specific keep rules here. diff --git a/examples/example-android/app/src/main/AndroidManifest.xml b/examples/example-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..24b21c6 --- /dev/null +++ b/examples/example-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/example-android/app/src/main/kotlin/app/screeb/example/MainActivity.kt b/examples/example-android/app/src/main/kotlin/app/screeb/example/MainActivity.kt index d1d8f9d..a05f801 100644 --- a/examples/example-android/app/src/main/kotlin/app/screeb/example/MainActivity.kt +++ b/examples/example-android/app/src/main/kotlin/app/screeb/example/MainActivity.kt @@ -1,38 +1,202 @@ package app.screeb.example +import android.Manifest +import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import app.screeb.sdk.InitOptions import app.screeb.sdk.Screeb -import app.screeb.sdk.VisitorProperties -import java.util.Date -class MainActivity : AppCompatActivity() { +class MainActivity : Activity() { + private lateinit var status: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - Screeb.initSdk( - this, - "", - "", // optional - VisitorProperties().apply { // optional - this["firstname"] = "" - this["lastname"] = "" - this["plan"] = "" - this["age"] = 42 - this["logged_at"] = Date() - this["authenticated"] = true - }, - language = "en" // optional - ) + setContentView(createContentView()) + requestOptionalMediaPermissions() + initializeScreeb() Screeb.handleDeepLink(intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) Screeb.handleDeepLink(intent) + setStatus("Deep link handled") + } + + private fun initializeScreeb() { + val visitorProperties = hashMapOf( + "firstname" to "Ada", + "lastname" to "Lovelace", + "plan" to "public-example", + "authenticated" to true, + ) + + Screeb.initSdk( + context = this, + channelId = SCREEB_CHANNEL_ID, + visitorId = "android-example-user", + visitorProperties = visitorProperties, + initOptions = InitOptions(isDebugMode = false, disableMirror = false), + hooks = null, + language = "en", + ) + + setStatus("Screeb initialized with channel $SCREEB_CHANNEL_ID") + } + + private fun createContentView(): View { + val density = resources.displayMetrics.density + val root = ScrollView(this) + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding((20 * density).toInt(), (24 * density).toInt(), (20 * density).toInt(), (32 * density).toInt()) + } + root.addView(content) + + content.addView(title("Screeb Android Example")) + content.addView(body("A complete native Android integration sample using the public Maven artifact.")) + + status = body("Starting...") + content.addView(status) + + content.addView(action("Set identity") { + Screeb.setIdentity( + "android-example-user", + hashMapOf("role" to "tester", "source" to "native-android-example"), + ) + setStatus("Identity sent") + }) + + content.addView(action("Set visitor properties") { + Screeb.setVisitorProperties( + hashMapOf("company" to "Screeb", "example_session" to System.currentTimeMillis()), + ) + setStatus("Visitor properties sent") + }) + + content.addView(action("Track event") { + Screeb.trackEvent( + "android_example_button_clicked", + hashMapOf("button" to "track_event", "screen" to "home"), + ) + setStatus("Event tracked") + }) + + content.addView(action("Track screen") { + Screeb.trackScreen("Android Example", hashMapOf("tab" to "main")) + setStatus("Screen tracked") + }) + + content.addView(action("Start survey") { + Screeb.startSurvey( + surveyId = "replace-with-survey-id", + allowMultipleResponses = true, + hiddenFields = hashMapOf("example" to "android"), + ignoreSurveyStatus = true, + hooks = null, + language = "en", + distributionId = null, + ) + setStatus("Survey start requested") + }) + + content.addView(action("Start message") { + Screeb.startMessage( + messageId = "replace-with-message-id", + allowMultipleResponses = true, + hiddenFields = hashMapOf("example" to "android"), + ignoreMessageStatus = true, + hooks = null, + language = "en", + distributionId = null, + ) + setStatus("Message start requested") + }) + + content.addView(action("Session replay start") { + Screeb.sessionReplayStart() + setStatus("Session replay start requested") + }) + + content.addView(action("Session replay stop") { + Screeb.sessionReplayStop() + setStatus("Session replay stop requested") + }) + + content.addView(action("Debug SDK") { + Screeb.debug { result, error -> + runOnUiThread { + setStatus(error?.message ?: result.ifBlank { "Debug command sent" }) + } + } + }) + + content.addView(action("Close SDK") { + Screeb.closeSdk() + setStatus("SDK closed") + }) + + return root + } + + private fun title(text: String): TextView = + TextView(this).apply { + this.text = text + textSize = 24f + setTextColor(0xFF111827.toInt()) + setPadding(0, 0, 0, 20) + } + + private fun body(text: String): TextView = + TextView(this).apply { + this.text = text + textSize = 15f + setTextColor(0xFF374151.toInt()) + setPadding(0, 0, 0, 18) + } + + private fun action(label: String, onClick: () -> Unit): Button = + Button(this).apply { + text = label + isAllCaps = false + setOnClickListener { + try { + onClick() + } catch (e: Exception) { + setStatus("Error: ${e.message ?: e.javaClass.simpleName}") + } + } + } + + private fun setStatus(message: String) { + status.text = "Status: $message" + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun requestOptionalMediaPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + val permissions = arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ).filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } + + if (permissions.isNotEmpty()) { + requestPermissions(permissions.toTypedArray(), REQUEST_MEDIA_PERMISSIONS) + } + } + + private companion object { + const val SCREEB_CHANNEL_ID = "0e2b609a-8dce-4695-a80f-966fbfa87a88" + const val REQUEST_MEDIA_PERMISSIONS = 42 } } diff --git a/examples/example-android/app/src/main/res/values/styles.xml b/examples/example-android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1b707d5 --- /dev/null +++ b/examples/example-android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/examples/example-android/build.gradle b/examples/example-android/build.gradle new file mode 100644 index 0000000..a534651 --- /dev/null +++ b/examples/example-android/build.gradle @@ -0,0 +1,4 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false + id 'org.jetbrains.kotlin.android' version '2.1.0' apply false +} diff --git a/examples/example-android/gradle.properties b/examples/example-android/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/examples/example-android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/examples/example-android/gradle/wrapper/gradle-wrapper.jar b/examples/example-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/examples/example-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/example-android/gradle/wrapper/gradle-wrapper.properties b/examples/example-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..adf1ca0 --- /dev/null +++ b/examples/example-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Oct 24 15:14:15 CEST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/example-android/gradlew b/examples/example-android/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/examples/example-android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/examples/example-android/gradlew.bat b/examples/example-android/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/examples/example-android/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/example-android/settings.gradle b/examples/example-android/settings.gradle new file mode 100644 index 0000000..d57a9b2 --- /dev/null +++ b/examples/example-android/settings.gradle @@ -0,0 +1,29 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + mavenLocal() + } + google() + mavenCentral() + } +} + +if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + includeBuild("../../../sdk-android") { + dependencySubstitution { + substitute(module("app.screeb.sdk:survey")).using(project(":sdk")) + } + } +} + +rootProject.name = 'screeb-example-android' +include ':app' diff --git a/examples/example-kmp/.gitignore b/examples/example-kmp/.gitignore new file mode 100644 index 0000000..2f9f152 --- /dev/null +++ b/examples/example-kmp/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +build/ +composeApp/build/ +local.properties +*.iml +.idea/ diff --git a/examples/example-kmp/composeApp/build.gradle.kts b/examples/example-kmp/composeApp/build.gradle.kts new file mode 100644 index 0000000..16088db --- /dev/null +++ b/examples/example-kmp/composeApp/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + kotlin("multiplatform") version "2.1.0" + id("com.android.application") version "8.7.3" + id("org.jetbrains.compose") version "1.7.3" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" +} + +kotlin { + androidTarget() + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation("app.screeb.sdk.kmp:screeb-kmp:0.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } + androidMain.dependencies { + implementation("androidx.activity:activity-compose:1.9.3") + } + } +} + +android { + namespace = "app.screeb.example.kmp" + compileSdk = 35 + defaultConfig { + applicationId = "app.screeb.example.kmp" + minSdk = 21 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/examples/example-kmp/composeApp/src/androidMain/AndroidManifest.xml b/examples/example-kmp/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..eda5c6c --- /dev/null +++ b/examples/example-kmp/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/examples/example-kmp/composeApp/src/androidMain/kotlin/app/screeb/example/kmp/MainActivity.kt b/examples/example-kmp/composeApp/src/androidMain/kotlin/app/screeb/example/kmp/MainActivity.kt new file mode 100644 index 0000000..736342c --- /dev/null +++ b/examples/example-kmp/composeApp/src/androidMain/kotlin/app/screeb/example/kmp/MainActivity.kt @@ -0,0 +1,14 @@ +package app.screeb.example.kmp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App(channelId = "0e2b609a-8dce-4695-a80f-966fbfa87a88") + } + } +} diff --git a/examples/example-kmp/composeApp/src/commonMain/kotlin/app/screeb/example/kmp/App.kt b/examples/example-kmp/composeApp/src/commonMain/kotlin/app/screeb/example/kmp/App.kt new file mode 100644 index 0000000..85dc6c7 --- /dev/null +++ b/examples/example-kmp/composeApp/src/commonMain/kotlin/app/screeb/example/kmp/App.kt @@ -0,0 +1,110 @@ +package app.screeb.example.kmp + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.screeb.sdk.kmp.Screeb +import app.screeb.sdk.kmp.ScreebInitOptions +import kotlinx.coroutines.launch + +@Composable +fun App(channelId: String) { + val scope = rememberCoroutineScope() + var status by remember { mutableStateOf("Not initialized") } + + LaunchedEffect(Unit) { + val ok = Screeb.initSdk( + channelId = channelId, + initOptions = ScreebInitOptions(isDebugMode = true), + ) + if (ok == true) { + Screeb.trackScreen("KMP Example", mapOf("platform" to "android")) + } + status = if (ok == true) "SDK initialized" else "SDK init failed" + } + + MaterialTheme { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Screeb KMP Example") + Text(status) + + Button(onClick = { + scope.launch { + val ok = Screeb.trackScreen("KMP Example", mapOf("button" to "track_screen")) + status = if (ok == true) "Screen tracked" else "Screen tracking failed" + } + }) { Text("Track Screen") } + + Button(onClick = { + scope.launch { + val ok = Screeb.trackEvent("button_tapped", mapOf("source" to "example_kmp")) + status = if (ok == true) "Event tracked" else "Event tracking failed" + } + }) { Text("Track Event") } + + Button(onClick = { + scope.launch { + val ok = Screeb.setIdentity("user_123", mapOf("plan" to "pro")) + status = if (ok == true) "Identity sent" else "Identity failed" + } + }) { Text("Set Identity") } + + Button(onClick = { + scope.launch { + val ok = Screeb.startSurvey( + surveyId = "replace-with-survey-id", + hiddenFields = mapOf("example" to "kmp"), + language = "en", + ) + status = if (ok == true) "Survey start requested" else "Survey start failed" + } + }) { Text("Start Survey") } + + Button(onClick = { + scope.launch { + val ok = Screeb.startMessage( + messageId = "replace-with-message-id", + hiddenFields = mapOf("example" to "kmp"), + language = "en", + ) + status = if (ok == true) "Message start requested" else "Message start failed" + } + }) { Text("Start Message") } + + Button(onClick = { + scope.launch { + val ok = Screeb.sessionReplayStart() + status = if (ok == true) "Session replay started" else "Session replay failed" + } + }) { Text("Start Replay") } + + Button(onClick = { + scope.launch { + val debug = Screeb.debug() + status = debug?.take(160) ?: "Debug failed" + } + }) { Text("Debug SDK") } + + Button(onClick = { + scope.launch { + val targeting = Screeb.debugTargeting() + status = targeting?.take(160) ?: "Targeting debug failed" + } + }) { Text("Debug Targeting") } + + Button(onClick = { + scope.launch { + val ok = Screeb.resetIdentity() + status = if (ok == true) "Identity reset" else "Reset failed" + } + }) { Text("Reset Identity") } + } + } +} diff --git a/examples/example-kmp/composeApp/src/iosMain/kotlin/app/screeb/example/kmp/MainViewController.kt b/examples/example-kmp/composeApp/src/iosMain/kotlin/app/screeb/example/kmp/MainViewController.kt new file mode 100644 index 0000000..9ca21fd --- /dev/null +++ b/examples/example-kmp/composeApp/src/iosMain/kotlin/app/screeb/example/kmp/MainViewController.kt @@ -0,0 +1,7 @@ +package app.screeb.example.kmp + +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { + App(channelId = "0e2b609a-8dce-4695-a80f-966fbfa87a88") +} diff --git a/examples/example-kmp/gradle.properties b/examples/example-kmp/gradle.properties new file mode 100644 index 0000000..dc1854e --- /dev/null +++ b/examples/example-kmp/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g +kotlin.daemon.jvm.options=-Xmx2g diff --git a/examples/example-kmp/gradle/wrapper/gradle-wrapper.jar b/examples/example-kmp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/examples/example-kmp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/example-kmp/gradle/wrapper/gradle-wrapper.properties b/examples/example-kmp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/examples/example-kmp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/example-kmp/gradlew b/examples/example-kmp/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/examples/example-kmp/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/examples/example-kmp/gradlew.bat b/examples/example-kmp/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/examples/example-kmp/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/example-kmp/settings.gradle.kts b/examples/example-kmp/settings.gradle.kts new file mode 100644 index 0000000..7f6a556 --- /dev/null +++ b/examples/example-kmp/settings.gradle.kts @@ -0,0 +1,35 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + mavenLocal() + } + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + includeBuild("../../../sdk-android") { + dependencySubstitution { + substitute(module("app.screeb.sdk:survey")).using(project(":sdk")) + } + } +} + +includeBuild("../../packages/sdk-kmp") { + dependencySubstitution { + substitute(module("app.screeb.sdk.kmp:screeb-kmp")).using(project(":")) + } +} + +rootProject.name = "example-kmp" +include(":composeApp") diff --git a/packages/sdk-angular/docs/classes/Screeb.md b/packages/sdk-angular/docs/classes/Screeb.md index aba4da8..c45d0b9 100644 --- a/packages/sdk-angular/docs/classes/Screeb.md +++ b/packages/sdk-angular/docs/classes/Screeb.md @@ -33,7 +33,6 @@ - [sessionReplayStop](Screeb.md#sessionreplaystop) - [surveyClose](Screeb.md#surveyclose) - [surveyStart](Screeb.md#surveystart) -- [targetingCheck](Screeb.md#targetingcheck) - [targetingDebug](Screeb.md#targetingdebug) ## Constructors @@ -582,24 +581,6 @@ this.screeb.surveyStart( ___ -### targetingCheck - -▸ **targetingCheck**(): `Promise`\<`unknown`\> - -Forces a targeting check. - -#### Returns - -`Promise`\<`unknown`\> - -**`Example`** - -```ts -this.screeb.targetingCheck(); -``` - -___ - ### targetingDebug ▸ **targetingDebug**(): `Promise`\<`unknown`\> diff --git a/packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts b/packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts index 85b2e0c..8d52b82 100644 --- a/packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts +++ b/packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts @@ -423,20 +423,6 @@ export class Screeb { return _Screeb.sessionReplayStart(); } - /** - * Forces a targeting check. - * - * @example - * ```ts - * this.screeb.targetingCheck(); - * ``` - */ - public async targetingCheck() { - await this.ensureScreeb("targetingCheck"); - - return _Screeb.targetingCheck(); - } - /** * Prints the current state of the targeting engine. * diff --git a/packages/sdk-browser/docs/README.md b/packages/sdk-browser/docs/README.md index 4bf4312..bfb1235 100644 --- a/packages/sdk-browser/docs/README.md +++ b/packages/sdk-browser/docs/README.md @@ -64,7 +64,6 @@ - [sessionReplayStop](README.md#sessionreplaystop) - [surveyClose](README.md#surveyclose) - [surveyStart](README.md#surveystart) -- [targetingCheck](README.md#targetingcheck) - [targetingDebug](README.md#targetingdebug) ## Type Aliases @@ -1173,26 +1172,6 @@ Screeb.surveyStart( ___ -### targetingCheck - -▸ **targetingCheck**(): `void` \| `Promise`\<`unknown`\> - -Forces a targeting check. - -#### Returns - -`void` \| `Promise`\<`unknown`\> - -**`Example`** - -```ts -import * as Screeb from "@screeb/sdk-browser"; - -Screeb.targetingCheck(); -``` - -___ - ### targetingDebug ▸ **targetingDebug**(): `void` \| `Promise`\<`unknown`\> diff --git a/packages/sdk-browser/src/index.ts b/packages/sdk-browser/src/index.ts index 730f58f..7ef7267 100644 --- a/packages/sdk-browser/src/index.ts +++ b/packages/sdk-browser/src/index.ts @@ -543,18 +543,6 @@ export const sessionReplayStop = () => callScreebCommand("session-replay.stop"); export const sessionReplayStart = () => callScreebCommand("session-replay.start"); -/** - * Forces a targeting check. - * - * @example - * ```ts - * import * as Screeb from "@screeb/sdk-browser"; - * - * Screeb.targetingCheck(); - * ``` - */ -export const targetingCheck = () => callScreebCommand("targeting.check"); - /** * Prints the current state of the targeting engine. * diff --git a/packages/sdk-kmp/README.md b/packages/sdk-kmp/README.md new file mode 100644 index 0000000..81555f1 --- /dev/null +++ b/packages/sdk-kmp/README.md @@ -0,0 +1,169 @@ +

+ + Logo + +

+

Screeb KMP SDK

+

+ Screeb's mobile SDK for Kotlin Multiplatform (Android & iOS). + + Continuous Product Discovery, Without the Time Sink. + + Screeb is the only Continuous Product Discovery platform that lets you analyse users' behaviour, ask in-app questions, recruit people for interviews and analyse data in a blink with AI. +

+ +

+ + ci + + + Maven Central + + + Cocoapods + + + Native Android SDK + + + License: Proprietary + +

+ +Kotlin Multiplatform SDK for the [Screeb](https://screeb.app) survey & messaging platform. + +Supports **Android** and **iOS** targets. Wraps the native [Android](https://github.com/ScreebApp/sdk-android) and iOS SDKs behind a single idiomatic Kotlin suspend-fun API. + +## Installation + +Add to your `build.gradle.kts`: + +```kotlin +commonMain.dependencies { + implementation("app.screeb.sdk.kmp:screeb-kmp:0.1.0") +} +``` + +> **Building from source:** Android uses `SCREEB_ANDROID_SDK_VERSION` and iOS uses `SCREEB_IOS_SDK_VERSION`, so both native SDKs can move independently. Screeb contributors can set `SCREEB_USE_LOCAL_SDK=true` to build against sibling `../sdk-android` and `../sdk-ios` checkouts without publishing native releases. + +## Package Size + +Current package size snapshot. Native SDK sizes are listed separately to help estimate app impact: + +- KMP Maven artifacts: 143.9 KB, 7 files +- native Android SDK AAR: 110.3 KB +- native iOS app size impact: about 450 KB + +## Battery usage + +Screeb is optimized to minimize battery impact. Most features are event-driven, and session replay adapts automatically to app activity and device conditions. + +When session replay is enabled, the SDK reduces work while idle and under Low Power Mode, Battery Saver, thermal pressure, or memory pressure. It prioritizes reducing image quality, resolution, and changed-region processing before lowering active capture cadence. + +## Usage + +```kotlin +import app.screeb.sdk.kmp.Screeb +import app.screeb.sdk.kmp.ScreebInitOptions +import app.screeb.sdk.kmp.ScreebHooks + +// Initialize (call once, e.g. in App.kt LaunchedEffect) +Screeb.initSdk( + channelId = "YOUR_CHANNEL_ID", + userId = "user_123", + properties = mapOf("plan" to "pro"), + initOptions = ScreebInitOptions(isDebugMode = true), +) + +// Track events +Screeb.trackEvent("button_tapped", mapOf("source" to "home")) +Screeb.trackScreen("HomeScreen") + +// Identity +Screeb.setIdentity("user_456", mapOf("email" to "user@example.com")) +Screeb.setProperties(mapOf("language" to "fr")) +Screeb.resetIdentity() + +// Groups +Screeb.assignGroup(groupName = "beta_testers") +Screeb.unassignGroup(groupName = "beta_testers") + +// Surveys & Messages +Screeb.startSurvey("survey-id") +Screeb.closeSurvey() +Screeb.startMessage("message-id") +Screeb.closeMessage() + +// Session replay +Screeb.sessionReplayStart() +Screeb.sessionReplayStop() + +// Privacy helpers for native Android View and iOS UIView +view.screebMaskText() +view.screebNoCapture() +view.screebId("checkout_button") + +// Debug +val debugInfo = Screeb.debug() +val targeting = Screeb.debugTargeting() + +// Hooks +Screeb.startSurvey( + surveyId = "survey-id", + hooks = ScreebHooks( + version = "1.0.0", + callbacks = mapOf( + "onSurveyShowed" to { payload -> println("Survey shown: $payload") }, + "onSurveyCompleted" to { payload -> println("Survey completed: $payload") }, + ) + ) +) +``` + +## API Reference + +| Method | Description | +|---|---| +| `initSdk(channelId, userId?, properties?, hooks?, initOptions?, language?)` | Initialize the SDK | +| `closeSdk()` | Tear down SDK and clear hook registry | +| `setIdentity(userId, properties?)` | Identify the current user | +| `setProperties(properties?)` | Update visitor properties | +| `resetIdentity()` | Reset to anonymous visitor | +| `getIdentity()` | Fetch current identity as `Map` | +| `assignGroup(groupType?, groupName, properties?)` | Assign visitor to a group | +| `unassignGroup(groupType?, groupName, properties?)` | Remove visitor from a group | +| `trackEvent(name, properties?)` | Track a custom event | +| `trackScreen(name, properties?)` | Track a screen view | +| `startSurvey(surveyId, ...)` | Programmatically start a survey | +| `closeSurvey(surveyId?)` | Close currently open survey | +| `startMessage(messageId, ...)` | Programmatically start a message | +| `closeMessage(messageId?)` | Close currently open message | +| `sessionReplayStart()` | Start session replay recording | +| `sessionReplayStop()` | Stop session replay recording | +| `debug()` | Fetch SDK debug info as JSON string | +| `debugTargeting()` | Fetch targeting debug info as JSON string | + +All methods return `Boolean?` (or the appropriate type), and `null` on unexpected error. Wrap calls in `runCatching {}` for robust error handling. + +## Requirements + +- Kotlin 2.1.0+ +- Android minSdk 21 +- iOS 14+ + +## Documentation + +- Install guide: [developers.screeb.app/sdk-kmp/install](https://developers.screeb.app/sdk-kmp/install) +- API reference: [developers.screeb.app/sdk-kmp/reference](https://developers.screeb.app/sdk-kmp/reference) + +## Support + +For any issues, please contact our support team at support@screeb.com. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same license terms that the project is provided under. + +## License + +Proprietary — see [Screeb Terms of Service](https://screeb.app/terms). diff --git a/packages/sdk-kmp/build.gradle.kts b/packages/sdk-kmp/build.gradle.kts new file mode 100644 index 0000000..b6ab271 --- /dev/null +++ b/packages/sdk-kmp/build.gradle.kts @@ -0,0 +1,180 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("multiplatform") version "2.1.0" + id("com.android.library") version "8.7.3" + id("maven-publish") + id("signing") +} + +group = project.property("GROUP") as String +version = project.property("VERSION_NAME") as String + +val useLocalSdk = providers.gradleProperty("SCREEB_USE_LOCAL_SDK") + .orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")) + .map { it.equals("true", ignoreCase = true) } + .orElse(false) +val defaultLocalIosSdkPath = projectDir.resolve("../../../sdk-ios").canonicalFile.absolutePath +val localIosSdkPath = providers.environmentVariable("SCREEB_IOS_SDK_PATH").orElse(defaultLocalIosSdkPath) +val localIosXcframework = layout.buildDirectory.dir("local-ios/Screeb.xcframework") +val packagedIosXcframework = projectDir.resolve("native/ios/Screeb.xcframework").absolutePath +val xcframeworkPath = if (useLocalSdk.get()) { + localIosXcframework.get().asFile.absolutePath +} else { + packagedIosXcframework +} +val screebAndroidSdkVersion = project.property("SCREEB_ANDROID_SDK_VERSION") as String + +val buildLocalScreebIosXcframework by tasks.registering(Exec::class) { + onlyIf { useLocalSdk.get() } + commandLine( + "node", + projectDir.resolve("../../scripts/build-local-ios-xcframework.mjs").canonicalFile.absolutePath, + "--sdk-ios", + localIosSdkPath.get(), + "--output", + localIosXcframework.get().asFile.absolutePath, + ) +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + publishLibraryVariants("release") + } + + iosArm64 { + compilations["main"].cinterops.create("Screeb") { + defFile("iosInterop/Screeb.def") + compilerOpts("-F", "$xcframeworkPath/ios-arm64") + } + binaries.all { + linkerOpts("-framework", "Screeb", "-F", "$xcframeworkPath/ios-arm64") + } + } + + iosSimulatorArm64 { + compilations["main"].cinterops.create("Screeb") { + defFile("iosInterop/Screeb.def") + compilerOpts("-F", "$xcframeworkPath/ios-arm64_x86_64-simulator") + } + binaries.all { + linkerOpts("-framework", "Screeb", "-F", "$xcframeworkPath/ios-arm64_x86_64-simulator") + } + } + + iosX64 { + compilations["main"].cinterops.create("Screeb") { + defFile("iosInterop/Screeb.def") + compilerOpts("-F", "$xcframeworkPath/ios-arm64_x86_64-simulator") + } + binaries.all { + linkerOpts("-framework", "Screeb", "-F", "$xcframeworkPath/ios-arm64_x86_64-simulator") + } + } + + sourceSets { + commonMain.dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } + androidMain.dependencies { + implementation("app.screeb.sdk:survey:$screebAndroidSdkVersion") + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } +} + +android { + namespace = "app.screeb.sdk.kmp" + compileSdk = 35 + defaultConfig { + minSdk = 21 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +tasks.matching { it.name in setOf("iosArm64Test", "iosSimulatorArm64Test", "iosX64Test") }.configureEach { + // The native framework is linked for compilation, but Kotlin/Native test binaries do not embed it. + // Common behavior is covered by Android unit tests; iOS release confidence comes from target compilation. + enabled = false +} + +tasks.configureEach { + if (useLocalSdk.get() && (name.startsWith("cinteropScreeb") || name.startsWith("compileKotlinIos") || name.startsWith("link"))) { + dependsOn(buildLocalScreebIosXcframework) + } +} + +// ---- Publishing ---- + +val javadocJar by tasks.registering(Jar::class) { + archiveClassifier.set("javadoc") +} + +publishing { + publications.withType { + artifact(javadocJar) + pom { + name.set(project.property("POM_NAME") as String) + description.set(project.property("POM_DESCRIPTION") as String) + url.set(project.property("POM_URL") as String) + licenses { + license { + name.set(project.property("POM_LICENCE_NAME") as String) + url.set(project.property("POM_LICENCE_URL") as String) + } + } + developers { + developer { + id.set(project.property("POM_DEVELOPER_ID") as String) + name.set(project.property("POM_DEVELOPER_NAME") as String) + url.set(project.property("POM_DEVELOPER_URL") as String) + } + } + scm { + url.set(project.property("POM_SCM_URL") as String) + connection.set(project.property("POM_SCM_CONNECTION") as String) + developerConnection.set(project.property("POM_SCM_DEV_CONNECTION") as String) + } + } + } + repositories { + maven { + name = "OSSRH" + url = uri( + if ((version as String).endsWith("SNAPSHOT")) + "https://s01.oss.sonatype.org/content/repositories/snapshots/" + else + "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" + ) + credentials { + username = System.getenv("OSSRH_USERNAME") + password = System.getenv("OSSRH_PASSWORD") + } + } + } +} + +signing { + val gpgKey = System.getenv("GPG_KEY") + val gpgKeyId = System.getenv("GPG_KEY_ID") + val gpgPassword = System.getenv("GPG_PASSWORD") + if (gpgKey != null && gpgKeyId != null && gpgPassword != null) { + useInMemoryPgpKeys(gpgKeyId, gpgKey, gpgPassword) + sign(publishing.publications) + } +} diff --git a/packages/sdk-kmp/gradle.properties b/packages/sdk-kmp/gradle.properties new file mode 100644 index 0000000..13eec84 --- /dev/null +++ b/packages/sdk-kmp/gradle.properties @@ -0,0 +1,22 @@ +# KMP +kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.stability.nowarn=true +android.useAndroidX=true + +# Publishing +GROUP=app.screeb.sdk.kmp +VERSION_NAME=0.1.0 +SCREEB_ANDROID_SDK_VERSION=4.0.0 +SCREEB_IOS_SDK_VERSION=4.0.0 +POM_ARTIFACT_ID=screeb-kmp +POM_NAME=Screeb KMP SDK +POM_DESCRIPTION=Kotlin Multiplatform SDK for Screeb survey & messaging platform +POM_URL=https://github.com/ScreebApp/sdk +POM_SCM_URL=https://github.com/ScreebApp/sdk +POM_SCM_CONNECTION=scm:git:git://github.com/ScreebApp/sdk.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://github.com/ScreebApp/sdk.git +POM_LICENCE_NAME=Proprietary +POM_LICENCE_URL=https://screeb.app/terms +POM_DEVELOPER_ID=screeb +POM_DEVELOPER_NAME=Screeb +POM_DEVELOPER_URL=https://screeb.app diff --git a/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.jar b/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.properties b/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/packages/sdk-kmp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/sdk-kmp/gradlew b/packages/sdk-kmp/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/packages/sdk-kmp/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/packages/sdk-kmp/gradlew.bat b/packages/sdk-kmp/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/packages/sdk-kmp/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/sdk-kmp/iosInterop/Screeb.def b/packages/sdk-kmp/iosInterop/Screeb.def new file mode 100644 index 0000000..657f689 --- /dev/null +++ b/packages/sdk-kmp/iosInterop/Screeb.def @@ -0,0 +1,3 @@ +language = Objective-C +headers = Screeb/Screeb-Swift.h +package = app.screeb.sdk.ios.cinterop diff --git a/packages/sdk-kmp/readme/screeb-logo.svg b/packages/sdk-kmp/readme/screeb-logo.svg new file mode 100644 index 0000000..b74bd6e --- /dev/null +++ b/packages/sdk-kmp/readme/screeb-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/sdk-kmp/settings.gradle.kts b/packages/sdk-kmp/settings.gradle.kts new file mode 100644 index 0000000..1c8822d --- /dev/null +++ b/packages/sdk-kmp/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + mavenLocal() + } + google() + mavenCentral() + } +} + +if (providers.gradleProperty("SCREEB_USE_LOCAL_SDK").orElse(providers.environmentVariable("SCREEB_USE_LOCAL_SDK")).orNull == "true") { + includeBuild("../../../sdk-android") { + dependencySubstitution { + substitute(module("app.screeb.sdk:survey")).using(project(":sdk")) + } + } +} + +rootProject.name = "sdk-kmp" diff --git a/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/HooksAndroid.kt b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/HooksAndroid.kt new file mode 100644 index 0000000..be83106 --- /dev/null +++ b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/HooksAndroid.kt @@ -0,0 +1,37 @@ +package app.screeb.sdk.kmp + +import app.screeb.sdk.Screeb as AndroidScreeb +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** Module-level scope for invoking hook callbacks. Cancelled and recreated on closeSdk(). */ +internal object HooksScope { + var scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private set + + fun reset() { + scope.cancel() + scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + } +} + +internal object HooksAndroid { + + fun toHooks(uuidMap: Map): HashMap? { + if (uuidMap.isEmpty()) return null + return AndroidScreeb.makeHooks(uuidMap) { uuid, nativeHookId, payload -> + val fn = HooksRegistry.get(uuid) + if (fn != null) { + HooksScope.scope.launch { + val result = runCatching { fn(payload) }.getOrElse { false } + if (nativeHookId.isNotBlank()) { + AndroidScreeb.onHookResult(nativeHookId, result) + } + } + } + } + } +} diff --git a/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Platform.android.kt b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Platform.android.kt new file mode 100644 index 0000000..93f2939 --- /dev/null +++ b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Platform.android.kt @@ -0,0 +1,5 @@ +package app.screeb.sdk.kmp + +import java.util.UUID + +internal actual fun randomUuid(): String = UUID.randomUUID().toString() diff --git a/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Screeb.android.kt b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Screeb.android.kt new file mode 100644 index 0000000..bf835a7 --- /dev/null +++ b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/Screeb.android.kt @@ -0,0 +1,214 @@ +package app.screeb.sdk.kmp + +import app.screeb.sdk.Screeb as AndroidScreeb +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual object Screeb { + + actual suspend fun initSdk( + channelId: String, + userId: String?, + properties: Map?, + hooks: ScreebHooks?, + initOptions: ScreebInitOptions?, + language: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + AndroidScreeb.setSecondarySDK("kmp", SDK_VERSION) + AndroidScreeb.pluginInit( + channelId, + userId, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + initOptions?.let { + hashMapOf("isDebugMode" to it.isDebugMode, "disableMirror" to it.disableMirror) + }, + HooksAndroid.toHooks(uuidMap), + language, + ) + true + }.getOrNull() + } + + actual suspend fun closeSdk(): Boolean? = withContext(Dispatchers.Main) { + runCatching { + HooksRegistry.clear() + HooksScope.reset() + AndroidScreeb.closeSdk() + true + }.getOrNull() + } + + actual suspend fun setIdentity( + userId: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + AndroidScreeb.setIdentity( + userId, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + ) + true + }.getOrNull() + } + + actual suspend fun setProperties(properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + val props = ScreebUtils.formatProperties(properties)?.let { HashMap(it) } + if (props != null) AndroidScreeb.setVisitorProperties(props) + true + }.getOrNull() + } + + actual suspend fun resetIdentity(): Boolean? = withContext(Dispatchers.Main) { + runCatching { AndroidScreeb.resetIdentity(); true }.getOrNull() + } + + actual suspend fun getIdentity(): Map? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + AndroidScreeb.getIdentity { identity: HashMap?, error: Exception? -> + if (error != null) cont.resumeWithException(error) + else cont.resume(identity?.filterValues { it != null }?.mapValues { it.value!! }) + } + } + }.getOrNull() + } + + actual suspend fun assignGroup( + groupType: String?, + groupName: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + AndroidScreeb.assignGroup( + groupType, groupName, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + ) + true + }.getOrNull() + } + + actual suspend fun unassignGroup( + groupType: String?, + groupName: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + AndroidScreeb.unassignGroup( + groupType, groupName, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + ) + true + }.getOrNull() + } + + actual suspend fun trackEvent(name: String, properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + AndroidScreeb.trackEvent( + name, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + ) + true + }.getOrNull() + } + + actual suspend fun trackScreen(name: String, properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + AndroidScreeb.trackScreen( + name, + ScreebUtils.formatProperties(properties)?.let { HashMap(it) }, + ) + true + }.getOrNull() + } + + actual suspend fun startSurvey( + surveyId: String, + allowMultipleResponses: Boolean, + hiddenFields: Map?, + ignoreSurveyStatus: Boolean, + hooks: ScreebHooks?, + language: String?, + distributionId: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + AndroidScreeb.startSurvey( + surveyId, allowMultipleResponses, + ScreebUtils.formatProperties(hiddenFields)?.let { HashMap(it) }, + ignoreSurveyStatus, + HooksAndroid.toHooks(uuidMap), + language, distributionId, + ) + true + }.getOrNull() + } + + actual suspend fun closeSurvey(surveyId: String?): Boolean? = withContext(Dispatchers.Main) { + runCatching { AndroidScreeb.closeSurvey(surveyId); true }.getOrNull() + } + + actual suspend fun startMessage( + messageId: String, + allowMultipleResponses: Boolean, + hiddenFields: Map?, + ignoreMessageStatus: Boolean, + hooks: ScreebHooks?, + language: String?, + distributionId: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + AndroidScreeb.startMessage( + messageId, allowMultipleResponses, + ScreebUtils.formatProperties(hiddenFields)?.let { HashMap(it) }, + ignoreMessageStatus, + HooksAndroid.toHooks(uuidMap), + language, distributionId, + ) + true + }.getOrNull() + } + + actual suspend fun closeMessage(messageId: String?): Boolean? = withContext(Dispatchers.Main) { + runCatching { AndroidScreeb.closeMessage(messageId); true }.getOrNull() + } + + actual suspend fun sessionReplayStart(): Boolean? = withContext(Dispatchers.Main) { + runCatching { AndroidScreeb.sessionReplayStart(); true }.getOrNull() + } + + actual suspend fun sessionReplayStop(): Boolean? = withContext(Dispatchers.Main) { + runCatching { AndroidScreeb.sessionReplayStop(); true }.getOrNull() + } + + actual suspend fun debug(): String? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + AndroidScreeb.debug { info: String, error: Exception? -> + if (error != null) cont.resumeWithException(error) + else cont.resume(info) + } + } + }.getOrNull() + } + + actual suspend fun debugTargeting(): String? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + AndroidScreeb.debugTargeting { info: String, error: Exception? -> + if (error != null) cont.resumeWithException(error) + else cont.resume(info) + } + } + }.getOrNull() + } +} diff --git a/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/ScreebView.android.kt b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/ScreebView.android.kt new file mode 100644 index 0000000..b07ab36 --- /dev/null +++ b/packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/ScreebView.android.kt @@ -0,0 +1,19 @@ +package app.screeb.sdk.kmp + +import android.view.View +import app.screeb.sdk.R + +fun View.screebId(id: String): View { + setTag(R.id.screeb_id, id) + return this +} + +fun View.screebMaskText(): View { + setTag(R.id.screeb_sensitive_tag, true) + return this +} + +fun View.screebNoCapture(): View { + setTag(R.id.screeb_no_capture_tag, true) + return this +} diff --git a/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/Screeb.kt b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/Screeb.kt new file mode 100644 index 0000000..6830e12 --- /dev/null +++ b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/Screeb.kt @@ -0,0 +1,79 @@ +package app.screeb.sdk.kmp + +/** + * Entry point for the Screeb KMP SDK. + * All methods are suspend functions dispatched on Dispatchers.Main. + * Platform implementations live in androidMain/Screeb.android.kt and iosMain/Screeb.ios.kt. + */ +expect object Screeb { + + suspend fun initSdk( + channelId: String, + userId: String? = null, + properties: Map? = null, + hooks: ScreebHooks? = null, + initOptions: ScreebInitOptions? = null, + language: String? = null, + ): Boolean? + + suspend fun closeSdk(): Boolean? + + suspend fun setIdentity( + userId: String, + properties: Map? = null, + ): Boolean? + + suspend fun setProperties(properties: Map? = null): Boolean? + + suspend fun resetIdentity(): Boolean? + + suspend fun getIdentity(): Map? + + suspend fun assignGroup( + groupType: String? = null, + groupName: String, + properties: Map? = null, + ): Boolean? + + suspend fun unassignGroup( + groupType: String? = null, + groupName: String, + properties: Map? = null, + ): Boolean? + + suspend fun trackEvent(name: String, properties: Map? = null): Boolean? + + suspend fun trackScreen(name: String, properties: Map? = null): Boolean? + + suspend fun startSurvey( + surveyId: String, + allowMultipleResponses: Boolean = true, + hiddenFields: Map? = null, + ignoreSurveyStatus: Boolean = true, + hooks: ScreebHooks? = null, + language: String? = null, + distributionId: String? = null, + ): Boolean? + + suspend fun closeSurvey(surveyId: String? = null): Boolean? + + suspend fun startMessage( + messageId: String, + allowMultipleResponses: Boolean = true, + hiddenFields: Map? = null, + ignoreMessageStatus: Boolean = true, + hooks: ScreebHooks? = null, + language: String? = null, + distributionId: String? = null, + ): Boolean? + + suspend fun closeMessage(messageId: String? = null): Boolean? + + suspend fun sessionReplayStart(): Boolean? + + suspend fun sessionReplayStop(): Boolean? + + suspend fun debug(): String? + + suspend fun debugTargeting(): String? +} diff --git a/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebHooks.kt b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebHooks.kt new file mode 100644 index 0000000..9ce858d --- /dev/null +++ b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebHooks.kt @@ -0,0 +1,42 @@ +package app.screeb.sdk.kmp + +/** + * Hook callbacks to pass to initSdk, startSurvey, or startMessage. + * [version] is passed verbatim to the native SDK. + * [callbacks] maps hook names (e.g. "onSurveyShowed") to suspend lambdas + * that receive the JSON payload string. + */ +data class ScreebHooks( + val version: String, + val callbacks: Map Any?> = emptyMap(), +) + +/** + * Internal registry mapping UUID → suspend callback. + * UUID format: "_" to aid debugging. + * All accesses are performed on the Main dispatcher (enforced by callers via withContext(Dispatchers.Main)), + * so no additional synchronization is required. + */ +internal object HooksRegistry { + private val registry = mutableMapOf Any?>() + + /** Registers callbacks, returns a map of hookName → UUID for passing to native SDK. */ + fun register(hooks: ScreebHooks?): Map { + if (hooks == null) return emptyMap() + val uuidMap = mutableMapOf() + uuidMap["version"] = hooks.version + for ((key, callback) in hooks.callbacks) { + val uuid = "${key}_${randomUuid()}" + registry[uuid] = callback + uuidMap[key] = uuid + } + return uuidMap + } + + fun get(uuid: String): (suspend (String) -> Any?)? = registry[uuid] + + fun clear() = registry.clear() +} + +/** Platform-provided UUID. Defined as expect/actual. */ +internal expect fun randomUuid(): String diff --git a/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebInitOptions.kt b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebInitOptions.kt new file mode 100644 index 0000000..2188363 --- /dev/null +++ b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebInitOptions.kt @@ -0,0 +1,6 @@ +package app.screeb.sdk.kmp + +data class ScreebInitOptions( + val isDebugMode: Boolean = false, + val disableMirror: Boolean = false, +) diff --git a/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebUtils.kt b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebUtils.kt new file mode 100644 index 0000000..9f57be7 --- /dev/null +++ b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebUtils.kt @@ -0,0 +1,21 @@ +package app.screeb.sdk.kmp + +/** + * Converts Map? to a form safe for native SDK consumption. + * Nested maps are recursively processed. + * Mirrors MAUI's ScreebUtils.FormatProperties. + */ +internal object ScreebUtils { + fun formatProperties(props: Map?): Map? { + if (props == null) return null + return props.mapValues { (_, v) -> formatValue(v) } + } + + private fun formatValue(value: Any): Any = when (value) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + formatProperties(value as Map) ?: emptyMap() + } + else -> value + } +} diff --git a/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/SdkVersion.kt b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/SdkVersion.kt new file mode 100644 index 0000000..104d14a --- /dev/null +++ b/packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/SdkVersion.kt @@ -0,0 +1,3 @@ +package app.screeb.sdk.kmp + +internal const val SDK_VERSION = "0.1.0" diff --git a/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/HooksRegistryTest.kt b/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/HooksRegistryTest.kt new file mode 100644 index 0000000..bcce492 --- /dev/null +++ b/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/HooksRegistryTest.kt @@ -0,0 +1,64 @@ +package app.screeb.sdk.kmp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class HooksRegistryTest { + + @Test + fun registerNullHooksReturnsEmptyMap() { + HooksRegistry.clear() + val result = HooksRegistry.register(null) + assertTrue(result.isEmpty()) + } + + @Test + fun registerHooksReturnsVersionAndUuids() { + HooksRegistry.clear() + val hooks = ScreebHooks( + version = "1.0", + callbacks = mapOf("onSurveyShowed" to { _ -> null }) + ) + val uuidMap = HooksRegistry.register(hooks) + assertEquals("1.0", uuidMap["version"]) + val uuid = uuidMap["onSurveyShowed"] + assertNotNull(uuid) + assertTrue(uuid.startsWith("onSurveyShowed_")) + } + + @Test + fun getReturnsRegisteredCallback() = runTest { + HooksRegistry.clear() + var receivedPayload: String? = null + val hooks = ScreebHooks( + version = "1.0", + callbacks = mapOf("onSurveyShowed" to { payload -> + receivedPayload = payload + true + }) + ) + val uuidMap = HooksRegistry.register(hooks) + val uuid = uuidMap["onSurveyShowed"]!! + val fn = HooksRegistry.get(uuid) + assertNotNull(fn) + assertEquals(true, fn("""{"hook_id":"native-hook"}""")) + assertEquals("""{"hook_id":"native-hook"}""", receivedPayload) + } + + @Test + fun clearRemovesAllCallbacks() { + HooksRegistry.clear() + val hooks = ScreebHooks( + version = "1.0", + callbacks = mapOf("onSurveyShowed" to { _ -> null }) + ) + val uuidMap = HooksRegistry.register(hooks) + val uuid = uuidMap["onSurveyShowed"]!! + HooksRegistry.clear() + assertNull(HooksRegistry.get(uuid)) + } +} diff --git a/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/ScreebUtilsTest.kt b/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/ScreebUtilsTest.kt new file mode 100644 index 0000000..4b8934a --- /dev/null +++ b/packages/sdk-kmp/src/commonTest/kotlin/app/screeb/sdk/kmp/ScreebUtilsTest.kt @@ -0,0 +1,40 @@ +package app.screeb.sdk.kmp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ScreebUtilsTest { + + @Test + fun nullInputReturnsNull() { + assertNull(ScreebUtils.formatProperties(null)) + } + + @Test + fun primitiveValuesArePassedThrough() { + val input = mapOf("name" to "Alice", "age" to 30, "score" to 9.5, "active" to true) + val result = ScreebUtils.formatProperties(input)!! + assertEquals("Alice", result["name"]) + assertEquals(30, result["age"]) + assertEquals(9.5, result["score"]) + assertEquals(true, result["active"]) + } + + @Test + fun nestedMapIsRecursivelyProcessed() { + val input = mapOf( + "meta" to mapOf("key" to "value") + ) + val result = ScreebUtils.formatProperties(input)!! + @Suppress("UNCHECKED_CAST") + val nested = result["meta"] as Map + assertEquals("value", nested["key"]) + } + + @Test + fun emptyMapReturnsEmptyMap() { + val result = ScreebUtils.formatProperties(emptyMap()) + assertEquals(emptyMap(), result) + } +} diff --git a/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/HooksIOS.kt b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/HooksIOS.kt new file mode 100644 index 0000000..a984df3 --- /dev/null +++ b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/HooksIOS.kt @@ -0,0 +1,38 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package app.screeb.sdk.kmp + +import app.screeb.sdk.ios.cinterop.Screeb as NativeScreeb +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** Module-level scope for invoking iOS hook callbacks. Cancelled and recreated on closeSdk(). */ +internal object HooksScope { + var scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private set + + fun reset() { + scope.cancel() + scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + } +} + +internal object HooksIOS { + + @Suppress("UNCHECKED_CAST") + fun toMap(uuidMap: Map): Map? { + if (uuidMap.isEmpty()) return null + return NativeScreeb.makeHooks(uuidMap as Map) { wrapperHookId, nativeHookId, payload -> + val fn = wrapperHookId?.let { HooksRegistry.get(it) } + if (fn != null && !nativeHookId.isNullOrBlank()) { + HooksScope.scope.launch { + val result = runCatching { fn(payload ?: "{}") }.getOrElse { false } + NativeScreeb.onHookResult(nativeHookId, result) + } + } + } as Map + } +} diff --git a/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Platform.ios.kt b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Platform.ios.kt new file mode 100644 index 0000000..2064fef --- /dev/null +++ b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Platform.ios.kt @@ -0,0 +1,5 @@ +package app.screeb.sdk.kmp + +import platform.Foundation.NSUUID + +internal actual fun randomUuid(): String = NSUUID().UUIDString() diff --git a/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Screeb.ios.kt b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Screeb.ios.kt new file mode 100644 index 0000000..ec9e6cc --- /dev/null +++ b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/Screeb.ios.kt @@ -0,0 +1,236 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package app.screeb.sdk.kmp + +import app.screeb.sdk.ios.cinterop.InitOptions +import app.screeb.sdk.ios.cinterop.Screeb as NativeScreeb +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** Converts a KMP property map to a Kotlin Map that the cinterop bridge converts to NSDictionary. */ +@Suppress("UNCHECKED_CAST") +private fun Map?.toObjCMap(): Map = + (ScreebUtils.formatProperties(this) ?: emptyMap()) as Map + +/** Reads the cinterop-bridged NSDictionary (exposed as Map) to a typed Kotlin Map. */ +@Suppress("UNCHECKED_CAST") +private fun Map?.toKotlinMap(): Map? { + if (this == null) return null + return entries.associate { (k, v) -> k.toString() to (v?.toString() ?: "") } +} + +actual object Screeb { + + actual suspend fun initSdk( + channelId: String, + userId: String?, + properties: Map?, + hooks: ScreebHooks?, + initOptions: ScreebInitOptions?, + language: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + NativeScreeb.setSecondarySDK("kmp", version = SDK_VERSION) + val opts = InitOptions() + opts.setIsDebugMode(initOptions?.isDebugMode ?: false) + opts.setDisableMirror(initOptions?.disableMirror ?: false) + NativeScreeb.initSdk( + null, + channelId = channelId, + identity = userId, + visitorProperty = properties.toObjCMap(), + initOptions = opts, + hooks = HooksIOS.toMap(uuidMap), + language = language, + ) + true + }.getOrNull() + } + + actual suspend fun closeSdk(): Boolean? = withContext(Dispatchers.Main) { + runCatching { + HooksRegistry.clear() + HooksScope.reset() + NativeScreeb.closeSdk() + true + }.getOrNull() + } + + actual suspend fun setIdentity( + userId: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.setIdentity( + userId, + visitorProperty = properties.toObjCMap(), + ) + true + }.getOrNull() + } + + actual suspend fun setProperties(properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.visitorProperty(properties.toObjCMap()) + true + }.getOrNull() + } + + actual suspend fun resetIdentity(): Boolean? = withContext(Dispatchers.Main) { + runCatching { NativeScreeb.resetIdentity(); true }.getOrNull() + } + + actual suspend fun getIdentity(): Map? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + NativeScreeb.getIdentity { identity, error -> + if (error != null) cont.resumeWithException(Exception(error.localizedDescription)) + else cont.resume((identity as Map?).toKotlinMap()) + } + } + }.getOrNull() + } + + actual suspend fun assignGroup( + groupType: String?, + groupName: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.assignGroup( + groupType, + name = groupName, + properties = properties.toObjCMap(), + ) + true + }.getOrNull() + } + + actual suspend fun unassignGroup( + groupType: String?, + groupName: String, + properties: Map?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.unassignGroup( + groupType, + name = groupName, + properties = properties.toObjCMap(), + ) + true + }.getOrNull() + } + + actual suspend fun trackEvent(name: String, properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.trackEvent( + name, + trackingEventProperties = properties.toObjCMap(), + ) + true + }.getOrNull() + } + + actual suspend fun trackScreen(name: String, properties: Map?): Boolean? = + withContext(Dispatchers.Main) { + runCatching { + NativeScreeb.trackScreen( + name, + trackingEventProperties = properties.toObjCMap(), + ) + true + }.getOrNull() + } + + actual suspend fun startSurvey( + surveyId: String, + allowMultipleResponses: Boolean, + hiddenFields: Map?, + ignoreSurveyStatus: Boolean, + hooks: ScreebHooks?, + language: String?, + distributionId: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + NativeScreeb.startSurvey( + surveyId, + allowMultipleResponses = allowMultipleResponses, + hiddenFields = hiddenFields.toObjCMap(), + ignoreSurveyStatus = ignoreSurveyStatus, + hooks = HooksIOS.toMap(uuidMap), + language = language, + distributionId = distributionId, + ) + true + }.getOrNull() + } + + actual suspend fun closeSurvey(surveyId: String?): Boolean? = withContext(Dispatchers.Main) { + runCatching { NativeScreeb.closeSurvey(surveyId); true }.getOrNull() + } + + actual suspend fun startMessage( + messageId: String, + allowMultipleResponses: Boolean, + hiddenFields: Map?, + ignoreMessageStatus: Boolean, + hooks: ScreebHooks?, + language: String?, + distributionId: String?, + ): Boolean? = withContext(Dispatchers.Main) { + runCatching { + val uuidMap = HooksRegistry.register(hooks) + NativeScreeb.startMessage( + messageId, + allowMultipleResponses = allowMultipleResponses, + hiddenFields = hiddenFields.toObjCMap(), + ignoreMessageStatus = ignoreMessageStatus, + hooks = HooksIOS.toMap(uuidMap), + language = language, + distributionId = distributionId, + ) + true + }.getOrNull() + } + + actual suspend fun closeMessage(messageId: String?): Boolean? = withContext(Dispatchers.Main) { + runCatching { NativeScreeb.closeMessage(messageId); true }.getOrNull() + } + + actual suspend fun sessionReplayStart(): Boolean? = withContext(Dispatchers.Main) { + runCatching { NativeScreeb.sessionReplayStart(); true }.getOrNull() + } + + actual suspend fun sessionReplayStop(): Boolean? = withContext(Dispatchers.Main) { + runCatching { NativeScreeb.sessionReplayStop(); true }.getOrNull() + } + + actual suspend fun debug(): String? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + NativeScreeb.debug { info, error -> + if (error != null) cont.resumeWithException(Exception(error.localizedDescription)) + else cont.resume(info) + } + } + }.getOrNull() + } + + actual suspend fun debugTargeting(): String? = withContext(Dispatchers.Main) { + runCatching { + suspendCancellableCoroutine { cont -> + NativeScreeb.debugTargeting { info, error -> + if (error != null) cont.resumeWithException(Exception(error.localizedDescription)) + else cont.resume(info) + } + } + }.getOrNull() + } +} diff --git a/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/ScreebView.ios.kt b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/ScreebView.ios.kt new file mode 100644 index 0000000..f82a709 --- /dev/null +++ b/packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/ScreebView.ios.kt @@ -0,0 +1,28 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package app.screeb.sdk.kmp + +import app.screeb.sdk.ios.cinterop.ScreebView as NativeScreebView +import kotlinx.cinterop.interpretObjCPointerOrNull +import kotlinx.cinterop.objcPtr +import objcnames.classes.UIView as NativeUIView +import platform.UIKit.UIView + +fun UIView.screebId(id: String): UIView { + NativeScreebView.screebId(toNativeUIView(), id = id) + return this +} + +fun UIView.screebMaskText(): UIView { + NativeScreebView.screebMaskText(toNativeUIView()) + return this +} + +fun UIView.screebNoCapture(): UIView { + NativeScreebView.screebNoCapture(toNativeUIView()) + return this +} + +private fun UIView.toNativeUIView(): NativeUIView = + interpretObjCPointerOrNull(objcPtr()) + ?: error("Unable to bridge UIKit.UIView to Screeb UIView") diff --git a/packages/sdk-react/docs/README.md b/packages/sdk-react/docs/README.md index 016fdba..8a0d2ef 100644 --- a/packages/sdk-react/docs/README.md +++ b/packages/sdk-react/docs/README.md @@ -26,7 +26,6 @@ - [SessionReplayStopFunction](README.md#sessionreplaystopfunction) - [SurveyCloseFunction](README.md#surveyclosefunction) - [SurveyStartFunction](README.md#surveystartfunction) -- [TargetingCheckFunction](README.md#targetingcheckfunction) - [TargetingDebugFunction](README.md#targetingdebugfunction) ### Functions @@ -533,7 +532,6 @@ Screeb context API | `sessionReplayStop` | [`SessionReplayStopFunction`](README.md#sessionreplaystopfunction) | | `surveyClose` | [`SurveyCloseFunction`](README.md#surveyclosefunction) | | `surveyStart` | [`SurveyStartFunction`](README.md#surveystartfunction) | -| `targetingCheck` | [`TargetingCheckFunction`](README.md#targetingcheckfunction) | | `targetingDebug` | [`TargetingDebugFunction`](README.md#targetingdebugfunction) | ___ @@ -686,30 +684,6 @@ surveyStart( ___ -### TargetingCheckFunction - -Ƭ **TargetingCheckFunction**: () => `Promise`\<`unknown`\> - -Forces a targeting check. - -**`Example`** - -```ts -const { targetingCheck } = useScreeb(); - -targetingCheck(); -``` - -#### Type declaration - -▸ (): `Promise`\<`unknown`\> - -##### Returns - -`Promise`\<`unknown`\> - -___ - ### TargetingDebugFunction Ƭ **TargetingDebugFunction**: () => `Promise`\<`unknown`\> diff --git a/packages/sdk-react/src/provider.tsx b/packages/sdk-react/src/provider.tsx index e046515..60825c9 100644 --- a/packages/sdk-react/src/provider.tsx +++ b/packages/sdk-react/src/provider.tsx @@ -260,12 +260,6 @@ export const ScreebProvider: React.FC< [], ); - const targetingCheck = React.useCallback( - async () => - await ensureScreeb("targetingCheck", () => Screeb.targetingCheck()), - [], - ); - const targetingDebug = React.useCallback( async () => await ensureScreeb("targetingDebug", () => Screeb.targetingDebug()), @@ -310,7 +304,6 @@ export const ScreebProvider: React.FC< messageStart, sessionReplayStop, sessionReplayStart, - targetingCheck, targetingDebug, }), [ @@ -331,7 +324,6 @@ export const ScreebProvider: React.FC< messageStart, sessionReplayStop, sessionReplayStart, - targetingCheck, targetingDebug, ], ); diff --git a/packages/sdk-react/src/types.ts b/packages/sdk-react/src/types.ts index f377554..69c0a5b 100644 --- a/packages/sdk-react/src/types.ts +++ b/packages/sdk-react/src/types.ts @@ -421,18 +421,6 @@ export type SessionReplayStopFunction = () => Promise; */ export type SessionReplayStartFunction = () => Promise; -/** - * Forces a targeting check. - * - * @example - * ```ts - * const { targetingCheck } = useScreeb(); - * - * targetingCheck(); - * ``` - */ -export type TargetingCheckFunction = () => Promise; - /** * Prints the current state of the targeting engine. * @@ -478,7 +466,6 @@ export type ScreebContextValues = { messageStart: MessageStartFunction; sessionReplayStart: SessionReplayStartFunction; sessionReplayStop: SessionReplayStopFunction; - targetingCheck: TargetingCheckFunction; targetingDebug: TargetingDebugFunction; }; diff --git a/packages/sdk-vue/docs/README.md b/packages/sdk-vue/docs/README.md index 646f8a4..c60574e 100644 --- a/packages/sdk-vue/docs/README.md +++ b/packages/sdk-vue/docs/README.md @@ -25,7 +25,6 @@ - [SessionReplayStopFunction](README.md#sessionreplaystopfunction) - [SurveyCloseFunction](README.md#surveyclosefunction) - [SurveyStartFunction](README.md#surveystartfunction) -- [TargetingCheckFunction](README.md#targetingcheckfunction) - [TargetingDebugFunction](README.md#targetingdebugfunction) ### Variables @@ -330,7 +329,6 @@ All Screeb methods provided via `useScreeb()` | `sessionReplayStop` | [`SessionReplayStopFunction`](README.md#sessionreplaystopfunction) | | `surveyClose` | [`SurveyCloseFunction`](README.md#surveyclosefunction) | | `surveyStart` | [`SurveyStartFunction`](README.md#surveystartfunction) | -| `targetingCheck` | [`TargetingCheckFunction`](README.md#targetingcheckfunction) | | `targetingDebug` | [`TargetingDebugFunction`](README.md#targetingdebugfunction) | ___ @@ -403,20 +401,6 @@ ___ ___ -### TargetingCheckFunction - -Ƭ **TargetingCheckFunction**: () => `Promise`\<`unknown`\> - -#### Type declaration - -▸ (): `Promise`\<`unknown`\> - -##### Returns - -`Promise`\<`unknown`\> - -___ - ### TargetingDebugFunction Ƭ **TargetingDebugFunction**: () => `Promise`\<`unknown`\> diff --git a/packages/sdk-vue/src/plugin.ts b/packages/sdk-vue/src/plugin.ts index ae6d0d2..4f0eaed 100644 --- a/packages/sdk-vue/src/plugin.ts +++ b/packages/sdk-vue/src/plugin.ts @@ -23,7 +23,6 @@ import { SessionReplayStopFunction, SurveyCloseFunction, SurveyStartFunction, - TargetingCheckFunction, TargetingDebugFunction, } from "./types"; import { isSSR } from "./utils"; @@ -215,9 +214,6 @@ export const ScreebPlugin: Plugin = { Screeb.sessionReplayStart(), ); - const targetingCheck: TargetingCheckFunction = async () => - await ensureScreeb("targetingCheck", () => Screeb.targetingCheck()); - const targetingDebug: TargetingDebugFunction = async () => await ensureScreeb("targetingDebug", () => Screeb.targetingDebug()); @@ -252,7 +248,6 @@ export const ScreebPlugin: Plugin = { messageStart, sessionReplayStop, sessionReplayStart, - targetingCheck, targetingDebug, }; diff --git a/packages/sdk-vue/src/types.ts b/packages/sdk-vue/src/types.ts index 95bc368..9d5c3fa 100644 --- a/packages/sdk-vue/src/types.ts +++ b/packages/sdk-vue/src/types.ts @@ -102,7 +102,6 @@ export type MessageStartFunction = ( export type SessionReplayStopFunction = () => Promise; export type SessionReplayStartFunction = () => Promise; -export type TargetingCheckFunction = () => Promise; export type TargetingDebugFunction = () => Promise; /** All Screeb methods provided via `useScreeb()` */ @@ -124,6 +123,5 @@ export type ScreebContextValues = { messageStart: MessageStartFunction; sessionReplayStart: SessionReplayStartFunction; sessionReplayStop: SessionReplayStopFunction; - targetingCheck: TargetingCheckFunction; targetingDebug: TargetingDebugFunction; }; diff --git a/scripts/update-public-docs-reference.mjs b/scripts/update-public-docs-reference.mjs new file mode 100644 index 0000000..9182443 --- /dev/null +++ b/scripts/update-public-docs-reference.mjs @@ -0,0 +1,1369 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const docsRoot = resolve(root, process.env.DOCS_PUBLIC_PATH || "../screeb/docs/public"); +const checkOnly = process.argv.includes("--check"); +const checkCapabilitiesOnly = process.argv.includes("--check-capabilities"); + +const browserTypeFiles = [ + resolve(root, "packages/sdk-browser/src/types.ts"), + resolve(root, "packages/sdk-browser/src/hooks.types.ts"), +]; + +const links = { + close: "[👉](./install)", + closeMessage: "[👉](./start-message-programmatically)", + closeSdk: "[👉](./install)", + closeSurvey: "-", + debug: "[👉](./troubleshooting)", + debugTargeting: "[👉](./troubleshooting)", + assignGroup: "[👉](./group-assignation)", + eventTrack: "[👉](./event-tracking)", + getIdentity: "[👉](./identity)", + identity: "[👉](./identity)", + identityGet: "[👉](./identity)", + identityGroupAssign: "[👉](./group-assignation)", + identityGroupUnassign: "[👉](./group-assignation)", + identityProperties: "[👉](./identity)", + identityReset: "[👉](./identity)", + init: "[👉](./install)", + initSdk: "[👉](./install)", + isLoaded: "[👉](./troubleshooting)", + load: "[👉](./install)", + messageClose: "[👉](./start-message-programmatically)", + messageStart: "[👉](./start-message-programmatically)", + resetIdentity: "[👉](./identity)", + ScreebId: "[👉](./privacy-helpers)", + ScreebMaskText: "[👉](./privacy-helpers)", + ScreebNoCapture: "[👉](./privacy-helpers)", + screebId: "[👉](./privacy-helpers)", + screebMaskText: "[👉](./privacy-helpers)", + screebNoCapture: "[👉](./privacy-helpers)", + sessionReplayStart: "[👉](./session-replay)", + sessionReplayStop: "[👉](./session-replay)", + setIdentity: "[👉](./identity)", + setProperty: "[👉](./identity)", + setProperties: "[👉](./identity)", + startMessage: "[👉](./start-message-programmatically)", + startSurvey: "[👉](./start-survey-programmatically)", + surveyClose: "[👉](./start-survey-programmatically)", + surveyStart: "[👉](./start-survey-programmatically)", + targetingDebug: "[👉](./troubleshooting)", + trackEvent: "[👉](./event-tracking)", + trackScreen: "[👉](./screen-tracking)", + unassignGroup: "[👉](./group-assignation)", +}; + +const methodGroups = [ + { + title: "Lifecycle", + names: ["init", "initSdk", "load", "close", "closeSdk"], + }, + { + title: "Identity", + names: [ + "setIdentity", + "identity", + "setProperties", + "setProperty", + "identityProperties", + "resetIdentity", + "identityReset", + "getIdentity", + "identityGet", + ], + }, + { + title: "Groups", + names: ["assignGroup", "identityGroupAssign", "unassignGroup", "identityGroupUnassign"], + }, + { + title: "Tracking", + names: ["trackEvent", "eventTrack", "trackScreen"], + }, + { + title: "Surveys", + names: ["startSurvey", "surveyStart", "closeSurvey", "surveyClose"], + }, + { + title: "Messages", + names: ["startMessage", "messageStart", "closeMessage", "messageClose"], + }, + { + title: "Session replay", + names: ["sessionReplayStart", "sessionReplayStop"], + }, + { + title: "Debug", + names: ["debug", "debugTargeting", "targetingDebug", "isLoaded"], + }, + { + title: "Privacy helpers", + names: ["ScreebMaskText", "ScreebNoCapture", "ScreebId", "screebMaskText", "screebNoCapture", "screebId"], + }, +]; + +const methodDescriptions = { + assignGroup: "Assign the current user to a group.", + close: "Stop the SDK.", + closeMessage: "Close the currently displayed message.", + closeSdk: "Stop the SDK.", + closeSurvey: "Close the currently displayed survey.", + debug: "Get SDK debug information.", + debugTargeting: "Get targeting debug information.", + eventTrack: "Track a custom event.", + getIdentity: "Get the current visitor identity and properties.", + identity: "Identify the current user with optional properties.", + identityGet: "Get the current visitor identity and properties.", + identityGroupAssign: "Assign the current user to a group.", + identityGroupUnassign: "Remove the current user from a group.", + identityProperties: "Send visitor properties without changing the identity.", + identityReset: "Reset the current visitor identity.", + init: "Initialize the Screeb SDK.", + initSdk: "Initialize the Screeb SDK.", + isLoaded: "Check whether the SDK is loaded.", + load: "Load the Screeb tag.", + messageClose: "Close the currently displayed message.", + messageStart: "Start a specific message programmatically.", + resetIdentity: "Reset the current visitor identity.", + ScreebId: "Set a stable Screeb element ID for IAM targeting.", + ScreebMaskText: "Mask a view or component in session replay.", + ScreebNoCapture: "Exclude a view or component from session replay capture.", + screebId: "Set a stable Screeb element ID for IAM targeting.", + screebMaskText: "Mask a view or component in session replay.", + screebNoCapture: "Exclude a view or component from session replay capture.", + sessionReplayStart: "Start session replay recording.", + sessionReplayStop: "Stop session replay recording.", + setIdentity: "Identify the current user with optional properties.", + setProperty: "Deprecated alias for setting visitor properties.", + setProperties: "Send visitor properties without changing the identity.", + startMessage: "Start a specific message programmatically.", + startSurvey: "Start a specific survey programmatically.", + surveyClose: "Close the currently displayed survey.", + surveyStart: "Start a specific survey programmatically.", + targetingDebug: "Get targeting debug information.", + trackEvent: "Track a custom event.", + trackScreen: "Track a screen navigation event.", + unassignGroup: "Remove the current user from a group.", +}; + +const methodOrder = new Map(); +const methodGroupByName = new Map(); +methodGroups.forEach((group, groupIndex) => { + group.names.forEach((name, nameIndex) => { + methodOrder.set(name, groupIndex * 100 + nameIndex); + methodGroupByName.set(name, group.title); + }); +}); + +const capabilityChecks = [ + { label: "Initialize SDK", aliases: ["init", "initSdk"] }, + { label: "Close SDK", aliases: ["close", "closeSdk"] }, + { label: "Set identity", aliases: ["identity", "setIdentity"] }, + { label: "Set properties", aliases: ["identityProperties", "setProperties", "setProperty"] }, + { label: "Reset identity", aliases: ["identityReset", "resetIdentity"] }, + { label: "Get identity", aliases: ["identityGet", "getIdentity"] }, + { label: "Assign group", aliases: ["identityGroupAssign", "assignGroup"] }, + { label: "Unassign group", aliases: ["identityGroupUnassign", "unassignGroup"] }, + { label: "Track event", aliases: ["eventTrack", "trackEvent"] }, + { label: "Track screen", aliases: ["trackScreen"], mobileOnly: true }, + { label: "Start survey", aliases: ["surveyStart", "startSurvey"] }, + { label: "Close survey", aliases: ["surveyClose", "closeSurvey"] }, + { label: "Start message", aliases: ["messageStart", "startMessage"] }, + { label: "Close message", aliases: ["messageClose", "closeMessage"] }, + { label: "Start session replay", aliases: ["sessionReplayStart"] }, + { label: "Stop session replay", aliases: ["sessionReplayStop"] }, + { label: "Debug", aliases: ["debug"] }, + { label: "Debug targeting", aliases: ["debugTargeting", "targetingDebug"] }, + { label: "Privacy mask text", aliases: ["ScreebMaskText", "screebMaskText"] }, + { label: "Privacy no capture", aliases: ["ScreebNoCapture", "screebNoCapture"] }, + { label: "Privacy element ID", aliases: ["ScreebId", "screebId"] }, +]; + +const capabilityReports = []; +const mobileTargetIds = new Set(["sdk-react-native", "sdk-flutter", "sdk-kmp", "sdk-maui"]); + +const targets = [ + { + id: "sdk-browser", + title: "@screeb/sdk-browser", + kind: "browser", + entry: resolve(root, "packages/sdk-browser/src/index.ts"), + reference: resolve(docsRoot, "docs/sdk-browser/reference.md"), + sidebarPosition: 11, + }, + { + id: "sdk-react", + title: "@screeb/sdk-react", + kind: "typed-wrapper", + source: resolve(root, "packages/sdk-react/src/types.ts"), + clientType: "ScreebContextValues", + propsTypes: ["ScreebProps", "ScreebProviderProps"], + reference: resolve(docsRoot, "docs/sdk-react/reference.md"), + intro: "Complete reference for `@screeb/sdk-react`.", + usageTitle: "useScreeb() methods", + sidebarPosition: 11, + }, + { + id: "sdk-vue", + title: "@screeb/sdk-vue", + kind: "typed-wrapper", + source: resolve(root, "packages/sdk-vue/src/types.ts"), + clientType: "ScreebContextValues", + propsTypes: ["ScreebConfig"], + reference: resolve(docsRoot, "docs/sdk-vue/reference.md"), + intro: "Complete reference for `@screeb/sdk-vue`.", + usageTitle: "useScreeb() methods", + sidebarPosition: 12, + }, + { + id: "sdk-svelte", + title: "@screeb/sdk-svelte", + kind: "typed-wrapper", + source: resolve(root, "packages/sdk-svelte/src/types.ts"), + clientType: "ScreebClient", + propsTypes: ["ScreebConfig"], + reference: resolve(docsRoot, "docs/sdk-svelte/reference.md"), + intro: "Complete reference for `@screeb/sdk-svelte`.", + usageTitle: "useScreeb() methods", + sidebarPosition: 12, + }, + { + id: "sdk-angular", + title: "@screeb/sdk-angular", + kind: "angular", + source: resolve(root, "packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts"), + configSource: resolve(root, "packages/sdk-angular/projects/sdk-angular/src/lib/screeb-config.ts"), + reference: resolve(docsRoot, "docs/sdk-angular/reference.md"), + intro: "Complete reference for `@screeb/sdk-angular`.", + sidebarPosition: 11, + }, + { + id: "sdk-ionic", + title: "Ionic SDK", + kind: "angular", + source: resolve(root, "packages/sdk-angular/projects/sdk-angular/src/lib/screeb.ts"), + configSource: resolve(root, "packages/sdk-angular/projects/sdk-angular/src/lib/screeb-config.ts"), + reference: resolve(docsRoot, "docs/sdk-ionic/reference.md"), + intro: "Complete reference for the Ionic SDK, which uses `@screeb/sdk-angular` internally.", + sidebarPosition: 11, + }, + { + id: "sdk-react-native", + title: "@screeb/react-native", + kind: "ts-functions", + source: resolve(root, "packages/sdk-reactnative/src/index.tsx"), + nativeSource: resolve(root, "packages/sdk-reactnative/src/NativeScreebReactNative.ts"), + reference: resolve(docsRoot, "docs/sdk-react-native/reference.md"), + intro: "Complete reference for `@screeb/react-native`.", + usageTitle: "Screeb methods and components", + sidebarPosition: 14, + }, + { + id: "sdk-flutter", + title: "plugin_screeb", + kind: "dart", + source: resolve(root, "packages/sdk-flutter/lib/plugin_screeb.dart"), + reference: resolve(docsRoot, "docs/sdk-flutter/reference.md"), + intro: "Complete reference for the Screeb Flutter SDK.", + usageTitle: "Screeb methods and widgets", + sidebarPosition: 14, + }, + { + id: "sdk-kmp", + title: "Screeb KMP SDK", + kind: "kotlin", + source: resolve(root, "packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/Screeb.kt"), + extraSources: [ + resolve(root, "packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebHooks.kt"), + resolve(root, "packages/sdk-kmp/src/commonMain/kotlin/app/screeb/sdk/kmp/ScreebInitOptions.kt"), + resolve(root, "packages/sdk-kmp/src/androidMain/kotlin/app/screeb/sdk/kmp/ScreebView.android.kt"), + resolve(root, "packages/sdk-kmp/src/iosMain/kotlin/app/screeb/sdk/kmp/ScreebView.ios.kt"), + ], + reference: resolve(docsRoot, "docs/sdk-kmp/reference.md"), + intro: "Complete reference for the Screeb Kotlin Multiplatform SDK.", + usageTitle: "Screeb methods", + sidebarPosition: 14, + }, + { + id: "sdk-maui", + title: "Screeb.Maui", + kind: "csharp", + source: resolve(root, "packages/sdk-maui/Screeb.cs"), + extraSources: [ + resolve(root, "packages/sdk-maui/ScreebHooks.cs"), + resolve(root, "packages/sdk-maui/ScreebInitOptions.cs"), + resolve(root, "packages/sdk-maui/ScreebViewExtensions.cs"), + ], + reference: resolve(docsRoot, "docs/sdk-maui/reference.md"), + intro: "Complete reference for the Screeb .NET MAUI SDK.", + usageTitle: "Screeb methods and extension helpers", + sidebarPosition: 14, + }, +]; + +function sourceFile(file) { + return ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true); +} + +function hasExportModifier(node) { + return node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +function stripComments(text) { + return text + .replace(/\/\*\*[\s\S]*?\*\/\n?/g, "") + .replace(/^\s*\/\/ eslint-disable-next-line.*\n/gm, "") + .trim(); +} + +function normalizeTypeText(text) { + return stripComments(text) + .replace(/^export\s+/gm, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function nodeName(node) { + if (ts.isClassDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) { + return node.name?.text; + } + if (ts.isVariableStatement(node)) { + const [declaration] = node.declarationList.declarations; + return declaration?.name && ts.isIdentifier(declaration.name) ? declaration.name.text : undefined; + } + return undefined; +} + +function jsDocSummary(node) { + const comment = node.jsDoc?.[0]?.comment; + if (typeof comment !== "string") { + return ""; + } + + const [summary] = comment.replace(/\s+/g, " ").trim().split(/\.(?:\s|$)/); + return summary ? `${summary}.` : ""; +} + +function exportedTypes(filePaths) { + const exported = []; + + for (const filePath of filePaths) { + const file = sourceFile(filePath); + for (const statement of file.statements) { + if ( + (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && + hasExportModifier(statement) + ) { + exported.push({ + description: jsDocSummary(statement), + name: nodeName(statement), + text: normalizeTypeText(statement.getFullText(file)), + }); + } + } + } + + return exported; +} + +function namedTypes(filePath, names) { + const wanted = new Set(names); + const types = []; + const file = sourceFile(filePath); + + for (const statement of file.statements) { + if ( + (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && + statement.name && + wanted.has(statement.name.text) + ) { + types.push({ + description: jsDocSummary(statement), + name: nodeName(statement), + text: normalizeTypeText(statement.getFullText(file)), + }); + } + } + + return types; +} + +function inferTypeFromInitializer(initializer) { + if (!initializer) { + return undefined; + } + if (initializer.kind === ts.SyntaxKind.TrueKeyword || initializer.kind === ts.SyntaxKind.FalseKeyword) { + return "boolean"; + } + if (ts.isStringLiteral(initializer)) { + return "string"; + } + if (ts.isNumericLiteral(initializer)) { + return "number"; + } + if (ts.isArrayLiteralExpression(initializer)) { + return "unknown[]"; + } + if (ts.isObjectLiteralExpression(initializer)) { + return "Record"; + } + return undefined; +} + +function parameterSignature(parameter, printer, file) { + const name = parameter.name.getText(file); + const isOptional = Boolean(parameter.questionToken || parameter.initializer); + const type = parameter.type + ? printer.printNode(ts.EmitHint.Unspecified, parameter.type, file) + : inferTypeFromInitializer(parameter.initializer) || "unknown"; + return `${name}${isOptional ? "?" : ""}: ${type.replace(/_Screeb\./g, "")}`; +} + +function callableSignature(name, declaration, printer, file) { + const typeParameters = declaration.typeParameters?.length + ? `<${declaration.typeParameters + .map((parameter) => printer.printNode(ts.EmitHint.Unspecified, parameter, file)) + .join(", ")}>` + : ""; + const params = declaration.parameters.map((parameter) => parameterSignature(parameter, printer, file)); + const returnType = declaration.type + ? `: ${printer.printNode(ts.EmitHint.Unspecified, declaration.type, file).replace(/_Screeb\./g, "")}` + : ""; + return `${name}${typeParameters}(${params.join(", ")})${returnType}`; +} + +function callableSignatureWithFallback(name, declaration, printer, file, returnTypesByName = new Map()) { + const signature = callableSignature(name, declaration, printer, file); + if (declaration.type || !returnTypesByName.has(name)) { + return signature; + } + return `${signature}: ${returnTypesByName.get(name)}`; +} + +function exportedConstMethods(filePath, options = {}) { + const file = sourceFile(filePath); + const printer = ts.createPrinter({ removeComments: true }); + const methods = []; + + for (const statement of file.statements) { + if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) { + continue; + } + + const [declaration] = statement.declarationList.declarations; + if (!declaration?.initializer || !ts.isIdentifier(declaration.name)) { + continue; + } + + const initializer = declaration.initializer; + if (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer)) { + continue; + } + + methods.push({ + description: jsDocSummary(statement), + name: declaration.name.text, + signature: callableSignatureWithFallback( + declaration.name.text, + initializer, + printer, + file, + options.returnTypesByName, + ), + }); + } + + return methods; +} + +function exportedFunctionMethods(filePath, options = {}) { + const file = sourceFile(filePath); + const printer = ts.createPrinter({ removeComments: true }); + const methods = exportedConstMethods(filePath, options); + + for (const statement of file.statements) { + if (!ts.isFunctionDeclaration(statement) || !hasExportModifier(statement) || !statement.name) { + continue; + } + methods.push({ + description: jsDocSummary(statement), + name: statement.name.text, + signature: callableSignatureWithFallback( + statement.name.text, + statement, + printer, + file, + options.returnTypesByName, + ), + }); + } + + return methods; +} + +function interfaceMethodReturnTypes(filePath, interfaceName) { + const file = sourceFile(filePath); + const printer = ts.createPrinter({ removeComments: true }); + const returnTypes = new Map(); + + for (const statement of file.statements) { + if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) { + continue; + } + + for (const member of statement.members) { + if (!ts.isMethodSignature(member) || !member.name || !ts.isIdentifier(member.name) || !member.type) { + continue; + } + returnTypes.set(member.name.text, printer.printNode(ts.EmitHint.Unspecified, member.type, file)); + } + } + + return returnTypes; +} + +function publicClassMethods(filePath) { + const file = sourceFile(filePath); + const printer = ts.createPrinter({ removeComments: true }); + const methods = []; + + for (const statement of file.statements) { + if (!ts.isClassDeclaration(statement)) { + continue; + } + for (const member of statement.members) { + if (!ts.isMethodDeclaration(member) || !member.name || !ts.isIdentifier(member.name)) { + continue; + } + if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword)) { + continue; + } + methods.push({ + description: jsDocSummary(member), + name: member.name.text, + signature: callableSignature(member.name.text, member, printer, file), + }); + } + } + + return methods; +} + +function typeAliasesByName(filePath) { + const aliases = new Map(); + for (const type of exportedTypes([filePath])) { + aliases.set(type.name, type); + } + return aliases; +} + +function literalTypeProperties(typeText) { + const file = ts.createSourceFile("inline.ts", `export ${typeText}`, ts.ScriptTarget.Latest, true); + const statement = file.statements[0]; + if (!statement || !ts.isTypeAliasDeclaration(statement)) { + return []; + } + + const collectMembers = (node) => { + if (ts.isTypeLiteralNode(node)) { + return node.members; + } + if (ts.isIntersectionTypeNode(node)) { + return node.types.flatMap(collectMembers); + } + return []; + }; + + return collectMembers(statement.type) + .filter(ts.isPropertySignature) + .map((member) => ({ + name: member.name.getText(file).replace(/^["']|["']$/g, ""), + optional: Boolean(member.questionToken), + type: member.type?.getText(file).replace(/_Screeb\./g, "") || "unknown", + })); +} + +function methodsFromClientType(sourcePath, clientTypeName) { + const aliases = typeAliasesByName(sourcePath); + const clientType = aliases.get(clientTypeName); + if (!clientType) { + throw new Error(`Could not find ${clientTypeName} in ${sourcePath}`); + } + + return literalTypeProperties(clientType.text).map((property) => { + const functionType = aliases.get(property.type); + const signature = functionType + ? `${property.name}${cleanFunctionType(functionType.text)}` + : `${property.name}: ${property.type}`; + return { + description: functionType?.description || "", + name: property.name, + signature, + }; + }); +} + +function cleanFunctionType(typeText) { + return typeText + .replace(/^type\s+\w+\s*=\s*/, "") + .replace(/;\s*$/, "") + .trim(); +} + +function propsFromTypes(sourcePath, typeNames) { + const aliases = typeAliasesByName(sourcePath); + return typeNames.flatMap((typeName) => { + const type = aliases.get(typeName); + return type ? literalTypeProperties(type.text) : []; + }); +} + +function classProperties(filePath, className) { + const file = sourceFile(filePath); + const printer = ts.createPrinter({ removeComments: true }); + const properties = []; + + for (const statement of file.statements) { + if (!ts.isClassDeclaration(statement) || statement.name?.text !== className) { + continue; + } + for (const member of statement.members) { + if (!ts.isPropertyDeclaration(member) || !member.name) { + continue; + } + properties.push({ + description: jsDocSummary(member), + name: member.name.getText(file), + optional: Boolean(member.questionToken), + type: member.type ? printer.printNode(ts.EmitHint.Unspecified, member.type, file) : "unknown", + }); + } + } + + return properties; +} + +function hookTypes(types) { + return types.filter((type) => type.name?.startsWith("HookOn")); +} + +function methodKey(name) { + if (!name) return ""; + return `${name[0].toLowerCase()}${name.slice(1)}`; +} + +function methodRank(method) { + const key = methodKey(method.name); + return methodOrder.get(method.name) ?? methodOrder.get(key) ?? 10000; +} + +function methodGroup(method) { + const key = methodKey(method.name); + return methodGroupByName.get(method.name) ?? methodGroupByName.get(key) ?? "Other"; +} + +function normalizeMethod(method) { + const key = methodKey(method.name); + return { + ...method, + description: method.description && method.description !== "-" ? method.description : methodDescriptions[method.name] || methodDescriptions[key] || "-", + }; +} + +function sortMethods(methods) { + return methods + .map(normalizeMethod) + .sort((left, right) => methodRank(left) - methodRank(right) || left.name.localeCompare(right.name)); +} + +function reportCapabilities(target, methods) { + const methodNames = new Set(methods.map((method) => methodKey(method.name))); + const missing = capabilityChecks + .filter((capability) => !capability.mobileOnly || mobileTargetIds.has(target.id)) + .filter((capability) => !capability.aliases.some((alias) => methodNames.has(methodKey(alias)))) + .map((capability) => capability.label); + + capabilityReports.push({ + missing, + target: target.id, + }); +} + +function printCapabilityReport() { + console.log("\nCapability coverage:"); + for (const report of capabilityReports) { + if (report.missing.length === 0) { + console.log(`- ${report.target}: complete`); + continue; + } + console.log(`- ${report.target}: missing ${report.missing.join(", ")}`); + } +} + +function methodsByGroup(methods) { + const sorted = sortMethods(methods); + const byGroup = new Map(); + for (const method of sorted) { + const group = methodGroup(method); + byGroup.set(group, [...(byGroup.get(group) || []), method]); + } + + const orderedGroups = methodGroups.map((group) => group.title).filter((group) => byGroup.has(group)); + if (byGroup.has("Other")) { + orderedGroups.push("Other"); + } + + return orderedGroups.map((group) => ({ title: group, methods: byGroup.get(group) })); +} + +function methodsTable(methods) { + return [ + "| Method | Description | More |", + "|---|---|---|", + ...methods.map((method) => { + const link = methodLink(method.name); + return `| \`${tableCode(inlineSignature(method.signature))}\` | ${inlineDescription(method.description)} | ${link} |`; + }), + ].join("\n"); +} + +function methodsByCapabilitySection(methods) { + return methodsByGroup(methods) + .map( + (group) => `### ${group.title} + +${methodsTable(group.methods)}`, + ) + .join("\n\n"); +} + +function normalizeWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); +} + +function methodLink(name) { + if (!name) return "-"; + const lowerCamel = `${name[0].toLowerCase()}${name.slice(1)}`; + return links[name] || links[lowerCamel] || "-"; +} + +function inlineSignature(signature) { + return normalizeWhitespace(signature.replace(/;\s*$/, "")) + .replace(/\(\s+/g, "(") + .replace(/\s+\)/g, ")") + .replace(/,\s*\)/g, ")"); +} + +function tableCode(text) { + return text.replace(/\|/g, "\\|"); +} + +function inlineDescription(description) { + return normalizeWhitespace(description || "-").replace(/\|/g, "\\|"); +} + +function countChar(text, char) { + return [...text].filter((current) => current === char).length; +} + +function collectDeclaration(lines, startIndex) { + const declaration = []; + let balance = 0; + + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index].trim(); + declaration.push(line); + balance += countChar(line, "(") - countChar(line, ")"); + if (balance <= 0 && line.includes(")")) { + return declaration.join(" "); + } + } + + return declaration.join(" "); +} + +function parameterList(declaration) { + const start = declaration.indexOf("("); + if (start === -1) { + return ""; + } + + let balance = 0; + for (let index = start; index < declaration.length; index += 1) { + const char = declaration[index]; + if (char === "(") { + balance += 1; + } + if (char === ")") { + balance -= 1; + if (balance === 0) { + return declaration.slice(start + 1, index); + } + } + } + + return declaration.slice(start + 1); +} + +function lineCommentSummary(lines, index, marker) { + const docs = []; + let current = index - 1; + + while (current >= 0 && lines[current].trim().startsWith("@")) { + current -= 1; + } + + for (; current >= 0; current -= 1) { + const line = lines[current].trim(); + if (!line.startsWith(marker)) { + break; + } + docs.unshift(line.slice(marker.length).trim()); + } + + return normalizeWhitespace(docs.join(" ")) || "-"; +} + +function dartPublicMembers(filePath) { + const source = readFileSync(filePath, "utf8"); + const lines = source.split(/\r?\n/); + const members = []; + const internalMethods = new Set(["channelHandler", "handleHooks"]); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + const match = line.match(/^static\s+(?!const\b|final\b|var\b)(.+?)\s+(\w+)\s*\(/); + if (!match) { + continue; + } + + const [, returnType, name] = match; + if (name.startsWith("_")) continue; + if (internalMethods.has(name)) continue; + + const declaration = collectDeclaration(lines, index); + const params = parameterList(declaration); + members.push({ + description: lineCommentSummary(lines, index, "///"), + name, + signature: `${name}(${normalizeWhitespace(params)}): ${normalizeWhitespace(returnType)}`, + }); + } + + const classPattern = /((?:\s*\/\/\/[^\n]*\n)+)?\s*class\s+(Screeb\w+)\s+extends\s+StatelessWidget/g; + let match; + while ((match = classPattern.exec(source))) { + const [, docs = "", name] = match; + const signatures = { + ScreebId: "ScreebId(String id, {Key? key, required Widget child})", + ScreebMaskText: "ScreebMaskText({Key? key, required Widget child})", + ScreebNoCapture: "ScreebNoCapture({Key? key, required Widget child})", + }; + members.push({ + description: docs.replace(/^\s*\/\/\/\s?/gm, " ").trim() || "-", + name, + signature: signatures[name] || name, + }); + } + return members; +} + +function kotlinPublicMembers(filePath) { + const source = readFileSync(filePath, "utf8"); + const members = []; + const methodPattern = /((?:\s*\/\*\*[\s\S]*?\*\/\s*)?)\s*suspend\s+fun\s+(\w+)\s*\(([\s\S]*?)\)\s*:\s*([^\n{]+)/g; + let match; + while ((match = methodPattern.exec(source))) { + const [, docs = "", name, params, returnType] = match; + members.push({ + description: kdocSummary(docs), + name, + signature: `${name}(${normalizeWhitespace(params)}): ${normalizeWhitespace(returnType)}`, + }); + } + return members; +} + +function kotlinExtensionMembers(filePaths) { + return filePaths.flatMap((filePath) => { + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + const members = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + const match = line.match(/^fun\s+([\w.]+)\.(\w+)\s*\(([^)]*)\)\s*:\s*([^{=]+)/); + if (!match) { + continue; + } + + const [, receiver, name, params, returnType] = match; + if (!["screebId", "screebMaskText", "screebNoCapture"].includes(name)) { + continue; + } + + members.push({ + description: "-", + name, + signature: `${receiver}.${name}(${normalizeWhitespace(params)}): ${normalizeWhitespace(returnType)}`, + }); + } + + return members; + }); +} + +function kotlinTypes(filePaths) { + return filePaths.flatMap((filePath) => { + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + const types = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line.startsWith("data class ")) { + continue; + } + + types.push({ + name: line.match(/^data class\s+(\w+)/)?.[1] || "", + text: normalizeWhitespace(collectDeclaration(lines, index)).replace(/\s*,\s*/g, ", "), + }); + } + + return types; + }); +} + +function kdocSummary(text) { + return text + .replace(/^\/\*\*|\*\/$/g, "") + .replace(/^\s*\*\s?/gm, " ") + .replace(/\s+/g, " ") + .trim() || "-"; +} + +function csharpPublicMembers(filePaths) { + return filePaths.flatMap((filePath) => { + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + const members = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + const match = line.match(/^public\s+static(?:\s+partial)?\s+(.+?)\s+(\w+)(?:<[^>]+>)?\s*\(/); + if (!match) { + continue; + } + + const [, returnType, name] = match; + const declaration = collectDeclaration(lines, index); + const params = parameterList(declaration); + members.push({ + description: xmlSummary(lineCommentSummary(lines, index, "///")), + name, + signature: `${name}(${normalizeWhitespace(params)}): ${normalizeWhitespace(returnType)}`, + }); + } + + return members; + }); +} + +function csharpTypes(filePaths) { + return filePaths.flatMap((filePath) => { + const source = readFileSync(filePath, "utf8"); + const matches = source.match(/public\s+(?:sealed\s+)?(?:class|record)\s+\w+[\s\S]*?\n}/g) || []; + return matches.map((text) => ({ name: "", text: text.trim() })); + }); +} + +function xmlSummary(text) { + const match = text.match(/([\s\S]*?)<\/summary>/); + return match ? normalizeWhitespace(match[1].replace(/\/\/\/\s?/g, "")) : "-"; +} + +function propsTable(props) { + return [ + "| Option | Type | Required | Description |", + "|---|---|---|---|", + ...props.map((prop) => + `| \`${prop.name}\` | \`${tableCode(prop.type)}\` | ${prop.optional ? "No" : "Yes"} | ${prop.description || "-"} |` + ), + ].join("\n"); +} + +function hooksSection(types) { + return hookTypes(types) + .map( + (type) => `### \`${type.name}\` + +\`\`\`ts +${type.text} +\`\`\``, + ) + .join("\n\n"); +} + +function typeCodeBlock(types, exclude = new Set()) { + return types + .filter((type) => !exclude.has(type.name)) + .map((type) => type.text) + .join("\n\n"); +} + +function signatureLine(method, prefix = "") { + return `${prefix}${inlineSignature(method.signature)};`; +} + +function angularSignatureLine(method) { + return signatureLine( + method, + ["ScreebId", "ScreebMaskText", "ScreebNoCapture"].includes(method.name) + ? "public " + : "public async ", + ); +} + +function rawSignatureLine(method) { + return inlineSignature(method.signature); +} + +function groupedSignatureSection(methods, language, formatter = rawSignatureLine) { + return methodsByGroup(methods) + .map( + (group) => `### ${group.title} + +\`\`\`${language} +${group.methods.map((method) => formatter(method)).join("\n")} +\`\`\``, + ) + .join("\n\n"); +} + +function hookReferenceSection(language) { + const examples = { + ts: `const hooks = { + version: "1.0.0", + onSurveyShowed: async (payload: string) => { + // handle hook payload + }, +};`, + dart: `final hooks = { + 'version': '1.0.0', + 'onSurveyShowed': (String payload) async { + // handle hook payload + }, +};`, + kotlin: `val hooks = ScreebHooks( + version = "1.0.0", + callbacks = mapOf( + "onSurveyShowed" to { payload -> /* handle hook payload */ }, + ), +)`, + csharp: `var hooks = new ScreebHooks +{ + Version = "1.0.0", + Callbacks = + { + ["onSurveyShowed"] = async payload => + { + // handle hook payload + return null; + }, + }, +};`, + }; + + return `## Hooks + +Hooks can be passed to initialization and programmatic survey/message starts. Callback payloads are forwarded as JSON strings by the mobile wrappers. + +\`\`\`${language} +${examples[language] || examples.ts} +\`\`\` + +For complete hook payload definitions, see the [JS tag hooks reference](../sdk-js/js-hooks).`; +} + +function privacyHelpersSection(language) { + const examples = { + ts: ` + Sensitive content + + + + Do not record + + + +