diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..790151f --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,7 @@ +paths: + - src + - android/src/main/java + - .github/actions + +paths-ignore: + - 'examples/**/android/**' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ad828eb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,75 @@ + + +# READ AND DELETE THIS SECTION BEFORE SUBMITTING PR + +- **Fill out each _REQUIRED_ section** +- **Fill out _OPTIONAL_ sections, remove section if it doesn't apply to your PR** +- **Read and fill out each of the checklists below** +- **Remove this section after reading** + + +# Description + +## One Line Summary + +**REQUIRED** - Very short description that summaries the changes in this PR. + +## Details + +### Motivation + +**REQUIRED -** Why is this code change being made? Or what is the goal of this PR? Examples: Fixes a specific bug, provides additional logging to debug future issues, feature to allow X. + +### Scope + +**RECOMMEND - OPTIONAL -** What is intended to be effected. What is known not to change. Example: Notifications are grouped when parameter X is set, not enabled by default. + +### OPTIONAL - Other + +**OPTIONAL -** Feel free to add any other sections or sub-sections that can explain your PR better. + +# Testing + +## Unit testing + +**OPTIONAL -** Explain unit tests added, if not clear in the code. + +## Manual testing + +**RECOMMEND - OPTIONAL -** Explain what scenarios were tested and the environment. +Example: Tested opening a notification while the app was foregrounded, app build with Android Studio 2020.3 with a fresh install of the OneSignal example app on a Pixel 6 with Android 12. + +# Affected code checklist + +- [ ] Notifications + - [ ] Display + - [ ] Open + - [ ] Push Processing + - [ ] Confirm Deliveries +- [ ] Outcomes +- [ ] Sessions +- [ ] In-App Messaging +- [ ] REST API requests +- [ ] Public API changes + +# Checklist + +## Overview + +- [ ] I have filled out all **REQUIRED** sections above +- [ ] PR does one thing + - If it is hard to explain how any codes changes are related to each other then it most likely needs to be more than one PR +- [ ] Any Public API changes are explained in the PR details and conform to existing APIs + +## Testing + +- [ ] I have included test coverage for these changes, or explained why they are not needed +- [ ] All automated tests pass, or I explained why that is not possible +- [ ] I have personally tested this on my device, or explained why that is not possible + +## Final pass + +- [ ] Code is as readable as possible. + - Simplify with less code, followed by splitting up code into well named functions and variables, followed by adding comments to the code. +- [ ] I have reviewed this PR myself, ensuring it meets each checklist item + - WIP (Work In Progress) is ok, but explain what is still in progress and what you would like feedback on. Start the PR title with "WIP" to indicate this. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..3a6910c --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,25 @@ +# Filename (cd.yml) should match the workflow filename in Trusted Publisher setting on npmjs +name: Publish to NPM & Github +on: + pull_request: + types: + - closed + branches: + - main + - rel/** + +permissions: + id-token: write # Required for OIDC + contents: write + +jobs: + publish: + if: | + github.event.pull_request.merged == true && + contains(github.event.pull_request.title, 'chore: Release') + uses: OneSignal/sdk-shared/.github/workflows/publish-npm-github.yml@main + secrets: + GH_PUSH_TOKEN: ${{ secrets.GH_PUSH_TOKEN }} + with: + branch: ${{ github.event.pull_request.base.ref }} + toolchain: vite-plus diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eebee25 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +name: Run Checks +on: + pull_request: + branches: + - '**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + call: + uses: OneSignal/sdk-shared/.github/workflows/wrapper-js-ci.yml@main + with: + toolchain: vite-plus diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6fa7cc0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,66 @@ +name: 'CodeQL' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + schedule: + - cron: '37 19 * * 4' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + dependency-caching: true + - language: javascript-typescript + build-mode: none + dependency-caching: true + - language: java + build-mode: none + dependency-caching: true + # Not analyzing .m (Objective-C) & .h (Objective-C header) files for now unless CodeQL supports it in the future + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + + with: + dependency-caching: ${{ matrix.dependency-caching }} + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql-config.yml + + - name: Autobuild + if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml new file mode 100644 index 0000000..b55bfd7 --- /dev/null +++ b/.github/workflows/create-release-pr.yml @@ -0,0 +1,174 @@ +name: Create Release PR + +on: + # For making a release pr from android / ios sdk actions + workflow_call: + secrets: + GH_PUSH_TOKEN: + required: false + description: 'GitHub token for pushing changes' + inputs: + capacitor_version: + description: 'New Capacitor plugin version (e.g., 1.1.0 or 1.1.0-beta.1)' + required: true + type: string + android_version: + description: 'New Android SDK Version (e.g., 5.7.6). Leave blank to skip.' + required: false + type: string + ios_version: + description: 'New iOS SDK Version (e.g., 5.5.0). Leave blank to skip.' + required: false + type: string + target_branch: + description: 'Target branch to create the release PR on. Defaults to main.' + required: false + type: string + default: main + + # For making a release pr from the github actions + workflow_dispatch: + inputs: + capacitor_version: + description: 'New Capacitor plugin version (e.g., 1.1.0 or 1.1.0-beta.1)' + required: true + type: string + android_version: + description: 'New Android SDK Version (e.g., 5.7.6). Leave blank to skip.' + required: false + type: string + ios_version: + description: 'New iOS SDK Version (e.g., 5.5.0). Leave blank to skip.' + required: false + type: string + target_branch: + description: 'Target branch to create the release PR on. Defaults to main.' + required: false + type: string + default: main + +jobs: + prep: + uses: OneSignal/sdk-shared/.github/workflows/prep-release.yml@main + secrets: + # Need this cross-repo token (sdk-shared & this repo) to perform changes + + GH_PUSH_TOKEN: ${{ secrets.GH_PUSH_TOKEN }} + with: + target_repo: OneSignal/OneSignal-Capacitor-SDK + version: ${{ inputs.capacitor_version }} + + # Capacitor plugin: bump npm version + optional native SDK pins + update_version: + needs: prep + runs-on: macos-latest + outputs: + capacitor_from: ${{ steps.current_versions.outputs.capacitor_from }} + ios_from: ${{ steps.current_versions.outputs.ios_from }} + android_from: ${{ steps.current_versions.outputs.android_from }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Need repository otherwise caller would set github.repository to the caller itself (e.g. sdk-shared) + repository: OneSignal/OneSignal-Capacitor-SDK + ref: ${{ needs.prep.outputs.release_branch }} + token: ${{ secrets.GH_PUSH_TOKEN || github.token }} + + - name: Setup Git User + uses: OneSignal/sdk-shared/.github/actions/setup-git-user@main + + - name: Set up Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + cache: true + run-install: true + + - name: Get current native SDK versions from target branch + id: current_versions + run: | + git fetch origin ${{ inputs.target_branch }} + + # Get versions from target branch (not the release branch) + CURRENT_VERSION=$(git show origin/${{ inputs.target_branch }}:package.json | jq -r .version) + ANDROID_VERSION=$(git show origin/${{ inputs.target_branch }}:android/build.gradle.kts | grep "com.onesignal:OneSignal:" | sed -E "s/.*OneSignal:([0-9.]+).*/\1/") + IOS_VERSION=$(git show origin/${{ inputs.target_branch }}:OneSignalCapacitorPlugin.podspec | grep "OneSignalXCFramework" | sed -E "s/.*'([0-9.]+)'.*/\1/") + + echo "capacitor_from=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "android_from=$ANDROID_VERSION" >> $GITHUB_OUTPUT + echo "ios_from=$IOS_VERSION" >> $GITHUB_OUTPUT + + - name: Update Android SDK version + if: inputs.android_version != '' + run: | + VERSION="${{ inputs.android_version }}" + + # Validate version exists on GitHub + RELEASE=$(curl -s -H "Authorization: token ${{ github.token }}" \ + "https://api.github.com/repos/OneSignal/OneSignal-Android-SDK/releases/tags/${VERSION}") + + + if [ -z "$RELEASE" ]; then + echo "✗ Android SDK version ${VERSION} not found" + exit 1 + fi + + # Update Android SDK version in build.gradle.kts + sed -i '' -E "s/(com\.onesignal:OneSignal:)[0-9.]+/\1$VERSION/" android/build.gradle.kts + echo "✓ Updated android/build.gradle.kts with Android SDK ${VERSION}" + + # Only commit if there are changes + git add -A + git diff --staged --quiet && exit 0 + git commit -m "Update Android SDK to ${VERSION}" && git push + + - name: Update iOS SDK version + if: inputs.ios_version != '' + run: | + VERSION="${{ inputs.ios_version }}" + + # Validate version exists on GitHub + RELEASE=$(curl -s -H "Authorization: token ${{ github.token }}" \ + "https://api.github.com/repos/OneSignal/OneSignal-iOS-SDK/releases/tags/${VERSION}") + + if [ -z "$RELEASE" ]; then + echo "✗ iOS SDK version ${VERSION} not found" + exit 1 + fi + + sed -i '' "s/s\.dependency 'OneSignalXCFramework', '[^']*'/s.dependency 'OneSignalXCFramework', '$VERSION'/" OneSignalCapacitorPlugin.podspec + echo "✓ Updated OneSignalCapacitorPlugin.podspec with iOS SDK ${VERSION}" + + # Only commit if there are changes + git add -A + git diff --staged --quiet && exit 0 + git commit -m "Update iOS SDK to ${VERSION}" && git push + + - name: Update sdk version + run: | + NEW_VERSION="${{ inputs.capacitor_version }}" + + # Update package.json version + npm pkg set version="$NEW_VERSION" + + # Only commit if there are changes + git add -A + git diff --staged --quiet && exit 0 + git commit -m "Release $NEW_VERSION" && git push + + create-pr: + needs: [prep, update_version] + uses: OneSignal/sdk-shared/.github/workflows/create-release.yml@main + secrets: + # Need this cross-repo token (sdk-shared & this repo) to perform changes + GH_PUSH_TOKEN: ${{ secrets.GH_PUSH_TOKEN }} + with: + # Need target_repo otherwise caller would set github.repository to the caller itself (e.g. sdk-shared) + target_repo: OneSignal/OneSignal-Capacitor-SDK + release_branch: ${{ needs.prep.outputs.release_branch }} + target_branch: ${{ inputs.target_branch }} + android_from: ${{ needs.update_version.outputs.android_from }} + android_to: ${{ inputs.android_version }} + ios_from: ${{ needs.update_version.outputs.ios_from }} + ios_to: ${{ inputs.ios_version }} diff --git a/.github/workflows/linear-deployed.yml b/.github/workflows/linear-deployed.yml new file mode 100644 index 0000000..b5af2c9 --- /dev/null +++ b/.github/workflows/linear-deployed.yml @@ -0,0 +1,13 @@ +name: Move Linear tickets to Deployed + +on: + release: + types: [published, edited] + +jobs: + linear-deployed: + uses: OneSignal/sdk-shared/.github/workflows/linear-deployed.yml@main + with: + release_body: ${{ github.event.release.body }} + secrets: + LINEAR_GITHUB_API_KEY: ${{ secrets.LINEAR_GITHUB_API_KEY }} diff --git a/.github/workflows/lint-pr-title.yml b/.github/workflows/lint-pr-title.yml new file mode 100644 index 0000000..1f1a24d --- /dev/null +++ b/.github/workflows/lint-pr-title.yml @@ -0,0 +1,11 @@ +# Checks if the PR title follows semantic commit message conventions +name: Lint PR + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + call: + uses: OneSignal/sdk-shared/.github/workflows/lint-pr-title.yml@main + secrets: inherit diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml new file mode 100644 index 0000000..cef4e78 --- /dev/null +++ b/.github/workflows/project.yml @@ -0,0 +1,16 @@ +name: Add issues to project +on: + issues: + types: + - opened + +jobs: + add-to-project: + runs-on: ubuntu-latest + steps: + - name: Add issue to project + uses: actions/add-to-project@v1.0.2 + with: + # SDK Cross-Platform Project + project-url: https://github.com/orgs/OneSignal/projects/10 + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9942827 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +node_modules +*.tgz + +# Build & test output +dist/ +coverage/ + +# macOS +.DS_Store + +# IDE +.idea +*.iml + +# iOS +Pods/ +Podfile.lock +Package.resolved +DerivedData/ +xcuserdata/ +/.build +.swiftpm/ + +# Android +.gradle/ +build/ +local.properties +**/capacitor.build.gradle +*.orig +*.apk +*.dex +*.class diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100755 index 0000000..85fb65b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/OneSignalCapacitorPlugin.podspec b/OneSignalCapacitorPlugin.podspec new file mode 100644 index 0000000..8b973a8 --- /dev/null +++ b/OneSignalCapacitorPlugin.podspec @@ -0,0 +1,20 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'OneSignalCapacitorPlugin' + s.version = package['version'] + s.summary = 'OneSignal Push Notifications Capacitor Plugin' + s.license = package['license'] + s.homepage = package['homepage'] + s.author = 'OneSignal' + s.source = { :git => package['repository']['url'], :tag => s.version.to_s } + s.source_files = 'ios/Sources/OneSignalCapacitorPlugin/**/*.swift' + + s.ios.deployment_target = '14.0' + s.swift_version = '5.9' + + s.dependency 'Capacitor' + s.dependency 'OneSignalXCFramework', '5.5.0' +end diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..12d19a4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "OneSignalCapacitorPlugin", + platforms: [.iOS(.v14)], + products: [ + .library( + name: "OneSignalCapacitorPlugin", + targets: ["OneSignalCapacitorPlugin"] + ) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0"), + .package(url: "https://github.com/nicklama/onesignal-xcframework-spm", from: "5.0.0") + ], + targets: [ + .target( + name: "OneSignalCapacitorPlugin", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "OneSignalFramework", package: "onesignal-xcframework-spm") + ], + path: "ios/Sources/OneSignalCapacitorPlugin" + ) + ] +) diff --git a/README.md b/README.md index 94fdb1d..c00767a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # OneSignal-Capacitor-SDK + For setting up push notifications & live activities for Capacitor projects diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..441d268 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,126 @@ +buildscript { + val catalogFile = file("gradle/libs.versions.toml") + + // Lightweight reader for the [versions] table of libs.versions.toml. + // Inlined here because buildscript {} is evaluated before the rest of the + // script body, and Gradle's built-in version catalog APIs aren't available + // to a Capacitor plugin consumed as a sub-project. + fun fromCatalog(key: String): String { + var inVersions = false + var result: String? = null + catalogFile.forEachLine { raw -> + if (result != null) return@forEachLine + val line = raw.substringBefore("#").trim() + when { + line.startsWith("[") && line.endsWith("]") -> inVersions = (line == "[versions]") + inVersions && "=" in line -> { + val (rawKey, rawValue) = line.split("=", limit = 2) + if (rawKey.trim() == key) { + result = rawValue.trim().trim('"') + } + } + } + } + return result ?: error("Version '$key' not found in ${catalogFile.name}") + } + + val kotlinVersion: String = if (project.hasProperty("kotlin_version")) { + rootProject.extra["kotlin_version"] as String + } else { + fromCatalog("kotlin") + } + val androidGradlePluginVersion: String = fromCatalog("androidGradlePlugin") + + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:$androidGradlePluginVersion") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +// Duplicates the buildscript-local reader intentionally: the buildscript block +// closes over its own scope, so this helper is reused for the module body. +fun catalogVersion(key: String): String { + val toml = file("gradle/libs.versions.toml") + var inVersions = false + var result: String? = null + toml.forEachLine { raw -> + if (result != null) return@forEachLine + val line = raw.substringBefore("#").trim() + when { + line.startsWith("[") && line.endsWith("]") -> inVersions = (line == "[versions]") + inVersions && "=" in line -> { + val (rawKey, rawValue) = line.split("=", limit = 2) + if (rawKey.trim() == key) { + result = rawValue.trim().trim('"') + } + } + } + } + return result ?: error("Version '$key' not found in ${toml.name}") +} + +fun propertyOrCatalog(propertyName: String, catalogKey: String): String = + if (project.hasProperty(propertyName)) { + rootProject.extra[propertyName] as String + } else { + catalogVersion(catalogKey) + } + +fun intPropertyOrCatalog(propertyName: String, catalogKey: String): Int = + if (project.hasProperty(propertyName)) { + rootProject.extra[propertyName] as Int + } else { + catalogVersion(catalogKey).toInt() + } + +val junitVersion: String = propertyOrCatalog("junitVersion", "junit") +val androidxAppCompatVersion: String = propertyOrCatalog("androidxAppCompatVersion", "androidxAppCompat") + +extra["junitVersion"] = junitVersion +extra["androidxAppCompatVersion"] = androidxAppCompatVersion + +apply(plugin = "com.android.library") +apply(plugin = "kotlin-android") + +configure { + namespace = "com.onesignal.capacitor" + compileSdk = intPropertyOrCatalog("compileSdkVersion", "compileSdk") + defaultConfig { + minSdk = intPropertyOrCatalog("minSdkVersion", "minSdk") + @Suppress("DEPRECATION") + targetSdk = intPropertyOrCatalog("targetSdkVersion", "targetSdk") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = "17" +} + +repositories { + google() + mavenCentral() +} + +dependencies { + "implementation"(project(":capacitor-android")) + "implementation"("androidx.appcompat:appcompat:$androidxAppCompatVersion") + "implementation"("com.onesignal:OneSignal:${catalogVersion("onesignal")}") + "testImplementation"("junit:junit:$junitVersion") + "androidTestImplementation"("androidx.test.ext:junit:${catalogVersion("androidxTestJunit")}") + "androidTestImplementation"("androidx.test.espresso:espresso-core:${catalogVersion("androidxEspresso")}") +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..bf45eae --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +# Version catalog for the OneSignal Capacitor Android plugin. +# Defaults below can be overridden by the consuming app via gradle +# properties or rootProject.extra (see android/build.gradle.kts). +# +# compileSdk / minSdk / targetSdk / kotlin / appcompat / junit follow +# Capacitor 7's required versions, not the OneSignal Android SDK's. + +[versions] +androidGradlePlugin = "8.7.3" +androidxAppCompat = "1.7.0" +androidxEspresso = "3.6.1" +androidxTestJunit = "1.2.1" +compileSdk = "35" +junit = "4.13.2" +kotlin = "1.9.25" +minSdk = "23" +onesignal = "5.7.7" +targetSdk = "35" + +[libraries] +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } +junit = { module = "junit:junit", version.ref = "junit" } +onesignal = { module = "com.onesignal:OneSignal", version.ref = "onesignal" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c06529f --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt new file mode 100644 index 0000000..54f4f9b --- /dev/null +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -0,0 +1,672 @@ +package com.onesignal.capacitor + +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import com.onesignal.OneSignal +import com.onesignal.common.OneSignalWrapper +import com.onesignal.inAppMessages.IInAppMessageClickEvent +import com.onesignal.inAppMessages.IInAppMessageClickListener +import com.onesignal.inAppMessages.IInAppMessageDidDismissEvent +import com.onesignal.inAppMessages.IInAppMessageDidDisplayEvent +import com.onesignal.inAppMessages.IInAppMessageLifecycleListener +import com.onesignal.inAppMessages.IInAppMessageWillDismissEvent +import com.onesignal.inAppMessages.IInAppMessageWillDisplayEvent +import com.onesignal.notifications.INotificationClickEvent +import com.onesignal.notifications.INotificationClickListener +import com.onesignal.notifications.INotificationLifecycleListener +import com.onesignal.notifications.INotificationWillDisplayEvent +import org.json.JSONObject + +@CapacitorPlugin(name = "OneSignalCapacitor") +class OneSignalCapacitorPlugin : Plugin(), + INotificationLifecycleListener, + INotificationClickListener, + IInAppMessageLifecycleListener, + IInAppMessageClickListener { + + private val notificationWillDisplayCache = mutableMapOf() + private val preventDefaultCache = mutableSetOf() + private var pendingClickEvent: INotificationClickEvent? = null + + // region Core + + @PluginMethod + fun initialize(call: PluginCall) { + val appId = call.getString("appId") + if (appId == null) { + call.reject("appId is required") + return + } + + OneSignalWrapper.sdkType = "capacitor" + OneSignalWrapper.sdkVersion = "010000" + OneSignal.initWithContext(context, appId) + + OneSignal.Notifications.addPermissionObserver { permission -> + val ret = JSObject() + ret.put("permission", permission) + notifyListeners("permissionChange", ret) + } + + OneSignal.Notifications.addForegroundLifecycleListener(this) + OneSignal.Notifications.addClickListener(this) + + OneSignal.User.pushSubscription.addObserver { state -> + val ret = JSObject() + val prev = JSObject() + prev.put("id", state.previous.id ?: JSONObject.NULL) + prev.put("token", state.previous.token ?: JSONObject.NULL) + prev.put("optedIn", state.previous.optedIn) + ret.put("previous", prev) + + val curr = JSObject() + curr.put("id", state.current.id ?: JSONObject.NULL) + curr.put("token", state.current.token ?: JSONObject.NULL) + curr.put("optedIn", state.current.optedIn) + ret.put("current", curr) + + notifyListeners("pushSubscriptionChange", ret) + } + + OneSignal.User.addObserver { state -> + val ret = JSObject() + val curr = JSObject() + curr.put("onesignalId", state.current.onesignalId ?: JSONObject.NULL) + curr.put("externalId", state.current.externalId ?: JSONObject.NULL) + ret.put("current", curr) + notifyListeners("userStateChange", ret) + } + + OneSignal.InAppMessages.addLifecycleListener(this) + OneSignal.InAppMessages.addClickListener(this) + + pendingClickEvent?.let { event -> + val ret = JSObject() + ret.put("result", JSObject(event.result.toJSONObject().toString())) + ret.put("notification", JSObject(event.notification.toJSONObject().toString())) + notifyListeners("notificationClick", ret) + pendingClickEvent = null + } + + call.resolve() + } + + @PluginMethod + fun login(call: PluginCall) { + val externalId = call.getString("externalId") + if (externalId == null) { + call.reject("externalId is required") + return + } + OneSignal.login(externalId) + call.resolve() + } + + @PluginMethod + fun logout(call: PluginCall) { + OneSignal.logout() + call.resolve() + } + + @PluginMethod + fun setConsentRequired(call: PluginCall) { + val required = call.getBoolean("required") ?: false + OneSignal.setConsentRequired(required) + call.resolve() + } + + @PluginMethod + fun setConsentGiven(call: PluginCall) { + val granted = call.getBoolean("granted") ?: false + OneSignal.setConsentGiven(granted) + call.resolve() + } + + // endregion + + // region Debug + + @PluginMethod + fun setLogLevel(call: PluginCall) { + val level = call.getInt("logLevel") ?: 0 + OneSignal.Debug.logLevel = com.onesignal.debug.LogLevel.fromInt(level) + call.resolve() + } + + @PluginMethod + fun setAlertLevel(call: PluginCall) { + val level = call.getInt("logLevel") ?: 0 + OneSignal.Debug.alertLevel = com.onesignal.debug.LogLevel.fromInt(level) + call.resolve() + } + + // endregion + + // region User + + @PluginMethod + fun setLanguage(call: PluginCall) { + val language = call.getString("language") + if (language == null) { + call.reject("language is required") + return + } + OneSignal.User.setLanguage(language) + call.resolve() + } + + @PluginMethod + fun addAliases(call: PluginCall) { + val aliasesObj = call.getObject("aliases") ?: run { + call.reject("aliases is required") + return + } + val aliases = mutableMapOf() + aliasesObj.keys().forEach { key -> aliases[key] = aliasesObj.getString(key) } + OneSignal.User.addAliases(aliases) + call.resolve() + } + + @PluginMethod + fun removeAliases(call: PluginCall) { + val labels = call.getArray("labels") ?: run { + call.reject("labels is required") + return + } + val list = mutableListOf() + for (i in 0 until labels.length()) { + list.add(labels.getString(i)) + } + OneSignal.User.removeAliases(list) + call.resolve() + } + + @PluginMethod + fun addEmail(call: PluginCall) { + val email = call.getString("email") + if (email == null) { + call.reject("email is required") + return + } + OneSignal.User.addEmail(email) + call.resolve() + } + + @PluginMethod + fun removeEmail(call: PluginCall) { + val email = call.getString("email") + if (email == null) { + call.reject("email is required") + return + } + OneSignal.User.removeEmail(email) + call.resolve() + } + + @PluginMethod + fun addSms(call: PluginCall) { + val smsNumber = call.getString("smsNumber") + if (smsNumber == null) { + call.reject("smsNumber is required") + return + } + OneSignal.User.addSms(smsNumber) + call.resolve() + } + + @PluginMethod + fun removeSms(call: PluginCall) { + val smsNumber = call.getString("smsNumber") + if (smsNumber == null) { + call.reject("smsNumber is required") + return + } + OneSignal.User.removeSms(smsNumber) + call.resolve() + } + + @PluginMethod + fun addTags(call: PluginCall) { + val tagsObj = call.getObject("tags") ?: run { + call.reject("tags is required") + return + } + val tags = mutableMapOf() + tagsObj.keys().forEach { key -> tags[key] = tagsObj.getString(key) } + OneSignal.User.addTags(tags) + call.resolve() + } + + @PluginMethod + fun removeTags(call: PluginCall) { + val keysArr = call.getArray("keys") ?: run { + call.reject("keys is required") + return + } + val keys = mutableListOf() + for (i in 0 until keysArr.length()) { + keys.add(keysArr.getString(i)) + } + OneSignal.User.removeTags(keys) + call.resolve() + } + + @PluginMethod + fun getTags(call: PluginCall) { + val tags = OneSignal.User.getTags() + val tagsObj = JSObject() + tags.forEach { (key, value) -> tagsObj.put(key, value) } + val ret = JSObject() + ret.put("tags", tagsObj) + call.resolve(ret) + } + + @PluginMethod + fun getOnesignalId(call: PluginCall) { + val ret = JSObject() + ret.put("onesignalId", OneSignal.User.onesignalId ?: JSONObject.NULL) + call.resolve(ret) + } + + @PluginMethod + fun getExternalId(call: PluginCall) { + val ret = JSObject() + ret.put("externalId", OneSignal.User.externalId ?: JSONObject.NULL) + call.resolve(ret) + } + + @PluginMethod + fun trackEvent(call: PluginCall) { + val name = call.getString("name") + if (name == null) { + call.reject("name is required") + return + } + val propertiesObj = call.getObject("properties") + val properties = if (propertiesObj != null) { + val map = mutableMapOf() + propertiesObj.keys().forEach { key -> + map[key] = propertiesObj.get(key) + } + map + } else null + + OneSignal.User.trackEvent(name, properties) + call.resolve() + } + + // endregion + + // region Push Subscription + + @PluginMethod + fun getPushSubscriptionId(call: PluginCall) { + val ret = JSObject() + ret.put("id", OneSignal.User.pushSubscription.id) + call.resolve(ret) + } + + @PluginMethod + fun getPushSubscriptionToken(call: PluginCall) { + val ret = JSObject() + ret.put("token", OneSignal.User.pushSubscription.token) + call.resolve(ret) + } + + @PluginMethod + fun getPushSubscriptionOptedIn(call: PluginCall) { + val ret = JSObject() + ret.put("optedIn", OneSignal.User.pushSubscription.optedIn) + call.resolve(ret) + } + + @PluginMethod + fun optInPushSubscription(call: PluginCall) { + OneSignal.User.pushSubscription.optIn() + call.resolve() + } + + @PluginMethod + fun optOutPushSubscription(call: PluginCall) { + OneSignal.User.pushSubscription.optOut() + call.resolve() + } + + // endregion + + // region Notifications + + @PluginMethod + fun getPermission(call: PluginCall) { + val ret = JSObject() + ret.put("permission", OneSignal.Notifications.permission) + call.resolve(ret) + } + + @PluginMethod + fun permissionNative(call: PluginCall) { + val ret = JSObject() + ret.put("permission", if (OneSignal.Notifications.permission) 2 else 1) + call.resolve(ret) + } + + @PluginMethod + fun requestPermission(call: PluginCall) { + val fallback = call.getBoolean("fallbackToSettings") ?: false + OneSignal.Notifications.requestPermission(fallback) { accepted -> + val ret = JSObject() + ret.put("permission", accepted) + call.resolve(ret) + } + } + + @PluginMethod + fun canRequestPermission(call: PluginCall) { + val ret = JSObject() + ret.put("canRequest", OneSignal.Notifications.canRequestPermission) + call.resolve(ret) + } + + @PluginMethod + fun registerForProvisionalAuthorization(call: PluginCall) { + val ret = JSObject() + ret.put("accepted", true) + call.resolve(ret) + } + + @PluginMethod + fun clearAllNotifications(call: PluginCall) { + OneSignal.Notifications.clearAllNotifications() + call.resolve() + } + + @PluginMethod + fun removeNotification(call: PluginCall) { + val id = call.getInt("id") ?: run { + call.reject("id is required") + return + } + OneSignal.Notifications.removeNotification(id) + call.resolve() + } + + @PluginMethod + fun removeGroupedNotifications(call: PluginCall) { + val id = call.getString("id") + if (id == null) { + call.reject("id is required") + return + } + OneSignal.Notifications.removeGroupedNotifications(id) + call.resolve() + } + + @PluginMethod + fun preventDefault(call: PluginCall) { + val notificationId = call.getString("notificationId") + if (notificationId == null) { + call.reject("notificationId is required") + return + } + val event = notificationWillDisplayCache[notificationId] + if (event == null) { + call.reject("Could not find notification will display event") + return + } + event.preventDefault() + preventDefaultCache.add(notificationId) + call.resolve() + } + + @PluginMethod + fun proceedWithWillDisplay(call: PluginCall) { + val notificationId = call.getString("notificationId") + if (notificationId == null) { + call.reject("notificationId is required") + return + } + val event = notificationWillDisplayCache[notificationId] + if (event == null) { + call.reject("Could not find notification will display event") + return + } + if (!preventDefaultCache.contains(notificationId)) { + event.notification.display() + } + call.resolve() + } + + @PluginMethod + fun displayNotification(call: PluginCall) { + val notificationId = call.getString("notificationId") + if (notificationId == null) { + call.reject("notificationId is required") + return + } + val event = notificationWillDisplayCache[notificationId] + if (event == null) { + call.reject("Could not find notification will display event") + return + } + event.notification.display() + call.resolve() + } + + // endregion + + // region In-App Messages + + @PluginMethod + fun addTriggers(call: PluginCall) { + val triggersObj = call.getObject("triggers") ?: run { + call.reject("triggers is required") + return + } + val triggers = mutableMapOf() + triggersObj.keys().forEach { key -> triggers[key] = triggersObj.getString(key) } + OneSignal.InAppMessages.addTriggers(triggers) + call.resolve() + } + + @PluginMethod + fun removeTriggers(call: PluginCall) { + val keysArr = call.getArray("keys") ?: run { + call.reject("keys is required") + return + } + val keys = mutableListOf() + for (i in 0 until keysArr.length()) { + keys.add(keysArr.getString(i)) + } + OneSignal.InAppMessages.removeTriggers(keys) + call.resolve() + } + + @PluginMethod + fun clearTriggers(call: PluginCall) { + OneSignal.InAppMessages.clearTriggers() + call.resolve() + } + + @PluginMethod + fun setPaused(call: PluginCall) { + val pause = call.getBoolean("pause") ?: false + OneSignal.InAppMessages.paused = pause + call.resolve() + } + + @PluginMethod + fun isPaused(call: PluginCall) { + val ret = JSObject() + ret.put("paused", OneSignal.InAppMessages.paused) + call.resolve(ret) + } + + // endregion + + // region Session / Outcomes + + @PluginMethod + fun addOutcome(call: PluginCall) { + val name = call.getString("name") + if (name == null) { + call.reject("name is required") + return + } + OneSignal.Session.addOutcome(name) + call.resolve() + } + + @PluginMethod + fun addUniqueOutcome(call: PluginCall) { + val name = call.getString("name") + if (name == null) { + call.reject("name is required") + return + } + OneSignal.Session.addUniqueOutcome(name) + call.resolve() + } + + @PluginMethod + fun addOutcomeWithValue(call: PluginCall) { + val name = call.getString("name") + if (name == null) { + call.reject("name is required") + return + } + val value = call.getFloat("value") ?: 0f + OneSignal.Session.addOutcomeWithValue(name, value) + call.resolve() + } + + // endregion + + // region Location + + @PluginMethod + fun requestLocationPermission(call: PluginCall) { + OneSignal.Location.requestPermission() + call.resolve() + } + + @PluginMethod + fun setLocationShared(call: PluginCall) { + val shared = call.getBoolean("shared") ?: false + OneSignal.Location.isShared = shared + call.resolve() + } + + @PluginMethod + fun isLocationShared(call: PluginCall) { + val ret = JSObject() + ret.put("shared", OneSignal.Location.isShared) + call.resolve(ret) + } + + // endregion + + // region Live Activities (no-op on Android) + + @PluginMethod + fun enterLiveActivity(call: PluginCall) { + call.resolve() + } + + @PluginMethod + fun exitLiveActivity(call: PluginCall) { + call.resolve() + } + + @PluginMethod + fun setPushToStartToken(call: PluginCall) { + call.resolve() + } + + @PluginMethod + fun removePushToStartToken(call: PluginCall) { + call.resolve() + } + + @PluginMethod + fun setupDefaultLiveActivity(call: PluginCall) { + call.resolve() + } + + @PluginMethod + fun startDefaultLiveActivity(call: PluginCall) { + call.resolve() + } + + // endregion + + // region Observer Callbacks + + override fun onWillDisplay(event: INotificationWillDisplayEvent) { + val notificationId = event.notification.notificationId + notificationWillDisplayCache[notificationId] = event + event.preventDefault() + val ret = JSObject(event.notification.toJSONObject().toString()) + notifyListeners("notificationForegroundWillDisplay", ret) + } + + override fun onClick(event: INotificationClickEvent) { + if (bridge != null) { + val ret = JSObject() + ret.put("result", JSObject(event.result.toJSONObject().toString())) + ret.put("notification", JSObject(event.notification.toJSONObject().toString())) + notifyListeners("notificationClick", ret) + } else { + pendingClickEvent = event + } + } + + override fun onWillDisplay(event: IInAppMessageWillDisplayEvent) { + val ret = JSObject() + ret.put("message", JSObject().put("messageId", event.message.messageId)) + notifyListeners("inAppMessageWillDisplay", ret) + } + + override fun onDidDisplay(event: IInAppMessageDidDisplayEvent) { + val ret = JSObject() + ret.put("message", JSObject().put("messageId", event.message.messageId)) + notifyListeners("inAppMessageDidDisplay", ret) + } + + override fun onWillDismiss(event: IInAppMessageWillDismissEvent) { + val ret = JSObject() + ret.put("message", JSObject().put("messageId", event.message.messageId)) + notifyListeners("inAppMessageWillDismiss", ret) + } + + override fun onDidDismiss(event: IInAppMessageDidDismissEvent) { + val ret = JSObject() + ret.put("message", JSObject().put("messageId", event.message.messageId)) + notifyListeners("inAppMessageDidDismiss", ret) + } + + override fun onClick(event: IInAppMessageClickEvent) { + val urlTarget = when (event.result.urlTarget.ordinal) { + 0 -> "browser" + 1 -> "webview" + 2 -> "replacement" + else -> "browser" + } + + val clickResult = JSObject() + clickResult.put("closingMessage", event.result.closingMessage) + clickResult.put("actionId", event.result.actionId) + clickResult.put("url", event.result.url) + clickResult.put("urlTarget", urlTarget) + + val ret = JSObject() + ret.put("message", JSObject().put("messageId", event.message.messageId)) + ret.put("result", clickResult) + notifyListeners("inAppMessageClick", ret) + } + + // endregion +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..978cb06 --- /dev/null +++ b/bun.lock @@ -0,0 +1,335 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "onesignal-capacitor-plugin", + "devDependencies": { + "@capacitor/core": "^7.0.0", + "@capacitor/docgen": "^0.2.2", + "@types/bun": "latest", + "@vitest/coverage-v8": "^4.1.2", + "happy-dom": "^20.0.8", + "typescript": "^5.9.3", + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plus": "0.1.15", + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0", + }, + }, + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest", + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@capacitor/core": ["@capacitor/core@7.6.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-nsNouCMxgYenyemy20sZwZYMtFi93LSZVWm2KqHTYIPIDgwx24+PzwHIdRQBZdK7hpvD5jQEhWuo/QyLLnAyBQ=="], + + "@capacitor/docgen": ["@capacitor/docgen@0.2.2", "", { "dependencies": { "@types/node": "^14.18.0", "colorette": "^2.0.16", "github-slugger": "^1.4.0", "minimist": "^1.2.5", "typescript": "~4.2.4" }, "bin": { "docgen": "bin/docgen" } }, "sha512-617jB5DlKjUljxfoANORWeqffsHqHekckH48oslKWFGzfCHYIAEPfJfMa5M4mVSzn2ifSvWB5C3st4augwhl5w=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@oxc-project/runtime": ["@oxc-project/runtime@0.122.0", "", {}, "sha512-vevyz3bNjevQFCV2Yg5o6Sp9BSoiYiJVymMrzA3S1ZGj4J8ak4YiywhFyQMueQ3UNlJU6HZOZYDy70TUc99aHw=="], + + "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.43.0", "", { "os": "android", "cpu": "arm" }, "sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.43.0", "", { "os": "android", "cpu": "arm64" }, "sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.43.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.43.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.43.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.43.0", "", { "os": "linux", "cpu": "arm" }, "sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.43.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.43.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.43.0", "", { "os": "linux", "cpu": "none" }, "sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.43.0", "", { "os": "linux", "cpu": "none" }, "sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.43.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.43.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.43.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.43.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g=="], + + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.18.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CxSd15ZwHn70UJFTXVvy76bZ9zwI097cVyjvUFmYRJwvkQF3VnrTf2oe1gomUacErksvtqLgn9OKvZhLMYwvog=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.18.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-LE7VW/T/VcKhl3Z1ev5BusrxdlQ3DWweSeOB+qpBeur2h8+vCWq+M7tCO29C7lveBDfx1+rNwj4aiUVlA+Qs+g=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.18.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2AG8YIXVJJbnM0rcsJmzzWOjZXBu5REwowgUpbHZueF7OYM3wR7Xu8pXEpAojEHAtYYZ3X4rpPoetomkJx7kCw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.18.1", "", { "os": "linux", "cpu": "x64" }, "sha512-f8vDYPEdiwpA2JaDEkadTXfuqIgweQ8zcL4SX75EN2kkW2oAynjN7cd8m86uXDgB0JrcyOywbRtwnXdiIzXn2A=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.18.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-fBdML05KMDAL9ebWeoHIzkyI86Eq6r9YH5UDRuXJ9vAIo1EnKo0ti7hLUxNdc2dy2FF/T4k98p5wkkXvLyXqfA=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.18.1", "", { "os": "win32", "cpu": "x64" }, "sha512-cYZMhNrsq9ZZ3OUWHyawqiS+c8HfieYG0zuZP2LbEuWWPfdZM/22iAlo608J+27G1s9RXQhvgX6VekwWbXbD7A=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], + + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.1.15", "", { "dependencies": { "@oxc-project/runtime": "=0.122.0", "@oxc-project/types": "=0.122.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.21.7", "@tsdown/exe": "0.21.7", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "yaml"] }, "sha512-0qAbqwcvQwiC8xGKSSuFtsjJUEM4LZzpXF7dffRazghGEQ8HH8NAvVryp/PiMSFwreJlV3rujwL4amKjnwCHpg=="], + + "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.1.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-arFq8phXg96rQ5J+FYvkBYdEGxIhP1ePAXlUeQY2hV8hJPzse+CdxusWxcjfpTgvFi+dpsKzE4KSNS22PyBo7w=="], + + "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.1.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-2eY+gTEIZvLH33nQmcL2tKlf+iHfClaqaSMYIlUpTp/CN+xqh4Ir4y2vN1XGEuFDIW0FshSZTg3ulPtduneEDA=="], + + "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-jJgz84pp61oHeXAYIUXKsVwQsMQ7NHK0+dBe6v1Q+Z034xXsyBrxi/JASSeVmCpAd6CN+xzOCsfMyn3whVTTxQ=="], + + "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-F0Wig+We0ERhGecf3fDIwM/kfqT0vP2htH0vKUnV/inHIVbPc1MsrjcExX1eJ6KFSp5YTfchRN8HGecqtsudPA=="], + + "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-aT5Yr2GphvRjoc2URmELDqjWwhe5VPvyy15Tzum+jPhEjY4I/lPXxKXEROjQe3TIv6MmFSHCe3oNCSaFdUE1pA=="], + + "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-Q6qMBMdVp5v84YVzFvMUpzVIHLfJuwZQR/KUtAOn/hzpfNITigKR2GrZZDgQvszFW+0CPhDFcK3kqLkxlJCdFg=="], + + "@voidzero-dev/vite-plus-test": ["@voidzero-dev/vite-plus-test@0.1.15", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@voidzero-dev/vite-plus-core": "0.1.15", "es-module-lexer": "^1.7.0", "obug": "^2.1.1", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "std-env": "^4.0.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "ws": "^8.18.3" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/ui", "happy-dom", "jsdom"] }, "sha512-jxMUEX6PDpzMUz+KOVOoB8HiODMf5mWjH19pof0k9l/RZT4iLDyVXB+p9PoWjbVrEMMGzq9BTOVob7wfOZeZEA=="], + + "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-EePrs+NIUy3gE60qaXPXzj8mw+JAXEBfGKsfweYBgNK6jo9ZXZto5ViKTuQsVVuWLVaELZSjoudbkzXB8wnJoQ=="], + + "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.1.15", "", { "os": "win32", "cpu": "x64" }, "sha512-vfYfwOG/5a/WUtgGrbUCatRkc5x0Rq/9GDlCzQQIAFGDB5BfyIjGbdCOqamQWOh+yQbeOHwvgAhqjZ7Dv1oo/w=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "github-slugger": ["github-slugger@1.5.0", "", {}, "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw=="], + + "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "oxfmt": ["oxfmt@0.43.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.43.0", "@oxfmt/binding-android-arm64": "0.43.0", "@oxfmt/binding-darwin-arm64": "0.43.0", "@oxfmt/binding-darwin-x64": "0.43.0", "@oxfmt/binding-freebsd-x64": "0.43.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.43.0", "@oxfmt/binding-linux-arm-musleabihf": "0.43.0", "@oxfmt/binding-linux-arm64-gnu": "0.43.0", "@oxfmt/binding-linux-arm64-musl": "0.43.0", "@oxfmt/binding-linux-ppc64-gnu": "0.43.0", "@oxfmt/binding-linux-riscv64-gnu": "0.43.0", "@oxfmt/binding-linux-riscv64-musl": "0.43.0", "@oxfmt/binding-linux-s390x-gnu": "0.43.0", "@oxfmt/binding-linux-x64-gnu": "0.43.0", "@oxfmt/binding-linux-x64-musl": "0.43.0", "@oxfmt/binding-openharmony-arm64": "0.43.0", "@oxfmt/binding-win32-arm64-msvc": "0.43.0", "@oxfmt/binding-win32-ia32-msvc": "0.43.0", "@oxfmt/binding-win32-x64-msvc": "0.43.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA=="], + + "oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@0.18.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.18.1", "@oxlint-tsgolint/darwin-x64": "0.18.1", "@oxlint-tsgolint/linux-arm64": "0.18.1", "@oxlint-tsgolint/linux-x64": "0.18.1", "@oxlint-tsgolint/win32-arm64": "0.18.1", "@oxlint-tsgolint/win32-x64": "0.18.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-Hgb0wMfuXBYL0ddY+1hAG8IIfC40ADwPnBuUaC6ENAuCtTF4dHwsy7mCYtQ2e7LoGvfoSJRY0+kqQRiembJ/jQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "vite": ["@voidzero-dev/vite-plus-core@0.1.15", "", { "dependencies": { "@oxc-project/runtime": "=0.122.0", "@oxc-project/types": "=0.122.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.21.7", "@tsdown/exe": "0.21.7", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "yaml"] }, "sha512-0qAbqwcvQwiC8xGKSSuFtsjJUEM4LZzpXF7dffRazghGEQ8HH8NAvVryp/PiMSFwreJlV3rujwL4amKjnwCHpg=="], + + "vite-plus": ["vite-plus@0.1.15", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@voidzero-dev/vite-plus-core": "0.1.15", "@voidzero-dev/vite-plus-test": "0.1.15", "cac": "^7.0.0", "cross-spawn": "^7.0.5", "jsonc-parser": "^3.3.1", "oxfmt": "=0.43.0", "oxlint": "=1.58.0", "oxlint-tsgolint": "=0.18.1", "picocolors": "^1.1.1" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.1.15", "@voidzero-dev/vite-plus-darwin-x64": "0.1.15", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.1.15", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.1.15", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.1.15", "@voidzero-dev/vite-plus-linux-x64-musl": "0.1.15", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.1.15", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.1.15" }, "bin": { "vp": "bin/vp", "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint" } }, "sha512-PBUvTq4D4BJcuusCA3mrSQmXcGVdPX9CIPpS7Y6+T+LbDsrmAZ+ITl9FzuE6zXvpT6Nht9cpHtwOLJw7m3adog=="], + + "vitest": ["@voidzero-dev/vite-plus-test@0.1.15", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@voidzero-dev/vite-plus-core": "0.1.15", "es-module-lexer": "^1.7.0", "obug": "^2.1.1", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "std-env": "^4.0.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "ws": "^8.18.3" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/ui", "happy-dom", "jsdom"] }, "sha512-jxMUEX6PDpzMUz+KOVOoB8HiODMf5mWjH19pof0k9l/RZT4iLDyVXB+p9PoWjbVrEMMGzq9BTOVob7wfOZeZEA=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "@capacitor/docgen/typescript": ["typescript@4.2.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg=="], + + "@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "happy-dom/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + } +} diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift new file mode 100644 index 0000000..7597c89 --- /dev/null +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -0,0 +1,621 @@ +import Foundation +import Capacitor +import OneSignalFramework +import OneSignalLiveActivities + +@objc(OneSignalCapacitorPlugin) +public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, + OSNotificationPermissionObserver, + OSNotificationLifecycleListener, + OSNotificationClickListener, + OSPushSubscriptionObserver, + OSInAppMessageLifecycleListener, + OSInAppMessageClickListener, + OSUserStateObserver { + + public let identifier = "OneSignalCapacitorPlugin" + public let jsName = "OneSignalCapacitor" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "initialize", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "login", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setConsentRequired", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setConsentGiven", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setLogLevel", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setAlertLevel", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setLanguage", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addAliases", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeAliases", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addEmail", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeEmail", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addSms", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeSms", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addTags", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeTags", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getTags", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getOnesignalId", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getExternalId", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "trackEvent", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getPushSubscriptionId", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getPushSubscriptionToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getPushSubscriptionOptedIn", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "optInPushSubscription", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "optOutPushSubscription", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getPermission", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "permissionNative", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "requestPermission", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "canRequestPermission", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "registerForProvisionalAuthorization", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearAllNotifications", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeNotification", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeGroupedNotifications", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "preventDefault", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "proceedWithWillDisplay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "displayNotification", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addTriggers", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeTriggers", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearTriggers", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setPaused", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "isPaused", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addOutcome", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addUniqueOutcome", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "addOutcomeWithValue", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "requestLocationPermission", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setLocationShared", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "isLocationShared", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "enterLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "exitLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setPushToStartToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removePushToStartToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setupDefaultLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "startDefaultLiveActivity", returnType: CAPPluginReturnPromise), + ] + + private var notificationWillDisplayCache = [String: OSNotificationWillDisplayEvent]() + private var preventDefaultCache = [String: OSNotificationWillDisplayEvent]() + private var pendingClickEvent: OSNotificationClickEvent? + + // MARK: - Core + + @objc func initialize(_ call: CAPPluginCall) { + guard let appId = call.getString("appId") else { + call.reject("appId is required") + return + } + OneSignalWrapper.sdkType = "capacitor" + OneSignalWrapper.sdkVersion = "010000" + OneSignal.initialize(appId, withLaunchOptions: nil) + OneSignal.Notifications.addPermissionObserver(self) + OneSignal.Notifications.addForegroundLifecycleListener(self) + OneSignal.Notifications.addClickListener(self) + OneSignal.User.pushSubscription.addObserver(self) + OneSignal.User.addObserver(self) + OneSignal.InAppMessages.addLifecycleListener(self) + OneSignal.InAppMessages.addClickListener(self) + + if let pending = pendingClickEvent { + notifyListeners("notificationClick", data: pending.jsonRepresentation) + pendingClickEvent = nil + } + call.resolve() + } + + @objc func login(_ call: CAPPluginCall) { + guard let externalId = call.getString("externalId") else { + call.reject("externalId is required") + return + } + OneSignal.login(externalId) + call.resolve() + } + + @objc func logout(_ call: CAPPluginCall) { + OneSignal.logout() + call.resolve() + } + + @objc func setConsentRequired(_ call: CAPPluginCall) { + let required = call.getBool("required") ?? false + OneSignal.setConsentRequired(required) + call.resolve() + } + + @objc func setConsentGiven(_ call: CAPPluginCall) { + let granted = call.getBool("granted") ?? false + OneSignal.setConsentGiven(granted) + call.resolve() + } + + // MARK: - Debug + + @objc func setLogLevel(_ call: CAPPluginCall) { + let level = call.getInt("logLevel") ?? 0 + OneSignal.Debug.setLogLevel(ONE_S_LOG_LEVEL(rawValue: UInt32(level))!) + call.resolve() + } + + @objc func setAlertLevel(_ call: CAPPluginCall) { + let level = call.getInt("logLevel") ?? 0 + OneSignal.Debug.setAlertLevel(ONE_S_LOG_LEVEL(rawValue: UInt32(level))!) + call.resolve() + } + + // MARK: - User + + @objc func setLanguage(_ call: CAPPluginCall) { + guard let language = call.getString("language") else { + call.reject("language is required") + return + } + OneSignal.User.setLanguage(language) + call.resolve() + } + + @objc func addAliases(_ call: CAPPluginCall) { + guard let aliases = call.getObject("aliases") as? [String: String] else { + call.reject("aliases is required") + return + } + OneSignal.User.addAliases(aliases) + call.resolve() + } + + @objc func removeAliases(_ call: CAPPluginCall) { + guard let labels = call.getArray("labels", String.self) else { + call.reject("labels is required") + return + } + OneSignal.User.removeAliases(labels) + call.resolve() + } + + @objc func addEmail(_ call: CAPPluginCall) { + guard let email = call.getString("email") else { + call.reject("email is required") + return + } + OneSignal.User.addEmail(email) + call.resolve() + } + + @objc func removeEmail(_ call: CAPPluginCall) { + guard let email = call.getString("email") else { + call.reject("email is required") + return + } + OneSignal.User.removeEmail(email) + call.resolve() + } + + @objc func addSms(_ call: CAPPluginCall) { + guard let smsNumber = call.getString("smsNumber") else { + call.reject("smsNumber is required") + return + } + OneSignal.User.addSms(smsNumber) + call.resolve() + } + + @objc func removeSms(_ call: CAPPluginCall) { + guard let smsNumber = call.getString("smsNumber") else { + call.reject("smsNumber is required") + return + } + OneSignal.User.removeSms(smsNumber) + call.resolve() + } + + @objc func addTags(_ call: CAPPluginCall) { + guard let tags = call.getObject("tags") as? [String: String] else { + call.reject("tags is required") + return + } + OneSignal.User.addTags(tags) + call.resolve() + } + + @objc func removeTags(_ call: CAPPluginCall) { + guard let keys = call.getArray("keys", String.self) else { + call.reject("keys is required") + return + } + OneSignal.User.removeTags(keys) + call.resolve() + } + + @objc func getTags(_ call: CAPPluginCall) { + let tags = OneSignal.User.getTags() + call.resolve(["tags": tags]) + } + + @objc func getOnesignalId(_ call: CAPPluginCall) { + call.resolve(["onesignalId": OneSignal.User.onesignalId ?? NSNull()]) + } + + @objc func getExternalId(_ call: CAPPluginCall) { + call.resolve(["externalId": OneSignal.User.externalId ?? NSNull()]) + } + + @objc func trackEvent(_ call: CAPPluginCall) { + guard let name = call.getString("name") else { + call.reject("name is required") + return + } + let properties = call.getObject("properties") + OneSignal.User.trackEvent(withName: name, properties: properties) + call.resolve() + } + + // MARK: - Push Subscription + + @objc func getPushSubscriptionId(_ call: CAPPluginCall) { + call.resolve(["id": OneSignal.User.pushSubscription.id ?? NSNull()]) + } + + @objc func getPushSubscriptionToken(_ call: CAPPluginCall) { + call.resolve(["token": OneSignal.User.pushSubscription.token ?? NSNull()]) + } + + @objc func getPushSubscriptionOptedIn(_ call: CAPPluginCall) { + call.resolve(["optedIn": OneSignal.User.pushSubscription.optedIn]) + } + + @objc func optInPushSubscription(_ call: CAPPluginCall) { + OneSignal.User.pushSubscription.optIn() + call.resolve() + } + + @objc func optOutPushSubscription(_ call: CAPPluginCall) { + OneSignal.User.pushSubscription.optOut() + call.resolve() + } + + // MARK: - Notifications + + @objc func getPermission(_ call: CAPPluginCall) { + call.resolve(["permission": OneSignal.Notifications.permission]) + } + + @objc func permissionNative(_ call: CAPPluginCall) { + call.resolve(["permission": OneSignal.Notifications.permissionNative().rawValue]) + } + + @objc func requestPermission(_ call: CAPPluginCall) { + let fallback = call.getBool("fallbackToSettings") ?? false + OneSignal.Notifications.requestPermission({ accepted in + call.resolve(["permission": accepted]) + }, fallbackToSettings: fallback) + } + + @objc func canRequestPermission(_ call: CAPPluginCall) { + call.resolve(["canRequest": OneSignal.Notifications.canRequestPermission]) + } + + @objc func registerForProvisionalAuthorization(_ call: CAPPluginCall) { + OneSignal.Notifications.registerForProvisionalAuthorization { accepted in + call.resolve(["accepted": accepted]) + } + } + + @objc func clearAllNotifications(_ call: CAPPluginCall) { + OneSignal.Notifications.clearAll() + call.resolve() + } + + @objc func removeNotification(_ call: CAPPluginCall) { + call.resolve() + } + + @objc func removeGroupedNotifications(_ call: CAPPluginCall) { + call.resolve() + } + + @objc func preventDefault(_ call: CAPPluginCall) { + guard let notificationId = call.getString("notificationId") else { + call.reject("notificationId is required") + return + } + guard let event = notificationWillDisplayCache[notificationId] else { + call.reject("Could not find notification will display event") + return + } + event.preventDefault() + preventDefaultCache[notificationId] = event + call.resolve() + } + + @objc func proceedWithWillDisplay(_ call: CAPPluginCall) { + guard let notificationId = call.getString("notificationId") else { + call.reject("notificationId is required") + return + } + guard let event = notificationWillDisplayCache[notificationId] else { + call.reject("Could not find notification will display event") + return + } + if preventDefaultCache[notificationId] == nil { + event.notification.display() + } + call.resolve() + } + + @objc func displayNotification(_ call: CAPPluginCall) { + guard let notificationId = call.getString("notificationId") else { + call.reject("notificationId is required") + return + } + guard let event = notificationWillDisplayCache[notificationId] else { + call.reject("Could not find notification will display event") + return + } + event.notification.display() + call.resolve() + } + + // MARK: - In-App Messages + + @objc func addTriggers(_ call: CAPPluginCall) { + guard let triggers = call.getObject("triggers") as? [String: String] else { + call.reject("triggers is required") + return + } + OneSignal.InAppMessages.addTriggers(triggers) + call.resolve() + } + + @objc func removeTriggers(_ call: CAPPluginCall) { + guard let keys = call.getArray("keys", String.self) else { + call.reject("keys is required") + return + } + OneSignal.InAppMessages.removeTriggers(keys) + call.resolve() + } + + @objc func clearTriggers(_ call: CAPPluginCall) { + OneSignal.InAppMessages.clearTriggers() + call.resolve() + } + + @objc func setPaused(_ call: CAPPluginCall) { + let pause = call.getBool("pause") ?? false + OneSignal.InAppMessages.paused = pause + call.resolve() + } + + @objc func isPaused(_ call: CAPPluginCall) { + call.resolve(["paused": OneSignal.InAppMessages.paused]) + } + + // MARK: - Session / Outcomes + + @objc func addOutcome(_ call: CAPPluginCall) { + guard let name = call.getString("name") else { + call.reject("name is required") + return + } + OneSignal.Session.addOutcome(name) + call.resolve() + } + + @objc func addUniqueOutcome(_ call: CAPPluginCall) { + guard let name = call.getString("name") else { + call.reject("name is required") + return + } + OneSignal.Session.addUniqueOutcome(name) + call.resolve() + } + + @objc func addOutcomeWithValue(_ call: CAPPluginCall) { + guard let name = call.getString("name") else { + call.reject("name is required") + return + } + let value = call.getFloat("value") ?? 0 + OneSignal.Session.addOutcome(withValue: name, value: NSNumber(value: value)) + call.resolve() + } + + // MARK: - Location + + @objc func requestLocationPermission(_ call: CAPPluginCall) { + OneSignal.Location.requestPermission() + call.resolve() + } + + @objc func setLocationShared(_ call: CAPPluginCall) { + let shared = call.getBool("shared") ?? false + OneSignal.Location.isShared = shared + call.resolve() + } + + @objc func isLocationShared(_ call: CAPPluginCall) { + call.resolve(["shared": OneSignal.Location.isShared]) + } + + // MARK: - Live Activities + + @objc func enterLiveActivity(_ call: CAPPluginCall) { + guard let activityId = call.getString("activityId"), + let token = call.getString("token") else { + call.reject("activityId and token are required") + return + } + OneSignal.LiveActivities.enter(activityId, withToken: token, withSuccess: { _ in + call.resolve() + }, withFailure: { error in + call.reject(error.localizedDescription) + }) + } + + @objc func exitLiveActivity(_ call: CAPPluginCall) { + guard let activityId = call.getString("activityId") else { + call.reject("activityId is required") + return + } + OneSignal.LiveActivities.exit(activityId, withSuccess: { _ in + call.resolve() + }, withFailure: { error in + call.reject(error.localizedDescription) + }) + } + + @objc func setPushToStartToken(_ call: CAPPluginCall) { + #if !targetEnvironment(macCatalyst) + guard let activityType = call.getString("activityType"), + let token = call.getString("token") else { + call.reject("activityType and token are required") + return + } + if #available(iOS 17.2, *) { + do { + try OneSignalLiveActivitiesManagerImpl.setPushToStartToken(activityType, withToken: token) + } catch { + call.reject("activityType must be the name of your ActivityAttributes struct") + return + } + } + #endif + call.resolve() + } + + @objc func removePushToStartToken(_ call: CAPPluginCall) { + #if !targetEnvironment(macCatalyst) + guard let activityType = call.getString("activityType") else { + call.reject("activityType is required") + return + } + if #available(iOS 17.2, *) { + do { + try OneSignalLiveActivitiesManagerImpl.removePushToStartToken(activityType) + } catch { + call.reject("activityType must be the name of your ActivityAttributes struct") + return + } + } + #endif + call.resolve() + } + + @objc func setupDefaultLiveActivity(_ call: CAPPluginCall) { + #if !targetEnvironment(macCatalyst) + if #available(iOS 16.1, *) { + var laOptions: LiveActivitySetupOptions? = nil + if let enablePushToStart = call.getBool("enablePushToStart"), + let enablePushToUpdate = call.getBool("enablePushToUpdate") { + laOptions = LiveActivitySetupOptions() + laOptions?.enablePushToStart = enablePushToStart + laOptions?.enablePushToUpdate = enablePushToUpdate + } + OneSignalLiveActivitiesManagerImpl.setupDefault(withOptions: laOptions) + } + #endif + call.resolve() + } + + @objc func startDefaultLiveActivity(_ call: CAPPluginCall) { + #if !targetEnvironment(macCatalyst) + guard let activityId = call.getString("activityId"), + let attributes = call.getObject("attributes"), + let content = call.getObject("content") else { + call.reject("activityId, attributes, and content are required") + return + } + if #available(iOS 16.1, *) { + OneSignalLiveActivitiesManagerImpl.startDefault(activityId, attributes: attributes, content: content) + } + #endif + call.resolve() + } + + // MARK: - Observer Callbacks + + public func onNotificationPermissionDidChange(_ permission: Bool) { + notifyListeners("permissionChange", data: ["permission": permission]) + } + + public func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { + var result: [String: Any] = [:] + result["previous"] = [ + "id": state.previous.id ?? NSNull(), + "token": state.previous.token ?? NSNull(), + "optedIn": state.previous.optedIn + ] + result["current"] = [ + "id": state.current.id ?? NSNull(), + "token": state.current.token ?? NSNull(), + "optedIn": state.current.optedIn + ] + notifyListeners("pushSubscriptionChange", data: result) + } + + public func onUserStateDidChange(state: OSUserChangedState) { + var result: [String: Any] = [:] + result["current"] = [ + "onesignalId": state.current.onesignalId ?? NSNull(), + "externalId": state.current.externalId ?? NSNull() + ] + notifyListeners("userStateChange", data: result) + } + + public func onWillDisplay(event: OSNotificationWillDisplayEvent) { + let notificationId = event.notification.notificationId + notificationWillDisplayCache[notificationId] = event + event.preventDefault() + if let data = event.notification.stringify().data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + notifyListeners("notificationForegroundWillDisplay", data: json) + } + } + + public func onClick(event: OSNotificationClickEvent) { + if bridge != nil { + notifyListeners("notificationClick", data: event.jsonRepresentation) + } else { + pendingClickEvent = event + } + } + + public func onWillDisplay(event: OSInAppMessageWillDisplayEvent) { + notifyListeners("inAppMessageWillDisplay", data: event.jsonRepresentation) + } + + public func onDidDisplay(event: OSInAppMessageDidDisplayEvent) { + notifyListeners("inAppMessageDidDisplay", data: event.jsonRepresentation) + } + + public func onWillDismiss(event: OSInAppMessageWillDismissEvent) { + notifyListeners("inAppMessageWillDismiss", data: event.jsonRepresentation) + } + + public func onDidDismiss(event: OSInAppMessageDidDismissEvent) { + notifyListeners("inAppMessageDidDismiss", data: event.jsonRepresentation) + } + + public func onClick(event: OSInAppMessageClickEvent) { + let urlTargetStr: String + switch event.result.urlTarget.rawValue { + case 0: urlTargetStr = "browser" + case 1: urlTargetStr = "webview" + case 2: urlTargetStr = "replacement" + default: urlTargetStr = "browser" + } + + var clickResult: [String: Any] = [ + "closingMessage": event.result.closingMessage + ] + if let actionId = event.result.actionId { + clickResult["actionId"] = actionId + } + if let url = event.result.url { + clickResult["url"] = url + } + clickResult["urlTarget"] = urlTargetStr + + notifyListeners("inAppMessageClick", data: [ + "message": event.message.jsonRepresentation, + "result": clickResult + ]) + } +} diff --git a/mocks/capacitor.ts b/mocks/capacitor.ts new file mode 100644 index 0000000..fc9bf7a --- /dev/null +++ b/mocks/capacitor.ts @@ -0,0 +1,67 @@ +import { vi } from 'vitest'; + +import type { OneSignalCapacitorPlugin } from '../src/definitions'; + +export type MockPlugin = { + [K in keyof OneSignalCapacitorPlugin]: ReturnType; +} & OneSignalCapacitorPlugin; + +export function createMockPlugin(): MockPlugin { + return { + initialize: vi.fn().mockResolvedValue(undefined), + login: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), + setConsentRequired: vi.fn().mockResolvedValue(undefined), + setConsentGiven: vi.fn().mockResolvedValue(undefined), + setLogLevel: vi.fn().mockResolvedValue(undefined), + setAlertLevel: vi.fn().mockResolvedValue(undefined), + setLanguage: vi.fn().mockResolvedValue(undefined), + addAliases: vi.fn().mockResolvedValue(undefined), + removeAliases: vi.fn().mockResolvedValue(undefined), + addEmail: vi.fn().mockResolvedValue(undefined), + removeEmail: vi.fn().mockResolvedValue(undefined), + addSms: vi.fn().mockResolvedValue(undefined), + removeSms: vi.fn().mockResolvedValue(undefined), + addTags: vi.fn().mockResolvedValue(undefined), + removeTags: vi.fn().mockResolvedValue(undefined), + getTags: vi.fn().mockResolvedValue({ tags: {} }), + getOnesignalId: vi.fn().mockResolvedValue({ onesignalId: null }), + getExternalId: vi.fn().mockResolvedValue({ externalId: null }), + trackEvent: vi.fn().mockResolvedValue(undefined), + getPushSubscriptionId: vi.fn().mockResolvedValue({ id: null }), + getPushSubscriptionToken: vi.fn().mockResolvedValue({ token: null }), + getPushSubscriptionOptedIn: vi.fn().mockResolvedValue({ optedIn: false }), + optInPushSubscription: vi.fn().mockResolvedValue(undefined), + optOutPushSubscription: vi.fn().mockResolvedValue(undefined), + getPermission: vi.fn().mockResolvedValue({ permission: false }), + permissionNative: vi.fn().mockResolvedValue({ permission: 0 }), + requestPermission: vi.fn().mockResolvedValue({ permission: false }), + canRequestPermission: vi.fn().mockResolvedValue({ canRequest: true }), + registerForProvisionalAuthorization: vi.fn().mockResolvedValue({ accepted: false }), + clearAllNotifications: vi.fn().mockResolvedValue(undefined), + removeNotification: vi.fn().mockResolvedValue(undefined), + removeGroupedNotifications: vi.fn().mockResolvedValue(undefined), + preventDefault: vi.fn().mockResolvedValue(undefined), + proceedWithWillDisplay: vi.fn().mockResolvedValue(undefined), + displayNotification: vi.fn().mockResolvedValue(undefined), + addTriggers: vi.fn().mockResolvedValue(undefined), + removeTriggers: vi.fn().mockResolvedValue(undefined), + clearTriggers: vi.fn().mockResolvedValue(undefined), + setPaused: vi.fn().mockResolvedValue(undefined), + isPaused: vi.fn().mockResolvedValue({ paused: false }), + addOutcome: vi.fn().mockResolvedValue(undefined), + addUniqueOutcome: vi.fn().mockResolvedValue(undefined), + addOutcomeWithValue: vi.fn().mockResolvedValue(undefined), + requestLocationPermission: vi.fn().mockResolvedValue(undefined), + setLocationShared: vi.fn().mockResolvedValue(undefined), + isLocationShared: vi.fn().mockResolvedValue({ shared: false }), + enterLiveActivity: vi.fn().mockResolvedValue(undefined), + exitLiveActivity: vi.fn().mockResolvedValue(undefined), + setPushToStartToken: vi.fn().mockResolvedValue(undefined), + removePushToStartToken: vi.fn().mockResolvedValue(undefined), + setupDefaultLiveActivity: vi.fn().mockResolvedValue(undefined), + startDefaultLiveActivity: vi.fn().mockResolvedValue(undefined), + addListener: vi.fn().mockResolvedValue({ remove: vi.fn() }), + removeAllListeners: vi.fn().mockResolvedValue(undefined), + }; +} diff --git a/mocks/constants.ts b/mocks/constants.ts new file mode 100644 index 0000000..8eb62bf --- /dev/null +++ b/mocks/constants.ts @@ -0,0 +1,6 @@ +export const APP_ID = 'test-app-id'; +export const SUB_TOKEN = 'test-token'; +export const SUB_ID = 'test-sub-id'; + +export const PREV_SUB_TOKEN = 'prev-sub-token'; +export const PREV_SUB_ID = 'prev-sub-id'; diff --git a/mocks/data.ts b/mocks/data.ts new file mode 100644 index 0000000..1014c17 --- /dev/null +++ b/mocks/data.ts @@ -0,0 +1,33 @@ +import { OSNotification, type ReceivedEvent } from '../src/OSNotification'; +import type { NotificationClickEvent } from '../src/types/NotificationClicked'; +import { deepMerge } from './deepmerge'; + +export const mockNotification = (props: Partial = {}): OSNotification => { + return new OSNotification( + deepMerge( + { + body: 'Test Notification', + rawPayload: '{"test": "payload"}', + additionalData: {}, + notificationId: '123', + title: 'Test Title', + }, + props, + ), + ); +}; + +export const mockNotificationClickEvent = ( + props: Partial = {}, +): NotificationClickEvent => { + return deepMerge( + { + result: { + actionId: 'test', + url: 'https://test.com', + }, + notification: mockNotification(), + }, + props, + ); +}; diff --git a/mocks/deepmerge.ts b/mocks/deepmerge.ts new file mode 100644 index 0000000..9acf0a9 --- /dev/null +++ b/mocks/deepmerge.ts @@ -0,0 +1,39 @@ +export type DeepPartial = T extends Function + ? T + : T extends readonly (infer U)[] + ? ReadonlyArray> + : T extends (infer U)[] + ? DeepPartial[] + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + +/** + * Recursively merges a patch object into a target object. + */ +export function deepMerge(target: T, patch: DeepPartial): T { + if (patch === undefined || patch === null) return target; + + const isObj = (v: unknown): v is Record => + Object.prototype.toString.call(v) === '[object Object]'; + + if (!isObj(target) || !isObj(patch)) { + return patch as unknown as T; + } + + const out: Record = { ...(target as any) }; + + for (const [k, v] of Object.entries(patch)) { + const tk = (target as any)[k]; + + if (Array.isArray(v)) { + out[k] = v.slice(); + } else if (isObj(v) && isObj(tk)) { + out[k] = deepMerge(tk, v as any); + } else if (v !== undefined) { + out[k] = v; + } + } + + return out as T; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c96ab62 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "onesignal-capacitor-plugin", + "version": "1.0.0", + "description": "OneSignal is a high volume Push Notification service for mobile apps. This is the pure Capacitor plugin for OneSignal, providing push notifications, in-app messaging, and more.", + "keywords": [ + "apns", + "capacitor", + "fcm", + "native", + "notification", + "onesignal", + "plugin", + "push", + "push notification", + "push notifications" + ], + "homepage": "https://github.com/OneSignal/OneSignal-Capacitor-SDK#readme", + "bugs": { + "url": "https://github.com/OneSignal/OneSignal-Capacitor-SDK/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/OneSignal/OneSignal-Capacitor-SDK.git" + }, + "files": [ + "dist", + "ios", + "android", + "Package.swift", + "OneSignalCapacitorPlugin.podspec" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vp pack", + "docgen": "docgen --api OneSignalCapacitorPlugin --output-readme README.md --output-json dist/docs.json", + "format": "vp fmt", + "format:check": "vp fmt --check", + "lint": "vp check", + "lint:fix": "vp check --fix", + "test": "vp test", + "verify": "vp build && vp test run && vp lint" + }, + "devDependencies": { + "@capacitor/core": "^7.0.0", + "@capacitor/docgen": "^0.2.2", + "@types/bun": "latest", + "@vitest/coverage-v8": "^4.1.2", + "happy-dom": "^20.0.8", + "typescript": "^5.9.3", + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plus": "0.1.15" + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "packageManager": "bun@1.3.11", + "capacitor": { + "ios": { + "src": "ios" + }, + "android": { + "src": "android" + } + } +} diff --git a/src/DebugNamespace.test.ts b/src/DebugNamespace.test.ts new file mode 100644 index 0000000..c0b5c01 --- /dev/null +++ b/src/DebugNamespace.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import Debug, { LogLevel } from './DebugNamespace'; + +describe('Debug', () => { + let mockPlugin: ReturnType; + let debug: Debug; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + debug = new Debug(mockPlugin); + }); + + test('should instantiate Debug class', () => { + expect(debug).toBeInstanceOf(Debug); + }); + + test.each([ + [LogLevel.None, 0], + [LogLevel.Fatal, 1], + [LogLevel.Error, 2], + [LogLevel.Warn, 3], + [LogLevel.Info, 4], + [LogLevel.Debug, 5], + [LogLevel.Verbose, 6], + ])('should call plugin for setLogLevel with %s', (logLevel, logLevelValue) => { + debug.setLogLevel(logLevel); + + expect(mockPlugin.setLogLevel).toHaveBeenCalledWith({ + logLevel: logLevelValue, + }); + }); + + test.each([ + [LogLevel.None, 0], + [LogLevel.Fatal, 1], + [LogLevel.Error, 2], + [LogLevel.Warn, 3], + [LogLevel.Info, 4], + [LogLevel.Debug, 5], + [LogLevel.Verbose, 6], + ])('should call plugin for setAlertLevel with %s', (logLevel, logLevelValue) => { + debug.setAlertLevel(logLevel); + + expect(mockPlugin.setAlertLevel).toHaveBeenCalledWith({ + logLevel: logLevelValue, + }); + }); +}); diff --git a/src/DebugNamespace.ts b/src/DebugNamespace.ts new file mode 100644 index 0000000..13714ca --- /dev/null +++ b/src/DebugNamespace.ts @@ -0,0 +1,39 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; + +export const LogLevel = { + None: 0, + Fatal: 1, + Error: 2, + Warn: 3, + Info: 4, + Debug: 5, + Verbose: 6, +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +export default class Debug { + private _plugin: OneSignalCapacitorPlugin; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + /** + * Enable logging to help debug if you run into an issue setting up OneSignal. + * @param {LogLevel} logLevel - Sets the logging level to print to the Android LogCat log or Xcode log. + * @returns void + */ + setLogLevel(logLevel: LogLevel): void { + void this._plugin.setLogLevel({ logLevel }); + } + + /** + * Enable logging to help debug if you run into an issue setting up OneSignal. + * @param {LogLevel} visualLogLevel - Sets the logging level to show as alert dialogs. + * @returns void + */ + setAlertLevel(visualLogLevel: LogLevel): void { + void this._plugin.setAlertLevel({ logLevel: visualLogLevel }); + } +} diff --git a/src/InAppMessagesNamespace.test.ts b/src/InAppMessagesNamespace.test.ts new file mode 100644 index 0000000..9ac2bb5 --- /dev/null +++ b/src/InAppMessagesNamespace.test.ts @@ -0,0 +1,200 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import * as helpers from './helpers'; +import InAppMessages from './InAppMessagesNamespace'; +import type { + InAppMessageClickEvent, + InAppMessageDidDismissEvent, + InAppMessageDidDisplayEvent, + InAppMessageWillDisplayEvent, +} from './types/InAppMessage'; + +describe('InAppMessages', () => { + let mockPlugin: ReturnType; + let inAppMessages: InAppMessages; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + inAppMessages = new InAppMessages(mockPlugin); + }); + + test('should instantiate InAppMessages class', () => { + expect(inAppMessages).toBeInstanceOf(InAppMessages); + }); + + describe('addEventListener', () => { + const messageData = { + click: { + message: { messageId: 'test' }, + result: { closingMessage: true, actionId: 'test' }, + } satisfies InAppMessageClickEvent, + willDisplay: { + message: { messageId: 'test' }, + } satisfies InAppMessageWillDisplayEvent, + didDisplay: { + message: { messageId: 'test' }, + } satisfies InAppMessageDidDisplayEvent, + willDismiss: { + message: { messageId: 'test' }, + } satisfies InAppMessageDidDismissEvent, + didDismiss: { + message: { messageId: 'test' }, + } satisfies InAppMessageDidDismissEvent, + }; + + test.each([ + ['click', 'inAppMessageClick', messageData.click], + ['willDisplay', 'inAppMessageWillDisplay', messageData.willDisplay], + ['didDisplay', 'inAppMessageDidDisplay', messageData.didDisplay], + ['willDismiss', 'inAppMessageWillDismiss', messageData.willDismiss], + ['didDismiss', 'inAppMessageDidDismiss', messageData.didDismiss], + ] as const)('should call addListener for %s event', (eventType, listenerName, data) => { + const mockListener = vi.fn(); + inAppMessages.addEventListener(eventType, mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith(listenerName, expect.any(Function)); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(data); + expect(mockListener).toHaveBeenCalledWith(data); + }); + + test('should not add listener for unknown event type', () => { + const mockListener = vi.fn(); + // @ts-expect-error - testing unknown event type + inAppMessages.addEventListener('unknown', mockListener); + + expect(mockPlugin.addListener).not.toHaveBeenCalled(); + }); + }); + + describe('removeEventListener', () => { + test.each([ + ['click'], + ['willDisplay'], + ['didDisplay'], + ['willDismiss'], + ['didDismiss'], + ] as const)('should remove %s event listener', (eventType) => { + const mockListener = vi.fn(); + inAppMessages.addEventListener(eventType, mockListener); + inAppMessages.removeEventListener(eventType, mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledTimes(1); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback('some-data'); + expect(mockListener).not.toHaveBeenCalled(); + }); + + test('should not remove listener for unknown event type', () => { + vi.spyOn(helpers, 'removeListener').mockImplementation(() => {}); + const mockListener = vi.fn(); + + // @ts-expect-error - testing unknown event type + inAppMessages.removeEventListener('unknown', mockListener); + expect(helpers.removeListener).not.toHaveBeenCalled(); + }); + }); + + describe('addTrigger', () => { + test('should call plugin for addTrigger', async () => { + await inAppMessages.addTrigger('key', 'value'); + + expect(mockPlugin.addTriggers).toHaveBeenCalledWith({ + triggers: { key: 'value' }, + }); + }); + }); + + describe('addTriggers', () => { + test('should call plugin for addTriggers with string values', async () => { + const triggers = { key1: 'value1', key2: 'value2' }; + await inAppMessages.addTriggers(triggers); + + expect(mockPlugin.addTriggers).toHaveBeenCalledWith({ triggers }); + }); + + test('should convert non-string values to JSON strings', async () => { + const triggers = { key1: 'value1', key2: 123, key3: true }; + + // @ts-expect-error - testing non-string values + await inAppMessages.addTriggers(triggers); + + expect(mockPlugin.addTriggers).toHaveBeenCalledWith({ + triggers: { key1: 'value1', key2: '123', key3: 'true' }, + }); + }); + }); + + describe('removeTrigger', () => { + test('should call plugin for removeTrigger', async () => { + await inAppMessages.removeTrigger('key'); + + expect(mockPlugin.removeTriggers).toHaveBeenCalledWith({ + keys: ['key'], + }); + }); + }); + + describe('removeTriggers', () => { + test('should call plugin for removeTriggers with valid array', async () => { + const keys = ['key1', 'key2']; + await inAppMessages.removeTriggers(keys); + + expect(mockPlugin.removeTriggers).toHaveBeenCalledWith({ keys }); + }); + + test('should handle non-array input gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await inAppMessages.removeTriggers('not-an-array' as any); + + expect(consoleSpy).toHaveBeenCalledWith( + 'OneSignal: removeTriggers: argument must be of type Array', + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('clearTriggers', () => { + test('should call plugin for clearTriggers', async () => { + await inAppMessages.clearTriggers(); + + expect(mockPlugin.clearTriggers).toHaveBeenCalled(); + }); + }); + + describe('setPaused', () => { + test.each([[true], [false]])('should call plugin for setPaused with %s', (pauseValue) => { + inAppMessages.setPaused(pauseValue); + + expect(mockPlugin.setPaused).toHaveBeenCalledWith({ + pause: pauseValue, + }); + }); + }); + + describe('getPaused', () => { + test('should return a Promise', () => { + const promise = inAppMessages.getPaused(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve Promise when plugin succeeds', async () => { + mockPlugin.isPaused.mockResolvedValue({ paused: true }); + + const result = await inAppMessages.getPaused(); + expect(result).toBe(true); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Test error'); + mockPlugin.isPaused.mockRejectedValue(mockError); + + await expect(inAppMessages.getPaused()).rejects.toThrow('Test error'); + }); + }); +}); diff --git a/src/InAppMessagesNamespace.ts b/src/InAppMessagesNamespace.ts new file mode 100644 index 0000000..485f60a --- /dev/null +++ b/src/InAppMessagesNamespace.ts @@ -0,0 +1,184 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import { removeListener } from './helpers'; +import type { + InAppMessageClickEvent, + InAppMessageDidDismissEvent, + InAppMessageDidDisplayEvent, + InAppMessageEventName, + InAppMessageEventTypeMap, + InAppMessageWillDismissEvent, + InAppMessageWillDisplayEvent, +} from './types/InAppMessage'; + +export default class InAppMessages { + private _plugin: OneSignalCapacitorPlugin; + private _inAppMessageClickListeners: ((action: InAppMessageClickEvent) => void)[] = []; + private _willDisplayInAppMessageListeners: ((event: InAppMessageWillDisplayEvent) => void)[] = []; + private _didDisplayInAppMessageListeners: ((event: InAppMessageDidDisplayEvent) => void)[] = []; + private _willDismissInAppMessageListeners: ((event: InAppMessageWillDismissEvent) => void)[] = []; + private _didDismissInAppMessageListeners: ((event: InAppMessageDidDismissEvent) => void)[] = []; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + private _processFunctionList(array: ((event: T) => void)[], param: T): void { + for (let i = 0; i < array.length; i++) { + array[i](param); + } + } + + /** + * Add event listeners for In-App Message click and/or lifecycle events. + * @param event + * @param listener + * @returns + */ + addEventListener( + event: K, + listener: (event: InAppMessageEventTypeMap[K]) => void, + ): void { + if (event === 'click') { + this._inAppMessageClickListeners.push(listener as (event: InAppMessageClickEvent) => void); + void this._plugin.addListener('inAppMessageClick', (json: InAppMessageClickEvent) => { + this._processFunctionList(this._inAppMessageClickListeners, json); + }); + } else if (event === 'willDisplay') { + this._willDisplayInAppMessageListeners.push( + listener as (event: InAppMessageWillDisplayEvent) => void, + ); + void this._plugin.addListener( + 'inAppMessageWillDisplay', + (event: InAppMessageWillDisplayEvent) => { + this._processFunctionList(this._willDisplayInAppMessageListeners, event); + }, + ); + } else if (event === 'didDisplay') { + this._didDisplayInAppMessageListeners.push( + listener as (event: InAppMessageDidDisplayEvent) => void, + ); + void this._plugin.addListener( + 'inAppMessageDidDisplay', + (event: InAppMessageDidDisplayEvent) => { + this._processFunctionList(this._didDisplayInAppMessageListeners, event); + }, + ); + } else if (event === 'willDismiss') { + this._willDismissInAppMessageListeners.push( + listener as (event: InAppMessageWillDismissEvent) => void, + ); + void this._plugin.addListener( + 'inAppMessageWillDismiss', + (event: InAppMessageWillDismissEvent) => { + this._processFunctionList(this._willDismissInAppMessageListeners, event); + }, + ); + } else if (event === 'didDismiss') { + this._didDismissInAppMessageListeners.push( + listener as (event: InAppMessageDidDismissEvent) => void, + ); + void this._plugin.addListener( + 'inAppMessageDidDismiss', + (event: InAppMessageDidDismissEvent) => { + this._processFunctionList(this._didDismissInAppMessageListeners, event); + }, + ); + } + } + + /** + * Remove event listeners for In-App Message click and/or lifecycle events. + * @param event + * @param listener + * @returns + */ + removeEventListener( + event: K, + listener: (obj: InAppMessageEventTypeMap[K]) => void, + ): void { + if (event === 'click') { + removeListener(this._inAppMessageClickListeners, listener); + } else if (event === 'willDisplay') { + removeListener(this._willDisplayInAppMessageListeners, listener); + } else if (event === 'didDisplay') { + removeListener(this._didDisplayInAppMessageListeners, listener); + } else if (event === 'willDismiss') { + removeListener(this._willDismissInAppMessageListeners, listener); + } else if (event === 'didDismiss') { + removeListener(this._didDismissInAppMessageListeners, listener); + } + } + + /** + * Add a trigger for the current user. Triggers are currently explicitly used to determine whether a specific IAM should be displayed to the user. + * @param {string} key + * @param {string} value + * @returns Promise + */ + addTrigger(key: string, value: string): Promise { + return this.addTriggers({ [key]: value }); + } + + /** + * Add multiple triggers for the current user. + * @param {[key: string]: string} triggers + * @returns Promise + */ + addTriggers(triggers: { [key: string]: string }): Promise { + Object.keys(triggers).forEach(function (key) { + if (typeof triggers[key] !== 'string') { + triggers[key] = JSON.stringify(triggers[key]); + } + }); + + return this._plugin.addTriggers({ triggers }); + } + + /** + * Remove the trigger with the provided key from the current user. + * @param {string} key + * @returns Promise + */ + removeTrigger(key: string): Promise { + return this.removeTriggers([key]); + } + + /** + * Remove multiple triggers from the current user. + * @param {string[]} keys + * @returns Promise + */ + removeTriggers(keys: string[]): Promise { + if (!Array.isArray(keys)) { + console.error('OneSignal: removeTriggers: argument must be of type Array'); + } + + return this._plugin.removeTriggers({ keys }); + } + + /** + * Clear all triggers from the current user. + * @returns Promise + */ + clearTriggers(): Promise { + return this._plugin.clearTriggers(); + } + + /** + * Set whether in-app messaging is currently paused. + * @param {boolean} pause + * @returns void + */ + setPaused(pause: boolean): void { + void this._plugin.setPaused({ pause }); + } + + /** + * Whether in-app messaging is currently paused. + * @returns {Promise} + */ + async getPaused(): Promise { + const result = await this._plugin.isPaused(); + return result.paused; + } +} diff --git a/src/LiveActivitiesNamespace.test.ts b/src/LiveActivitiesNamespace.test.ts new file mode 100644 index 0000000..9f4cfd6 --- /dev/null +++ b/src/LiveActivitiesNamespace.test.ts @@ -0,0 +1,143 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { SUB_TOKEN } from '../mocks/constants'; +import LiveActivities from './LiveActivitiesNamespace'; +import type { LiveActivitySetupOptions } from './types/LiveActivities'; + +const ACTIVITY_ID = 'test-activity-id'; +const ACTIVITY_TYPE = 'test-activity-type'; + +describe('LiveActivities', () => { + let mockPlugin: ReturnType; + let liveActivities: LiveActivities; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + liveActivities = new LiveActivities(mockPlugin); + }); + + test('should instantiate LiveActivities class', () => { + expect(liveActivities).toBeInstanceOf(LiveActivities); + }); + + describe('enter', () => { + test('should call plugin for enter with required parameters', () => { + liveActivities.enter(ACTIVITY_ID, SUB_TOKEN); + + expect(mockPlugin.enterLiveActivity).toHaveBeenCalledWith({ + activityId: ACTIVITY_ID, + token: SUB_TOKEN, + }); + }); + + test('should call onSuccess callback', async () => { + const onSuccess = vi.fn(); + mockPlugin.enterLiveActivity.mockResolvedValue({ success: true }); + + liveActivities.enter(ACTIVITY_ID, SUB_TOKEN, onSuccess); + + await vi.waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + test('should call onFailure callback on error', async () => { + const onFailure = vi.fn(); + mockPlugin.enterLiveActivity.mockRejectedValue(new Error('Enter failed')); + + liveActivities.enter(ACTIVITY_ID, SUB_TOKEN, undefined, onFailure); + + await vi.waitFor(() => { + expect(onFailure).toHaveBeenCalled(); + }); + }); + }); + + describe('exit', () => { + test('should call plugin for exit with required parameters', () => { + liveActivities.exit(ACTIVITY_ID); + + expect(mockPlugin.exitLiveActivity).toHaveBeenCalledWith({ + activityId: ACTIVITY_ID, + }); + }); + + test('should call onSuccess callback', async () => { + const onSuccess = vi.fn(); + mockPlugin.exitLiveActivity.mockResolvedValue({ success: true }); + + liveActivities.exit(ACTIVITY_ID, onSuccess); + + await vi.waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + test('should call onFailure callback on error', async () => { + const onFailure = vi.fn(); + mockPlugin.exitLiveActivity.mockRejectedValue(new Error('Exit failed')); + + liveActivities.exit(ACTIVITY_ID, undefined, onFailure); + + await vi.waitFor(() => { + expect(onFailure).toHaveBeenCalled(); + }); + }); + }); + + describe('setPushToStartToken', () => { + test('should call plugin for setPushToStartToken', async () => { + await liveActivities.setPushToStartToken(ACTIVITY_TYPE, SUB_TOKEN); + + expect(mockPlugin.setPushToStartToken).toHaveBeenCalledWith({ + activityType: ACTIVITY_TYPE, + token: SUB_TOKEN, + }); + }); + }); + + describe('removePushToStartToken', () => { + test('should call plugin for removePushToStartToken', async () => { + await liveActivities.removePushToStartToken(ACTIVITY_TYPE); + + expect(mockPlugin.removePushToStartToken).toHaveBeenCalledWith({ + activityType: ACTIVITY_TYPE, + }); + }); + }); + + describe('setupDefault', () => { + test('should call plugin for setupDefault without options', async () => { + await liveActivities.setupDefault(); + + expect(mockPlugin.setupDefaultLiveActivity).toHaveBeenCalledWith(undefined); + }); + + test('should call plugin for setupDefault with options', async () => { + const options: LiveActivitySetupOptions = { + enablePushToStart: true, + enablePushToUpdate: false, + }; + + await liveActivities.setupDefault(options); + + expect(mockPlugin.setupDefaultLiveActivity).toHaveBeenCalledWith(options); + }); + }); + + describe('startDefault', () => { + test('should call plugin for startDefault', async () => { + const attributes = { key1: 'value1', key2: 'value2' }; + const content = { title: 'Test Title', message: 'Test Message' }; + + await liveActivities.startDefault(ACTIVITY_ID, attributes, content); + + expect(mockPlugin.startDefaultLiveActivity).toHaveBeenCalledWith({ + activityId: ACTIVITY_ID, + attributes, + content, + }); + }); + }); +}); diff --git a/src/LiveActivitiesNamespace.ts b/src/LiveActivitiesNamespace.ts new file mode 100644 index 0000000..82a6acc --- /dev/null +++ b/src/LiveActivitiesNamespace.ts @@ -0,0 +1,103 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import type { LiveActivitySetupOptions } from './types/LiveActivities'; + +export default class LiveActivities { + private _plugin: OneSignalCapacitorPlugin; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + /** + * Enter a live activity + * @param {string} activityId + * @param {string} token + * @param {Function} onSuccess + * @param {Function} onFailure + * @returns void + */ + enter( + activityId: string, + token: string, + onSuccess?: (data: unknown) => void, + onFailure?: (data: unknown) => void, + ): void { + this._plugin + .enterLiveActivity({ activityId, token }) + .then((result) => { + onSuccess?.(result); + }) + .catch((error: unknown) => { + onFailure?.(error); + }); + } + + /** + * Exit a live activity + * @param {string} activityId + * @param {Function} onSuccess + * @param {Function} onFailure + * @returns void + */ + exit( + activityId: string, + onSuccess?: (data: unknown) => void, + onFailure?: (data: unknown) => void, + ): void { + this._plugin + .exitLiveActivity({ activityId }) + .then((result) => { + onSuccess?.(result); + }) + .catch((error: unknown) => { + onFailure?.(error); + }); + } + + /** + * Indicate this device is capable of receiving pushToStart live activities for the + * `activityType`. Only applies to iOS. + * @param {string} activityType + * @param {string} token + */ + setPushToStartToken(activityType: string, token: string): Promise { + return this._plugin.setPushToStartToken({ activityType, token }); + } + + /** + * Indicate this device is no longer capable of receiving pushToStart live activities + * for the `activityType`. Only applies to iOS. + * @param {string} activityType + */ + removePushToStartToken(activityType: string): Promise { + return this._plugin.removePushToStartToken({ activityType }); + } + + /** + * Enable the OneSignalSDK to setup the default `DefaultLiveActivityAttributes` structure. + * Only applies to iOS. + * @param {LiveActivitySetupOptions} options + */ + setupDefault(options?: LiveActivitySetupOptions): Promise { + return this._plugin.setupDefaultLiveActivity(options); + } + + /** + * Start a new LiveActivity that is modelled by the default `DefaultLiveActivityAttributes` + * structure. Only applies to iOS. + * @param {string} activityId + * @param {object} attributes + * @param {object} content + */ + startDefault( + activityId: string, + attributes: Record, + content: Record, + ): Promise { + return this._plugin.startDefaultLiveActivity({ + activityId, + attributes, + content, + }); + } +} diff --git a/src/LocationNamespace.test.ts b/src/LocationNamespace.test.ts new file mode 100644 index 0000000..832cbe9 --- /dev/null +++ b/src/LocationNamespace.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import Location from './LocationNamespace'; + +describe('Location', () => { + let mockPlugin: ReturnType; + let location: Location; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + location = new Location(mockPlugin); + }); + + test('should instantiate Location class', () => { + expect(location).toBeInstanceOf(Location); + }); + + describe('requestPermission', () => { + test('should call plugin for requestPermission', async () => { + await location.requestPermission(); + + expect(mockPlugin.requestLocationPermission).toHaveBeenCalled(); + }); + }); + + describe('setShared', () => { + test.each([[true], [false]])('should call plugin for setShared with %s', (sharedValue) => { + location.setShared(sharedValue); + + expect(mockPlugin.setLocationShared).toHaveBeenCalledWith({ + shared: sharedValue, + }); + }); + }); + + describe('isShared', () => { + test('should return a Promise', () => { + const promise = location.isShared(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve Promise when plugin succeeds', async () => { + mockPlugin.isLocationShared.mockResolvedValue({ shared: true }); + + const result = await location.isShared(); + expect(result).toBe(true); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Location permission denied'); + mockPlugin.isLocationShared.mockRejectedValue(mockError); + + await expect(location.isShared()).rejects.toThrow(mockError.message); + }); + }); +}); diff --git a/src/LocationNamespace.ts b/src/LocationNamespace.ts new file mode 100644 index 0000000..542d519 --- /dev/null +++ b/src/LocationNamespace.ts @@ -0,0 +1,35 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; + +export default class Location { + private _plugin: OneSignalCapacitorPlugin; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + /** + * Prompts the user for location permissions to allow geotagging from the OneSignal dashboard. + * @returns Promise + */ + requestPermission(): Promise { + return this._plugin.requestLocationPermission(); + } + + /** + * Disable or enable location collection (defaults to enabled if your app has location permission). + * @param {boolean} shared + * @returns void + */ + setShared(shared: boolean): void { + void this._plugin.setLocationShared({ shared }); + } + + /** + * Whether location is currently shared with OneSignal. + * @returns {Promise} + */ + async isShared(): Promise { + const result = await this._plugin.isLocationShared(); + return result.shared; + } +} diff --git a/src/NotificationReceivedEvent.test.ts b/src/NotificationReceivedEvent.test.ts new file mode 100644 index 0000000..b1dcfca --- /dev/null +++ b/src/NotificationReceivedEvent.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { mockNotification } from '../mocks/data'; +import { + NotificationWillDisplayEvent, + _setNotificationEventPlugin, +} from './NotificationReceivedEvent'; +import { OSNotification } from './OSNotification'; + +describe('NotificationWillDisplayEvent', () => { + let notificationData = mockNotification(); + let notificationEvent: NotificationWillDisplayEvent; + + beforeEach(() => { + const mockPlugin = createMockPlugin(); + _setNotificationEventPlugin(mockPlugin); + notificationEvent = new NotificationWillDisplayEvent(notificationData); + }); + + test('should instantiate NotificationWillDisplayEvent class', () => { + expect(notificationEvent).toBeInstanceOf(NotificationWillDisplayEvent); + }); + + test('should create OSNotification instance in constructor', () => { + const notification = notificationEvent.getNotification(); + + expect(notification).toBeInstanceOf(OSNotification); + expect(notification.notificationId).toBe(notificationData.notificationId); + expect(notification.body).toBe(notificationData.body); + expect(notification.title).toBe(notificationData.title); + }); + + describe('preventDefault', () => { + test('should call plugin for preventDefault with default (false)', () => { + const mockPlugin = createMockPlugin(); + _setNotificationEventPlugin(mockPlugin); + const event = new NotificationWillDisplayEvent(notificationData); + + event.preventDefault(); + + expect(mockPlugin.preventDefault).toHaveBeenCalledWith({ + notificationId: notificationData.notificationId, + discard: false, + }); + }); + + test('should call plugin for preventDefault with discard true', () => { + const mockPlugin = createMockPlugin(); + _setNotificationEventPlugin(mockPlugin); + const event = new NotificationWillDisplayEvent(notificationData); + + event.preventDefault(true); + + expect(mockPlugin.preventDefault).toHaveBeenCalledWith({ + notificationId: notificationData.notificationId, + discard: true, + }); + }); + }); + + describe('getNotification', () => { + test('should return the OSNotification instance', () => { + const notification1 = notificationEvent.getNotification(); + const notification2 = notificationEvent.getNotification(); + + expect(notification1).toBeInstanceOf(OSNotification); + expect(notification1.notificationId).toBe(notificationData.notificationId); + + expect(notification1).toBe(notification2); + }); + }); +}); diff --git a/src/NotificationReceivedEvent.ts b/src/NotificationReceivedEvent.ts new file mode 100644 index 0000000..525cdb7 --- /dev/null +++ b/src/NotificationReceivedEvent.ts @@ -0,0 +1,37 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import { OSNotification } from './OSNotification'; + +let _pluginRef: OneSignalCapacitorPlugin | undefined; + +export function _setNotificationEventPlugin(plugin: OneSignalCapacitorPlugin): void { + _pluginRef = plugin; +} + +export class NotificationWillDisplayEvent { + private notification: OSNotification; + + constructor(displayEvent: OSNotification) { + this.notification = new OSNotification(displayEvent); + } + + /** + * Call this to prevent OneSignal from displaying the notification automatically. + * This method can be called up to two times with false and then true, if processing time is needed. + * Typically this is only possible within a short + * time-frame (~30 seconds) after the notification is received on the device. + * @param discard an [preventDefault] set to true to dismiss the notification with no + * possibility of displaying it in the future. + */ + preventDefault(discard: boolean = false): void { + if (_pluginRef) { + void _pluginRef.preventDefault({ + notificationId: this.notification.notificationId, + discard, + }); + } + } + + getNotification(): OSNotification { + return this.notification; + } +} diff --git a/src/NotificationsNamespace.test.ts b/src/NotificationsNamespace.test.ts new file mode 100644 index 0000000..392a3c0 --- /dev/null +++ b/src/NotificationsNamespace.test.ts @@ -0,0 +1,296 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { mockNotification, mockNotificationClickEvent } from '../mocks/data'; +import * as helpers from './helpers'; +import { NotificationWillDisplayEvent } from './NotificationReceivedEvent'; +import Notifications, { OSNotificationPermission } from './NotificationsNamespace'; + +describe('Notifications', () => { + let mockPlugin: ReturnType; + let notifications: Notifications; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + notifications = new Notifications(mockPlugin); + }); + + test('should instantiate Notifications class', () => { + expect(notifications).toBeInstanceOf(Notifications); + }); + + describe('getPermissionAsync', () => { + test('should return a Promise', () => { + mockPlugin.getPermission.mockResolvedValue({ permission: true }); + + const promise = notifications.getPermissionAsync(); + + expect(promise).toBeInstanceOf(Promise); + return promise; + }); + + test('should resolve with boolean value', async () => { + mockPlugin.getPermission.mockResolvedValue({ permission: true }); + + const result = await notifications.getPermissionAsync(); + + expect(result).toBe(true); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Permission check failed'); + mockPlugin.getPermission.mockRejectedValue(mockError); + + await expect(notifications.getPermissionAsync()).rejects.toThrow(mockError.message); + }); + }); + + describe('permissionNative', () => { + test('should return a Promise', () => { + mockPlugin.permissionNative.mockResolvedValue({ + permission: OSNotificationPermission.Authorized, + }); + + const promise = notifications.permissionNative(); + + expect(promise).toBeInstanceOf(Promise); + return promise; + }); + + test('should resolve with OSNotificationPermission value', async () => { + mockPlugin.permissionNative.mockResolvedValue({ + permission: OSNotificationPermission.Authorized, + }); + + const result = await notifications.permissionNative(); + + expect(result).toBe(OSNotificationPermission.Authorized); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Permission check failed'); + mockPlugin.permissionNative.mockRejectedValue(mockError); + + await expect(notifications.permissionNative()).rejects.toThrow(mockError.message); + }); + }); + + describe('requestPermission', () => { + test.each([[true], [false]])( + 'should call plugin for requestPermission with fallbackToSettings %s', + async (fallback) => { + mockPlugin.requestPermission.mockResolvedValue({ permission: true }); + + await notifications.requestPermission(fallback); + + expect(mockPlugin.requestPermission).toHaveBeenCalledWith({ + fallbackToSettings: fallback, + }); + }, + ); + + test('should use default fallbackToSettings of false when not provided', async () => { + mockPlugin.requestPermission.mockResolvedValue({ permission: true }); + + await notifications.requestPermission(); + + expect(mockPlugin.requestPermission).toHaveBeenCalledWith({ + fallbackToSettings: false, + }); + }); + }); + + describe('canRequestPermission', () => { + test('should return a Promise', () => { + mockPlugin.canRequestPermission.mockResolvedValue({ canRequest: true }); + + const promise = notifications.canRequestPermission(); + + expect(promise).toBeInstanceOf(Promise); + return promise; + }); + + test('should resolve with boolean value', async () => { + mockPlugin.canRequestPermission.mockResolvedValue({ canRequest: false }); + + const result = await notifications.canRequestPermission(); + + expect(result).toBe(false); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Permission check failed'); + mockPlugin.canRequestPermission.mockRejectedValue(mockError); + + await expect(notifications.canRequestPermission()).rejects.toThrow(mockError.message); + }); + }); + + describe('registerForProvisionalAuthorization', () => { + test('should call plugin', () => { + notifications.registerForProvisionalAuthorization(); + + expect(mockPlugin.registerForProvisionalAuthorization).toHaveBeenCalled(); + }); + + test('should call handler with result', async () => { + mockPlugin.registerForProvisionalAuthorization.mockResolvedValue({ + accepted: true, + }); + const handler = vi.fn(); + notifications.registerForProvisionalAuthorization(handler); + + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('addEventListener', () => { + test('should call addListener for click event', () => { + const mockListener = vi.fn(); + notifications.addEventListener('click', mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith( + 'notificationClick', + expect.any(Function), + ); + }); + + test('should call all click listeners when event fires', () => { + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); + notifications.addEventListener('click', mockListener1); + notifications.addEventListener('click', mockListener2); + + const clickData = mockNotificationClickEvent(); + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(clickData); + + expect(mockListener1).toHaveBeenCalledWith(clickData); + expect(mockListener2).toHaveBeenCalledWith(clickData); + }); + + test('should call addListener for foregroundWillDisplay event', () => { + const mockListener = vi.fn(); + notifications.addEventListener('foregroundWillDisplay', mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith( + 'notificationForegroundWillDisplay', + expect.any(Function), + ); + }); + + test('should call foregroundWillDisplay listeners and proceedWithWillDisplay', () => { + const mockListener = vi.fn(); + notifications.addEventListener('foregroundWillDisplay', mockListener); + + const notificationData = mockNotification(); + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(notificationData); + + expect(mockListener).toHaveBeenCalledWith(expect.any(NotificationWillDisplayEvent)); + expect(mockPlugin.proceedWithWillDisplay).toHaveBeenCalledWith({ + notificationId: notificationData.notificationId, + }); + }); + + test('should call addListener for permissionChange event', () => { + const mockListener = vi.fn(); + notifications.addEventListener('permissionChange', mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith('permissionChange', expect.any(Function)); + }); + + test('should call all permissionChange listeners when event fires', () => { + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); + notifications.addEventListener('permissionChange', mockListener1); + notifications.addEventListener('permissionChange', mockListener2); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback({ permission: true }); + + expect(mockListener1).toHaveBeenCalledWith(true); + expect(mockListener2).toHaveBeenCalledWith(true); + }); + + test('should not add listener for unknown event type', () => { + const mockListener = vi.fn(); + // @ts-expect-error - testing unknown event type + notifications.addEventListener('unknown', mockListener); + + expect(mockPlugin.addListener).not.toHaveBeenCalled(); + }); + }); + + describe('removeEventListener', () => { + test.each([['click'], ['foregroundWillDisplay'], ['permissionChange']] as const)( + 'should remove %s event listener', + (eventType) => { + const mockListener = vi.fn(); + notifications.addEventListener(eventType, mockListener); + notifications.removeEventListener(eventType, mockListener); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback('some-data'); + expect(mockListener).not.toHaveBeenCalled(); + }, + ); + + test('should not remove listener for unknown event type', () => { + vi.spyOn(helpers, 'removeListener').mockImplementation(() => {}); + const mockListener = vi.fn(); + // @ts-expect-error - testing unknown event type + notifications.removeEventListener('unknown', mockListener); + + expect(helpers.removeListener).not.toHaveBeenCalled(); + }); + }); + + describe('clearAll', () => { + test('should call plugin for clearAll', async () => { + await notifications.clearAll(); + + expect(mockPlugin.clearAllNotifications).toHaveBeenCalled(); + }); + }); + + describe('removeNotification', () => { + test('should call plugin for removeNotification', async () => { + const notificationId = 123; + await notifications.removeNotification(notificationId); + + expect(mockPlugin.removeNotification).toHaveBeenCalledWith({ + id: notificationId, + }); + }); + }); + + describe('removeGroupedNotifications', () => { + test('should call plugin for removeGroupedNotifications', async () => { + const groupId = 'test-group-id'; + await notifications.removeGroupedNotifications(groupId); + + expect(mockPlugin.removeGroupedNotifications).toHaveBeenCalledWith({ + id: groupId, + }); + }); + }); + + describe('hasPermission (deprecated)', () => { + test('should return false when _permission is undefined', () => { + const result = notifications.hasPermission(); + expect(result).toBe(false); + }); + + test('should return true when permission is set via getPermission', async () => { + mockPlugin.getPermission.mockResolvedValue({ permission: true }); + notifications._setPropertyAndObserver(); + + await vi.waitFor(() => { + expect(notifications.hasPermission()).toBe(true); + }); + }); + }); +}); diff --git a/src/NotificationsNamespace.ts b/src/NotificationsNamespace.ts new file mode 100644 index 0000000..591801b --- /dev/null +++ b/src/NotificationsNamespace.ts @@ -0,0 +1,207 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import { removeListener } from './helpers'; +import { NotificationWillDisplayEvent } from './NotificationReceivedEvent'; +import type { OSNotification } from './OSNotification'; +import type { + NotificationClickEvent, + NotificationEventName, + NotificationEventTypeMap, +} from './types/NotificationClicked'; + +export const OSNotificationPermission = { + NotDetermined: 0, + Denied: 1, + Authorized: 2, + Provisional: 3, + Ephemeral: 4, +} as const; + +export type OSNotificationPermission = + (typeof OSNotificationPermission)[keyof typeof OSNotificationPermission]; + +export default class Notifications { + private _plugin: OneSignalCapacitorPlugin; + private _permissionObserverList: ((event: boolean) => void)[] = []; + private _notificationClickedListeners: ((event: NotificationClickEvent) => void)[] = []; + private _notificationWillDisplayListeners: ((event: NotificationWillDisplayEvent) => void)[] = []; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + private _processFunctionList(array: ((event: T) => void)[], param: T): void { + for (let i = 0; i < array.length; i++) { + array[i](param); + } + } + + private _permission?: boolean; + + /** + * Sets initial permission value and adds observer for changes. + * This internal method is kept to support the deprecated method {@link hasPermission}. + */ + _setPropertyAndObserver(): void { + void this._plugin.getPermission().then((result) => { + this._permission = result.permission; + }); + + this.addEventListener('permissionChange', (result) => { + this._permission = result; + }); + } + + /** + * @deprecated This method is deprecated. It has been replaced by {@link getPermissionAsync}. + */ + hasPermission(): boolean { + return this._permission || false; + } + + /** + * Whether this app has push notification permission. Returns true if the user has accepted permissions, + * or if the app has ephemeral or provisional permission. + */ + async getPermissionAsync(): Promise { + const result = await this._plugin.getPermission(); + return result.permission; + } + + /** + * iOS Only. + * Returns the native permission of the device. + * @returns {Promise} + */ + async permissionNative(): Promise { + const result = await this._plugin.permissionNative(); + return result.permission; + } + + /** + * Prompt the user for permission to receive push notifications. + * Use the fallbackToSettings parameter to prompt to open the settings app if a user has already declined push permissions. + * @param {boolean} fallbackToSettings + * @returns {Promise} + */ + async requestPermission(fallbackToSettings?: boolean): Promise { + const fallback = fallbackToSettings ?? false; + const result = await this._plugin.requestPermission({ + fallbackToSettings: fallback, + }); + return result.permission; + } + + /** + * Whether attempting to request notification permission will show a prompt. + * Returns true if the device has not been prompted for push notification permission already. + * @returns {Promise} + */ + async canRequestPermission(): Promise { + const result = await this._plugin.canRequestPermission(); + return result.canRequest; + } + + /** + * iOS Only. + * Instead of having to prompt the user for permission to send them push notifications, + * your app can request provisional authorization. + * @param {(response: boolean)=>void} handler + * @returns void + */ + registerForProvisionalAuthorization(handler?: (response: boolean) => void): void { + void this._plugin.registerForProvisionalAuthorization().then((result) => { + handler?.(result.accepted); + }); + } + + /** + * Add listeners for notification events. + * @param event + * @param listener + * @returns + */ + addEventListener( + event: K, + listener: (event: NotificationEventTypeMap[K]) => void, + ): void { + if (event === 'click') { + this._notificationClickedListeners.push(listener as (event: NotificationClickEvent) => void); + void this._plugin.addListener('notificationClick', (json: NotificationClickEvent) => { + this._processFunctionList(this._notificationClickedListeners, json); + }); + } else if (event === 'foregroundWillDisplay') { + this._notificationWillDisplayListeners.push( + listener as (event: NotificationWillDisplayEvent) => void, + ); + void this._plugin.addListener( + 'notificationForegroundWillDisplay', + (notification: OSNotification) => { + this._notificationWillDisplayListeners.forEach((listener) => { + listener(new NotificationWillDisplayEvent(notification)); + }); + void this._plugin.proceedWithWillDisplay({ + notificationId: notification.notificationId, + }); + }, + ); + } else if (event === 'permissionChange') { + this._permissionObserverList.push(listener as (event: boolean) => void); + void this._plugin.addListener('permissionChange', (state: { permission: boolean }) => { + this._processFunctionList(this._permissionObserverList, state.permission); + }); + } + } + + /** + * Remove listeners for notification events. + * @param event + * @param listener + * @returns + */ + removeEventListener( + event: K, + listener: (obj: NotificationEventTypeMap[K]) => void, + ): void { + if (event === 'click') { + removeListener( + this._notificationClickedListeners, + listener as (event: NotificationClickEvent) => void, + ); + } else if (event === 'foregroundWillDisplay') { + removeListener( + this._notificationWillDisplayListeners, + listener as (event: NotificationWillDisplayEvent) => void, + ); + } else if (event === 'permissionChange') { + removeListener(this._permissionObserverList, listener as (event: boolean) => void); + } + } + + /** + * Removes all OneSignal notifications. + * @returns Promise + */ + clearAll(): Promise { + return this._plugin.clearAllNotifications(); + } + + /** + * Android only. + * Cancels a single OneSignal notification based on its Android notification integer ID. + * @param {number} id - notification id to cancel + * @returns Promise + */ + removeNotification(id: number): Promise { + return this._plugin.removeNotification({ id }); + } + + /** + * Android only. + * Cancels a group of OneSignal notifications with the provided group key. + * @param {string} id - notification group id to cancel + * @returns Promise + */ + removeGroupedNotifications(id: string): Promise { + return this._plugin.removeGroupedNotifications({ id }); + } +} diff --git a/src/OSNotification.test.ts b/src/OSNotification.test.ts new file mode 100644 index 0000000..dc8e5c0 --- /dev/null +++ b/src/OSNotification.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { OSNotification, _setOSNotificationPlugin, type ReceivedEvent } from './OSNotification'; + +type AllProperties = Required>; + +describe('OSNotification', () => { + beforeEach(() => { + const mockPlugin = createMockPlugin(); + _setOSNotificationPlugin(mockPlugin); + }); + + describe('constructor', () => { + const props = { + body: 'Some body', + sound: 'Some sound', + title: 'Some title', + launchURL: 'Some launch URL', + rawPayload: '{"test": "data"}', + actionButtons: [{ id: 'Some button', text: 'Some text' }], + additionalData: { custom: 'value' }, + notificationId: 'test-123', + // android only + groupKey: 'test-group', + groupMessage: 'Some group message', + groupedNotifications: [ + { id: '1', body: 'Some body' }, + { id: '2', body: 'Some body' }, + ], + ledColor: 'FFFF0000', + priority: 0, + smallIcon: 'ic_stat_onesignal_default', + largeIcon: 'ic_large_icon', + bigPicture: 'https://example.com/image.png', + collapseId: 'collapse-123', + fromProjectNumber: '123456789', + smallIconAccentColor: 'FF0000FF', + lockScreenVisibility: '1', + androidNotificationId: 987654321, + // ios only + badge: '5', + badgeIncrement: '1', + category: 'MESSAGE_CATEGORY', + threadId: 'thread-123', + subtitle: 'Some subtitle', + templateId: 'template-456', + templateName: 'Welcome Template', + attachments: { id: 'image.jpg', url: 'https://example.com/image.jpg' }, + mutableContent: true, + contentAvailable: '1', + relevanceScore: 0.75, + interruptionLevel: 'time-sensitive', + } satisfies AllProperties; + + describe('rawPayload', () => { + test('should parse rawPayload when it is a string', () => { + const notification = new OSNotification({ + ...props, + rawPayload: JSON.stringify({ key: 'value', nested: { data: true } }), + }); + + expect(notification.rawPayload).toEqual({ + key: 'value', + nested: { data: true }, + }); + }); + + test('should keep rawPayload as-is when it is already an object', () => { + const payloadObj = { key: 'value', nested: { data: true } }; + const notification = new OSNotification({ + ...props, + rawPayload: payloadObj, + }); + + expect(notification.rawPayload).toEqual(payloadObj); + }); + }); + + test('should instantiate OSNotification with required properties', () => { + const notification = new OSNotification({ + notificationId: props.notificationId, + body: props.body, + rawPayload: props.rawPayload, + additionalData: props.additionalData, + }); + + expect(notification).toBeInstanceOf(OSNotification); + expect(notification.notificationId).toBe(props.notificationId); + expect(notification.body).toBe(props.body); + expect(notification.additionalData).toEqual(props.additionalData); + expect(notification.rawPayload).toEqual(JSON.parse(props.rawPayload)); + }); + + test('should handle all properties', () => { + const notification = new OSNotification(props); + + const { rawPayload, ...rest } = props; + + for (const [key, value] of Object.entries(rest)) { + expect(notification[key as keyof OSNotification]).toBe(value); + } + + expect(notification.rawPayload).toEqual(JSON.parse(rawPayload)); + }); + + describe('display', () => { + test('should call plugin with correct parameters', () => { + const mockPlugin = createMockPlugin(); + _setOSNotificationPlugin(mockPlugin); + const notification = new OSNotification(props); + notification.display(); + + expect(mockPlugin.displayNotification).toHaveBeenCalledWith({ + notificationId: props.notificationId, + }); + }); + }); + }); +}); diff --git a/src/OSNotification.ts b/src/OSNotification.ts new file mode 100644 index 0000000..00bf046 --- /dev/null +++ b/src/OSNotification.ts @@ -0,0 +1,160 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; + +export type ReceivedEvent = Omit & { + rawPayload: string | object; +}; + +let _pluginRef: OneSignalCapacitorPlugin | undefined; + +export function _setOSNotificationPlugin(plugin: OneSignalCapacitorPlugin): void { + _pluginRef = plugin; +} + +export class OSNotification { + body: string; + sound?: string; + title?: string; + launchURL?: string; + rawPayload: object; + actionButtons?: object[]; + additionalData: object; + notificationId: string; + // android only + groupKey?: string; + groupMessage?: string; + groupedNotifications?: object[]; + ledColor?: string; + priority?: number; + smallIcon?: string; + largeIcon?: string; + bigPicture?: string; + collapseId?: string; + fromProjectNumber?: string; + smallIconAccentColor?: string; + lockScreenVisibility?: string; + androidNotificationId?: number; + // ios only + badge?: string; + badgeIncrement?: string; + category?: string; + threadId?: string; + subtitle?: string; + templateId?: string; + templateName?: string; + attachments?: object; + mutableContent?: boolean; + contentAvailable?: string; + relevanceScore?: number; + interruptionLevel?: string; + + constructor(receivedEvent: ReceivedEvent) { + this.notificationId = receivedEvent.notificationId; + this.body = receivedEvent.body; + this.title = receivedEvent.title; + this.additionalData = receivedEvent.additionalData; + + if (typeof receivedEvent.rawPayload === 'string') { + this.rawPayload = JSON.parse(receivedEvent.rawPayload); + } else { + this.rawPayload = receivedEvent.rawPayload; + } + + this.launchURL = receivedEvent.launchURL; + this.sound = receivedEvent.sound; + + if (receivedEvent.actionButtons) { + this.actionButtons = receivedEvent.actionButtons; + } + + // Android + if (receivedEvent.groupKey) { + this.groupKey = receivedEvent.groupKey; + } + if (receivedEvent.ledColor) { + this.ledColor = receivedEvent.ledColor; + } + if (typeof receivedEvent.priority !== 'undefined') { + this.priority = receivedEvent.priority; + } + if (receivedEvent.smallIcon) { + this.smallIcon = receivedEvent.smallIcon; + } + if (receivedEvent.largeIcon) { + this.largeIcon = receivedEvent.largeIcon; + } + if (receivedEvent.bigPicture) { + this.bigPicture = receivedEvent.bigPicture; + } + if (receivedEvent.collapseId) { + this.collapseId = receivedEvent.collapseId; + } + if (receivedEvent.groupMessage) { + this.groupMessage = receivedEvent.groupMessage; + } + if (receivedEvent.fromProjectNumber) { + this.fromProjectNumber = receivedEvent.fromProjectNumber; + } + if (receivedEvent.smallIconAccentColor) { + this.smallIconAccentColor = receivedEvent.smallIconAccentColor; + } + if (receivedEvent.lockScreenVisibility) { + this.lockScreenVisibility = receivedEvent.lockScreenVisibility; + } + if (receivedEvent.androidNotificationId) { + this.androidNotificationId = receivedEvent.androidNotificationId; + } + if (receivedEvent.groupedNotifications && receivedEvent.groupedNotifications.length) { + this.groupedNotifications = receivedEvent.groupedNotifications; + } + + // iOS + if (receivedEvent.badge) { + this.badge = receivedEvent.badge; + } + if (receivedEvent.category) { + this.category = receivedEvent.category; + } + if (receivedEvent.threadId) { + this.threadId = receivedEvent.threadId; + } + if (receivedEvent.subtitle) { + this.subtitle = receivedEvent.subtitle; + } + if (receivedEvent.templateId) { + this.templateId = receivedEvent.templateId; + } + if (receivedEvent.attachments) { + this.attachments = receivedEvent.attachments; + } + if (receivedEvent.templateName) { + this.templateName = receivedEvent.templateName; + } + if (receivedEvent.mutableContent) { + this.mutableContent = receivedEvent.mutableContent; + } + if (receivedEvent.badgeIncrement) { + this.badgeIncrement = receivedEvent.badgeIncrement; + } + if (receivedEvent.contentAvailable) { + this.contentAvailable = receivedEvent.contentAvailable; + } + if (receivedEvent.relevanceScore) { + this.relevanceScore = receivedEvent.relevanceScore; + } + if (receivedEvent.interruptionLevel) { + this.interruptionLevel = receivedEvent.interruptionLevel; + } + } + + /** + * Display the notification. + * @returns void + */ + display(): void { + if (_pluginRef) { + void _pluginRef.displayNotification({ + notificationId: this.notificationId, + }); + } + } +} diff --git a/src/OneSignalPlugin.ts b/src/OneSignalPlugin.ts new file mode 100644 index 0000000..a4fe231 --- /dev/null +++ b/src/OneSignalPlugin.ts @@ -0,0 +1,86 @@ +import Debug from './DebugNamespace'; +import type { OneSignalCapacitorPlugin } from './definitions'; +import InAppMessages from './InAppMessagesNamespace'; +import LiveActivities from './LiveActivitiesNamespace'; +import Location from './LocationNamespace'; +import { _setNotificationEventPlugin } from './NotificationReceivedEvent'; +import Notifications from './NotificationsNamespace'; +import { _setOSNotificationPlugin } from './OSNotification'; +import Session from './SessionNamespace'; +import User from './UserNamespace'; + +export class OneSignalPlugin { + User: User; + Debug: Debug; + Session: Session; + Location: Location; + InAppMessages: InAppMessages; + Notifications: Notifications; + LiveActivities: LiveActivities; + + private _plugin: OneSignalCapacitorPlugin; + private _appID = ''; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + _setOSNotificationPlugin(plugin); + _setNotificationEventPlugin(plugin); + + this.User = new User(plugin); + this.Debug = new Debug(plugin); + this.Session = new Session(plugin); + this.Location = new Location(plugin); + this.InAppMessages = new InAppMessages(plugin); + this.Notifications = new Notifications(plugin); + this.LiveActivities = new LiveActivities(plugin); + } + + /** + * Initializes the OneSignal SDK. This should be called during startup of the application. + * @param {string} appId + * @returns Promise + */ + initialize(appId: string): Promise { + this._appID = appId; + + return this._plugin.initialize({ appId: this._appID }).then(() => { + this.User.pushSubscription._setPropertiesAndObserver(); + this.Notifications._setPropertyAndObserver(); + }); + } + + /** + * Login to OneSignal under the user identified by the [externalId] provided. The act of logging a user into the OneSignal SDK will switch the [user] context to that specific user. + * @param {string} externalId + * @returns Promise + */ + login(externalId: string): Promise { + return this._plugin.login({ externalId }); + } + + /** + * Logout the user previously logged in via [login]. The [user] property now references a new device-scoped user. + * @returns Promise + */ + logout(): Promise { + return this._plugin.logout(); + } + + /** + * Determines whether a user must consent to privacy prior to their user data being sent up to OneSignal. This should be set to true prior to the invocation of initialization to ensure compliance. + * @param {boolean} required + * @returns void + */ + setConsentRequired(required: boolean): void { + void this._plugin.setConsentRequired({ required }); + } + + /** + * Indicates whether privacy consent has been granted. This field is only relevant when the application has opted into data privacy protections. + * @param {boolean} granted + * @returns void + */ + setConsentGiven(granted: boolean): void { + void this._plugin.setConsentGiven({ granted }); + } +} diff --git a/src/PushSubscriptionNamespace.test.ts b/src/PushSubscriptionNamespace.test.ts new file mode 100644 index 0000000..92be1d7 --- /dev/null +++ b/src/PushSubscriptionNamespace.test.ts @@ -0,0 +1,225 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { PREV_SUB_ID, PREV_SUB_TOKEN, SUB_ID, SUB_TOKEN } from '../mocks/constants'; +import PushSubscription, { type PushSubscriptionChangedState } from './PushSubscriptionNamespace'; + +const SUB_CHANGED_STATE: PushSubscriptionChangedState = { + current: { id: SUB_ID, token: SUB_TOKEN, optedIn: true }, + previous: { id: PREV_SUB_ID, token: PREV_SUB_TOKEN, optedIn: false }, +}; + +describe('PushSubscription', () => { + let mockPlugin: ReturnType; + let pushSubscription: PushSubscription; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + pushSubscription = new PushSubscription(mockPlugin); + }); + + test('should instantiate PushSubscription class', () => { + expect(pushSubscription).toBeInstanceOf(PushSubscription); + }); + + describe('id (deprecated)', () => { + test('should set id via getPushSubscriptionId', async () => { + mockPlugin.getPushSubscriptionId.mockResolvedValue({ id: SUB_ID }); + pushSubscription._setPropertiesAndObserver(); + + await vi.waitFor(() => { + expect(pushSubscription.id).toBe(SUB_ID); + }); + }); + + test('should set id via addEventListener', () => { + pushSubscription._setPropertiesAndObserver(); + + const listener = mockPlugin.addListener.mock.calls.find( + (call) => call[0] === 'pushSubscriptionChange', + )?.[1]; + listener?.({ current: { id: SUB_ID, token: SUB_TOKEN, optedIn: true } }); + expect(pushSubscription.id).toBe(SUB_ID); + }); + }); + + describe('token (deprecated)', () => { + test('should set token via getPushSubscriptionToken', async () => { + mockPlugin.getPushSubscriptionToken.mockResolvedValue({ + token: SUB_TOKEN, + }); + pushSubscription._setPropertiesAndObserver(); + + await vi.waitFor(() => { + expect(pushSubscription.token).toBe(SUB_TOKEN); + }); + }); + + test('should set token via addEventListener', () => { + pushSubscription._setPropertiesAndObserver(); + + const listener = mockPlugin.addListener.mock.calls.find( + (call) => call[0] === 'pushSubscriptionChange', + )?.[1]; + listener?.({ current: { id: SUB_ID, token: SUB_TOKEN, optedIn: true } }); + expect(pushSubscription.token).toBe(SUB_TOKEN); + }); + }); + + describe('optedIn (deprecated)', () => { + test('should set optedIn via getPushSubscriptionOptedIn', async () => { + mockPlugin.getPushSubscriptionOptedIn.mockResolvedValue({ + optedIn: true, + }); + pushSubscription._setPropertiesAndObserver(); + + await vi.waitFor(() => { + expect(pushSubscription.optedIn).toBe(true); + }); + }); + + test('should set optedIn via addEventListener', () => { + pushSubscription._setPropertiesAndObserver(); + + const listener = mockPlugin.addListener.mock.calls.find( + (call) => call[0] === 'pushSubscriptionChange', + )?.[1]; + listener?.({ current: { id: SUB_ID, token: SUB_TOKEN, optedIn: true } }); + expect(pushSubscription.optedIn).toBe(true); + }); + }); + + describe('getIdAsync', () => { + test('should return a Promise', () => { + const promise = pushSubscription.getIdAsync(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with push subscription ID', async () => { + const testId = 'test-subscription-id-456'; + mockPlugin.getPushSubscriptionId.mockResolvedValue({ id: testId }); + + const result = await pushSubscription.getIdAsync(); + expect(result).toBe(testId); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Failed to get subscription ID'); + mockPlugin.getPushSubscriptionId.mockRejectedValue(mockError); + + await expect(pushSubscription.getIdAsync()).rejects.toThrow(mockError.message); + }); + }); + + describe('getTokenAsync', () => { + test('should return a Promise', () => { + const promise = pushSubscription.getTokenAsync(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with push token', async () => { + const testToken = 'test-push-token-xyz'; + mockPlugin.getPushSubscriptionToken.mockResolvedValue({ + token: testToken, + }); + + const result = await pushSubscription.getTokenAsync(); + expect(result).toBe(testToken); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Failed to get push token'); + mockPlugin.getPushSubscriptionToken.mockRejectedValue(mockError); + + await expect(pushSubscription.getTokenAsync()).rejects.toThrow(mockError.message); + }); + }); + + describe('getOptedInAsync', () => { + test('should return a Promise', () => { + const promise = pushSubscription.getOptedInAsync(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with true when user is opted in', async () => { + mockPlugin.getPushSubscriptionOptedIn.mockResolvedValue({ + optedIn: true, + }); + + const result = await pushSubscription.getOptedInAsync(); + expect(result).toBe(true); + }); + + test('should reject Promise when plugin fails', async () => { + const mockError = new Error('Failed to get opted in status'); + mockPlugin.getPushSubscriptionOptedIn.mockRejectedValue(mockError); + + await expect(pushSubscription.getOptedInAsync()).rejects.toThrow(mockError.message); + }); + }); + + describe('addEventListener', () => { + test('should call addListener', () => { + const mockListener = vi.fn(); + pushSubscription.addEventListener('change', mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith( + 'pushSubscriptionChange', + expect.any(Function), + ); + }); + + test('should call listener when subscription state changes', () => { + const mockListener = vi.fn(); + const mockListener2 = vi.fn(); + pushSubscription.addEventListener('change', mockListener); + pushSubscription.addEventListener('change', mockListener2); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(SUB_CHANGED_STATE); + + expect(mockListener).toHaveBeenCalledWith(SUB_CHANGED_STATE); + expect(mockListener2).toHaveBeenCalledWith(SUB_CHANGED_STATE); + }); + }); + + describe('removeEventListener', () => { + test('should remove listener from observer list', () => { + const mockListener = vi.fn(); + pushSubscription.addEventListener('change', mockListener); + pushSubscription.removeEventListener('change', mockListener); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(SUB_CHANGED_STATE); + expect(mockListener).not.toHaveBeenCalled(); + }); + + test('should only remove the specified listener', () => { + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); + + pushSubscription.addEventListener('change', mockListener1); + pushSubscription.addEventListener('change', mockListener2); + pushSubscription.removeEventListener('change', mockListener1); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(SUB_CHANGED_STATE); + expect(mockListener1).not.toHaveBeenCalled(); + expect(mockListener2).toHaveBeenCalledWith(SUB_CHANGED_STATE); + }); + }); + + describe('optIn', () => { + test('should call plugin', async () => { + await pushSubscription.optIn(); + expect(mockPlugin.optInPushSubscription).toHaveBeenCalled(); + }); + }); + + describe('optOut', () => { + test('should call plugin', async () => { + await pushSubscription.optOut(); + expect(mockPlugin.optOutPushSubscription).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/PushSubscriptionNamespace.ts b/src/PushSubscriptionNamespace.ts new file mode 100644 index 0000000..83c9638 --- /dev/null +++ b/src/PushSubscriptionNamespace.ts @@ -0,0 +1,159 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import { removeListener } from './helpers'; + +export interface PushSubscriptionState { + id?: string; + token?: string; + optedIn: boolean; +} + +export interface PushSubscriptionChangedState { + previous: PushSubscriptionState; + current: PushSubscriptionState; +} + +export default class PushSubscription { + private _plugin: OneSignalCapacitorPlugin; + private _id?: string | null; + private _token?: string | null; + private _optedIn?: boolean; + + private _subscriptionObserverList: ((event: PushSubscriptionChangedState) => void)[] = []; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + private _processFunctionList( + array: ((event: PushSubscriptionChangedState) => void)[], + param: PushSubscriptionChangedState, + ): void { + for (let i = 0; i < array.length; i++) { + array[i](param); + } + } + + /** + * Sets initial Push Subscription properties and adds observer for changes. + * This internal method is kept to support the deprecated methods {@link id}, {@link token}, {@link optedIn}. + */ + _setPropertiesAndObserver(): void { + void this._plugin.getPushSubscriptionId().then((result) => { + this._id = result.id; + }); + + void this._plugin.getPushSubscriptionToken().then((result) => { + this._token = result.token; + }); + + void this._plugin.getPushSubscriptionOptedIn().then((result) => { + this._optedIn = result.optedIn; + }); + + this.addEventListener('change', (subscriptionChange) => { + this._id = subscriptionChange.current.id; + this._token = subscriptionChange.current.token; + this._optedIn = subscriptionChange.current.optedIn; + }); + } + + /** + * @deprecated This method is deprecated. It has been replaced by {@link getIdAsync}. + */ + get id(): string | null | undefined { + console.warn( + 'OneSignal: This method has been deprecated. Use getIdAsync instead for getting push subscription id.', + ); + return this._id; + } + + /** + * @deprecated This method is deprecated. It has been replaced by {@link getTokenAsync}. + */ + get token(): string | null | undefined { + console.warn( + 'OneSignal: This method has been deprecated. Use getTokenAsync instead for getting push subscription token.', + ); + return this._token; + } + + /** + * @deprecated This method is deprecated. It has been replaced by {@link getOptedInAsync}. + */ + get optedIn(): boolean { + console.warn( + 'OneSignal: This method has been deprecated. Use getOptedInAsync instead for getting push subscription opted in status.', + ); + return this._optedIn || false; + } + + /** + * The readonly push subscription ID. + * @returns {Promise} + */ + async getIdAsync(): Promise { + const result = await this._plugin.getPushSubscriptionId(); + return result.id; + } + + /** + * The readonly push token. + * @returns {Promise} + */ + async getTokenAsync(): Promise { + const result = await this._plugin.getPushSubscriptionToken(); + return result.token; + } + + /** + * Gets a boolean value indicating whether the current user is opted in to push notifications. + * This returns true when the app has notifications permission and optOut() is NOT called. + * Note: Does not take into account the existence of the subscription ID and push token. + * This boolean may return true but push notifications may still not be received by the user. + * @returns {Promise} + */ + async getOptedInAsync(): Promise { + const result = await this._plugin.getPushSubscriptionOptedIn(); + return result.optedIn; + } + + /** + * Add a callback that fires when the OneSignal push subscription state changes. + * @param {(event: PushSubscriptionChangedState)=>void} listener + * @returns void + */ + addEventListener(_event: 'change', listener: (event: PushSubscriptionChangedState) => void) { + this._subscriptionObserverList.push(listener); + void this._plugin.addListener( + 'pushSubscriptionChange', + (state: PushSubscriptionChangedState) => { + this._processFunctionList(this._subscriptionObserverList, state); + }, + ); + } + + /** + * Remove a push subscription observer that has been previously added. + * @param {(event: PushSubscriptionChangedState)=>void} listener + * @returns void + */ + removeEventListener(_event: 'change', listener: (event: PushSubscriptionChangedState) => void) { + removeListener(this._subscriptionObserverList, listener); + } + + /** + * Call this method to receive push notifications on the device or to resume receiving of push notifications after calling optOut. If needed, this method will prompt the user for push notifications permission. + * @returns Promise + */ + optIn(): Promise { + return this._plugin.optInPushSubscription(); + } + + /** + * If at any point you want the user to stop receiving push notifications on the current device (regardless of system-level permission status), you can call this method to opt out. + * @returns Promise + */ + optOut(): Promise { + return this._plugin.optOutPushSubscription(); + } +} diff --git a/src/SessionNamespace.test.ts b/src/SessionNamespace.test.ts new file mode 100644 index 0000000..a995668 --- /dev/null +++ b/src/SessionNamespace.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import Session from './SessionNamespace'; + +describe('Session', () => { + let mockPlugin: ReturnType; + let session: Session; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + session = new Session(mockPlugin); + }); + + test('should instantiate Session class', () => { + expect(session).toBeInstanceOf(Session); + }); + + describe('addOutcome', () => { + test('should call plugin with correct parameters', async () => { + const outcomeName = 'test_outcome'; + + await session.addOutcome(outcomeName); + + expect(mockPlugin.addOutcome).toHaveBeenCalledWith({ + name: outcomeName, + }); + }); + }); + + describe('addUniqueOutcome', () => { + test('should call plugin with correct parameters', async () => { + const outcomeName = 'unique_test_outcome'; + + await session.addUniqueOutcome(outcomeName); + + expect(mockPlugin.addUniqueOutcome).toHaveBeenCalledWith({ + name: outcomeName, + }); + }); + }); + + describe('addOutcomeWithValue', () => { + test('should call plugin with correct parameters', async () => { + const outcomeName = 'purchase_value'; + const outcomeValue = 99.99; + + await session.addOutcomeWithValue(outcomeName, outcomeValue); + + expect(mockPlugin.addOutcomeWithValue).toHaveBeenCalledWith({ + name: outcomeName, + value: outcomeValue, + }); + }); + }); +}); diff --git a/src/SessionNamespace.ts b/src/SessionNamespace.ts new file mode 100644 index 0000000..bb2b16a --- /dev/null +++ b/src/SessionNamespace.ts @@ -0,0 +1,37 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; + +export default class Session { + private _plugin: OneSignalCapacitorPlugin; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + } + + /** + * Add an outcome with the provided name, captured against the current session. + * @param {string} name + * @returns Promise + */ + addOutcome(name: string): Promise { + return this._plugin.addOutcome({ name }); + } + + /** + * Add a unique outcome with the provided name, captured against the current session. + * @param {string} name + * @returns Promise + */ + addUniqueOutcome(name: string): Promise { + return this._plugin.addUniqueOutcome({ name }); + } + + /** + * Add an outcome with the provided name and value, captured against the current session. + * @param {string} name + * @param {number} value + * @returns Promise + */ + addOutcomeWithValue(name: string, value: number): Promise { + return this._plugin.addOutcomeWithValue({ name, value }); + } +} diff --git a/src/UserNamespace.test.ts b/src/UserNamespace.test.ts new file mode 100644 index 0000000..bd81efa --- /dev/null +++ b/src/UserNamespace.test.ts @@ -0,0 +1,348 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import PushSubscription from './PushSubscriptionNamespace'; +import User, { type UserChangedState } from './UserNamespace'; + +const USER_CHANGED_STATE: UserChangedState = { + current: { onesignalId: 'test-onesignal-id', externalId: 'test-external-id' }, +}; + +const LABEL = 'user_id'; +const EMAIL = 'test@example.com'; +const SMS_NUMBER = '+1234567890'; +const ONESIGNAL_ID = 'test-onesignal-id'; +const EXTERNAL_ID = 'test-external-id'; + +describe('User', () => { + let mockPlugin: ReturnType; + let user: User; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + user = new User(mockPlugin); + }); + + test('should instantiate User class', () => { + expect(user).toBeInstanceOf(User); + }); + + test('should have pushSubscription property', () => { + expect(user.pushSubscription).toBeInstanceOf(PushSubscription); + }); + + describe('setLanguage', () => { + test('should call plugin with correct parameters', async () => { + const language = 'en'; + await user.setLanguage(language); + + expect(mockPlugin.setLanguage).toHaveBeenCalledWith({ language }); + }); + }); + + describe('addAlias', () => { + test('should call plugin with correct parameters', async () => { + const id = '12345'; + await user.addAlias(LABEL, id); + + expect(mockPlugin.addAliases).toHaveBeenCalledWith({ + aliases: { [LABEL]: id }, + }); + }); + }); + + describe('addAliases', () => { + test('should call plugin with correct parameters', async () => { + const aliases = { [LABEL]: '12345', custom_id: 'abc-123' }; + await user.addAliases(aliases); + + expect(mockPlugin.addAliases).toHaveBeenCalledWith({ aliases }); + }); + + test('should handle empty aliases object', async () => { + const aliases = {}; + await user.addAliases(aliases); + + expect(mockPlugin.addAliases).toHaveBeenCalledWith({ aliases }); + }); + }); + + describe('removeAlias', () => { + test('should call plugin with correct parameters', async () => { + await user.removeAlias(LABEL); + + expect(mockPlugin.removeAliases).toHaveBeenCalledWith({ + labels: [LABEL], + }); + }); + }); + + describe('removeAliases', () => { + test('should call plugin with correct parameters', async () => { + const labels = [LABEL, 'custom_id', 'external_id']; + await user.removeAliases(labels); + + expect(mockPlugin.removeAliases).toHaveBeenCalledWith({ labels }); + }); + + test('should handle empty array', async () => { + const labels: string[] = []; + await user.removeAliases(labels); + + expect(mockPlugin.removeAliases).toHaveBeenCalledWith({ labels }); + }); + }); + + describe('addEmail', () => { + test('should call plugin with correct parameters', async () => { + await user.addEmail(EMAIL); + + expect(mockPlugin.addEmail).toHaveBeenCalledWith({ email: EMAIL }); + }); + }); + + describe('removeEmail', () => { + test('should call plugin with correct parameters', async () => { + await user.removeEmail(EMAIL); + + expect(mockPlugin.removeEmail).toHaveBeenCalledWith({ email: EMAIL }); + }); + }); + + describe('addSms', () => { + test('should call plugin with correct parameters', async () => { + await user.addSms(SMS_NUMBER); + + expect(mockPlugin.addSms).toHaveBeenCalledWith({ + smsNumber: SMS_NUMBER, + }); + }); + }); + + describe('removeSms', () => { + test('should call plugin with correct parameters', async () => { + await user.removeSms(SMS_NUMBER); + + expect(mockPlugin.removeSms).toHaveBeenCalledWith({ + smsNumber: SMS_NUMBER, + }); + }); + }); + + describe('addTag', () => { + test('should call plugin with correct parameters', async () => { + const key = 'level'; + const value = 'premium'; + await user.addTag(key, value); + + expect(mockPlugin.addTags).toHaveBeenCalledWith({ + tags: { [key]: value }, + }); + }); + }); + + describe('addTags', () => { + test('should call plugin with correct parameters', async () => { + const tags = { level: 'premium', status: 'active' }; + await user.addTags(tags); + + expect(mockPlugin.addTags).toHaveBeenCalledWith({ tags }); + }); + + test('should convert non-string values to JSON strings', async () => { + const tags = { count: 42, active: true, data: { nested: 'value' } }; + await user.addTags(tags); + + expect(mockPlugin.addTags).toHaveBeenCalledWith({ + tags: { count: '42', active: 'true', data: '{"nested":"value"}' }, + }); + }); + }); + + describe('removeTag', () => { + test('should call plugin with correct parameters', async () => { + const key = 'level'; + await user.removeTag(key); + + expect(mockPlugin.removeTags).toHaveBeenCalledWith({ keys: [key] }); + }); + }); + + describe('removeTags', () => { + test('should call plugin with correct parameters', async () => { + const keys = ['level', 'status', 'premium']; + await user.removeTags(keys); + + expect(mockPlugin.removeTags).toHaveBeenCalledWith({ keys }); + }); + + test('should handle empty array', async () => { + const keys: string[] = []; + await user.removeTags(keys); + + expect(mockPlugin.removeTags).toHaveBeenCalledWith({ keys }); + }); + }); + + describe('getTags', () => { + test('should return a Promise', () => { + const promise = user.getTags(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with tags object', async () => { + const testTags = { level: 'premium', status: 'active', count: '5' }; + mockPlugin.getTags.mockResolvedValue({ tags: testTags }); + + const result = await user.getTags(); + expect(result).toEqual(testTags); + }); + + test('should reject Promise when plugin fails', async () => { + mockPlugin.getTags.mockRejectedValue(new Error('Failed to get tags')); + + await expect(user.getTags()).rejects.toThrow('Failed to get tags'); + }); + }); + + describe('addEventListener', () => { + test('should add listener and call addListener', () => { + const mockListener = vi.fn(); + user.addEventListener('change', mockListener); + + expect(mockPlugin.addListener).toHaveBeenCalledWith('userStateChange', expect.any(Function)); + }); + + test('should call all listeners when user state changes', () => { + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); + const mockListener3 = vi.fn(); + + user.addEventListener('change', mockListener1); + user.addEventListener('change', mockListener2); + user.addEventListener('change', mockListener3); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(USER_CHANGED_STATE); + + expect(mockListener1).toHaveBeenCalledWith(USER_CHANGED_STATE); + expect(mockListener2).toHaveBeenCalledWith(USER_CHANGED_STATE); + expect(mockListener3).toHaveBeenCalledWith(USER_CHANGED_STATE); + }); + }); + + describe('removeEventListener', () => { + test('should remove listener from observer list', () => { + const mockListener = vi.fn(); + + user.addEventListener('change', mockListener); + user.removeEventListener('change', mockListener); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(USER_CHANGED_STATE); + expect(mockListener).not.toHaveBeenCalled(); + }); + + test('should only remove the specified listener', () => { + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); + + user.addEventListener('change', mockListener1); + user.addEventListener('change', mockListener2); + user.removeEventListener('change', mockListener1); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(USER_CHANGED_STATE); + expect(mockListener1).not.toHaveBeenCalled(); + expect(mockListener2).toHaveBeenCalledWith(USER_CHANGED_STATE); + }); + }); + + describe('getOnesignalId', () => { + test('should return a Promise', () => { + const promise = user.getOnesignalId(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with onesignal id', async () => { + mockPlugin.getOnesignalId.mockResolvedValue({ + onesignalId: ONESIGNAL_ID, + }); + + const result = await user.getOnesignalId(); + expect(result).toBe(ONESIGNAL_ID); + }); + + test('should reject Promise when plugin fails', async () => { + mockPlugin.getOnesignalId.mockRejectedValue(new Error('Failed to get onesignal id')); + + await expect(user.getOnesignalId()).rejects.toThrow('Failed to get onesignal id'); + }); + }); + + describe('getExternalId', () => { + test('should return a Promise', () => { + const promise = user.getExternalId(); + expect(promise).toBeInstanceOf(Promise); + }); + + test('should resolve with external id', async () => { + mockPlugin.getExternalId.mockResolvedValue({ + externalId: EXTERNAL_ID, + }); + + const result = await user.getExternalId(); + expect(result).toBe(EXTERNAL_ID); + }); + + test('should reject Promise when plugin fails', async () => { + mockPlugin.getExternalId.mockRejectedValue(new Error('Failed to get external id')); + + await expect(user.getExternalId()).rejects.toThrow('Failed to get external id'); + }); + }); + + describe('trackEvent', () => { + test('should call plugin with only event name', async () => { + const eventName = 'purchase'; + await user.trackEvent(eventName); + + expect(mockPlugin.trackEvent).toHaveBeenCalledWith({ name: eventName }); + }); + + test('should call plugin with event name and properties', async () => { + const eventName = 'purchase'; + const properties = { amount: 99.99, currency: 'USD' }; + await user.trackEvent(eventName, properties); + + expect(mockPlugin.trackEvent).toHaveBeenCalledWith({ + name: eventName, + properties, + }); + }); + + test('should not call plugin when properties are not serializable', async () => { + const eventName = 'purchase'; + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await user.trackEvent(eventName, circularObj); + + expect(consoleSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object'); + expect(mockPlugin.trackEvent).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + test('should not call plugin when properties is an array', async () => { + const eventName = 'purchase'; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await user.trackEvent(eventName, ['item1', 'item2'] as unknown as object); + + expect(consoleSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object'); + expect(mockPlugin.trackEvent).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/UserNamespace.ts b/src/UserNamespace.ts new file mode 100644 index 0000000..9b24972 --- /dev/null +++ b/src/UserNamespace.ts @@ -0,0 +1,225 @@ +import type { OneSignalCapacitorPlugin } from './definitions'; +import { isObjectSerializable, removeListener } from './helpers'; +import PushSubscription from './PushSubscriptionNamespace'; + +export interface UserState { + onesignalId?: string; + externalId?: string; +} + +export interface UserChangedState { + current: UserState; +} + +export default class User { + pushSubscription: PushSubscription; + + private _plugin: OneSignalCapacitorPlugin; + private _userStateObserverList: ((event: UserChangedState) => void)[] = []; + + constructor(plugin: OneSignalCapacitorPlugin) { + this._plugin = plugin; + this.pushSubscription = new PushSubscription(plugin); + } + + private _processFunctionList( + array: ((event: UserChangedState) => void)[], + param: UserChangedState, + ): void { + for (let i = 0; i < array.length; i++) { + array[i](param); + } + } + + /** + * Explicitly set a 2-character language code for the user. + * @param {string} language + * @returns Promise + */ + setLanguage(language: string): Promise { + return this._plugin.setLanguage({ language }); + } + + /** + * Set an alias for the current user. If this alias label already exists on this user, it will be overwritten with the new alias id. + * @param {string} label + * @param {string} id + * @returns Promise + */ + addAlias(label: string, id: string): Promise { + return this._plugin.addAliases({ aliases: { [label]: id } }); + } + + /** + * Set aliases for the current user. If any alias already exists, it will be overwritten to the new values. + * @param {object} aliases + * @returns Promise + */ + addAliases(aliases: Record): Promise { + return this._plugin.addAliases({ aliases }); + } + + /** + * Remove an alias from the current user. + * @param {string} label + * @returns Promise + */ + removeAlias(label: string): Promise { + return this._plugin.removeAliases({ labels: [label] }); + } + + /** + * Remove aliases from the current user. + * @param {string[]} labels + * @returns Promise + */ + removeAliases(labels: string[]): Promise { + return this._plugin.removeAliases({ labels }); + } + + /** + * Add a new email subscription to the current user. + * @param {string} email + * @returns Promise + */ + addEmail(email: string): Promise { + return this._plugin.addEmail({ email }); + } + + /** + * Remove an email subscription from the current user. + * @param {string} email + * @returns Promise + */ + removeEmail(email: string): Promise { + return this._plugin.removeEmail({ email }); + } + + /** + * Add a new SMS subscription to the current user. + * @param {string} smsNumber + * @returns Promise + */ + addSms(smsNumber: string): Promise { + return this._plugin.addSms({ smsNumber }); + } + + /** + * Remove an SMS subscription from the current user. + * @param {string} smsNumber + * @returns Promise + */ + removeSms(smsNumber: string): Promise { + return this._plugin.removeSms({ smsNumber }); + } + + /** + * Add a tag for the current user. Tags are key:value string pairs used as building blocks for targeting specific users and/or personalizing messages. + * @param {string} key + * @param {string} value + * @returns Promise + */ + addTag(key: string, value: string): Promise { + return this._plugin.addTags({ tags: { [key]: value } }); + } + + /** + * Add multiple tags for the current user. Tags are key:value string pairs used as building blocks for targeting specific users and/or personalizing messages. + * @param {object} tags + * @returns Promise + */ + addTags(tags: object): Promise { + const convertedTags = tags as { [key: string]: unknown }; + Object.keys(tags).forEach(function (key) { + if (typeof convertedTags[key] !== 'string') { + convertedTags[key] = JSON.stringify(convertedTags[key]); + } + }); + return this._plugin.addTags({ + tags: convertedTags as Record, + }); + } + + /** + * Remove the data tag with the provided key from the current user. + * @param {string} key + * @returns Promise + */ + removeTag(key: string): Promise { + return this._plugin.removeTags({ keys: [key] }); + } + + /** + * Remove multiple tags with the provided keys from the current user. + * @param {string[]} keys + * @returns Promise + */ + removeTags(keys: string[]): Promise { + return this._plugin.removeTags({ keys }); + } + + /** + * Returns the local tags for the current user. + * @returns Promise<{ [key: string]: string }> + */ + async getTags(): Promise<{ [key: string]: string }> { + const result = await this._plugin.getTags(); + return result.tags; + } + + /** + * Add a callback that fires when the OneSignal User state changes. + * @param {(event: UserChangedState)=>void} listener + * @returns void + */ + addEventListener(_event: 'change', listener: (event: UserChangedState) => void) { + this._userStateObserverList.push(listener); + void this._plugin.addListener('userStateChange', (state: UserChangedState) => { + this._processFunctionList(this._userStateObserverList, state); + }); + } + + /** + * Remove a User State observer that has been previously added. + * @param {(event: UserChangedState)=>void} listener + * @returns void + */ + removeEventListener(_event: 'change', listener: (event: UserChangedState) => void) { + removeListener(this._userStateObserverList, listener); + } + + /** + * Get the nullable OneSignal Id associated with the current user. + * @returns {Promise} + */ + async getOnesignalId(): Promise { + const result = await this._plugin.getOnesignalId(); + return result.onesignalId; + } + + /** + * Get the nullable External Id associated with the current user. + * @returns {Promise} + */ + async getExternalId(): Promise { + const result = await this._plugin.getExternalId(); + return result.externalId; + } + + /** + * Track a custom event with the provided name and optional properties. + * @param {string} name - The name of the custom event + * @param {object} [properties] - Optional properties to associate with the event + * @returns Promise + */ + trackEvent(name: string, properties?: object): Promise { + if (properties !== undefined && !isObjectSerializable(properties)) { + console.error('Properties must be a JSON-serializable object'); + return Promise.resolve(); + } + return this._plugin.trackEvent({ + name, + properties: properties as Record | undefined, + }); + } +} diff --git a/src/definitions.ts b/src/definitions.ts new file mode 100644 index 0000000..5c590cc --- /dev/null +++ b/src/definitions.ts @@ -0,0 +1,83 @@ +import type { Plugin } from '@capacitor/core'; + +import type { OSNotificationPermission } from './NotificationsNamespace'; + +export interface OneSignalCapacitorPlugin extends Plugin { + // Core + initialize(options: { appId: string }): Promise; + login(options: { externalId: string }): Promise; + logout(): Promise; + setConsentRequired(options: { required: boolean }): Promise; + setConsentGiven(options: { granted: boolean }): Promise; + + // Debug + setLogLevel(options: { logLevel: number }): Promise; + setAlertLevel(options: { logLevel: number }): Promise; + + // User + setLanguage(options: { language: string }): Promise; + addAliases(options: { aliases: Record }): Promise; + removeAliases(options: { labels: string[] }): Promise; + addEmail(options: { email: string }): Promise; + removeEmail(options: { email: string }): Promise; + addSms(options: { smsNumber: string }): Promise; + removeSms(options: { smsNumber: string }): Promise; + addTags(options: { tags: Record }): Promise; + removeTags(options: { keys: string[] }): Promise; + getTags(): Promise<{ tags: Record }>; + getOnesignalId(): Promise<{ onesignalId: string | null }>; + getExternalId(): Promise<{ externalId: string | null }>; + trackEvent(options: { name: string; properties?: Record }): Promise; + + // Push Subscription + getPushSubscriptionId(): Promise<{ id: string | null }>; + getPushSubscriptionToken(): Promise<{ token: string | null }>; + getPushSubscriptionOptedIn(): Promise<{ optedIn: boolean }>; + optInPushSubscription(): Promise; + optOutPushSubscription(): Promise; + + // Notifications + getPermission(): Promise<{ permission: boolean }>; + permissionNative(): Promise<{ permission: OSNotificationPermission }>; + requestPermission(options: { fallbackToSettings: boolean }): Promise<{ permission: boolean }>; + canRequestPermission(): Promise<{ canRequest: boolean }>; + registerForProvisionalAuthorization(): Promise<{ accepted: boolean }>; + clearAllNotifications(): Promise; + removeNotification(options: { id: number }): Promise; + removeGroupedNotifications(options: { id: string }): Promise; + preventDefault(options: { notificationId: string; discard: boolean }): Promise; + proceedWithWillDisplay(options: { notificationId: string }): Promise; + displayNotification(options: { notificationId: string }): Promise; + + // In-App Messages + addTriggers(options: { triggers: Record }): Promise; + removeTriggers(options: { keys: string[] }): Promise; + clearTriggers(): Promise; + setPaused(options: { pause: boolean }): Promise; + isPaused(): Promise<{ paused: boolean }>; + + // Session / Outcomes + addOutcome(options: { name: string }): Promise; + addUniqueOutcome(options: { name: string }): Promise; + addOutcomeWithValue(options: { name: string; value: number }): Promise; + + // Location + requestLocationPermission(): Promise; + setLocationShared(options: { shared: boolean }): Promise; + isLocationShared(): Promise<{ shared: boolean }>; + + // Live Activities + enterLiveActivity(options: { activityId: string; token: string }): Promise; + exitLiveActivity(options: { activityId: string }): Promise; + setPushToStartToken(options: { activityType: string; token: string }): Promise; + removePushToStartToken(options: { activityType: string }): Promise; + setupDefaultLiveActivity(options?: { + enablePushToStart: boolean; + enablePushToUpdate: boolean; + }): Promise; + startDefaultLiveActivity(options: { + activityId: string; + attributes: Record; + content: Record; + }): Promise; +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..1cbe0b3 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,29 @@ +/** + * Removes a listener from an array of listeners. + * @param array The array of listeners + * @param listener The listener to remove + */ +export function removeListener( + array: ((event: T) => void)[], + listener: (event: T) => void, +): void { + const index = array.indexOf(listener); + if (index !== -1) { + array.splice(index, 1); + } +} + +/** + * Returns true if the value is a JSON-serializable object. + */ +export function isObjectSerializable(value: unknown): boolean { + if (!(typeof value === 'object' && value !== null && !Array.isArray(value))) { + return false; + } + try { + JSON.stringify(value); + return true; + } catch { + return false; + } +} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..f71a558 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createMockPlugin } from '../mocks/capacitor'; +import { APP_ID } from '../mocks/constants'; +import { OneSignalPlugin } from './OneSignalPlugin'; + +describe('OneSignalPlugin', () => { + let mockPlugin: ReturnType; + let plugin: OneSignalPlugin; + + beforeEach(() => { + mockPlugin = createMockPlugin(); + plugin = new OneSignalPlugin(mockPlugin); + }); + + test('should instantiate OneSignalPlugin', () => { + expect(plugin).toBeInstanceOf(OneSignalPlugin); + }); + + test('should have all required namespaces', () => { + expect(plugin.User).toBeDefined(); + expect(plugin.Debug).toBeDefined(); + expect(plugin.Session).toBeDefined(); + expect(plugin.Location).toBeDefined(); + expect(plugin.InAppMessages).toBeDefined(); + expect(plugin.Notifications).toBeDefined(); + expect(plugin.LiveActivities).toBeDefined(); + }); + + test('should initialize with appId', async () => { + await plugin.initialize(APP_ID); + + expect(mockPlugin.initialize).toHaveBeenCalledWith({ appId: APP_ID }); + + await vi.waitFor(() => { + expect(mockPlugin.getPushSubscriptionId).toHaveBeenCalled(); + }); + }); + + test('should call plugin for login', async () => { + const externalId = 'test-user-123'; + await plugin.login(externalId); + + expect(mockPlugin.login).toHaveBeenCalledWith({ externalId }); + }); + + test('should call plugin for logout', async () => { + await plugin.logout(); + + expect(mockPlugin.logout).toHaveBeenCalled(); + }); + + test('should call plugin for setConsentRequired', () => { + plugin.setConsentRequired(true); + + expect(mockPlugin.setConsentRequired).toHaveBeenCalledWith({ + required: true, + }); + }); + + test('should call plugin for setConsentGiven', () => { + plugin.setConsentGiven(true); + + expect(mockPlugin.setConsentGiven).toHaveBeenCalledWith({ granted: true }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d4b4dc8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { OneSignalCapacitorPlugin } from './definitions'; +import { OneSignalPlugin } from './OneSignalPlugin'; + +const OneSignalCapacitor = registerPlugin('OneSignalCapacitor'); + +const OneSignal = new OneSignalPlugin(OneSignalCapacitor); + +export { LogLevel } from './DebugNamespace'; +export { NotificationWillDisplayEvent } from './NotificationReceivedEvent'; +export { OSNotificationPermission } from './NotificationsNamespace'; +export { OSNotification } from './OSNotification'; +export { OneSignalPlugin } from './OneSignalPlugin'; + +export type { + PushSubscriptionChangedState, + PushSubscriptionState, +} from './PushSubscriptionNamespace'; + +export type { NotificationClickEvent, NotificationClickResult } from './types/NotificationClicked'; + +export type { + InAppMessageActionUrlType, + InAppMessageClickEvent, + InAppMessageClickResult, + InAppMessageDidDismissEvent, + InAppMessageDidDisplayEvent, + InAppMessageWillDismissEvent, + InAppMessageWillDisplayEvent, + OSInAppMessage, +} from './types/InAppMessage'; + +export type { UserChangedState, UserState } from './UserNamespace'; + +export type { OneSignalCapacitorPlugin } from './definitions'; + +export default OneSignal; diff --git a/src/types/InAppMessage.ts b/src/types/InAppMessage.ts new file mode 100644 index 0000000..cbf8683 --- /dev/null +++ b/src/types/InAppMessage.ts @@ -0,0 +1,48 @@ +export type InAppMessageEventName = + | 'click' + | 'willDisplay' + | 'didDisplay' + | 'willDismiss' + | 'didDismiss'; + +export type InAppMessageEventTypeMap = { + click: InAppMessageClickEvent; + willDisplay: InAppMessageWillDisplayEvent; + didDisplay: InAppMessageDidDisplayEvent; + willDismiss: InAppMessageWillDismissEvent; + didDismiss: InAppMessageDidDismissEvent; +}; + +export interface InAppMessageClickEvent { + message: OSInAppMessage; + result: InAppMessageClickResult; +} + +export interface InAppMessageClickResult { + closingMessage: boolean; + actionId?: string; + url?: string; + urlTarget?: InAppMessageActionUrlType; +} + +export type InAppMessageActionUrlType = 'browser' | 'webview' | 'replacement'; + +export interface InAppMessageWillDisplayEvent { + message: OSInAppMessage; +} + +export interface InAppMessageDidDisplayEvent { + message: OSInAppMessage; +} + +export interface InAppMessageWillDismissEvent { + message: OSInAppMessage; +} + +export interface InAppMessageDidDismissEvent { + message: OSInAppMessage; +} + +export interface OSInAppMessage { + messageId: string; +} diff --git a/src/types/LiveActivities.ts b/src/types/LiveActivities.ts new file mode 100644 index 0000000..191a94b --- /dev/null +++ b/src/types/LiveActivities.ts @@ -0,0 +1,15 @@ +/** + * The setup options for `OneSignal.LiveActivities.setupDefault`. + */ +export type LiveActivitySetupOptions = { + /** + * When true, OneSignal will listen for pushToStart tokens for the `OneSignalLiveActivityAttributes` structure. + */ + enablePushToStart: boolean; + + /** + * When true, OneSignal will listen for pushToUpdate tokens for each start live activity that uses the + * `OneSignalLiveActivityAttributes` structure. + */ + enablePushToUpdate: boolean; +}; diff --git a/src/types/NotificationClicked.ts b/src/types/NotificationClicked.ts new file mode 100644 index 0000000..ba5dbed --- /dev/null +++ b/src/types/NotificationClicked.ts @@ -0,0 +1,20 @@ +import type { NotificationWillDisplayEvent } from '../NotificationReceivedEvent'; +import type { OSNotification } from '../OSNotification'; + +export type NotificationEventName = 'click' | 'foregroundWillDisplay' | 'permissionChange'; + +export type NotificationEventTypeMap = { + click: NotificationClickEvent; + foregroundWillDisplay: NotificationWillDisplayEvent; + permissionChange: boolean; +}; + +export interface NotificationClickEvent { + result: NotificationClickResult; + notification: OSNotification; +} + +export interface NotificationClickResult { + actionId?: string; + url?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..83b5865 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "outDir": "dist", + "target": "es2020", + "module": "ESNext", + "moduleResolution": "bundler", + "noImplicitAny": true, + "lib": ["es2020", "dom"], + "skipLibCheck": true, + "declaration": true, + "declarationDir": "dist", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "noUnusedLocals": true, + "types": [] + }, + "include": ["src", "mocks"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5a580bf --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,51 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + '*': 'vp check --fix', + }, + fmt: { + singleQuote: true, + sortImports: { + enabled: true, + }, + sortPackageJson: { + sortScripts: true, + }, + }, + lint: { + options: { typeAware: true, typeCheck: true }, + overrides: [ + { + files: ['**/*.test.ts', '**/*.test.tsx'], + rules: { + 'typescript/unbound-method': 'off', + }, + }, + ], + }, + pack: { + entry: 'src/index.ts', + dts: true, + format: 'es', + outExtensions: () => ({ js: '.js', dts: '.d.ts' }), + }, + test: { + clearMocks: true, + environment: 'happy-dom', + include: ['**/*.test.ts', '**/*.test.tsx'], + coverage: { + exclude: ['mocks/**', 'src/helpers.ts'], + enabled: true, + reporter: ['text-summary', 'lcov'], + reportOnFailure: true, + reportsDirectory: 'coverage', + thresholds: { + statements: 95, + branches: 95, + functions: 95, + lines: 95, + }, + }, + }, +});