Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Build

on:
pull_request:
push:
branches: [master]

jobs:
build:
name: Build and test
# macos-26 (Xcode 26.x), not macos-latest: Compose Multiplatform 1.11 references
# UIKit symbols (e.g. UIViewLayoutRegion, auto-linking the private UIUtilities
# framework) that the older Xcode on macos-latest/macos-15 can't resolve when
# linking the iOS-simulator test binary (linkDebugTestIosSimulatorArm64).
runs-on: macos-26
steps:
- uses: actions/checkout@v6

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/master' }}

- name: Build and test
run: ./gradlew build
102 changes: 102 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Publish

on:
push:
branches: [master]
workflow_dispatch:

jobs:
detect:
name: Detect version bump
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.changed.outputs.publish }}
version: ${{ steps.changed.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2

- name: Check for sdk version change
id: changed
run: |
version=$(grep -E '^version\s*=' sdk/build.gradle.kts | sed -E 's/.*"(.+)".*/\1/')
echo "version=$version" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Only publish when the `version = "..."` line changed in this push — Maven Central
# rejects republishing the same GAV anyway.
if git diff HEAD~1 HEAD -- sdk/build.gradle.kts | grep -qE '^[+-]version\s*=\s*"'; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
fi

publish:
name: Publish sdk ${{ needs.detect.outputs.version }}
needs: detect
if: needs.detect.outputs.publish == 'true'
# macos-26 (Xcode 26.x) required: macOS for the iOS targets in the KMP publication,
# and pinned to 26 (not macos-latest/macos-15) so Compose Multiplatform 1.11's UIKit
# symbols resolve when the build links the iOS-simulator test binary.
runs-on: macos-26
permissions:
contents: write
steps:
- uses: actions/checkout@v6

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

- name: Build, test and publish to Maven Central
run: ./gradlew :sdk:build :sdk:publishToMavenCentral
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}

- name: Assemble XCFramework
run: ./gradlew :sdk:assembleChatwootSDKReleaseXCFramework

- name: Zip XCFramework and compute SPM checksum
id: xcframework
run: |
cd sdk/build/XCFrameworks/release
zip -r -X ChatwootSDK.xcframework.zip ChatwootSDK.xcframework
checksum=$(swift package compute-checksum ChatwootSDK.xcframework.zip)
echo "checksum=$checksum" >> "$GITHUB_OUTPUT"

- name: Create GitHub release with XCFramework
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.detect.outputs.version }}
run: |
gh release create "sdk-v$VERSION" \
--title "sdk v$VERSION" \
--notes "Chatwoot SDK $VERSION — Maven: com.chatwoot.android:sdk:$VERSION" \
sdk/build/XCFrameworks/release/ChatwootSDK.xcframework.zip

- name: Point Package.swift at the new release
env:
VERSION: ${{ needs.detect.outputs.version }}
CHECKSUM: ${{ steps.xcframework.outputs.checksum }}
run: |
sed -i '' \
-e "s|url: \".*\"|url: \"https://github.com/${{ github.repository }}/releases/download/sdk-v$VERSION/ChatwootSDK.xcframework.zip\"|" \
-e "s|checksum: \".*\"|checksum: \"$CHECKSUM\"|" \
Package.swift
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Package.swift
git commit -m "Point Package.swift at sdk-v$VERSION"
git push
15 changes: 8 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,7 @@ captures/

# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/*

# Keystore files
# Uncomment the following line if you do not want to check your keystore files in.
Expand All @@ -63,3 +57,10 @@ fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md

# Kotlin
.kotlin/

# Xcode
xcuserdata/
DerivedData/
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

A Kotlin Multiplatform (Android + iOS) chat SDK for Chatwoot, published as
`com.chatwoot.android:sdk`. The public surface is `ChatPage(show, onFinish,
styleConfig = DefaultStyle)` (Compose Multiplatform), the `Chatwoot` singleton
(`configure()`, plus the visitor-identity calls `setUser()` / `setCustomAttributes()` /
`reset()`), and, on iOS, the `ChatPageViewController()` wrapper — everything else is
`internal` (the module uses `explicitApi()`).

**Read `CONTEXT.md` first** for the domain glossary and the *verified* Chatwoot protocol
contract (REST + ActionCable websocket). The upstream wiki this project started from is
stale — `CONTEXT.md` reflects reality as probed against app.chatwoot.com; trust it over
the wiki. `docs/adr/` records the load-bearing decisions (Maven coordinates, KMP+CMP,
media stack, visitor identity).

