diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 71b513cfdd..01324c94d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -234,6 +234,10 @@ jobs: echo "::endgroup::" - name: Reset Supabase DB before Cloudflare Workers tests run: supabase db reset + - name: Setup Node.js for Wrangler + uses: actions/setup-node@v6 # v6 + with: + node-version: 24 - id: start_cloudflare_workers name: Start Cloudflare Workers for testing env: diff --git a/bun.lock b/bun.lock index 849374fb68..e678a31694 100644 --- a/bun.lock +++ b/bun.lock @@ -191,6 +191,18 @@ "zod": "^4.3.6", }, }, + "packages/capacitor-notifications": { + "name": "@capgo/capacitor-notifications", + "version": "0.0.1-private.0", + "devDependencies": { + "@capacitor/android": "^8.0.0", + "@capacitor/ios": "^8.0.0", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "@capacitor/core": "^8.0.0", + }, + }, }, "trustedDependencies": [ "supabase", @@ -422,6 +434,8 @@ "@capgo/capacitor-native-biometric": ["@capgo/capacitor-native-biometric@8.4.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-mue8KmjtOO3l4vymwM34tHGt6NVg0Wg3GqdOPsjqoFAGrZC+tRPa4Xzn5n0B2XzDtAeMtfynxSJOhl8l1c2X0Q=="], + "@capgo/capacitor-notifications": ["@capgo/capacitor-notifications@workspace:packages/capacitor-notifications"], + "@capgo/capacitor-persistent-account": ["@capgo/capacitor-persistent-account@8.0.28", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-jBISTq+FuIy7m/YhFiODPTR4rdllMyKSeP8nVX/mP0pCFOPeOFiJV3rPExyqWvvVVH+vbezUaMgpsVWyWigDuA=="], "@capgo/capacitor-screen-orientation": ["@capgo/capacitor-screen-orientation@8.1.12", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-Rs6pAgX0dU3Mzts2E+0QfvSKT0E35ek4h0SDpum16s+Qa6LQqp/Wfy/rqTm4F0wPUC+Rj1ik6uOAw7a7fYmaew=="], @@ -2724,6 +2738,8 @@ "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "@capgo/capacitor-notifications/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@capgo/cli/@antfu/eslint-config": ["@antfu/eslint-config@7.7.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@clack/prompts": "^1.1.0", "@e18e/eslint-plugin": "^0.2.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1", "@eslint/markdown": "^7.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "@vitest/eslint-plugin": "^1.6.10", "ansis": "^4.2.0", "cac": "^7.0.0", "eslint-config-flat-gitignore": "^2.2.1", "eslint-flat-config-utils": "^3.0.2", "eslint-merge-processors": "^2.0.0", "eslint-plugin-antfu": "^3.2.2", "eslint-plugin-command": "^3.5.2", "eslint-plugin-import-lite": "^0.5.2", "eslint-plugin-jsdoc": "^62.7.1", "eslint-plugin-jsonc": "^3.1.1", "eslint-plugin-n": "^17.24.0", "eslint-plugin-no-only-tests": "^3.3.0", "eslint-plugin-perfectionist": "^5.6.0", "eslint-plugin-pnpm": "^1.6.0", "eslint-plugin-regexp": "^3.1.0", "eslint-plugin-toml": "^1.3.1", "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-vue": "^10.8.0", "eslint-plugin-yml": "^3.3.1", "eslint-processor-vue-blocks": "^2.0.0", "globals": "^17.4.0", "local-pkg": "^1.1.2", "parse-gitignore": "^2.0.0", "toml-eslint-parser": "^1.0.3", "vue-eslint-parser": "^10.4.0", "yaml-eslint-parser": "^2.0.0" }, "peerDependencies": { "@angular-eslint/eslint-plugin": "^21.1.0", "@angular-eslint/eslint-plugin-template": "^21.1.0", "@angular-eslint/template-parser": "^21.1.0", "@eslint-react/eslint-plugin": "^2.11.0", "@next/eslint-plugin-next": ">=15.0.0", "@prettier/plugin-xml": "^3.4.1", "@unocss/eslint-plugin": ">=0.50.0", "astro-eslint-parser": "^1.0.2", "eslint": "^9.10.0 || ^10.0.0", "eslint-plugin-astro": "^1.2.0", "eslint-plugin-format": ">=0.1.0", "eslint-plugin-jsx-a11y": ">=6.10.2", "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-solid": "^0.14.3", "eslint-plugin-svelte": ">=2.35.1", "eslint-plugin-vuejs-accessibility": "^2.4.1", "prettier-plugin-astro": "^0.14.0", "prettier-plugin-slidev": "^1.0.5", "svelte-eslint-parser": ">=0.37.0" }, "optionalPeers": ["@angular-eslint/eslint-plugin", "@angular-eslint/eslint-plugin-template", "@angular-eslint/template-parser", "@eslint-react/eslint-plugin", "@next/eslint-plugin-next", "@prettier/plugin-xml", "@unocss/eslint-plugin", "astro-eslint-parser", "eslint-plugin-astro", "eslint-plugin-format", "eslint-plugin-jsx-a11y", "eslint-plugin-react-hooks", "eslint-plugin-react-refresh", "eslint-plugin-solid", "eslint-plugin-svelte", "eslint-plugin-vuejs-accessibility", "prettier-plugin-astro", "prettier-plugin-slidev", "svelte-eslint-parser"], "bin": { "eslint-config": "bin/index.mjs" } }, "sha512-BtroDxTvmWtvr3yJkdWVCvwsKlnEdkreoeOyrdNezc/W5qaiQNf2xjcsQ3N5Yy0x27h+0WFfW8rG8YlVioG6dw=="], "@capgo/cli/eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], diff --git a/cli/src/notifications/setup.ts b/cli/src/notifications/setup.ts new file mode 100644 index 0000000000..682919806d --- /dev/null +++ b/cli/src/notifications/setup.ts @@ -0,0 +1,142 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { cwd } from 'node:process' +import { intro, log, outro, spinner } from '@clack/prompts' +import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' +import { defaultApiHost, formatError, getConfig, getPMAndCommand, updateConfigbyKey } from '../utils' +import { writeFileAtomic } from '../utils/safeWrites' + +const notificationPackages = [ + '@capgo/capacitor-notifications', + '@capacitor/push-notifications', + '@capacitor/preferences', + '@capacitor/app', + '@capacitor/device', +] + +interface NotificationSetupOptions { + serverUrl?: string + file?: string + force?: boolean + install?: boolean + sync?: boolean +} + +function getConfigAppId(config: Awaited>) { + return String(config.config?.plugins?.CapacitorUpdater?.appId || config.config?.appId || '') +} + +function renderNotificationHelper(appId: string, serverUrl: string) { + const appIdLiteral = JSON.stringify(appId) + const serverUrlLiteral = JSON.stringify(serverUrl) + + return `import { CapgoNotifications } from '@capgo/capacitor-notifications' + +export interface CapgoNotificationIdentity { + externalId: string + identityProof: string + tags?: string[] + attributes?: Record + consent?: boolean +} + +export async function setupCapgoNotifications(identity: CapgoNotificationIdentity) { + if (!identity.externalId) + return + if (!identity.identityProof) + throw new Error('Capgo notification identityProof is required') + + await CapgoNotifications.configure({ + appId: ${appIdLiteral}, + serverUrl: ${serverUrlLiteral}, + }) + + return CapgoNotifications.register({ + externalId: identity.externalId, + identityProof: identity.identityProof, + tags: identity.tags ?? [], + attributes: identity.attributes ?? {}, + consent: identity.consent ?? true, + }) +} + +export { CapgoNotifications } +` +} + +function runCommand(command: string, args: string[], failureMessage: string) { + const result = spawnSync(command, args, { stdio: 'inherit' }) + if (result.error) + throw result.error + if (result.status !== 0) + throw new Error(`${failureMessage} exited with code ${result.status}`) +} + +function runInstall() { + const pm = getPMAndCommand() + log.info(`Installing notification packages with ${pm.installCommand}`) + runCommand(pm.pm, [pm.command, ...notificationPackages], 'Notification package install') +} + +function runSync() { + const pm = getPMAndCommand() + const runner = splitRunnerCommand(pm.runner) + const displayCommand = formatRunnerCommand(pm.runner, ['cap', 'sync']) + log.info(`Running ${displayCommand}`) + runCommand(runner.command, [...runner.args, 'cap', 'sync'], 'Capacitor sync') +} + +function assertHelperFileWritable(filePath: string, force: boolean | undefined) { + const absolutePath = resolve(cwd(), filePath) + if (existsSync(absolutePath) && !force) + throw new Error(`${filePath} already exists. Re-run with --force to overwrite it.`) + return absolutePath +} + +async function writeHelperFile(filePath: string, appId: string, serverUrl: string, force: boolean | undefined) { + const absolutePath = assertHelperFileWritable(filePath, force) + mkdirSync(dirname(absolutePath), { recursive: true }) + await writeFileAtomic(absolutePath, renderNotificationHelper(appId, serverUrl), { mode: 0o644 }) + return absolutePath +} + +export async function setupNotifications(appIdArg: string | undefined, options: NotificationSetupOptions) { + intro('Capgo native notifications setup') + const progress = spinner() + + try { + const config = await getConfig() + const appId = appIdArg || getConfigAppId(config) + if (!appId) + throw new Error('Missing appId. Pass it as `notifications setup com.example.app` or set it in capacitor.config.') + + const serverUrl = options.serverUrl || defaultApiHost + const helperFile = options.file || 'src/capgo-notifications.ts' + assertHelperFileWritable(helperFile, options.force) + + if (options.install !== false) + runInstall() + + progress.start('Saving Capacitor notification config') + await updateConfigbyKey('CapgoNotifications', { appId, serverUrl }) + progress.stop('Capacitor notification config saved') + + const writtenPath = await writeHelperFile(helperFile, appId, serverUrl, options.force) + log.success(`Created ${writtenPath}`) + + if (options.sync !== false) + runSync() + + log.info('Import setupCapgoNotifications(...) after your user is known, and pass your stable customer external ID.') + log.info('Then configure FCM/APNs in the Capgo app Notifications tab before sending production notifications.') + outro('Notifications setup done') + } + catch (error) { + progress.stop('Notifications setup failed') + log.error(formatError(error)) + throw error + } +} + +export { renderNotificationHelper } diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 0a36a4311f..95be008580 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -38,6 +38,7 @@ import { app as bundle } from '../../supabase/functions/_backend/public/bundle/i import { app as channel } from '../../supabase/functions/_backend/public/channel/index.ts' import { app as check_cpu_usage } from '../../supabase/functions/_backend/public/check_cpu_usage.ts' import { app as device } from '../../supabase/functions/_backend/public/device/index.ts' +import { app as notifications } from '../../supabase/functions/_backend/public/notifications/index.ts' import { app as ok } from '../../supabase/functions/_backend/public/ok.ts' import { app as pluginRegions } from '../../supabase/functions/_backend/public/plugin_regions.ts' import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts' @@ -74,6 +75,7 @@ import { app as stripe_event } from '../../supabase/functions/_backend/triggers/ import { app as webhook_delivery } from '../../supabase/functions/_backend/triggers/webhook_delivery.ts' import { app as webhook_dispatcher } from '../../supabase/functions/_backend/triggers/webhook_dispatcher.ts' import { createAllCatch, createHono } from '../../supabase/functions/_backend/utils/hono.ts' +import { processNativeNotificationQueueBatch } from '../../supabase/functions/_backend/utils/nativeNotificationSender.ts' import { version } from '../../supabase/functions/_backend/utils/version.ts' // Public API @@ -85,6 +87,7 @@ app.route('/bundle', bundle) app.route('/channel', channel) app.route('/device', device) app.route('/organization', organization) +app.route('/notifications', notifications) app.route('/statistics', statistics) app.route('/webhooks', webhooks) app.route('/app', appEndpoint) @@ -173,4 +176,5 @@ createAllCatch(appTriggers, functionNameTriggers) export default { fetch: app.fetch, + queue: processNativeNotificationQueueBatch, } diff --git a/cloudflare_workers/api/wrangler.jsonc b/cloudflare_workers/api/wrangler.jsonc index ca56e8aaff..4bc0ca88b9 100644 --- a/cloudflare_workers/api/wrangler.jsonc +++ b/cloudflare_workers/api/wrangler.jsonc @@ -62,6 +62,14 @@ { "binding": "DEVICE_INFO", "dataset": "device_info" + }, + { + "binding": "NOTIFICATION_REGISTRY", + "dataset": "notification_registry" + }, + { + "binding": "NOTIFICATION_EVENTS", + "dataset": "notification_events" } ], "d1_databases": [ @@ -70,7 +78,24 @@ "database_name": "capgo_prod_storeapps", "database_id": "81236a0c-db6e-454d-87da-944fa9bc100c" } - ] + ], + "queues": { + "producers": [ + { + "binding": "NOTIFICATION_QUEUE", + "queue": "capgo-native-notifications-prod" + } + ], + "consumers": [ + { + "queue": "capgo-native-notifications-prod", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "capgo-native-notifications-prod-dlq" + } + ] + } }, "preprod": { "name": "capgo_api-preprod", @@ -117,6 +142,14 @@ { "binding": "DEVICE_INFO", "dataset": "device_info" + }, + { + "binding": "NOTIFICATION_REGISTRY", + "dataset": "notification_registry" + }, + { + "binding": "NOTIFICATION_EVENTS", + "dataset": "notification_events" } ], "d1_databases": [ @@ -125,7 +158,24 @@ "database_name": "capgo_prod_storeapps", "database_id": "81236a0c-db6e-454d-87da-944fa9bc100c" } - ] + ], + "queues": { + "producers": [ + { + "binding": "NOTIFICATION_QUEUE", + "queue": "capgo-native-notifications-preprod" + } + ], + "consumers": [ + { + "queue": "capgo-native-notifications-preprod", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "capgo-native-notifications-preprod-dlq" + } + ] + } }, "alpha": { "name": "capgo_api-alpha", @@ -172,6 +222,14 @@ { "binding": "DEVICE_INFO", "dataset": "device_info_alpha" + }, + { + "binding": "NOTIFICATION_REGISTRY", + "dataset": "notification_registry_alpha" + }, + { + "binding": "NOTIFICATION_EVENTS", + "dataset": "notification_events_alpha" } ], "d1_databases": [ @@ -180,7 +238,24 @@ "database_name": "capgo_prod_storeapps", "database_id": "81236a0c-db6e-454d-87da-944fa9bc100c" } - ] + ], + "queues": { + "producers": [ + { + "binding": "NOTIFICATION_QUEUE", + "queue": "capgo-native-notifications-alpha" + } + ], + "consumers": [ + { + "queue": "capgo-native-notifications-alpha", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "capgo-native-notifications-alpha-dlq" + } + ] + } }, "local": { "name": "capgo_api-local", diff --git a/docs/pr-screenshots/native-notifications-desktop.png b/docs/pr-screenshots/native-notifications-desktop.png new file mode 100644 index 0000000000..58c5a7d9d7 Binary files /dev/null and b/docs/pr-screenshots/native-notifications-desktop.png differ diff --git a/docs/pr-screenshots/native-notifications-mobile.png b/docs/pr-screenshots/native-notifications-mobile.png new file mode 100644 index 0000000000..b5d424a2b9 Binary files /dev/null and b/docs/pr-screenshots/native-notifications-mobile.png differ diff --git a/messages/en.json b/messages/en.json index 6514070654..843e6fa8b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -36,7 +36,9 @@ "6-characters-minimum": "6 characters minimum, 1 uppercase, 1 lowercase, 1 special character", "90-days": "Last 90 Days", "Bandwidth": "Bandwidth", + "badge": "Badge", "Current": "Current", + "configured": "Configured", "Filters": "Filters", "MAU": "MAU", "Storage": "Storage", @@ -912,6 +914,7 @@ "dont-have-an-account": "Don't have an account?", "downgrade": "Downgrade", "download": "Download", + "draft": "Draft", "download-csv": "Download CSV", "edit-role": "Edit role", "edit-webhook": "Edit Webhook", @@ -1268,6 +1271,33 @@ "not-logged-in": "Not logged in", "not-set": "Not set", "notifications": "notifications", + "notification-action-error": "Notification action failed", + "notification-audience-json": "Audience JSON", + "notification-body": "Body", + "notification-campaign-name": "Campaign name", + "notification-campaigns": "Notification campaigns", + "notification-create-campaign": "Create campaign", + "notification-create-success": "Campaign created", + "notification-invalid-json": "Invalid JSON", + "notification-load-error": "Failed to load notifications", + "notification-lookup": "Lookup", + "notification-lookup-success": "Recipient loaded", + "notification-no-campaigns": "No campaigns yet", + "notification-no-devices": "No active devices found", + "notification-payload-json": "Payload JSON", + "notification-provider-config-json": "Provider config JSON", + "notification-provider-secret-ref": "Secret env name", + "notification-provider-setup": "Provider setup", + "notification-quick-send": "Test send", + "notification-recipient-external-id": "Customer external ID", + "notification-recipient-lookup": "Recipient lookup", + "notification-save-provider": "Save provider", + "notification-save-settings": "Save settings", + "notification-save-success": "Provider saved", + "notification-send-success": "Notification queued", + "notification-send-test": "Send test", + "notification-stats": "Notification stats", + "notification-title": "Notifications", "notifications-activity": "Activity Notifications", "notifications-billing-period-stats": "Billing period statistics", "notifications-billing-period-stats-desc": "Receive usage statistics on your billing anniversary date with plan upgrade recommendations", @@ -1967,5 +1997,23 @@ "restore-account": "Restore account", "restoring-account": "Restoring account...", "translation-not-ready": "Translation is being prepared. Try again in a bit.", - "translation-unavailable": "This language is not available right now." + "translation-unavailable": "This language is not available right now.", + "provider": "Provider", + "notification-configured-providers": "Configured providers", + "notification-events-30d": "Events in 30 days", + "notification-device-results": "Devices found", + "notification-active-providers": "Active providers", + "notification-no-providers": "No providers configured", + "notification-provider-secret-ref-placeholder": "NOTIFICATIONS_FCM_SERVICE_ACCOUNT", + "notification-message-title": "Title", + "notification-message-body": "Body", + "notification-no-stats": "No notification events yet", + "notification-push-update": "Push update", + "notification-push-update-enabled": "Enable push update", + "notification-update-install-mode": "Install mode", + "notification-update-install-next": "Next launch", + "notification-update-install-now": "Immediately", + "notification-push-update-now": "Push now", + "notification-settings-save-success": "Notification settings saved", + "notification-update-push-success": "Update push queued" } diff --git a/package.json b/package.json index e18aca7cf5..9f8ec4967a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "12.136.1", "private": true, "workspaces": [ - "cli" + "cli", + "packages/*" ], "license": "GPL-3.0", "scripts": { diff --git a/packages/capacitor-notifications/.gitignore b/packages/capacitor-notifications/.gitignore new file mode 100644 index 0000000000..b6aac63804 --- /dev/null +++ b/packages/capacitor-notifications/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.swiftpm +.build +android/.gradle +android/build +android/local.properties +local.properties diff --git a/packages/capacitor-notifications/CapgoCapacitorNotifications.podspec b/packages/capacitor-notifications/CapgoCapacitorNotifications.podspec new file mode 100644 index 0000000000..b7c020eb0d --- /dev/null +++ b/packages/capacitor-notifications/CapgoCapacitorNotifications.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'CapgoCapacitorNotifications' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://capgo.app' + s.author = 'Capgo ' + s.source = { :git => 'https://github.com/Cap-go/capgo.git', :tag => package['name'] + '@' + package['version'] } + s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}' + s.ios.deployment_target = '15.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/packages/capacitor-notifications/Package.resolved b/packages/capacitor-notifications/Package.resolved new file mode 100644 index 0000000000..7e636d227c --- /dev/null +++ b/packages/capacitor-notifications/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "1af38be000bb5fcd1d8fec09694115cdcb179695", + "version" : "8.3.3" + } + } + ], + "version" : 2 +} diff --git a/packages/capacitor-notifications/Package.swift b/packages/capacitor-notifications/Package.swift new file mode 100644 index 0000000000..35835c529d --- /dev/null +++ b/packages/capacitor-notifications/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "CapgoCapacitorNotifications", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapgoCapacitorNotifications", + targets: ["CapgoNotificationsPlugin"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0") + ], + targets: [ + .target( + name: "CapgoNotificationsPlugin", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm") + ], + path: "ios/Sources/CapgoNotificationsPlugin") + ] +) diff --git a/packages/capacitor-notifications/README.md b/packages/capacitor-notifications/README.md new file mode 100644 index 0000000000..56f0164644 --- /dev/null +++ b/packages/capacitor-notifications/README.md @@ -0,0 +1,66 @@ +# @capgo/capacitor-notifications + +First-party Capgo native notification plugin for Capacitor apps. + +It handles: + +- APNs and FCM token registration with Capgo +- Foreground notification receive events +- Notification open tracking +- Badge count reads and writes +- Background data notification callbacks +- Silent Capgo live update checks through `@capgo/capacitor-updater` + +## Setup + +This package is in private Capgo preview. Until the private package is enabled for your npm account, use this only from Capgo-managed workspaces or an internal registry token. + +```bash +npm install @capgo/capacitor-notifications @capgo/capacitor-updater +npx cap sync +``` + +Or let the Capgo CLI patch the app entrypoint: + +```bash +npx @capgo/cli@latest notifications setup +``` + +## Usage + +```ts +import { CapgoNotifications } from '@capgo/capacitor-notifications' + +await CapgoNotifications.configure({ + appId: 'com.example.app', + autoUpdater: true, + updateInstallMode: 'next', +}) + +await CapgoNotifications.register({ + externalId: 'customer-user-123', + identityProof: '', + tags: ['paid'], + attributes: { plan: 'team' }, + consent: true, +}) + +CapgoNotifications.addListener('notificationReceived', (notification) => { + console.log('Received', notification) +}) + +CapgoNotifications.addListener('notificationOpened', (event) => { + console.log('Opened', event) +}) +``` + +Mint `identityProof` from your backend with `POST /notifications/recipients/proof` using your Capgo API key, then pass it to the app after your own user authentication succeeds. + +## Silent Update Checks + +When Capgo sends a silent notification with `capgoAction=update_check`, this plugin asks `@capgo/capacitor-updater` for the latest bundle, downloads it, and either: + +- queues it with `next`, so it installs on the next app restart/background cycle +- installs it with `set`, when configured by the Capgo app setting + +iOS background pushes remain best-effort and can be throttled by the OS. diff --git a/packages/capacitor-notifications/android/build.gradle b/packages/capacitor-notifications/android/build.gradle new file mode 100644 index 0000000000..41dff3d1bc --- /dev/null +++ b/packages/capacitor-notifications/android/build.gradle @@ -0,0 +1,64 @@ +ext { + capacitorVersion = System.getenv('CAPACITOR_VERSION') ?: '8.0.0' + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1' + androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0' + androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0' + firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '25.0.1' +} + +buildscript { + repositories { + google() + mavenCentral() + maven { url = "https://plugins.gradle.org/m2/" } + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +apply plugin: 'com.android.library' + +android { + namespace = "app.capgo.capacitornotifications" + compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + lintOptions { abortOnError = false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + publishing { singleVariant("release") } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + if (System.getenv("CAP_PLUGIN_PUBLISH") == "true") { + implementation "com.capacitorjs:core:$capacitorVersion" + } else { + implementation project(':capacitor-android') + } + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" +} diff --git a/packages/capacitor-notifications/android/gradle.properties b/packages/capacitor-notifications/android/gradle.properties new file mode 100644 index 0000000000..646c51b977 --- /dev/null +++ b/packages/capacitor-notifications/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/capacitor-notifications/android/settings.gradle b/packages/capacitor-notifications/android/settings.gradle new file mode 100644 index 0000000000..8fa0780aa4 --- /dev/null +++ b/packages/capacitor-notifications/android/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'capgo-capacitor-notifications' +include ':capacitor-android' +project(':capacitor-android').projectDir = new File(settingsDir, '../../../node_modules/@capacitor/android/capacitor') diff --git a/packages/capacitor-notifications/android/src/main/AndroidManifest.xml b/packages/capacitor-notifications/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..209887aac5 --- /dev/null +++ b/packages/capacitor-notifications/android/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/CapgoNotificationsPlugin.java b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/CapgoNotificationsPlugin.java new file mode 100644 index 0000000000..6d78728df8 --- /dev/null +++ b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/CapgoNotificationsPlugin.java @@ -0,0 +1,407 @@ +package app.capgo.capacitornotifications; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.service.notification.StatusBarNotification; +import com.getcapacitor.Bridge; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.PermissionState; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginHandle; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; +import com.google.firebase.messaging.CommonNotificationBuilder; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.NotificationParams; +import com.google.firebase.messaging.RemoteMessage; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Queue; +import java.util.UUID; +import org.json.JSONException; +import org.json.JSONObject; + +@CapacitorPlugin( + name = "CapgoNotifications", + permissions = @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = CapgoNotificationsPlugin.PUSH_NOTIFICATIONS) +) +public class CapgoNotificationsPlugin extends Plugin { + + static final String PUSH_NOTIFICATIONS = "receive"; + private static final String EVENT_TOKEN_CHANGE = "registration"; + private static final String EVENT_TOKEN_ERROR = "registrationError"; + private static final String PREFS_NAME = "capgo_notifications"; + private static final String INSTALL_ID_KEY = "nativeInstallId"; + private static final String BADGE_KEY = "badge"; + private static final int MAX_PENDING_MESSAGES = 64; + private static final Object pendingMessagesLock = new Object(); + private static final Queue pendingMessages = new ArrayDeque<>(); + + public static Bridge staticBridge = null; + public NotificationManager notificationManager; + private NotificationChannelManager notificationChannelManager; + private SharedPreferences preferences; + + public void load() { + notificationManager = (NotificationManager) getActivity().getSystemService(Context.NOTIFICATION_SERVICE); + preferences = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + staticBridge = this.bridge; + while (true) { + RemoteMessage pendingMessage; + synchronized (pendingMessagesLock) { + pendingMessage = pendingMessages.poll(); + } + if (pendingMessage == null) { + break; + } + fireNotification(pendingMessage); + } + notificationChannelManager = new NotificationChannelManager(getActivity(), notificationManager); + } + + @Override + protected void handleOnNewIntent(Intent data) { + super.handleOnNewIntent(data); + Bundle bundle = data.getExtras(); + if (bundle != null && bundle.containsKey("google.message_id")) { + JSObject notificationJson = new JSObject(); + JSObject dataObject = new JSObject(); + for (String key : bundle.keySet()) { + if (key.equals("google.message_id")) { + notificationJson.put("id", bundle.getString(key)); + } else { + dataObject.put(key, bundle.get(key)); + } + } + notificationJson.put("data", dataObject); + JSObject actionJson = new JSObject(); + actionJson.put("actionId", "tap"); + actionJson.put("notification", notificationJson); + notifyListeners("notificationOpened", actionJson, true); + } + } + + @PluginMethod + public void checkPermissions(PluginCall call) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + JSObject permissionsResultJSON = new JSObject(); + permissionsResultJSON.put("receive", "granted"); + call.resolve(permissionsResultJSON); + } else { + super.checkPermissions(call); + } + } + + @PluginMethod + public void requestPermissions(PluginCall call) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || getPermissionState(PUSH_NOTIFICATIONS) == PermissionState.GRANTED) { + JSObject permissionsResultJSON = new JSObject(); + permissionsResultJSON.put("receive", "granted"); + call.resolve(permissionsResultJSON); + } else { + requestPermissionForAlias(PUSH_NOTIFICATIONS, call, "permissionsCallback"); + } + } + + @PluginMethod + public void registerPush(PluginCall call) { + FirebaseMessaging.getInstance().setAutoInitEnabled(true); + FirebaseMessaging.getInstance() + .getToken() + .addOnCompleteListener((task) -> { + if (!task.isSuccessful()) { + Exception exception = task.getException(); + sendError(exception == null ? "Unable to register for FCM" : exception.getLocalizedMessage()); + return; + } + sendToken(task.getResult()); + }); + call.resolve(); + } + + @PluginMethod + public void unregisterPush(PluginCall call) { + FirebaseMessaging.getInstance().setAutoInitEnabled(false); + FirebaseMessaging.getInstance().deleteToken(); + call.resolve(); + } + + @PluginMethod + public void setBadge(PluginCall call) { + int count = Math.max(0, call.getInt("count", 0)); + preferences.edit().putInt(BADGE_KEY, count).apply(); + JSObject result = new JSObject(); + result.put("count", count); + call.resolve(result); + } + + @PluginMethod + public void clearBadge(PluginCall call) { + preferences.edit().putInt(BADGE_KEY, 0).apply(); + JSObject result = new JSObject(); + result.put("count", 0); + call.resolve(result); + } + + @PluginMethod + public void getBadge(PluginCall call) { + JSObject result = new JSObject(); + result.put("count", preferences.getInt(BADGE_KEY, 0)); + call.resolve(result); + } + + @PluginMethod + public void getNativeInstallId(PluginCall call) { + String installId = preferences.getString(INSTALL_ID_KEY, null); + if (installId == null || installId.isEmpty()) { + installId = UUID.randomUUID().toString(); + preferences.edit().putString(INSTALL_ID_KEY, installId).apply(); + } + JSObject result = new JSObject(); + result.put("nativeInstallId", installId); + call.resolve(result); + } + + @PluginMethod + public void getAppInfo(PluginCall call) { + JSObject result = new JSObject(); + try { + PackageManager packageManager = getContext().getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(getContext().getPackageName(), 0); + ApplicationInfo applicationInfo = packageManager.getApplicationInfo(getContext().getPackageName(), 0); + result.put("version", packageInfo.versionName == null ? "" : packageInfo.versionName); + result.put("build", String.valueOf(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? packageInfo.getLongVersionCode() : packageInfo.versionCode)); + result.put("name", packageManager.getApplicationLabel(applicationInfo).toString()); + result.put("id", getContext().getPackageName()); + call.resolve(result); + } catch (PackageManager.NameNotFoundException exception) { + call.reject(exception.getMessage()); + } + } + + @PluginMethod + public void createDefaultChannel(PluginCall call) { + notificationChannelManager.createDefaultChannel(call); + } + + @PluginMethod + public void createChannel(PluginCall call) { + notificationChannelManager.createChannel(call); + } + + @PluginMethod + public void deleteChannel(PluginCall call) { + notificationChannelManager.deleteChannel(call); + } + + @PluginMethod + public void listChannels(PluginCall call) { + notificationChannelManager.listChannels(call); + } + + @PluginMethod + public void getDeliveredNotifications(PluginCall call) { + JSArray notifications = new JSArray(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + JSObject result = new JSObject(); + result.put("notifications", notifications); + call.resolve(result); + return; + } + StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); + for (StatusBarNotification notif : activeNotifications) { + JSObject jsNotif = new JSObject(); + jsNotif.put("id", notif.getId()); + jsNotif.put("tag", notif.getTag()); + Notification notification = notif.getNotification(); + if (notification != null) { + jsNotif.put("title", notification.extras.getCharSequence(Notification.EXTRA_TITLE)); + jsNotif.put("body", notification.extras.getCharSequence(Notification.EXTRA_TEXT)); + jsNotif.put("group", notification.getGroup()); + jsNotif.put("groupSummary", 0 != (notification.flags & Notification.FLAG_GROUP_SUMMARY)); + JSObject extras = new JSObject(); + for (String key : notification.extras.keySet()) { + extras.put(key, notification.extras.get(key)); + } + jsNotif.put("data", extras); + } + notifications.put(jsNotif); + } + JSObject result = new JSObject(); + result.put("notifications", notifications); + call.resolve(result); + } + + @PluginMethod + public void removeDeliveredNotifications(PluginCall call) { + JSArray notifications = call.getArray("notifications"); + if (notifications == null) { + call.reject("notifications is required"); + return; + } + try { + for (Object item : notifications.toList()) { + if (item instanceof JSONObject) { + JSObject notif = JSObject.fromJSONObject((JSONObject) item); + String tag = notif.getString("tag"); + Integer id = notif.getInteger("id"); + if (id == null) { + call.reject("notification id is required"); + return; + } + if (tag == null) { + notificationManager.cancel(id); + } else { + notificationManager.cancel(tag, id); + } + } else { + call.reject("Expected notifications to be a list of notification objects"); + return; + } + } + } catch (JSONException exception) { + call.reject(exception.getMessage()); + return; + } + call.resolve(); + } + + @PluginMethod + public void removeAllDeliveredNotifications(PluginCall call) { + notificationManager.cancelAll(); + preferences.edit().putInt(BADGE_KEY, 0).apply(); + call.resolve(); + } + + public void sendToken(String token) { + JSObject data = new JSObject(); + data.put("value", token); + notifyListeners(EVENT_TOKEN_CHANGE, data, true); + } + + public void sendError(String error) { + JSObject data = new JSObject(); + data.put("error", error); + notifyListeners(EVENT_TOKEN_ERROR, data, true); + } + + public static void onNewToken(String newToken) { + CapgoNotificationsPlugin plugin = CapgoNotificationsPlugin.getCapgoNotificationsInstance(); + if (plugin != null) { + plugin.sendToken(newToken); + } + } + + public static void sendRemoteMessage(RemoteMessage remoteMessage) { + CapgoNotificationsPlugin plugin = CapgoNotificationsPlugin.getCapgoNotificationsInstance(); + if (plugin != null) { + plugin.fireNotification(remoteMessage); + } else { + synchronized (pendingMessagesLock) { + if (pendingMessages.size() >= MAX_PENDING_MESSAGES) { + pendingMessages.poll(); + } + pendingMessages.add(remoteMessage); + } + } + } + + public void fireNotification(RemoteMessage remoteMessage) { + JSObject remoteMessageData = new JSObject(); + JSObject data = new JSObject(); + remoteMessageData.put("id", remoteMessage.getMessageId()); + for (String key : remoteMessage.getData().keySet()) { + data.put(key, remoteMessage.getData().get(key)); + } + remoteMessageData.put("data", data); + + RemoteMessage.Notification notification = remoteMessage.getNotification(); + if (notification != null) { + String title = notification.getTitle(); + String body = notification.getBody(); + String[] presentation = getConfig().getArray("presentationOptions"); + if (presentation != null && Arrays.asList(presentation).contains("alert")) { + Bundle bundle = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? getBundleApi33() : getBundleLegacy(); + if (bundle != null) { + NotificationParams params = new NotificationParams(remoteMessage.toIntent().getExtras()); + String channelId = CommonNotificationBuilder.getOrCreateChannel(getContext(), params.getNotificationChannelId(), bundle); + CommonNotificationBuilder.DisplayNotificationInfo notificationInfo = CommonNotificationBuilder.createNotificationInfo(getContext(), getContext(), params, channelId, bundle); + notificationManager.notify(notificationInfo.tag, notificationInfo.id, notificationInfo.notificationBuilder.build()); + } + } + remoteMessageData.put("title", title); + remoteMessageData.put("body", body); + remoteMessageData.put("click_action", notification.getClickAction()); + Uri link = notification.getLink(); + if (link != null) { + remoteMessageData.put("link", link.toString()); + } + } + + notifyListeners("notificationReceived", remoteMessageData, true); + if (isCapgoBackgroundMessage(remoteMessage)) { + notifyListeners("backgroundNotification", remoteMessageData, true); + } + } + + public static CapgoNotificationsPlugin getCapgoNotificationsInstance() { + if (staticBridge != null && staticBridge.getWebView() != null) { + PluginHandle handle = staticBridge.getPlugin("CapgoNotifications"); + if (handle == null) { + return null; + } + return (CapgoNotificationsPlugin) handle.getInstance(); + } + return null; + } + + @PermissionCallback + private void permissionsCallback(PluginCall call) { + this.checkPermissions(call); + } + + private boolean isCapgoBackgroundMessage(RemoteMessage remoteMessage) { + String action = remoteMessage.getData().get("capgoAction"); + if (action == null) { + action = remoteMessage.getData().get("capgo_action"); + } + return "update_check".equals(action) || "capgo_update_check".equals(action) || "background".equals(action); + } + + @SuppressWarnings("deprecation") + private Bundle getBundleLegacy() { + try { + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + return applicationInfo.metaData; + } catch (PackageManager.NameNotFoundException exception) { + exception.printStackTrace(); + return null; + } + } + + private Bundle getBundleApi33() { + try { + ApplicationInfo applicationInfo = getContext() + .getPackageManager() + .getApplicationInfo(getContext().getPackageName(), PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + return applicationInfo.metaData; + } catch (PackageManager.NameNotFoundException exception) { + exception.printStackTrace(); + return null; + } + } +} diff --git a/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/MessagingService.java b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/MessagingService.java new file mode 100644 index 0000000000..3875abcc56 --- /dev/null +++ b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/MessagingService.java @@ -0,0 +1,20 @@ +package app.capgo.capacitornotifications; + +import androidx.annotation.NonNull; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +public class MessagingService extends FirebaseMessagingService { + + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + CapgoNotificationsPlugin.sendRemoteMessage(remoteMessage); + } + + @Override + public void onNewToken(@NonNull String token) { + super.onNewToken(token); + CapgoNotificationsPlugin.onNewToken(token); + } +} diff --git a/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/NotificationChannelManager.java b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/NotificationChannelManager.java new file mode 100644 index 0000000000..0987919128 --- /dev/null +++ b/packages/capacitor-notifications/android/src/main/java/app/capgo/capacitornotifications/NotificationChannelManager.java @@ -0,0 +1,160 @@ +package app.capgo.capacitornotifications; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.Context; +import android.media.AudioAttributes; +import android.net.Uri; +import android.os.Build; +import androidx.core.app.NotificationCompat; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Logger; +import com.getcapacitor.PluginCall; +import com.getcapacitor.util.WebColor; +import java.util.List; + +public class NotificationChannelManager { + + private final Context context; + private final NotificationManager notificationManager; + + private static final String CHANNEL_ID = "id"; + private static final String CHANNEL_NAME = "name"; + private static final String CHANNEL_DESCRIPTION = "description"; + private static final String CHANNEL_IMPORTANCE = "importance"; + private static final String CHANNEL_VISIBILITY = "visibility"; + private static final String CHANNEL_SOUND = "sound"; + private static final String CHANNEL_VIBRATE = "vibration"; + private static final String CHANNEL_USE_LIGHTS = "lights"; + private static final String CHANNEL_LIGHT_COLOR = "lightColor"; + private static final String CHANNEL_SHOW_BADGE = "showBadge"; + + public NotificationChannelManager(Context context, NotificationManager manager) { + this.context = context; + this.notificationManager = manager; + } + + public void createDefaultChannel(PluginCall call) { + JSObject channel = new JSObject(); + channel.put(CHANNEL_ID, call.getString(CHANNEL_ID, "capgo")); + channel.put(CHANNEL_NAME, call.getString(CHANNEL_NAME, "Capgo")); + channel.put(CHANNEL_DESCRIPTION, call.getString(CHANNEL_DESCRIPTION, "Capgo notifications")); + channel.put(CHANNEL_IMPORTANCE, call.getInt(CHANNEL_IMPORTANCE, NotificationManager.IMPORTANCE_DEFAULT)); + channel.put(CHANNEL_VISIBILITY, call.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC)); + channel.put(CHANNEL_SOUND, call.getString(CHANNEL_SOUND, null)); + channel.put(CHANNEL_VIBRATE, call.getBoolean(CHANNEL_VIBRATE, true)); + channel.put(CHANNEL_USE_LIGHTS, call.getBoolean(CHANNEL_USE_LIGHTS, false)); + channel.put(CHANNEL_LIGHT_COLOR, call.getString(CHANNEL_LIGHT_COLOR, null)); + channel.put(CHANNEL_SHOW_BADGE, call.getBoolean(CHANNEL_SHOW_BADGE, true)); + createChannel(channel); + call.resolve(); + } + + public void createChannel(PluginCall call) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + JSObject channel = new JSObject(); + if (call.getString(CHANNEL_ID) != null) { + channel.put(CHANNEL_ID, call.getString(CHANNEL_ID)); + } else { + call.reject("Channel missing identifier"); + return; + } + if (call.getString(CHANNEL_NAME) != null) { + channel.put(CHANNEL_NAME, call.getString(CHANNEL_NAME)); + } else { + call.reject("Channel missing name"); + return; + } + channel.put(CHANNEL_IMPORTANCE, call.getInt(CHANNEL_IMPORTANCE, NotificationManager.IMPORTANCE_DEFAULT)); + channel.put(CHANNEL_DESCRIPTION, call.getString(CHANNEL_DESCRIPTION, "")); + channel.put(CHANNEL_VISIBILITY, call.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC)); + channel.put(CHANNEL_SOUND, call.getString(CHANNEL_SOUND, null)); + channel.put(CHANNEL_VIBRATE, call.getBoolean(CHANNEL_VIBRATE, false)); + channel.put(CHANNEL_USE_LIGHTS, call.getBoolean(CHANNEL_USE_LIGHTS, false)); + channel.put(CHANNEL_LIGHT_COLOR, call.getString(CHANNEL_LIGHT_COLOR, null)); + channel.put(CHANNEL_SHOW_BADGE, call.getBoolean(CHANNEL_SHOW_BADGE, true)); + createChannel(channel); + call.resolve(); + } else { + call.unavailable(); + } + } + + public void createChannel(JSObject channel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel notificationChannel = new NotificationChannel( + channel.getString(CHANNEL_ID), + channel.getString(CHANNEL_NAME), + channel.getInteger(CHANNEL_IMPORTANCE) + ); + notificationChannel.setDescription(channel.getString(CHANNEL_DESCRIPTION)); + notificationChannel.setLockscreenVisibility(channel.getInteger(CHANNEL_VISIBILITY)); + notificationChannel.enableVibration(channel.getBool(CHANNEL_VIBRATE)); + notificationChannel.enableLights(channel.getBool(CHANNEL_USE_LIGHTS)); + notificationChannel.setShowBadge(channel.getBool(CHANNEL_SHOW_BADGE)); + String lightColor = channel.getString(CHANNEL_LIGHT_COLOR); + if (lightColor != null) { + try { + notificationChannel.setLightColor(WebColor.parseColor(lightColor)); + } catch (IllegalArgumentException ex) { + Logger.error(Logger.tags("CapgoNotificationChannel"), "Invalid light color.", null); + } + } + String sound = channel.getString(CHANNEL_SOUND, null); + if (sound != null && !sound.isEmpty()) { + if (sound.contains(".")) { + sound = sound.substring(0, sound.lastIndexOf('.')); + } + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + Uri soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/raw/" + sound); + notificationChannel.setSound(soundUri, audioAttributes); + } + notificationManager.createNotificationChannel(notificationChannel); + } + } + + public void deleteChannel(PluginCall call) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String channelId = call.getString("id"); + if (channelId == null) { + call.reject("Channel id is required"); + return; + } + notificationManager.deleteNotificationChannel(channelId); + call.resolve(); + } else { + call.unavailable(); + } + } + + public void listChannels(PluginCall call) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + List notificationChannels = notificationManager.getNotificationChannels(); + JSArray channels = new JSArray(); + for (NotificationChannel notificationChannel : notificationChannels) { + JSObject channel = new JSObject(); + channel.put(CHANNEL_ID, notificationChannel.getId()); + channel.put(CHANNEL_NAME, notificationChannel.getName()); + channel.put(CHANNEL_DESCRIPTION, notificationChannel.getDescription()); + channel.put(CHANNEL_IMPORTANCE, notificationChannel.getImportance()); + channel.put(CHANNEL_VISIBILITY, notificationChannel.getLockscreenVisibility()); + channel.put(CHANNEL_SOUND, notificationChannel.getSound()); + channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate()); + channel.put(CHANNEL_USE_LIGHTS, notificationChannel.shouldShowLights()); + channel.put(CHANNEL_SHOW_BADGE, notificationChannel.canShowBadge()); + channel.put(CHANNEL_LIGHT_COLOR, String.format("#%06X", (0xFFFFFF & notificationChannel.getLightColor()))); + channels.put(channel); + } + JSObject result = new JSObject(); + result.put("channels", channels); + call.resolve(result); + } else { + call.unavailable(); + } + } +} diff --git a/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsHandler.swift b/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsHandler.swift new file mode 100644 index 0000000000..3c517cf7df --- /dev/null +++ b/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsHandler.swift @@ -0,0 +1,84 @@ +import Capacitor +import UserNotifications + +public class CapgoNotificationsHandler: NSObject, NotificationHandlerProtocol { + public weak var plugin: CAPPlugin? + + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + completion?(granted, error) + } + } + + public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + completion?(settings.authorizationStatus) + } + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + let notificationData = makeNotificationRequestJSObject(notification.request) + self.plugin?.notifyListeners("notificationReceived", data: notificationData, retainUntilConsumed: true) + + if isCapgoBackgroundPayload(notification.request.content.userInfo) { + self.plugin?.notifyListeners("backgroundNotification", data: notificationData, retainUntilConsumed: true) + return UNNotificationPresentationOptions.init(rawValue: 0) + } + + if let optionsArray = self.plugin?.getConfig().getArray("presentationOptions") as? [String] { + var presentationOptions = UNNotificationPresentationOptions.init() + optionsArray.forEach { option in + switch option { + case "alert": + presentationOptions.insert(.alert) + case "badge": + presentationOptions.insert(.badge) + case "sound": + presentationOptions.insert(.sound) + default: + print("Unrecognized presentation option: (option)") + } + } + return presentationOptions + } + + return [] + } + + public func didReceive(response: UNNotificationResponse) { + var data = JSObject() + let originalNotificationRequest = response.notification.request + let actionId = response.actionIdentifier + + if actionId == UNNotificationDefaultActionIdentifier { + data["actionId"] = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + data["actionId"] = "dismiss" + } else { + data["actionId"] = actionId + } + + if let inputType = response as? UNTextInputNotificationResponse { + data["inputValue"] = inputType.userText + } + + data["notification"] = makeNotificationRequestJSObject(originalNotificationRequest) + self.plugin?.notifyListeners("notificationOpened", data: data, retainUntilConsumed: true) + } + + func makeNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + return [ + "id": request.identifier, + "title": request.content.title, + "subtitle": request.content.subtitle, + "badge": request.content.badge ?? 0, + "body": request.content.body, + "data": JSTypes.coerceDictionaryToJSObject(request.content.userInfo) ?? [:] + ] + } + + private func isCapgoBackgroundPayload(_ userInfo: [AnyHashable: Any]) -> Bool { + let action = (userInfo["capgoAction"] as? String) ?? (userInfo["capgo_action"] as? String) ?? "" + return action == "update_check" || action == "capgo_update_check" || action == "background" + } +} diff --git a/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsPlugin.swift b/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsPlugin.swift new file mode 100644 index 0000000000..1b7cba0c65 --- /dev/null +++ b/packages/capacitor-notifications/ios/Sources/CapgoNotificationsPlugin/CapgoNotificationsPlugin.swift @@ -0,0 +1,220 @@ +import Foundation +import Capacitor +import UIKit +import UserNotifications + +enum CapgoNotificationError: Error { + case tokenParsingFailed +} + +enum CapgoNotificationsPermissions: String { + case prompt + case denied + case granted +} + +@objc(CapgoNotificationsPlugin) +public class CapgoNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CapgoNotificationsPlugin" + public let jsName = "CapgoNotifications" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "unregisterPush", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setBadge", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearBadge", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getBadge", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getNativeInstallId", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getAppInfo", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "createDefaultChannel", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "createChannel", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "deleteChannel", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getDeliveredNotifications", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeDeliveredNotifications", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "removeAllDeliveredNotifications", returnType: CAPPluginReturnPromise) + ] + + private let notificationDelegateHandler = CapgoNotificationsHandler() + private let installIdKey = "capgo.notifications.nativeInstallId" + + override public func load() { + self.bridge?.notificationRouter.pushNotificationHandler = self.notificationDelegateHandler + self.notificationDelegateHandler.plugin = self + + NotificationCenter.default.addObserver(self, + selector: #selector(self.didRegisterForRemoteNotificationsWithDeviceToken(notification:)), + name: .capacitorDidRegisterForRemoteNotifications, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(self.didFailToRegisterForRemoteNotificationsWithError(notification:)), + name: .capacitorDidFailToRegisterForRemoteNotifications, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc func registerPush(_ call: CAPPluginCall) { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + call.resolve() + } + + @objc func unregisterPush(_ call: CAPPluginCall) { + DispatchQueue.main.async { + UIApplication.shared.unregisterForRemoteNotifications() + call.resolve() + } + } + + @objc override public func requestPermissions(_ call: CAPPluginCall) { + self.notificationDelegateHandler.requestPermissions { granted, error in + guard error == nil else { + call.reject(error?.localizedDescription ?? "unknown error in permissions request") + return + } + call.resolve(["receive": granted ? CapgoNotificationsPermissions.granted.rawValue : CapgoNotificationsPermissions.denied.rawValue]) + } + } + + @objc override public func checkPermissions(_ call: CAPPluginCall) { + self.notificationDelegateHandler.checkPermissions { status in + var result: CapgoNotificationsPermissions = .prompt + switch status { + case .notDetermined: + result = .prompt + case .denied: + result = .denied + case .ephemeral, .authorized, .provisional: + result = .granted + @unknown default: + result = .prompt + } + call.resolve(["receive": result.rawValue]) + } + } + + @objc func setBadge(_ call: CAPPluginCall) { + let count = max(0, call.getInt("count") ?? 0) + setBadgeCount(count) { error in + if let error = error { + call.reject(error.localizedDescription) + return + } + call.resolve(["count": count]) + } + } + + @objc func clearBadge(_ call: CAPPluginCall) { + setBadgeCount(0) { error in + if let error = error { + call.reject(error.localizedDescription) + return + } + call.resolve(["count": 0]) + } + } + + @objc func getBadge(_ call: CAPPluginCall) { + DispatchQueue.main.async { + call.resolve(["count": UIApplication.shared.applicationIconBadgeNumber]) + } + } + + @objc func getNativeInstallId(_ call: CAPPluginCall) { + let defaults = UserDefaults.standard + if let installId = defaults.string(forKey: installIdKey), !installId.isEmpty { + call.resolve(["nativeInstallId": installId]) + return + } + let installId = UUID().uuidString + defaults.set(installId, forKey: installIdKey) + call.resolve(["nativeInstallId": installId]) + } + + @objc func getAppInfo(_ call: CAPPluginCall) { + let bundle = Bundle.main + call.resolve([ + "version": bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "", + "build": bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "", + "name": bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "", + "id": bundle.bundleIdentifier ?? "" + ]) + } + + @objc func getDeliveredNotifications(_ call: CAPPluginCall) { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in + let ret = notifications.map({ notification -> [String: Any] in + return self.notificationDelegateHandler.makeNotificationRequestJSObject(notification.request) + }) + call.resolve(["notifications": ret]) + }) + } + + @objc func removeDeliveredNotifications(_ call: CAPPluginCall) { + guard let notifications = call.getArray("notifications", JSObject.self) else { + call.reject("Must supply notifications to remove") + return + } + let ids = notifications.map { $0["id"] as? String ?? "" } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + call.resolve() + } + + @objc func removeAllDeliveredNotifications(_ call: CAPPluginCall) { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + setBadgeCount(0) { _ in + call.resolve() + } + } + + @objc func createDefaultChannel(_ call: CAPPluginCall) { + call.resolve() + } + + @objc func createChannel(_ call: CAPPluginCall) { + call.unimplemented("Not available on iOS") + } + + @objc func deleteChannel(_ call: CAPPluginCall) { + call.unimplemented("Not available on iOS") + } + + @objc func listChannels(_ call: CAPPluginCall) { + call.resolve(["channels": []]) + } + + @objc public func didRegisterForRemoteNotificationsWithDeviceToken(notification: NSNotification) { + if let deviceToken = notification.object as? Data { + let deviceTokenString = deviceToken.reduce("", { $0 + String(format: "%02X", $1) }) + notifyListeners("registration", data: ["value": deviceTokenString], retainUntilConsumed: true) + } else if let stringToken = notification.object as? String { + notifyListeners("registration", data: ["value": stringToken], retainUntilConsumed: true) + } else { + notifyListeners("registrationError", data: ["error": CapgoNotificationError.tokenParsingFailed.localizedDescription], retainUntilConsumed: true) + } + } + + @objc public func didFailToRegisterForRemoteNotificationsWithError(notification: NSNotification) { + guard let error = notification.object as? Error else { return } + notifyListeners("registrationError", data: ["error": error.localizedDescription], retainUntilConsumed: true) + } + + private func setBadgeCount(_ count: Int, completion: @escaping (Error?) -> Void) { + DispatchQueue.main.async { + if #available(iOS 16.0, *) { + UNUserNotificationCenter.current().setBadgeCount(count) { error in + completion(error) + } + } else { + UIApplication.shared.applicationIconBadgeNumber = count + completion(nil) + } + } + } +} diff --git a/packages/capacitor-notifications/package.json b/packages/capacitor-notifications/package.json new file mode 100644 index 0000000000..17e40ebaf9 --- /dev/null +++ b/packages/capacitor-notifications/package.json @@ -0,0 +1,52 @@ +{ + "name": "@capgo/capacitor-notifications", + "version": "0.0.1-private.0", + "description": "Capgo native push notifications, badge, and live-update trigger plugin for Capacitor apps.", + "license": "AGPL-3.0-only", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "android/src/main/", + "android/build.gradle", + "android/gradle.properties", + "dist/", + "ios/Sources/", + "CapgoCapacitorNotifications.podspec", + "Package.swift", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "bun run typecheck && bun run build" + }, + "peerDependencies": { + "@capacitor/core": "^8.0.0" + }, + "devDependencies": { + "@capacitor/android": "^8.0.0", + "@capacitor/ios": "^8.0.0", + "typescript": "^5.9.3" + }, + "keywords": [ + "capacitor", + "capgo", + "push-notifications", + "badge", + "live-updates" + ], + "capacitor": { + "ios": { + "src": "ios" + }, + "android": { + "src": "android" + } + }, + "publishConfig": { + "access": "restricted", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/capacitor-notifications/src/definitions.ts b/packages/capacitor-notifications/src/definitions.ts new file mode 100644 index 0000000000..ee00cfe048 --- /dev/null +++ b/packages/capacitor-notifications/src/definitions.ts @@ -0,0 +1,186 @@ +import type { PermissionState, PluginListenerHandle } from '@capacitor/core' + +export type CapgoNotificationPermission = 'unknown' | 'prompt' | 'granted' | 'denied' +export type CapgoNotificationPlatform = 'ios' | 'android' +export type CapgoNotificationProvider = 'fcm' | 'apns' +export type CapgoNotificationInstallMode = 'next' | 'set' +export type CapgoNotificationImportance = 1 | 2 | 3 | 4 | 5 +export type CapgoNotificationVisibility = -1 | 0 | 1 + +export interface CapgoNotificationsConfig { + appId: string + serverUrl?: string + autoUpdater?: boolean + updateInstallMode?: CapgoNotificationInstallMode + updateChannel?: string +} + +export interface CapgoNotificationRegisterOptions { + appId?: string + serverUrl?: string + externalId: string + identityProof: string + tags?: string[] + attributes?: Record + consent?: boolean + appVersion?: string +} + +export interface CapgoNotificationRegistration { + recipientKey: string + deviceKey: string + bucket: string + token: string + provider: CapgoNotificationProvider + platform: CapgoNotificationPlatform + permission: CapgoNotificationPermission + eventProof: string +} + +export interface CapgoNotificationEvent { + appId?: string + campaignId?: string + notificationId?: string + externalId?: string + nativeInstallId?: string + recipientKey?: string + deviceKey?: string + eventProof?: string + provider?: CapgoNotificationProvider + platform?: CapgoNotificationPlatform + error?: string + badge?: number +} + +export interface CapgoPushNotificationSchema { + title?: string + subtitle?: string + body?: string + id: string + tag?: string + badge?: number + data: Record + click_action?: string + link?: string + group?: string + groupSummary?: boolean +} + +export interface CapgoNotificationOpenedEvent { + notification: CapgoPushNotificationSchema + actionId?: string + inputValue?: string +} + +export interface CapgoBackgroundNotificationEvent { + notification: CapgoPushNotificationSchema + finish: () => Promise +} + +export interface CapgoNotificationToken { + value: string +} + +export interface CapgoNotificationRegistrationError { + error: string +} + +export interface CapgoNotificationChannel { + id: string + name: string + description?: string + sound?: string + importance?: CapgoNotificationImportance + visibility?: CapgoNotificationVisibility + lights?: boolean + lightColor?: string + vibration?: boolean + showBadge?: boolean +} + +export interface CapgoNotificationChannelList { + channels: CapgoNotificationChannel[] +} + +export interface CapgoDeliveredNotifications { + notifications: CapgoPushNotificationSchema[] +} + +export interface CapgoBadgeResult { + count: number +} + +export interface CapgoNativeInstallIdResult { + nativeInstallId: string +} + +export interface CapgoNativeAppInfo { + version?: string + build?: string + name?: string + id?: string +} + +export interface CapgoNotificationPermissionStatus { + receive: PermissionState | CapgoNotificationPermission +} + +export interface CapgoUpdaterIntegrationOptions { + enabled?: boolean + installMode?: CapgoNotificationInstallMode + channel?: string +} + +export interface CapgoUpdateCheckResult { + status: 'disabled' | 'unavailable' | 'no_update' | 'installed' | 'failed' + version?: string + bundleId?: string + error?: string +} + +export interface CapgoNotificationsNativePlugin { + checkPermissions: () => Promise + requestPermissions: () => Promise + registerPush: () => Promise + unregisterPush: () => Promise + setBadge: (options: { count: number }) => Promise + clearBadge: () => Promise + getBadge: () => Promise + getNativeInstallId: () => Promise + getAppInfo: () => Promise + createDefaultChannel: (channel?: Partial) => Promise + createChannel: (channel: CapgoNotificationChannel) => Promise + deleteChannel: (options: { id: string }) => Promise + listChannels: () => Promise + getDeliveredNotifications: () => Promise + removeDeliveredNotifications: (delivered: CapgoDeliveredNotifications) => Promise + removeAllDeliveredNotifications: () => Promise + addListener: { + (eventName: 'registration', listenerFunc: (token: CapgoNotificationToken) => void): Promise + (eventName: 'registrationError', listenerFunc: (error: CapgoNotificationRegistrationError) => void): Promise + (eventName: 'notificationReceived', listenerFunc: (notification: CapgoPushNotificationSchema) => void): Promise + (eventName: 'notificationOpened', listenerFunc: (event: CapgoNotificationOpenedEvent) => void): Promise + (eventName: 'backgroundNotification', listenerFunc: (event: CapgoPushNotificationSchema) => void): Promise + } + removeAllListeners: () => Promise +} + +export interface CapgoNotificationsPlugin { + configure: (config: CapgoNotificationsConfig) => Promise + register: (options: CapgoNotificationRegisterOptions) => Promise + setExternalId: (externalId: string, identityProof?: string) => Promise + setTags: (tags: string[]) => Promise + setBadge: (count: number) => Promise + clearBadge: () => Promise + incrementBadge: (by?: number) => Promise + enableUpdaterIntegration: (options?: CapgoUpdaterIntegrationOptions) => Promise + runUpdateCheck: (options?: CapgoUpdaterIntegrationOptions) => Promise + trackReceived: (event?: CapgoNotificationEvent) => Promise + trackOpened: (event?: CapgoNotificationEvent) => Promise + addListener: { + (eventName: 'notificationReceived', listenerFunc: (notification: CapgoPushNotificationSchema) => void): Promise + (eventName: 'notificationOpened', listenerFunc: (event: CapgoNotificationOpenedEvent) => void): Promise + (eventName: 'backgroundNotification', listenerFunc: (event: CapgoBackgroundNotificationEvent) => void): Promise + (eventName: 'registrationChanged', listenerFunc: (token: CapgoNotificationToken) => void): Promise + } +} diff --git a/packages/capacitor-notifications/src/index.ts b/packages/capacitor-notifications/src/index.ts new file mode 100644 index 0000000000..f0354a6670 --- /dev/null +++ b/packages/capacitor-notifications/src/index.ts @@ -0,0 +1,578 @@ +import { Capacitor, registerPlugin } from '@capacitor/core' +import type { PluginListenerHandle } from '@capacitor/core' +import type { + CapgoBackgroundNotificationEvent, + CapgoNotificationEvent, + CapgoNotificationInstallMode, + CapgoNotificationOpenedEvent, + CapgoNotificationPermission, + CapgoNotificationPlatform, + CapgoNotificationProvider, + CapgoNotificationRegisterOptions, + CapgoNotificationRegistration, + CapgoNotificationToken, + CapgoNotificationsConfig, + CapgoNotificationsNativePlugin, + CapgoNotificationsPlugin, + CapgoPushNotificationSchema, + CapgoUpdateCheckResult, + CapgoUpdaterIntegrationOptions, +} from './definitions' + +export * from './definitions' + +const NativeCapgoNotifications = registerPlugin('CapgoNotifications') +const DEFAULT_SERVER_URL = 'https://api.capgo.app' +const PLUGIN_VERSION = '0.0.1-private.0' + +interface UpdaterTriggerResult { + status?: string + version?: string + bundleId?: string + id?: string + queued?: boolean + error?: string + message?: string +} + +interface UpdaterPlugin { + triggerUpdateCheck?: (options?: { channel?: string, installMode?: CapgoNotificationInstallMode }) => Promise + getLatest: (options?: { channel?: string }) => Promise<{ version: string, url?: string, checksum?: string, sessionKey?: string, manifest?: unknown[], error?: string, message?: string }> + download: (options: { url: string, version: string, checksum?: string, sessionKey?: string, manifest?: unknown[] }) => Promise<{ id: string, version: string }> + next: (options: { id: string }) => Promise + set: (options: { id: string }) => Promise +} + +interface ListenerState { + notificationReceived: Set<(notification: CapgoPushNotificationSchema) => void> + notificationOpened: Set<(event: CapgoNotificationOpenedEvent) => void> + backgroundNotification: Set<(event: CapgoBackgroundNotificationEvent) => void> + registrationChanged: Set<(token: CapgoNotificationToken) => void> +} + +interface RuntimeState { + config?: CapgoNotificationsConfig + externalId?: string + identityProof?: string + tags: string[] + attributes: Record + consent: boolean + badge: number + token?: CapgoNotificationToken + installId?: string + bridgeListenersReady: boolean + bridgeListenersPromise?: Promise + lastRegistration?: CapgoNotificationRegistration + handledUpdateNotifications: Set + listeners: ListenerState + updater: Required> & { + installMode: CapgoNotificationInstallMode + channel?: string + } +} + +const state: RuntimeState = { + tags: [], + attributes: {}, + consent: true, + badge: 0, + bridgeListenersReady: false, + handledUpdateNotifications: new Set(), + listeners: { + notificationReceived: new Set(), + notificationOpened: new Set(), + backgroundNotification: new Set(), + registrationChanged: new Set(), + }, + updater: { + enabled: true, + installMode: 'next', + }, +} + +function assertNativePlatform(): CapgoNotificationPlatform { + const platform = Capacitor.getPlatform() + if (platform === 'ios' || platform === 'android') + return platform + throw new Error('Capgo notifications are only available on iOS and Android') +} + +function getProvider(platform: CapgoNotificationPlatform): CapgoNotificationProvider { + return platform === 'ios' ? 'apns' : 'fcm' +} + +function getServerUrl(options?: { serverUrl?: string }) { + return options?.serverUrl || state.config?.serverUrl || DEFAULT_SERVER_URL +} + +function getAppId(options?: { appId?: string }) { + const appId = options?.appId || state.config?.appId + if (!appId) + throw new Error('Capgo notification appId is required') + return appId +} + +async function getInstallId() { + if (state.installId) + return state.installId + const result = await NativeCapgoNotifications.getNativeInstallId() + state.installId = result.nativeInstallId + return result.nativeInstallId +} + +async function getPermissionState(): Promise { + const permissions = await NativeCapgoNotifications.checkPermissions() + if (permissions.receive === 'granted') + return 'granted' + if (permissions.receive === 'denied') + return 'denied' + if (permissions.receive === 'prompt') + return 'prompt' + return 'unknown' +} + +async function requestToken(): Promise { + const permission = await NativeCapgoNotifications.requestPermissions() + if (permission.receive !== 'granted') + throw new Error('Push notification permission was not granted') + + return new Promise((resolve, reject) => { + let resolved = false + let registrationHandle: PluginListenerHandle | undefined + let errorHandle: PluginListenerHandle | undefined + + const cleanup = async () => { + await Promise.allSettled([registrationHandle?.remove(), errorHandle?.remove()].filter((promise): promise is Promise => Boolean(promise))) + } + const settleAfterCleanup = (callback: () => void) => { + cleanup() + .catch(() => undefined) + .then(callback) + .catch(() => undefined) + } + const fail = (error: unknown) => { + if (resolved) + return + resolved = true + settleAfterCleanup(() => reject(error instanceof Error ? error : new Error(String(error)))) + } + + void (async () => { + try { + registrationHandle = await NativeCapgoNotifications.addListener('registration', (token) => { + if (resolved) + return + resolved = true + state.token = token + settleAfterCleanup(() => resolve(token)) + }) + errorHandle = await NativeCapgoNotifications.addListener('registrationError', (error) => { + fail(new Error(error.error)) + }) + await NativeCapgoNotifications.registerPush() + } + catch (error) { + fail(error) + } + })() + }) +} + +function normalizeUpdaterResult(result: UpdaterTriggerResult): CapgoUpdateCheckResult { + if (result.status === 'disabled' || result.status === 'unavailable' || result.status === 'no_update' || result.status === 'installed' || result.status === 'failed') { + return { + status: result.status, + version: result.version, + bundleId: result.bundleId || result.id, + error: result.error || result.message, + } + } + return { + status: result.queued ? 'installed' : 'failed', + version: result.version, + bundleId: result.bundleId || result.id, + error: result.error || result.message || 'Update check failed', + } +} + +function notificationData(notification?: CapgoPushNotificationSchema): Record { + const data = notification?.data + return data && typeof data === 'object' ? data : {} +} + +function getStringData(data: Record, ...keys: string[]) { + for (const key of keys) { + const value = data[key] + if (typeof value === 'string' && value) + return value + } + return '' +} + +function eventFromNotification(notification?: CapgoPushNotificationSchema): CapgoNotificationEvent { + const data = notificationData(notification) + return { + campaignId: getStringData(data, 'capgoCampaignId', 'capgo_campaign_id') || undefined, + notificationId: getStringData(data, 'capgoNotificationId', 'capgo_notification_id') || notification?.id, + } +} + +function updateNotificationKey(notification?: CapgoPushNotificationSchema) { + const data = notificationData(notification) + const campaignId = getStringData(data, 'capgoCampaignId', 'capgo_campaign_id') + const notificationId = getStringData(data, 'capgoNotificationId', 'capgo_notification_id') || notification?.id || '' + if (!campaignId && !notificationId) + return '' + return `${campaignId}:${notificationId}` +} + +function isUpdateCheckNotification(notification?: CapgoPushNotificationSchema) { + const data = notificationData(notification) + const action = getStringData(data, 'capgoAction', 'capgo_action', 'action') + return action === 'update_check' || action === 'capgo_update_check' +} + +function notifyListeners(listeners: Set<(event: T) => void>, event: T) { + for (const listener of [...listeners]) { + try { + listener(event) + } + catch (error) { + setTimeout(() => { + throw error + }, 0) + } + } +} + +function createListenerHandle(remove: () => void): PluginListenerHandle { + return { + remove: async () => remove(), + } +} + +function updateOptionsFromNotification(notification?: CapgoPushNotificationSchema): CapgoUpdaterIntegrationOptions { + const data = notificationData(notification) + const requestedInstallMode = getStringData(data, 'capgoUpdateInstallMode', 'capgo_update_install_mode') + const channel = getStringData(data, 'capgoUpdateChannel', 'capgo_update_channel') + let installMode: CapgoUpdaterIntegrationOptions['installMode'] + if (requestedInstallMode === 'set' || requestedInstallMode === 'next') + installMode = requestedInstallMode + + return { + enabled: true, + installMode, + channel: channel || undefined, + } +} + +async function postJson(serverUrl: string, route: string, body: Record) { + const url = new URL(route, serverUrl) + const response = await fetch(url.href, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + const error = await response.text().catch(() => '') + throw new Error(error || 'Capgo notification request failed') + } + return response.json() as Promise> +} + +async function registerToken(options: CapgoNotificationRegisterOptions, token: CapgoNotificationToken): Promise { + const platform = assertNativePlatform() + const appInfo = await NativeCapgoNotifications.getAppInfo().catch(() => ({ version: options.appVersion || '' })) + const nativeInstallId = await getInstallId() + const permission = await getPermissionState() + const appId = getAppId(options) + const serverUrl = getServerUrl(options) + const identityProof = options.identityProof || state.identityProof + if (!identityProof) + throw new Error('Capgo notification identityProof is required') + + const response = await postJson(serverUrl, '/notifications/register', { + appId, + externalId: options.externalId, + nativeInstallId, + pushToken: token.value, + provider: getProvider(platform), + platform, + locale: navigator.language || '', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '', + appVersion: options.appVersion || appInfo.version || '', + pluginVersion: PLUGIN_VERSION, + identityProof, + tags: options.tags ?? state.tags, + attributes: options.attributes ?? state.attributes, + permission, + badge: state.badge, + active: true, + consent: options.consent ?? state.consent, + }) + + const registration: CapgoNotificationRegistration = { + recipientKey: String(response.recipientKey), + deviceKey: String(response.deviceKey), + bucket: String(response.bucket), + token: token.value, + provider: getProvider(platform), + platform, + permission, + eventProof: String(response.eventProof), + } + state.lastRegistration = registration + return registration +} + +async function trackEvent(event: 'received' | 'opened' | 'background_started' | 'background_finished' | 'failed', input?: CapgoNotificationEvent) { + const appId = input?.appId || state.config?.appId + if (!appId) + return + const platform = assertNativePlatform() + await postJson(getServerUrl(), '/notifications/events', { + appId, + event, + nativeInstallId: input?.nativeInstallId || await getInstallId(), + externalId: input?.externalId || state.externalId, + recipientKey: input?.recipientKey || state.lastRegistration?.recipientKey, + deviceKey: input?.deviceKey || state.lastRegistration?.deviceKey, + eventProof: input?.eventProof || state.lastRegistration?.eventProof, + provider: input?.provider || getProvider(platform), + platform: input?.platform || platform, + campaignId: input?.campaignId, + notificationId: input?.notificationId, + error: input?.error, + badge: input?.badge ?? state.badge, + }) +} + +async function maybeRunUpdateCheck(notification?: CapgoPushNotificationSchema) { + if (!isUpdateCheckNotification(notification) || !state.updater.enabled) + return + const key = updateNotificationKey(notification) + if (key) { + if (state.handledUpdateNotifications.has(key)) + return + state.handledUpdateNotifications.add(key) + if (state.handledUpdateNotifications.size > 128) { + const oldest = state.handledUpdateNotifications.values().next().value + if (oldest) + state.handledUpdateNotifications.delete(oldest) + } + } + const baseEvent = eventFromNotification(notification) + await trackEvent('background_started', baseEvent) + const result = await CapgoNotifications.runUpdateCheck(updateOptionsFromNotification(notification)) + await trackEvent(result.status === 'failed' ? 'failed' : 'background_finished', { + ...baseEvent, + error: result.error, + }) +} + +async function ensureBridgeListeners() { + if (state.bridgeListenersReady) + return + if (state.bridgeListenersPromise) + return state.bridgeListenersPromise + + state.bridgeListenersPromise = (async () => { + const handles: PluginListenerHandle[] = [] + try { + handles.push(await NativeCapgoNotifications.addListener('registration', (token) => { + state.token = token + if (state.externalId) { + void registerToken({ + appId: state.config?.appId, + serverUrl: state.config?.serverUrl, + externalId: state.externalId, + identityProof: state.identityProof || '', + tags: state.tags, + attributes: state.attributes, + consent: state.consent, + }, token) + } + notifyListeners(state.listeners.registrationChanged, token) + })) + handles.push(await NativeCapgoNotifications.addListener('notificationReceived', (notification) => { + void trackEvent('received', eventFromNotification(notification)) + void maybeRunUpdateCheck(notification) + notifyListeners(state.listeners.notificationReceived, notification) + })) + handles.push(await NativeCapgoNotifications.addListener('notificationOpened', (event) => { + void trackEvent('opened', eventFromNotification(event.notification)) + notifyListeners(state.listeners.notificationOpened, event) + })) + handles.push(await NativeCapgoNotifications.addListener('backgroundNotification', (notification) => { + if (!isUpdateCheckNotification(notification)) + void trackEvent('background_started', eventFromNotification(notification)) + void maybeRunUpdateCheck(notification) + let finished = false + const finish = async () => { + if (finished) + return + finished = true + await trackEvent('background_finished', eventFromNotification(notification)) + } + notifyListeners(state.listeners.backgroundNotification, { notification, finish }) + })) + state.bridgeListenersReady = true + } + catch (error) { + await Promise.allSettled(handles.map(handle => handle.remove())) + throw error + } + finally { + state.bridgeListenersPromise = undefined + } + })() + + return state.bridgeListenersPromise +} + +async function getUpdater(): Promise { + if (!Capacitor.isPluginAvailable('CapacitorUpdater')) + return null + return registerPlugin('CapacitorUpdater') +} + +export const CapgoNotifications: CapgoNotificationsPlugin = { + async configure(config) { + state.config = config + state.updater.enabled = config.autoUpdater !== false + state.updater.installMode = config.updateInstallMode || 'next' + state.updater.channel = config.updateChannel + await ensureBridgeListeners() + if (Capacitor.getPlatform() === 'android') + await NativeCapgoNotifications.createDefaultChannel().catch(() => undefined) + }, + + async register(options) { + state.externalId = options.externalId + state.identityProof = options.identityProof + state.tags = options.tags ?? state.tags + state.attributes = options.attributes ?? state.attributes + state.consent = options.consent ?? state.consent + state.config = { + appId: options.appId || state.config?.appId || '', + serverUrl: options.serverUrl || state.config?.serverUrl, + autoUpdater: state.updater.enabled, + updateInstallMode: state.updater.installMode, + updateChannel: state.updater.channel, + } + await ensureBridgeListeners() + const token = state.token || await requestToken() + return registerToken(options, token) + }, + + async setExternalId(externalId, identityProof) { + state.externalId = externalId + state.identityProof = identityProof ?? state.identityProof + if (state.token) + await registerToken({ externalId, identityProof: state.identityProof || '', tags: state.tags, attributes: state.attributes, consent: state.consent }, state.token) + }, + + async setTags(tags) { + state.tags = tags + if (state.token && state.externalId) + await registerToken({ externalId: state.externalId, identityProof: state.identityProof || '', tags, attributes: state.attributes, consent: state.consent }, state.token) + }, + + async setBadge(count) { + state.badge = Math.max(0, Math.trunc(count)) + await NativeCapgoNotifications.setBadge({ count: state.badge }) + if (state.token && state.externalId) + await registerToken({ externalId: state.externalId, identityProof: state.identityProof || '', tags: state.tags, attributes: state.attributes, consent: state.consent }, state.token) + }, + + async clearBadge() { + state.badge = 0 + await NativeCapgoNotifications.clearBadge() + if (state.token && state.externalId) + await registerToken({ externalId: state.externalId, identityProof: state.identityProof || '', tags: state.tags, attributes: state.attributes, consent: state.consent }, state.token) + }, + + async incrementBadge(by = 1) { + const current = await NativeCapgoNotifications.getBadge().catch(() => ({ count: state.badge })) + await this.setBadge(Math.max(0, Math.trunc(current.count + by))) + }, + + async enableUpdaterIntegration(options) { + state.updater.enabled = options?.enabled ?? true + state.updater.installMode = options?.installMode || state.updater.installMode + state.updater.channel = options?.channel ?? state.updater.channel + }, + + async runUpdateCheck(options): Promise { + const enabled = options?.enabled ?? state.updater.enabled + if (!enabled) + return { status: 'disabled' } + const updater = await getUpdater() + if (!updater) + return { status: 'unavailable', error: 'CapacitorUpdater plugin is not installed' } + try { + const installMode = options?.installMode || state.updater.installMode + const channel = options?.channel || state.updater.channel + if (updater.triggerUpdateCheck) { + return normalizeUpdaterResult(await updater.triggerUpdateCheck({ + channel, + installMode, + })) + } + const latest = await updater.getLatest({ channel }) + if (!latest.url) + return { status: 'no_update', version: latest.version, error: latest.message || latest.error } + const bundle = await updater.download({ + url: latest.url, + version: latest.version, + checksum: latest.checksum, + sessionKey: latest.sessionKey, + manifest: latest.manifest, + }) + if (installMode === 'set') + await updater.set({ id: bundle.id }) + else + await updater.next({ id: bundle.id }) + return { status: 'installed', version: latest.version, bundleId: bundle.id } + } + catch (error) { + const message = error instanceof Error ? error.message : 'Update check failed' + if (message === 'No new version available' || message.includes('no_new_version_available')) + return { status: 'no_update', error: message } + return { status: 'failed', error: message } + } + }, + + async trackReceived(event) { + await trackEvent('received', event) + }, + + async trackOpened(event) { + await trackEvent('opened', event) + }, + + async addListener(eventName, listenerFunc) { + await ensureBridgeListeners() + if (eventName === 'notificationReceived') { + const listener = listenerFunc as (notification: CapgoPushNotificationSchema) => void + state.listeners.notificationReceived.add(listener) + return createListenerHandle(() => state.listeners.notificationReceived.delete(listener)) + } + + if (eventName === 'notificationOpened') { + const listener = listenerFunc as (event: CapgoNotificationOpenedEvent) => void + state.listeners.notificationOpened.add(listener) + return createListenerHandle(() => state.listeners.notificationOpened.delete(listener)) + } + + if (eventName === 'backgroundNotification') { + const listener = listenerFunc as (event: CapgoBackgroundNotificationEvent) => void + state.listeners.backgroundNotification.add(listener) + return createListenerHandle(() => state.listeners.backgroundNotification.delete(listener)) + } + + const listener = listenerFunc as (token: CapgoNotificationToken) => void + state.listeners.registrationChanged.add(listener) + return createListenerHandle(() => state.listeners.registrationChanged.delete(listener)) + }, +} diff --git a/packages/capacitor-notifications/tsconfig.json b/packages/capacitor-notifications/tsconfig.json new file mode 100644 index 0000000000..1848282cd7 --- /dev/null +++ b/packages/capacitor-notifications/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "skipLibCheck": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM"] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/playwright/e2e/apikeys.spec.ts b/playwright/e2e/apikeys.spec.ts index e56311c562..52e960c0b0 100644 --- a/playwright/e2e/apikeys.spec.ts +++ b/playwright/e2e/apikeys.spec.ts @@ -3,8 +3,11 @@ import { expect, test } from '../support/commands' async function createReadApiKey(page: Page, keyName: string) { await page.click('[data-test="create-key"]') - await page.locator('#dialog-v2-content input[type="text"]').fill(keyName) - await page.locator('#dialog-v2-content input[name="key-type"][value="read"]').check() + const dialog = page.locator('#dialog-v2-content') + await expect(dialog).toBeVisible() + await dialog.locator('input[type="text"]').fill(keyName) + await dialog.getByText('Read', { exact: true }).click() + await expect(dialog.locator('input[name="key-type"][value="read"]')).toBeChecked() await page.getByRole('button', { name: 'Create' }).click() await expect(page.locator('[data-test="toast"]')).toContainText('Added new API key successfully') } diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index e3431fb42d..c7e829c83c 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -11,8 +11,19 @@ export const test = base.extend({ await page.click('[data-test="continue"]') await page.waitForSelector('[data-test="password"]') await page.fill('[data-test="password"]', password) - await page.click('[data-test="submit"]') - await page.waitForURL(/\/(apps|dashboard)(\/|$)/) + for (let attempt = 0; attempt < 3; attempt++) { + await page.click('[data-test="submit"]') + try { + await page.waitForURL(/\/(apps|dashboard)(\/|$)/, { timeout: attempt === 2 ? 30000 : 10000 }) + return + } + catch (error) { + const formError = await page.locator('[data-test="form-error"]').textContent({ timeout: 1000 }).catch(() => '') + if (!formError?.includes('schema cache') || attempt === 2) + throw error + await page.waitForTimeout(1000) + } + } } await use(page) diff --git a/src/constants/appTabs.ts b/src/constants/appTabs.ts index 90bed8246f..68b7e62ebf 100644 --- a/src/constants/appTabs.ts +++ b/src/constants/appTabs.ts @@ -1,4 +1,5 @@ import type { Tab } from '~/components/comp_def' +import IconBell from '~icons/heroicons/bell-alert' import IconChart from '~icons/heroicons/chart-bar' import IconHistory from '~icons/heroicons/clock' import IconCog from '~icons/heroicons/cog-6-tooth' @@ -14,6 +15,7 @@ export const appTabs: Tab[] = [ { label: 'bundles', icon: IconCube, key: '/bundles' }, { label: 'channels', icon: IconChannel, key: '/channels' }, { label: 'devices', icon: IconDevice, key: '/devices' }, + { label: 'notifications', icon: IconBell, key: '/notifications' }, { label: 'logs', icon: IconHistory, key: '/logs' }, { label: 'builds', icon: IconBuild, key: '/builds' }, { label: 'access', icon: IconShield, key: '/access' }, diff --git a/src/pages/app/[app].notifications.vue b/src/pages/app/[app].notifications.vue new file mode 100644 index 0000000000..325fed208b --- /dev/null +++ b/src/pages/app/[app].notifications.vue @@ -0,0 +1,800 @@ + + +