## Commands

```bash
./gradlew build # all targets + tests (requires macOS for iOS)
./gradlew :sdk:testAndroidHostTest # JVM unit tests only (fastest loop)
./gradlew :sdk:iosSimulatorArm64Test # common tests on the iOS simulator
./gradlew :sdk:publishToMavenLocal # local publish, no signing needed
./gradlew :sample-app:installDebug # Android demo on a connected device/emulator
./gradlew :sdk:assembleChatwootSDKReleaseXCFramework # XCFramework for Swift consumers
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
ARCHS=arm64 ONLY_ACTIVE_ARCH=YES build # iOS demo (or open in Xcode)
# NB: generic simulator destinations fail — they request ios_x64, which :sdk deliberately
# doesn't target (arm64-only). Use a concrete simulator or ARCHS=arm64.
```

Manual e2e testing needs Chatwoot credentials in `local.properties` (not committed):
`chatwoot.baseUrl=…` and `chatwoot.websiteToken=…` — the sample app injects them via
BuildConfig.

## Architecture

One KMP module, `:sdk` (targets: `androidLibrary` via AGP's
`com.android.kotlin.multiplatform.library`, `iosArm64`, `iosSimulatorArm64`). Data flows:

```
ChatPage → ChatScreen → ChatViewModel (multiplatform lifecycle ViewModel)
→ ChatRepository (session lifecycle, message StateFlow, send; dedupes REST + ws)
→ WidgetApi (Ktor REST; bootstrap parses HTML via WidgetPageParser)
→ CableClient (websocket: subscribe/keepalive/backoff; pure frame logic in CableProtocol)
→ TokenStore (multiplatform-settings; persists the cw_conversation JWT + active
identifier per website token)
```

Key invariants:
- The bootstrap endpoint returns **HTML**, not JSON — session tokens are regex-parsed
(`WidgetPageParser`). There is no JSON alternative.
- First send of a fresh session goes through `POST /conversations`, later sends through
`POST /messages`; after conversation creation the repository *refetches* messages rather
than parsing the create response (its `message_type` is a string there, an int elsewhere).
- `CableProtocol` is pure functions (frame parse/build) so it stays unit-testable;
`CableClient` owns the connection loop, 30s `update_presence` keepalive, and reconnect
backoff — on reconnect the repository refreshes history to catch up missed events.
- `private: true` messages are agent notes — filter, never render. `message_type`: 0
contact, 1 agent, 2 activity, 3 template.
- Visitor identity lives in `Chatwoot.identity` (a `StateFlow`); the repository flushes it
after bootstrap (and on later changes) via `PATCH /widget/contact/set_user` when an
identifier is set, else `PATCH /widget/contact`. HMAC `identifier_hash` is **host-supplied**
(computed server-side) — never compute it in the SDK. A changed identifier clears the stored
session so the next bootstrap starts a fresh contact. See ADR 0004.
- Platform code is minimal by design: expect/actual for the config fallback
(manifest meta-data / Info.plist) and the Ktor engine comes from the classpath
(OkHttp on Android, Darwin on iOS). Keep new code in `commonMain`.

## Build system conventions (mirrors the sibling `dependables` repo)

- Root `build.gradle.kts` centralises group + vanniktech maven-publish
(`publishToMavenCentral(automaticRelease = true)`, signing only when
`ORG_GRADLE_PROJECT_signingInMemoryKey` is set, config-cache opt-out for publish tasks).
The `:sdk` module owns its own `version` and `pom {}`.
- Repositories only in `settings.gradle.kts` (`FAIL_ON_PROJECT_REPOS`); versions only in
`gradle/libs.versions.toml`. JVM target 21. The Compose compiler plugin version is the
Kotlin version.
- `kotlin.daemon.jvmargs`/`kotlin.native.jvmArgs` are raised in `gradle.properties` —
Kotlin/Native linking of Compose OOMs at the default heap; don't remove them.
- CI (`.github/workflows/`): `build.yml` runs `./gradlew build` on macOS;
`publish.yml` publishes **only when `version = "…"` changes** in `sdk/build.gradle.kts`
on master, then builds the XCFramework, attaches it to a `sdk-vX.Y.Z` GitHub release and
rewrites `Package.swift` (url + checksum) for SPM consumers.
- Publishing to Maven Central requires Chatwoot's verified Sonatype namespace
(`com.chatwoot`) — expected to stay red until upstreaming (ADR 0001).
98 changes: 98 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Context

Domain language and protocol contract for the Chatwoot SDK. Terms here are canonical — use
them in code, docs, and discussion.

## Glossary

- **Website inbox** — the Chatwoot inbox type this SDK talks to. Created in the Chatwoot
dashboard; identified by its **website token**.
- **Website token** — public token identifying a website inbox (`website_token` query param
on every API call). Not a secret.
- **Conversation token** (`cw_conversation`) — a JWT identifying one contact session
(claims: `source_id`, `inbox_id`). Returned by the widget bootstrap as `window.authToken`,
sent as the `X-Auth-Token` header on REST calls, and passed back as `?cw_conversation=` on
re-bootstrap to resume the same contact + conversation. Persisted by `TokenStore`
(SharedPreferences / NSUserDefaults), keyed per website token.
- **Pubsub token** — the contact's ActionCable subscription credential
(`window.chatwootPubsubToken`). A *contact* pubsub token only receives events for that
contact's own session; *user* (agent) tokens see account-wide events — the SDK only ever
holds contact tokens.
- **RoomChannel** — the single ActionCable channel Chatwoot broadcasts on. Contact
subscriptions identify as `{"channel":"RoomChannel","pubsub_token":"…"}` (no
account_id/user_id — those are for agent connections).
- **ChatPage** — the SDK's entire public UI surface:
`ChatPage(show, onFinish, styleConfig = DefaultStyle)`.
- **StyleConfig / DefaultStyle** — the one theming object; all visual customisation flows
through it. No other styling knobs exist.
- **Contact** — the end user chatting. Chatwoot auto-creates an anonymous contact (e.g.
"weathered-shape-813") on first bootstrap. The host names it via `Chatwoot.setUser(...)`.
- **Identifier** — a stable, host-defined id for the contact (e.g. the host app's user id).
Supplied via `setUser`; lets Chatwoot recognise the same person across reinstalls/devices.
Persisted next to the conversation token by `TokenStore`; changing it starts a fresh contact.
- **Identity validation / HMAC** — optional impersonation protection. When an inbox enables it,
associating an **identifier** requires an `identifier_hash` = `HMAC-SHA256(inbox_hmac_token,
identifier)`. The `hmac_token` is a per-inbox **secret** computed **server-side** by the host's
backend; the SDK only ever forwards a precomputed hash and never holds the secret.
- **Custom attributes** — inbox-defined key/value fields on the contact (`custom_attributes`).
The SDK takes a `Map<String,String>`; numbers/dates are passed as strings.
- **Attachment** — a file carried by a Message. Each has a `file_type` (`image`, `audio`,
`video`, `file`, plus rarer kinds — anything other than the first three renders as a generic
file), a `data_url` (original) and optional `thumb_url` (preview). Sent one-per-message and
caption-less (the upload carries no `content`). _Avoid_: media, upload.

## Protocol contract (verified against app.chatwoot.com, June 2026)

> The upstream wiki (`Steps-to-build-the-integration`) is stale: `GET /widgets.json` no
> longer exists, and message POSTs authenticate with `X-Auth-Token`, not bare query params.

### Bootstrap

`GET <base>/widget?website_token=T[&cw_conversation=JWT]` returns **HTML**; the session
tokens are embedded as script globals and parsed by `WidgetPageParser`:

```
window.authToken = '<conversation JWT>'
window.chatwootPubsubToken = '<pubsub token>'
```

Omitting `cw_conversation` creates a fresh contact; passing it resumes the session.

### REST (all under `/api/v1/widget`, all with `?website_token=T` + `X-Auth-Token` header)

| Call | Notes |
|---|---|
| `GET /messages` | `{"payload":[Message…]}`. `message_type`: 0 contact, 1 agent, 2 activity, 3 template. `created_at` is unix seconds. `private: true` messages are agent notes — never render. |
| `POST /messages` | Body `{"message":{"content","timestamp","referer_url"}}`. **Lazily creates the conversation** if none exists yet (verified: posting to a fresh session returns the created Message with `conversation_id` set and `message_type` as an **int**). The SDK still routes the *first text* send through `POST /conversations` (below); attachments always use this endpoint. Returns the created Message. |
| `POST /messages` (multipart) | **Attachment** upload (`multipart/form-data`): `message[attachments][]` (the file, with filename) + `message[referer_url]` + `message[timestamp]`. No `content`, no `echo_id` — one file, caption-less. Like the JSON form it **lazily creates the conversation** when none exists, so attachment-*first* sends use this — never `POST /conversations`. Returns the created Message with a parseable int `message_type` and a populated `attachments[]` (parse for the real `id` + attachment URLs; no refetch needed). |
| `POST /conversations` | First message of a session: `{"message":{…}}` (optional `contact:{name,email,phone_number}`). Creates conversation + message. Caveat: in *this* response `message_type` is a string (`"template"`); the SDK refetches `GET /messages` instead of parsing it. |
| `GET /conversations` | `{}` when no conversation exists yet. |
| `PATCH /contact` | Updates the (possibly anonymous) contact. **Flat** body (not nested under `contact`): `{name,email,phone_number,avatar_url,custom_attributes:{},additional_attributes:{}}`. No identity validation. Also the `setCustomAttributes` path (`{custom_attributes:{}}`). |
| `PATCH /contact/set_user` | Associates a stable `identifier`: body `{identifier, …same fields…, identifier_hash}`. Server runs `validate_hmac` (`HMAC-SHA256(hmac_token, identifier)`) — enforced only when the inbox has identity validation on; otherwise `identifier_hash` is optional. Response is `{id, has_email, has_name, has_phone_number}`, and **conditionally** a `widget_auth_token` — the server mints a fresh session JWT when identifying changes the underlying contact (a merge/swap). When present, the SDK must adopt it as the new active+persisted `X-Auth-Token` (it replaces the `cw_conversation` JWT). Not returned on inboxes without identity validation (unverified against such an inbox; implemented defensively as optional). No new `pubsub_token` is returned, so the realtime channel stays on the original contact's `RoomChannel`. |
| `GET /inbox_members` | `{"payload":[{id,name,avatar_url,availability_status}]}` — no auth header needed. |

A Message carries an `attachments` array; each entry: `{id, file_type, data_url, thumb_url,
file_size, width, height, extension}`. Received attachments need no special handling — they
arrive on `GET /messages` and on the `message.created`/`message.updated` websocket events like
any other field.

> **Attachment-first sends** (verified against app.chatwoot.com): the session's *first* message
> when it carries a file goes through multipart `POST /messages`, **not** `POST /conversations`.
> The server lazily creates the conversation and returns a fully parseable Message (int
> `message_type` + populated `attachments[]`), so the SDK reconciles its optimistic bubble
> directly from the response — no refetch, no separate "create with attachment" path.

### Realtime (`wss://<base>/cable`, ActionCable wire protocol)

1. On connect the server sends `{"type":"welcome"}`, then `{"type":"ping"}` every ~3s.
2. Subscribe: `{"command":"subscribe","identifier":"{\"channel\":\"RoomChannel\",\"pubsub_token\":\"…\"}"}`
— note the identifier is a JSON-*string*, not an object. Server confirms with
`{"type":"confirm_subscription"}`.
3. Keepalive: every 30s send
`{"command":"message","identifier":<same>,"data":"{\"action\":\"update_presence\"}"}`.
4. Broadcasts arrive as `{"identifier":…,"message":{"event":"…","data":{…}}}`. Events a
contact receives: `message.created`, `message.updated`, `conversation.typing_on/off`
(`data.user` may be the contact itself — filter `type=="contact"`; honour `is_private`),
`conversation.status_changed`, `presence.update`.
5. On drop: reconnect with exponential backoff and refetch `GET /messages` to catch up
(`CableClient` + `ChatRepository.onCableEvent`).
23 changes: 23 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// swift-tools-version:5.9
// Swift Package Manager distribution for pure-Swift apps. The binary XCFramework is built
// and attached to GitHub Releases by .github/workflows/publish.yml, which also rewrites the
// url + checksum below on every release. Until the first release exists, integrate via the
// local Gradle build instead (see iosApp/).
import PackageDescription

let package = Package(
name: "ChatwootSDK",
platforms: [
.iOS(.v15)
],
products: [
.library(name: "ChatwootSDK", targets: ["ChatwootSDK"])
],
targets: [
.binaryTarget(
name: "ChatwootSDK",
url: "https://github.com/chatwoot/android-sdk/releases/download/sdk-v0.0.0/ChatwootSDK.xcframework.zip",
checksum: "0000000000000000000000000000000000000000000000000000000000000000"
)
]
)
Loading