From ab2d13e567ec6f2ed1bcf3118ea4d410ae6a1f32 Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Fri, 12 Jun 2026 14:00:41 +0530 Subject: [PATCH 01/10] Ignore all files in the .idea directory --- .gitignore | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 39b6783..8a7c6bf 100644 --- a/.gitignore +++ b/.gitignore @@ -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. From d665ba147f5ba9572f0eabbff2d12d85179eba4c Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Fri, 12 Jun 2026 14:00:53 +0530 Subject: [PATCH 02/10] Add CLAUDE.md with project overview and development guidelines --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea57936 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Chatwoot Android SDK, published to Maven Central under the package/group `com.chatwoot.android`. The public API surface is a single Jetpack Compose entry point: + +```kotlin +ChatPage(show, onFinish, styleConfig = DefaultStyle) +``` + +The SDK wraps the Chatwoot website-widget HTTP API so Android apps get a native chat page instead of embedding the web widget. + +The repo is currently being scaffolded. Its Gradle/publishing structure deliberately mirrors the sibling [`dependables`](../../Other/dependables) repo — when in doubt about build, publishing, or module conventions, look there first. + +## Chatwoot Widget API (integration contract) + +From https://github.com/chatwoot/android-sdk/wiki/Steps-to-build-the-integration — the SDK talks to a Chatwoot instance as a "website inbox" client: + +1. **Bootstrap**: `GET /widgets.json?website_token=${website_token}` — returns widget config (id, name, account id, color), an auth token, and a contact object (id, name, `pubsub_token`). +2. **Persist the token**: store the returned conversation token locally (DataStore/DB); append it as `cw_conversation=${token}` on all subsequent calls so the contact/conversation survives app restarts. +3. **Messages**: `GET /api/v1/widget/messages?website_token=${website_token}&cw_conversation=${token}` to fetch, `POST` to the same path to send. Send payload: `{ message: { content, timestamp, referer_url } }` (`referer_url` empty for now). +4. **Agents**: `GET /api/v1/widget/inbox_members?website_token=${website_token}`. + +The wiki covers REST only. The `pubsub_token` exists for real-time updates (Rails ActionCable at `/cable`) but the wiki gives no instructions for it — verify against the Chatwoot web widget source before implementing websocket support. + +## Module Layout (planned, mirroring dependables) + +- Root `build.gradle.kts` — shared config only, no code: sets `group = "com.chatwoot.android"` for subprojects and centralises vanniktech maven-publish wiring (`publishToMavenCentral(automaticRelease = true)`, `signAllPublications()` only when `ORG_GRADLE_PROJECT_signingInMemoryKey` is set, config-cache opt-out for publish tasks per gradle/gradle#22779). +- SDK library module — `com.android.library` + `com.vanniktech.maven.publish`; owns its own `version = "x.y.z"` and `pom { }` block (root never sets versions). Source under `src/main/kotlin/com/chatwoot/android/`. +- `sample-app/` — minimal Android app exercising `ChatPage` end-to-end against a real Chatwoot inbox. +- Repositories (`google()`, `mavenCentral()`) live in `settings.gradle.kts` under `dependencyResolutionManagement` with `repositoriesMode = FAIL_ON_PROJECT_REPOS` — never redeclare them in subprojects. +- Dependency versions go in `gradle/libs.versions.toml` (version catalog), not inline. + +## Common Commands + +```bash +./gradlew build # build everything +./gradlew ::build # build one module +./gradlew ::test # unit tests for one module +./gradlew ::test --tests "com.chatwoot.android." # single test class +./gradlew ::publishToMavenLocal # local publish, no signing needed +./gradlew :sample-app:installDebug # install the demo on a connected device +``` + +## Publishing + +- Maven Central via Sonatype Central Portal (vanniktech maven-publish plugin). `automaticRelease = true` means a successful publish auto-promotes — no manual release click. +- Credentials come from env vars: `ORG_GRADLE_PROJECT_mavenCentralUsername`, `ORG_GRADLE_PROJECT_mavenCentralPassword`, `ORG_GRADLE_PROJECT_signingInMemoryKey`, `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` (CI: GitHub secrets `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`, `SIGNING_KEY`, `SIGNING_KEY_PASSWORD`, `SIGNING_KEY_ID`). +- CI publishes only when a module's `version = "..."` line changes in a push to the default branch (Maven Central rejects republishing the same GAV); `workflow_dispatch` accepts an explicit module list. See `dependables/.github/workflows/publish.yml` for the reference workflow. +- For the Android library publication use `AndroidSingleVariantLibrary(variant = "release", sourcesJar = true, publishJavadocJar = false)` — AGP 9's bundled Dokka can't read Kotlin 2.3 metadata, and Maven Central accepts sources-only Kotlin publishes. + +## Key Conventions + +- JVM target 21: `kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }` with matching `compileOptions`. +- AGP 9.x, Gradle 9.4+, config cache and build cache enabled in `gradle.properties`. +- The SDK module ships `consumer-rules.pro`; keep the public Compose API stable — `show`/`onFinish`/`styleConfig` is the whole contract, everything else stays `internal`. +- `styleConfig` defaults to a `DefaultStyle`; all theming flows through that one config object rather than scattered parameters. From de126de5bc441e2cc1df5e9f0892868ba19b7e10 Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Fri, 12 Jun 2026 15:26:01 +0530 Subject: [PATCH 03/10] Add Chatwoot KMP SDK: Compose ChatPage with live chat for Android and iOS Kotlin Multiplatform module (com.chatwoot.android:sdk) exposing ChatPage(show, onFinish, styleConfig) backed by the Chatwoot website-widget API: HTML bootstrap session parsing, widget REST endpoints, and live messages over the ActionCable websocket with presence keepalive and backoff reconnect. Includes Android and iOS sample apps, XCFramework + SPM distribution, version-bump-triggered publish CI, and the verified protocol contract in CONTEXT.md (the upstream wiki is stale). Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 28 ++ .github/workflows/publish.yml | 100 ++++++ .gitignore | 7 + CLAUDE.md | 111 ++++--- CONTEXT.md | 71 +++++ Package.swift | 23 ++ README.md | 88 +++++- build.gradle.kts | 35 +++ ...en-coordinates-under-chatwoot-namespace.md | 27 ++ ...02-kotlin-multiplatform-with-compose-ui.md | 28 ++ gradle.properties | 9 + gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 52 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++++++++++++ gradlew.bat | 93 ++++++ iosApp/iosApp.xcodeproj/project.pbxproj | 296 ++++++++++++++++++ iosApp/iosApp/ContentView.swift | 29 ++ iosApp/iosApp/Info.plist | 32 ++ iosApp/iosApp/iOSApp.swift | 19 ++ sample-app/build.gradle.kts | 63 ++++ sample-app/src/main/AndroidManifest.xml | 16 + .../chatwoot/android/sample/MainActivity.kt | 45 +++ sdk/build.gradle.kts | 99 ++++++ sdk/src/androidMain/AndroidManifest.xml | 18 ++ .../chatwoot/android/sdk/Chatwoot.android.kt | 26 ++ .../com/chatwoot/android/sdk/ChatPage.kt | 26 ++ .../com/chatwoot/android/sdk/ChatViewModel.kt | 56 ++++ .../com/chatwoot/android/sdk/Chatwoot.kt | 46 +++ .../android/sdk/data/ChatRepository.kt | 122 ++++++++ .../chatwoot/android/sdk/data/TokenStore.kt | 20 ++ .../chatwoot/android/sdk/net/CableClient.kt | 108 +++++++ .../chatwoot/android/sdk/net/CableProtocol.kt | 72 +++++ .../com/chatwoot/android/sdk/net/Dto.kt | 75 +++++ .../com/chatwoot/android/sdk/net/WidgetApi.kt | 87 +++++ .../android/sdk/net/WidgetPageParser.kt | 28 ++ .../chatwoot/android/sdk/style/StyleConfig.kt | 42 +++ .../com/chatwoot/android/sdk/ui/ChatHeader.kt | 67 ++++ .../com/chatwoot/android/sdk/ui/ChatScreen.kt | 87 +++++ .../com/chatwoot/android/sdk/ui/InputBar.kt | 61 ++++ .../chatwoot/android/sdk/ui/MessageBubble.kt | 51 +++ .../android/sdk/net/CableProtocolTest.kt | 69 ++++ .../com/chatwoot/android/sdk/net/DtoTest.kt | 53 ++++ .../android/sdk/net/WidgetPageParserTest.kt | 30 ++ .../android/sdk/ChatPageViewController.kt | 17 + .../com/chatwoot/android/sdk/Chatwoot.ios.kt | 10 + settings.gradle.kts | 23 ++ 48 files changed, 2688 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CONTEXT.md create mode 100644 Package.swift create mode 100644 build.gradle.kts create mode 100644 docs/adr/0001-maven-coordinates-under-chatwoot-namespace.md create mode 100644 docs/adr/0002-kotlin-multiplatform-with-compose-ui.md create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp/ContentView.swift create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/iOSApp.swift create mode 100644 sample-app/build.gradle.kts create mode 100644 sample-app/src/main/AndroidManifest.xml create mode 100644 sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt create mode 100644 sdk/build.gradle.kts create mode 100644 sdk/src/androidMain/AndroidManifest.xml create mode 100644 sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/Chatwoot.android.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableClient.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableProtocol.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetPageParser.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/CableProtocolTest.kt create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/WidgetPageParserTest.kt create mode 100644 sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/ChatPageViewController.kt create mode 100644 sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/Chatwoot.ios.kt create mode 100644 settings.gradle.kts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..572fbf0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build + +on: + pull_request: + push: + branches: [master] + +jobs: + build: + name: Build and test + # macOS so the iOS targets compile too. + runs-on: macos-latest + 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d0090d7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,100 @@ +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 required: the KMP publication includes the iOS targets. + runs-on: macos-latest + 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 diff --git a/.gitignore b/.gitignore index 8a7c6bf..83e2faf 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,10 @@ fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md + +# Kotlin +.kotlin/ + +# Xcode +xcuserdata/ +DerivedData/ diff --git a/CLAUDE.md b/CLAUDE.md index ea57936..d8c3e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,56 +4,79 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -A Chatwoot Android SDK, published to Maven Central under the package/group `com.chatwoot.android`. The public API surface is a single Jetpack Compose entry point: +A Kotlin Multiplatform (Android + iOS) chat SDK for Chatwoot, published as +`com.chatwoot.android:sdk`. The entire public surface is `ChatPage(show, onFinish, +styleConfig = DefaultStyle)` (Compose Multiplatform) plus `Chatwoot.configure()` and, on +iOS, the `ChatPageViewController()` wrapper — everything else is `internal` (the module +uses `explicitApi()`). -```kotlin -ChatPage(show, onFinish, styleConfig = DefaultStyle) -``` - -The SDK wraps the Chatwoot website-widget HTTP API so Android apps get a native chat page instead of embedding the web widget. - -The repo is currently being scaffolded. Its Gradle/publishing structure deliberately mirrors the sibling [`dependables`](../../Other/dependables) repo — when in doubt about build, publishing, or module conventions, look there first. - -## Chatwoot Widget API (integration contract) - -From https://github.com/chatwoot/android-sdk/wiki/Steps-to-build-the-integration — the SDK talks to a Chatwoot instance as a "website inbox" client: - -1. **Bootstrap**: `GET /widgets.json?website_token=${website_token}` — returns widget config (id, name, account id, color), an auth token, and a contact object (id, name, `pubsub_token`). -2. **Persist the token**: store the returned conversation token locally (DataStore/DB); append it as `cw_conversation=${token}` on all subsequent calls so the contact/conversation survives app restarts. -3. **Messages**: `GET /api/v1/widget/messages?website_token=${website_token}&cw_conversation=${token}` to fetch, `POST` to the same path to send. Send payload: `{ message: { content, timestamp, referer_url } }` (`referer_url` empty for now). -4. **Agents**: `GET /api/v1/widget/inbox_members?website_token=${website_token}`. +**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 two load-bearing decisions (Maven coordinates, KMP+CMP). -The wiki covers REST only. The `pubsub_token` exists for real-time updates (Rails ActionCable at `/cable`) but the wiki gives no instructions for it — verify against the Chatwoot web widget source before implementing websocket support. - -## Module Layout (planned, mirroring dependables) - -- Root `build.gradle.kts` — shared config only, no code: sets `group = "com.chatwoot.android"` for subprojects and centralises vanniktech maven-publish wiring (`publishToMavenCentral(automaticRelease = true)`, `signAllPublications()` only when `ORG_GRADLE_PROJECT_signingInMemoryKey` is set, config-cache opt-out for publish tasks per gradle/gradle#22779). -- SDK library module — `com.android.library` + `com.vanniktech.maven.publish`; owns its own `version = "x.y.z"` and `pom { }` block (root never sets versions). Source under `src/main/kotlin/com/chatwoot/android/`. -- `sample-app/` — minimal Android app exercising `ChatPage` end-to-end against a real Chatwoot inbox. -- Repositories (`google()`, `mavenCentral()`) live in `settings.gradle.kts` under `dependencyResolutionManagement` with `repositoriesMode = FAIL_ON_PROJECT_REPOS` — never redeclare them in subprojects. -- Dependency versions go in `gradle/libs.versions.toml` (version catalog), not inline. - -## Common Commands +## Commands ```bash -./gradlew build # build everything -./gradlew ::build # build one module -./gradlew ::test # unit tests for one module -./gradlew ::test --tests "com.chatwoot.android." # single test class -./gradlew ::publishToMavenLocal # local publish, no signing needed -./gradlew :sample-app:installDebug # install the demo on a connected device +./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. ``` -## Publishing +Manual e2e testing needs Chatwoot credentials in `local.properties` (not committed): +`chatwoot.baseUrl=…` and `chatwoot.websiteToken=…` — the sample app injects them via +BuildConfig. -- Maven Central via Sonatype Central Portal (vanniktech maven-publish plugin). `automaticRelease = true` means a successful publish auto-promotes — no manual release click. -- Credentials come from env vars: `ORG_GRADLE_PROJECT_mavenCentralUsername`, `ORG_GRADLE_PROJECT_mavenCentralPassword`, `ORG_GRADLE_PROJECT_signingInMemoryKey`, `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` (CI: GitHub secrets `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`, `SIGNING_KEY`, `SIGNING_KEY_PASSWORD`, `SIGNING_KEY_ID`). -- CI publishes only when a module's `version = "..."` line changes in a push to the default branch (Maven Central rejects republishing the same GAV); `workflow_dispatch` accepts an explicit module list. See `dependables/.github/workflows/publish.yml` for the reference workflow. -- For the Android library publication use `AndroidSingleVariantLibrary(variant = "release", sourcesJar = true, publishJavadocJar = false)` — AGP 9's bundled Dokka can't read Kotlin 2.3 metadata, and Maven Central accepts sources-only Kotlin publishes. +## Architecture -## Key Conventions +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 per website token) +``` -- JVM target 21: `kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }` with matching `compileOptions`. -- AGP 9.x, Gradle 9.4+, config cache and build cache enabled in `gradle.properties`. -- The SDK module ships `consumer-rules.pro`; keep the public Compose API stable — `show`/`onFinish`/`styleConfig` is the whole contract, everything else stays `internal`. -- `styleConfig` defaults to a `DefaultStyle`; all theming flows through that one config object rather than scattered parameters. +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. +- 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). diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..dd07491 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,71 @@ +# 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. + +## 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 /widget?website_token=T[&cw_conversation=JWT]` returns **HTML**; the session +tokens are embedded as script globals and parsed by `WidgetPageParser`: + +``` +window.authToken = '' +window.chatwootPubsubToken = '' +``` + +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"}}` — only for an **existing** conversation. Returns the created Message. | +| `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. | +| `GET /inbox_members` | `{"payload":[{id,name,avatar_url,availability_status}]}` — no auth header needed. | + +### Realtime (`wss:///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":,"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`). diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..eb43799 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md index 24bdac5..f37394b 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# android-sdk \ No newline at end of file +# Chatwoot SDK + +A Kotlin Multiplatform chat SDK for [Chatwoot](https://www.chatwoot.com) — one Compose +chat page that runs natively on **Android and iOS**, backed by the Chatwoot website-widget +API with live messages over websocket. + +```kotlin +ChatPage( + show = showChat, + onFinish = { showChat = false }, + styleConfig = DefaultStyle, // optional theming +) +``` + +## Install + +**Android / KMP (Maven Central):** + +```kotlin +implementation("com.chatwoot.android:sdk:0.1.0") +``` + +**iOS (Swift Package Manager):** add this repo as a package dependency — the +`ChatwootSDK` binary target ships as an XCFramework attached to GitHub releases. + +## Setup + +Create a *website inbox* in your Chatwoot dashboard, then configure the SDK once at startup: + +```kotlin +Chatwoot.configure( + baseUrl = "https://app.chatwoot.com", // or your self-hosted URL + websiteToken = "", +) +``` + +Or declare it statically instead: + +- **Android** — manifest ``: `com.chatwoot.android.BASE_URL` and + `com.chatwoot.android.WEBSITE_TOKEN` +- **iOS** — Info.plist keys: `ChatwootBaseUrl` and `ChatwootWebsiteToken` + +Then show the chat. On Android/Compose, call `ChatPage` (above). From Swift: + +```swift +ChatPageViewControllerKt.ChatPageViewController( + onFinish: { /* dismiss */ }, + styleConfig: StyleConfigKt.DefaultStyle +) +``` + +Everything else — anonymous contact creation, conversation persistence across launches, +history, live agent replies, typing indicators, reconnection — is handled inside. + +## Theming + +All visual customisation flows through one object: + +```kotlin +ChatPage(show, onFinish, styleConfig = DefaultStyle.copy( + primaryColor = Color(0xFF7C3AED), + title = "Support", +)) +``` + +## Layout + +``` +├── sdk/ # the KMP library (commonMain + androidMain/iosMain) +├── sample-app/ # Android demo — put chatwoot.baseUrl / chatwoot.websiteToken in local.properties +├── iosApp/ # iOS demo (Xcode project, builds the framework via Gradle) +├── docs/adr/ # architecture decision records +└── CONTEXT.md # domain glossary + verified protocol contract +``` + +## Build + +```bash +./gradlew build # everything incl. tests (needs macOS for iOS targets) +./gradlew :sample-app:installDebug # Android demo on a connected device +./gradlew :sdk:assembleChatwootSDKReleaseXCFramework # ChatwootSDK.xcframework for Swift apps +open iosApp/iosApp.xcodeproj # iOS demo +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d58aaba --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.android.kmp.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.vanniktech.maven.publish) apply false +} + +subprojects { + group = "com.chatwoot.android" + + // vanniktech maven-publish + Maven Central publish flow isn't config-cache compatible yet: + // https://github.com/gradle/gradle/issues/22779. Mark publish tasks as opt-out so the rest + // of the build still benefits from the config cache. + tasks.withType(org.gradle.api.publish.maven.tasks.PublishToMavenRepository::class.java).configureEach { + notCompatibleWithConfigurationCache( + "Maven Central publishing isn't config-cache compatible yet — gradle/gradle#22779." + ) + } + + // Centralised publishing config — each library only needs its own `pom { }` block. + // automaticRelease=true flips Sonatype Central Portal deployments to "released" once + // validation passes, removing the manual click-to-publish step. Local + // `publishToMavenLocal` is unaffected (no Central Portal involvement). + plugins.withId("com.vanniktech.maven.publish") { + extensions.configure { + publishToMavenCentral(automaticRelease = true) + if (System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey") != null) { + signAllPublications() + } + } + } +} diff --git a/docs/adr/0001-maven-coordinates-under-chatwoot-namespace.md b/docs/adr/0001-maven-coordinates-under-chatwoot-namespace.md new file mode 100644 index 0000000..23a7888 --- /dev/null +++ b/docs/adr/0001-maven-coordinates-under-chatwoot-namespace.md @@ -0,0 +1,27 @@ +# 0001 — Maven coordinates `com.chatwoot.android:sdk` under Chatwoot's namespace + +Date: 2026-06-12 · Status: accepted + +## Context + +This SDK is developed in the `Lascade-Co/android-sdk` fork but is intended as an upstream +contribution to `chatwoot/android-sdk`. Maven coordinates are effectively permanent: Maven +Central forbids republishing a GAV and consumers hard-code the group in their builds. The +Sonatype Central Portal additionally requires the publisher to own a *verified namespace* +matching the group. + +## Decision + +Publish as `com.chatwoot.android:sdk`, with POM url/scm pointing at +`github.com/chatwoot/android-sdk`. Lascade does not create a parallel coordinate under its +own namespace. + +## Consequences + +- CI publishing stays red until the project lands upstream and Chatwoot's Sonatype + namespace credentials are added as repo secrets (`MAVEN_CENTRAL_USERNAME/PASSWORD`, + `SIGNING_KEY*`). This is expected, not a bug. +- Local development is unaffected — `./gradlew :sdk:publishToMavenLocal` needs no + credentials. +- If upstreaming falls through, the group must change *before* any public release, never + after. diff --git a/docs/adr/0002-kotlin-multiplatform-with-compose-ui.md b/docs/adr/0002-kotlin-multiplatform-with-compose-ui.md new file mode 100644 index 0000000..3f38a4a --- /dev/null +++ b/docs/adr/0002-kotlin-multiplatform-with-compose-ui.md @@ -0,0 +1,28 @@ +# 0002 — Kotlin Multiplatform with shared Compose UI and Ktor + +Date: 2026-06-12 · Status: accepted + +## Context + +The SDK must serve Android first but iOS support is a requirement, including the chat UI +itself. Alternatives considered: (a) Android-only Jetpack Compose, port later; (b) KMP core +(networking/state) with native UI per platform; (c) KMP + Compose Multiplatform sharing the +UI too. + +## Decision + +Full sharing (c): one `:sdk` KMP module — `androidLibrary`, `iosArm64`, `iosSimulatorArm64` +targets — with the entire stack in `commonMain`: Ktor (OkHttp engine on Android, Darwin on +iOS) for REST + the ActionCable websocket, kotlinx-serialization, multiplatform-settings +for token persistence, and `ChatPage` itself in Compose Multiplatform. iOS additionally +gets a `ChatPageViewController()` UIKit wrapper. + +## Consequences + +- One implementation of the chat UI, protocol, and reconnect logic; platform code is + ~40 lines of expect/actual (config fallback) plus the UIViewController wrapper. +- iOS binary cost: Compose Multiplatform adds roughly 9 MB to the consuming app. +- CI must run on macOS for every build and publish (iOS targets compile there only), and + pure-Swift apps consume a binary XCFramework via SPM (GitHub Releases) rather than source. +- The repo name "android-sdk" undersells the artifact; the Maven group + `com.chatwoot.android` is kept regardless (ADR 0001). diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7b57c38 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +# Kotlin/Native linking of Compose Multiplatform needs a big heap (OOMs at the default). +kotlin.daemon.jvmargs=-Xmx6g +kotlin.native.jvmArgs=-Xmx6g +org.gradle.caching=true +org.gradle.configuration-cache=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..a026322 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,52 @@ +[versions] +kotlin = "2.4.0" +agp = "9.2.0" +vanniktechPublish = "0.33.0" +composeMultiplatform = "1.11.1" +ktor = "3.5.0" +coroutines = "1.11.0" +serializationJson = "1.11.0" +multiplatformSettings = "1.3.0" +jbLifecycle = "2.10.0" +androidxComposeBom = "2026.05.00" +activityCompose = "1.13.0" +androidxStartup = "1.2.0" +junit = "4.13.2" + +[plugins] +android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" } + +[libraries] +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } + +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } + +multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings", version.ref = "multiplatformSettings" } +multiplatform-settings-no-arg = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } + +jb-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "jbLifecycle" } +jb-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "jbLifecycle" } + +androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" } + +# sample-app only +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# test +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8b6d5ac --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,296 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000012C0000010000A001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000022C0000010000A002 /* iOSApp.swift */; }; + A10000032C0000010000A003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000042C0000010000A004 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000022C0000010000A002 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + A10000042C0000010000A004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A10000052C0000010000A005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000062C0000010000A006 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000072C0000010000A007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000082C0000010000A008 = { + isa = PBXGroup; + children = ( + A10000092C0000010000A009 /* iosApp */, + A100000A2C0000010000A00A /* Products */, + ); + sourceTree = ""; + }; + A10000092C0000010000A009 /* iosApp */ = { + isa = PBXGroup; + children = ( + A10000022C0000010000A002 /* iOSApp.swift */, + A10000042C0000010000A004 /* ContentView.swift */, + A10000052C0000010000A005 /* Info.plist */, + ); + path = iosApp; + sourceTree = ""; + }; + A100000A2C0000010000A00A /* Products */ = { + isa = PBXGroup; + children = ( + A10000062C0000010000A006 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A100000B2C0000010000A00B /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A100000C2C0000010000A00C /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + A100000D2C0000010000A00D /* Compile Kotlin Framework */, + A100000E2C0000010000A00E /* Sources */, + A10000072C0000010000A007 /* Frameworks */, + A100000F2C0000010000A00F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = A10000062C0000010000A006 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000102C0000010000A010 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + A100000B2C0000010000A00B = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = A10000112C0000010000A011 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000082C0000010000A008; + productRefGroup = A100000A2C0000010000A00A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A100000B2C0000010000A00B /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A100000F2C0000010000A00F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A100000D2C0000010000A00D /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :sdk:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A100000E2C0000010000A00E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000032C0000010000A003 /* ContentView.swift in Sources */, + A10000012C0000010000A001 /* iOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A10000122C0000010000A012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A10000132C0000010000A013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A10000142C0000010000A014 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sdk/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ChatwootSDK, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chatwoot.android.sample.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A10000152C0000010000A015 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sdk/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ChatwootSDK, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chatwoot.android.sample.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A100000C2C0000010000A00C /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000142C0000010000A014 /* Debug */, + A10000152C0000010000A015 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000112C0000010000A011 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000122C0000010000A012 /* Debug */, + A10000132C0000010000A013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A10000102C0000010000A010 /* Project object */; +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..21fc9fb --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import ChatwootSDK + +struct ContentView: View { + @State private var showChat = false + + var body: some View { + VStack { + Button("Open chat") { showChat = true } + } + .fullScreenCover(isPresented: $showChat) { + ChatView(onFinish: { showChat = false }) + .ignoresSafeArea() + } + } +} + +struct ChatView: UIViewControllerRepresentable { + let onFinish: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + ChatPageViewControllerKt.ChatPageViewController( + onFinish: onFinish, + styleConfig: StyleConfigKt.DefaultStyle + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..73bfc34 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..bc03dff --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,19 @@ +import SwiftUI +import ChatwootSDK + +@main +struct iOSApp: App { + init() { + // Or set ChatwootBaseUrl / ChatwootWebsiteToken in Info.plist instead. + Chatwoot.shared.configure( + baseUrl: "https://app.chatwoot.com", + websiteToken: "YOUR_WEBSITE_TOKEN" + ) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts new file mode 100644 index 0000000..c903784 --- /dev/null +++ b/sample-app/build.gradle.kts @@ -0,0 +1,63 @@ +import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +// Chatwoot credentials for manual testing live in local.properties (not committed): +// chatwoot.baseUrl=https://app.chatwoot.com +// chatwoot.websiteToken= +val localProps = Properties().apply { + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + +android { + namespace = "com.chatwoot.android.sample" + compileSdk = 37 + + defaultConfig { + applicationId = "com.chatwoot.android.sample" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + buildConfigField( + "String", + "CHATWOOT_BASE_URL", + "\"${localProps.getProperty("chatwoot.baseUrl", "https://app.chatwoot.com")}\"", + ) + buildConfigField( + "String", + "CHATWOOT_WEBSITE_TOKEN", + "\"${localProps.getProperty("chatwoot.websiteToken", "")}\"", + ) + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +dependencies { + implementation(project(":sdk")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) +} diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3765d71 --- /dev/null +++ b/sample-app/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt b/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt new file mode 100644 index 0000000..2b8ddab --- /dev/null +++ b/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt @@ -0,0 +1,45 @@ +package com.chatwoot.android.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.chatwoot.android.sdk.ChatPage +import com.chatwoot.android.sdk.Chatwoot + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Chatwoot.configure( + baseUrl = BuildConfig.CHATWOOT_BASE_URL, + websiteToken = BuildConfig.CHATWOOT_WEBSITE_TOKEN, + ) + + setContent { + MaterialTheme { + var showChat by remember { mutableStateOf(false) } + + if (showChat) { + ChatPage(show = true, onFinish = { showChat = false }) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Button(onClick = { showChat = true }) { + Text("Open chat") + } + } + } + } + } + } +} diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts new file mode 100644 index 0000000..d6144a7 --- /dev/null +++ b/sdk/build.gradle.kts @@ -0,0 +1,99 @@ +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kmp.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.vanniktech.maven.publish) +} + +version = "0.1.0" + +kotlin { + explicitApi() + jvmToolchain(21) + + androidLibrary { + namespace = "com.chatwoot.android.sdk" + compileSdk = 37 + minSdk = 26 + + withHostTestBuilder {} + } + + val xcfName = "ChatwootSDK" + val xcf = XCFramework(xcfName) + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = xcfName + isStatic = true + xcf.add(this) + } + } + + sourceSets { + commonMain.dependencies { + // Consumers see Color/Shape/@Composable in the public API surface. + api(compose.runtime) + api(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coroutines.core) + implementation(libs.multiplatform.settings) + implementation(libs.multiplatform.settings.no.arg) + implementation(libs.jb.lifecycle.viewmodel.compose) + implementation(libs.jb.lifecycle.runtime.compose) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.coroutines.test) + } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.startup) + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + } +} + +mavenPublishing { + // publishToMavenCentral(automaticRelease = true) and signing are configured centrally in + // the root build.gradle.kts `subprojects { }` block. + configure(KotlinMultiplatform(javadocJar = JavadocJar.Empty(), sourcesJar = true)) + + pom { + name.set("Chatwoot SDK") + description.set("Kotlin Multiplatform chat SDK for Chatwoot — a Compose ChatPage backed by the Chatwoot widget API with live messages over ActionCable.") + url.set("https://github.com/chatwoot/android-sdk") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("chatwoot") + name.set("Chatwoot") + url.set("https://www.chatwoot.com") + } + } + scm { + url.set("https://github.com/chatwoot/android-sdk") + connection.set("scm:git:git://github.com/chatwoot/android-sdk.git") + developerConnection.set("scm:git:ssh://git@github.com/chatwoot/android-sdk.git") + } + } +} diff --git a/sdk/src/androidMain/AndroidManifest.xml b/sdk/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..60c916a --- /dev/null +++ b/sdk/src/androidMain/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/Chatwoot.android.kt b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/Chatwoot.android.kt new file mode 100644 index 0000000..c5f828f --- /dev/null +++ b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/Chatwoot.android.kt @@ -0,0 +1,26 @@ +package com.chatwoot.android.sdk + +import android.content.Context +import android.content.pm.PackageManager +import androidx.startup.Initializer + +internal lateinit var appContext: Context + +/** Captures the application context before any SDK call; wired via androidx.startup. */ +public class ChatwootInitializer : Initializer { + override fun create(context: Context) { + appContext = context.applicationContext + } + + override fun dependencies(): List>> = emptyList() +} + +internal actual fun platformDefaultConfig(): ChatwootConfig? { + if (!::appContext.isInitialized) return null + val metaData = appContext.packageManager + .getApplicationInfo(appContext.packageName, PackageManager.GET_META_DATA) + .metaData ?: return null + val baseUrl = metaData.getString("com.chatwoot.android.BASE_URL") ?: return null + val token = metaData.getString("com.chatwoot.android.WEBSITE_TOKEN") ?: return null + return ChatwootConfig(baseUrl, token) +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt new file mode 100644 index 0000000..a3680aa --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatPage.kt @@ -0,0 +1,26 @@ +package com.chatwoot.android.sdk + +import androidx.compose.runtime.Composable +import com.chatwoot.android.sdk.style.DefaultStyle +import com.chatwoot.android.sdk.style.StyleConfig +import com.chatwoot.android.sdk.ui.ChatScreen + +/** + * The Chatwoot chat screen. + * + * Requires the SDK to be configured first — see [Chatwoot.configure]. + * + * @param show Whether the page is visible. When false, nothing is composed and the + * chat session (including the websocket) is torn down. + * @param onFinish Called when the user closes the chat via the header close button. + * @param styleConfig Visual customisation; defaults to the stock Chatwoot look. + */ +@Composable +public fun ChatPage( + show: Boolean, + onFinish: () -> Unit, + styleConfig: StyleConfig = DefaultStyle, +) { + if (!show) return + ChatScreen(onFinish = onFinish, style = styleConfig) +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt new file mode 100644 index 0000000..8fc5682 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt @@ -0,0 +1,56 @@ +package com.chatwoot.android.sdk + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chatwoot.android.sdk.data.ChatRepository +import com.chatwoot.android.sdk.data.ChatUiState +import com.chatwoot.android.sdk.net.defaultHttpClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +internal class ChatViewModel : ViewModel() { + private val client = defaultHttpClient() + private val repository = ChatRepository(Chatwoot.config, client) + + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + repository.state + .onEach { _state.value = it } + .launchIn(viewModelScope) + + viewModelScope.launch { + runCatching { repository.connect(this) } + .onFailure { e -> + _state.value = _state.value.copy( + loading = false, + error = e.message ?: "Could not connect to Chatwoot", + ) + } + } + } + + fun send(content: String) { + val text = content.trim() + if (text.isEmpty()) return + viewModelScope.launch { + runCatching { repository.send(text) } + .onFailure { e -> + _state.value = _state.value.copy(error = e.message ?: "Message failed to send") + } + } + } + + fun dismissError() { + _state.value = _state.value.copy(error = null) + } + + override fun onCleared() { + client.close() + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt new file mode 100644 index 0000000..cf0a780 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt @@ -0,0 +1,46 @@ +package com.chatwoot.android.sdk + +/** + * Connection settings for a Chatwoot website inbox. + * + * @property baseUrl Chatwoot installation URL, e.g. `https://app.chatwoot.com`. + * @property websiteToken The website inbox token from the Chatwoot dashboard. + */ +public data class ChatwootConfig( + val baseUrl: String, + val websiteToken: String, +) { + init { + require(baseUrl.startsWith("http")) { "baseUrl must be an http(s) URL, got '$baseUrl'" } + require(websiteToken.isNotBlank()) { "websiteToken must not be blank" } + } + + internal val normalizedBaseUrl: String = baseUrl.trimEnd('/') +} + +/** + * SDK entry point. Either call [configure] once (e.g. in `Application.onCreate` / + * `application(_:didFinishLaunchingWithOptions:)`) or declare the platform fallback: + * + * - Android: `` entries `com.chatwoot.android.BASE_URL` and + * `com.chatwoot.android.WEBSITE_TOKEN` in `AndroidManifest.xml`. + * - iOS: `ChatwootBaseUrl` and `ChatwootWebsiteToken` keys in `Info.plist`. + */ +public object Chatwoot { + private var explicit: ChatwootConfig? = null + + public fun configure(baseUrl: String, websiteToken: String) { + explicit = ChatwootConfig(baseUrl, websiteToken) + } + + internal val config: ChatwootConfig + get() = explicit ?: platformDefaultConfig() ?: error( + "Chatwoot SDK is not configured. Call Chatwoot.configure(baseUrl, websiteToken) " + + "or provide the platform metadata (Android manifest " + + "com.chatwoot.android.BASE_URL / com.chatwoot.android.WEBSITE_TOKEN, " + + "iOS Info.plist ChatwootBaseUrl / ChatwootWebsiteToken)." + ) +} + +/** Reads the platform fallback config (Android manifest meta-data / iOS Info.plist), if present. */ +internal expect fun platformDefaultConfig(): ChatwootConfig? diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt new file mode 100644 index 0000000..ef37b4a --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -0,0 +1,122 @@ +package com.chatwoot.android.sdk.data + +import com.chatwoot.android.sdk.ChatwootConfig +import com.chatwoot.android.sdk.net.CableClient +import com.chatwoot.android.sdk.net.CableEvent +import com.chatwoot.android.sdk.net.MessageDto +import com.chatwoot.android.sdk.net.WidgetApi +import com.chatwoot.android.sdk.net.WidgetSession +import io.ktor.client.HttpClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** A chat entry as rendered by the UI. */ +internal data class ChatMessage( + val id: Long, + val content: String, + val fromContact: Boolean, + val isActivity: Boolean, + val senderName: String?, + val createdAt: Long, +) + +internal data class ChatUiState( + val loading: Boolean = true, + val connected: Boolean = false, + val agentTyping: Boolean = false, + val messages: List = emptyList(), + val error: String? = null, +) + +/** + * Owns the session lifecycle: bootstrap (resuming any persisted `cw_conversation` token), + * history fetch, live RoomChannel events, and sending. The first send of a fresh session + * goes through conversation creation. + */ +internal class ChatRepository( + private val config: ChatwootConfig, + private val client: HttpClient, + private val api: WidgetApi = WidgetApi(config, client), + private val tokenStore: TokenStore = TokenStore(), +) { + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state.asStateFlow() + + private var session: WidgetSession? = null + private var hasConversation = false + + suspend fun connect(scope: CoroutineScope) { + val s = api.fetchSession(tokenStore.conversationToken(config.websiteToken)) + tokenStore.saveConversationToken(config.websiteToken, s.authToken) + session = s + + refreshMessages(s) + _state.update { it.copy(loading = false) } + + scope.launch { + CableClient(client, config.normalizedBaseUrl, s.pubsubToken).events().collect { event -> + onCableEvent(event, s) + } + } + } + + suspend fun send(content: String) { + val s = session ?: error("ChatRepository.send called before connect") + if (hasConversation) { + upsert(api.sendMessage(s.authToken, content)) + } else { + api.createConversation(s.authToken, content) + hasConversation = true + refreshMessages(s) + } + } + + private suspend fun onCableEvent(event: CableEvent, s: WidgetSession) { + when (event) { + is CableEvent.MessageCreated -> upsert(event.message) + is CableEvent.MessageUpdated -> upsert(event.message) + is CableEvent.TypingChanged -> _state.update { it.copy(agentTyping = event.typing) } + CableEvent.Connected -> { + _state.update { it.copy(connected = true) } + // Catch up on anything broadcast while we were offline. + runCatching { refreshMessages(s) } + } + CableEvent.Disconnected -> + _state.update { it.copy(connected = false, agentTyping = false) } + } + } + + private suspend fun refreshMessages(s: WidgetSession) { + val history = api.getMessages(s.authToken).mapNotNull { it.toChatMessage() } + hasConversation = hasConversation || history.isNotEmpty() + _state.update { state -> + state.copy(messages = (history + state.messages.filter { m -> history.none { it.id == m.id } }).sorted()) + } + } + + private fun upsert(dto: MessageDto) { + val message = dto.toChatMessage() ?: return + _state.update { state -> + state.copy(messages = (state.messages.filter { it.id != message.id } + message).sorted()) + } + } + + private fun List.sorted() = sortedWith(compareBy({ it.createdAt }, { it.id })) +} + +private fun MessageDto.toChatMessage(): ChatMessage? { + if (private) return null + val text = content?.takeIf { it.isNotBlank() } ?: return null + return ChatMessage( + id = id, + content = text, + fromContact = messageType == 0, + isActivity = messageType == 2, + senderName = sender?.availableName ?: sender?.name, + createdAt = createdAt, + ) +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt new file mode 100644 index 0000000..e9a66ea --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt @@ -0,0 +1,20 @@ +package com.chatwoot.android.sdk.data + +import com.russhwolf.settings.Settings + +/** + * Persists the `cw_conversation` session JWT (SharedPreferences / NSUserDefaults) so the + * same contact + conversation is resumed across app launches. Keyed per website token — + * switching inboxes must not leak another inbox's session. + */ +internal class TokenStore(private val settings: Settings = Settings()) { + + fun conversationToken(websiteToken: String): String? = + settings.getStringOrNull(key(websiteToken)) + + fun saveConversationToken(websiteToken: String, token: String) { + settings.putString(key(websiteToken), token) + } + + private fun key(websiteToken: String) = "cw_conversation_$websiteToken" +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableClient.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableClient.kt new file mode 100644 index 0000000..b985918 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableClient.kt @@ -0,0 +1,108 @@ +package com.chatwoot.android.sdk.net + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.min +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** SDK-level realtime events, mapped from RoomChannel broadcasts. */ +internal sealed interface CableEvent { + data object Connected : CableEvent + data object Disconnected : CableEvent + data class MessageCreated(val message: MessageDto) : CableEvent + data class MessageUpdated(val message: MessageDto) : CableEvent + data class TypingChanged(val typing: Boolean) : CableEvent +} + +private const val PRESENCE_INTERVAL_MS = 30_000L +private const val MAX_BACKOFF_MS = 30_000L + +/** + * Maintains a RoomChannel subscription on `wss:///cable`, including the 30s + * `update_presence` keepalive and exponential-backoff reconnects. Collect [events]; + * the connection lives as long as the collector. + */ +internal class CableClient( + private val client: HttpClient, + baseUrl: String, + private val pubsubToken: String, +) { + private val wsUrl = baseUrl + .replaceFirst("https://", "wss://") + .replaceFirst("http://", "ws://") + .trimEnd('/') + "/cable" + + fun events(): Flow = channelFlow { + var attempt = 0 + while (isActive) { + try { + client.webSocket(urlString = wsUrl) { + send(Frame.Text(CableProtocol.subscribeCommand(pubsubToken))) + val keepalive = launch { + while (true) { + delay(PRESENCE_INTERVAL_MS) + send(Frame.Text(CableProtocol.presenceCommand(pubsubToken))) + } + } + try { + for (frame in incoming) { + if (frame !is Frame.Text) continue + when (val parsed = CableProtocol.parseFrame(frame.readText())) { + CableFrame.ConfirmSubscription -> { + attempt = 0 + send(CableEvent.Connected) + } + is CableFrame.Event -> mapEvent(parsed)?.let { send(it) } + CableFrame.Disconnect -> return@webSocket + else -> Unit + } + } + } finally { + keepalive.cancel() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // fall through to reconnect + } + if (!isActive) break + send(CableEvent.Disconnected) + attempt++ + delay(min(MAX_BACKOFF_MS, 1_000L * (1L shl min(attempt, 5)))) + } + awaitClose() + } + + private fun mapEvent(event: CableFrame.Event): CableEvent? = when (event.name) { + "message.created" -> decodeMessage(event)?.let { CableEvent.MessageCreated(it) } + "message.updated" -> decodeMessage(event)?.let { CableEvent.MessageUpdated(it) } + "conversation.typing_on" -> typingEvent(event, typing = true) + "conversation.typing_off" -> typingEvent(event, typing = false) + else -> null + } + + private fun decodeMessage(event: CableFrame.Event): MessageDto? = + runCatching { ChatwootJson.decodeFromJsonElement(MessageDto.serializer(), event.data) } + .getOrNull() + + /** Only surface agent typing — ignore the contact's own echo and private-note typing. */ + private fun typingEvent(event: CableFrame.Event, typing: Boolean): CableEvent? { + val data = runCatching { event.data.jsonObject }.getOrNull() ?: return null + val isPrivate = data["is_private"]?.jsonPrimitive?.contentOrNull == "true" + val userType = data["user"]?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull + if (isPrivate || userType == "contact") return null + return CableEvent.TypingChanged(typing) + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableProtocol.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableProtocol.kt new file mode 100644 index 0000000..7258fe6 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/CableProtocol.kt @@ -0,0 +1,72 @@ +package com.chatwoot.android.sdk.net + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** One inbound frame on the `/cable` socket, in ActionCable's wire format. */ +internal sealed interface CableFrame { + data object Welcome : CableFrame + data object Ping : CableFrame + data object ConfirmSubscription : CableFrame + data object Disconnect : CableFrame + + /** A broadcast Chatwoot event, e.g. `message.created`. */ + data class Event(val name: String, val data: JsonElement) : CableFrame + + data object Unknown : CableFrame +} + +/** + * Rails ActionCable wire protocol for the Chatwoot contact RoomChannel. + * Contact subscriptions identify with the pubsub token only (no account_id/user_id). + */ +internal object CableProtocol { + + /** The subscription identifier — itself a JSON string nested inside command frames. */ + fun identifier(pubsubToken: String): String = + ChatwootJson.encodeToString( + buildJsonObject { + put("channel", "RoomChannel") + put("pubsub_token", pubsubToken) + } + ) + + fun subscribeCommand(pubsubToken: String): String = + ChatwootJson.encodeToString( + buildJsonObject { + put("command", "subscribe") + put("identifier", identifier(pubsubToken)) + } + ) + + /** Keepalive; the widget sends this every 30 seconds to stay "online". */ + fun presenceCommand(pubsubToken: String): String = + ChatwootJson.encodeToString( + buildJsonObject { + put("command", "message") + put("identifier", identifier(pubsubToken)) + put("data", """{"action":"update_presence"}""") + } + ) + + fun parseFrame(text: String): CableFrame { + val obj = runCatching { ChatwootJson.parseToJsonElement(text).jsonObject } + .getOrElse { return CableFrame.Unknown } + + when (obj["type"]?.jsonPrimitive?.contentOrNull) { + "welcome" -> return CableFrame.Welcome + "ping" -> return CableFrame.Ping + "confirm_subscription" -> return CableFrame.ConfirmSubscription + "disconnect" -> return CableFrame.Disconnect + } + + val message = obj["message"] as? kotlinx.serialization.json.JsonObject ?: return CableFrame.Unknown + val event = message["event"]?.jsonPrimitive?.contentOrNull ?: return CableFrame.Unknown + return CableFrame.Event(name = event, data = message["data"] ?: JsonNull) + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt new file mode 100644 index 0000000..9f61f85 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -0,0 +1,75 @@ +package com.chatwoot.android.sdk.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Lenient by design: Chatwoot payloads carry many fields the SDK doesn't need. */ +internal val ChatwootJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + coerceInputValues = true +} + +@Serializable +internal data class MessageDto( + val id: Long, + val content: String? = null, + // 0 = incoming (contact), 1 = outgoing (agent), 2 = activity, 3 = template + @SerialName("message_type") val messageType: Int = 0, + @SerialName("content_type") val contentType: String? = null, + @SerialName("created_at") val createdAt: Long = 0, + @SerialName("conversation_id") val conversationId: Long? = null, + val status: String? = null, + val private: Boolean = false, + val sender: SenderDto? = null, +) + +@Serializable +internal data class SenderDto( + val id: Long? = null, + val name: String? = null, + @SerialName("available_name") val availableName: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, + val thumbnail: String? = null, + val type: String? = null, +) + +@Serializable +internal data class MessagesPayloadDto( + val payload: List = emptyList(), +) + +@Serializable +internal data class AgentDto( + val id: Long? = null, + val name: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, + @SerialName("availability_status") val availabilityStatus: String? = null, +) + +@Serializable +internal data class AgentsPayloadDto( + val payload: List = emptyList(), +) + +// --- Request bodies (mirrors app/javascript/widget/api/endPoints.js in chatwoot/chatwoot) --- + +@Serializable +internal data class OutgoingMessageDto( + val content: String, + val timestamp: String, + // No default — defaults are skipped during encoding and the widget always sends this key. + @SerialName("referer_url") val refererUrl: String, +) + +@Serializable +internal data class SendMessageRequest( + val message: OutgoingMessageDto, +) + +@Serializable +internal data class CreateConversationRequest( + val message: OutgoingMessageDto, +) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt new file mode 100644 index 0000000..1817fa1 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt @@ -0,0 +1,87 @@ +package com.chatwoot.android.sdk.net + +import com.chatwoot.android.sdk.ChatwootConfig +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +internal fun defaultHttpClient(): HttpClient = HttpClient { + install(ContentNegotiation) { json(ChatwootJson) } + install(WebSockets) + expectSuccess = true +} + +/** + * Chatwoot website-widget REST API. All endpoints are scoped by `website_token`; everything + * except the bootstrap additionally authenticates with the session JWT via `X-Auth-Token`. + */ +internal class WidgetApi( + private val config: ChatwootConfig, + private val client: HttpClient, +) { + private val base = config.normalizedBaseUrl + + /** Bootstraps (or resumes, when [conversationToken] is set) a contact session. */ + suspend fun fetchSession(conversationToken: String?): WidgetSession { + val html = client.get("$base/widget") { + parameter("website_token", config.websiteToken) + if (!conversationToken.isNullOrBlank()) parameter("cw_conversation", conversationToken) + // This endpoint is HTML-only; without an explicit Accept, ContentNegotiation's + // application/json gets a 406 from Rails. + header(HttpHeaders.Accept, "text/html") + }.bodyAsText() + return WidgetPageParser.parse(html) ?: error( + "Could not bootstrap the Chatwoot widget from $base — " + + "check the baseUrl and websiteToken." + ) + } + + suspend fun getMessages(authToken: String): List = + client.get("$base/api/v1/widget/messages") { + authenticated(authToken) + }.body().payload + + suspend fun sendMessage(authToken: String, content: String): MessageDto = + client.post("$base/api/v1/widget/messages") { + authenticated(authToken) + contentType(ContentType.Application.Json) + setBody(SendMessageRequest(outgoing(content))) + }.body() + + /** First message of a session goes through conversation creation. */ + suspend fun createConversation(authToken: String, content: String) { + client.post("$base/api/v1/widget/conversations") { + authenticated(authToken) + contentType(ContentType.Application.Json) + setBody(CreateConversationRequest(outgoing(content))) + } + } + + suspend fun getAgents(): List = + client.get("$base/api/v1/widget/inbox_members") { + parameter("website_token", config.websiteToken) + }.body().payload + + private fun HttpRequestBuilder.authenticated(authToken: String) { + parameter("website_token", config.websiteToken) + header("X-Auth-Token", authToken) + } + + @OptIn(ExperimentalTime::class) + private fun outgoing(content: String) = + OutgoingMessageDto(content = content, timestamp = Clock.System.now().toString(), refererUrl = "") +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetPageParser.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetPageParser.kt new file mode 100644 index 0000000..5c52e1c --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetPageParser.kt @@ -0,0 +1,28 @@ +package com.chatwoot.android.sdk.net + +/** + * A widget session as bootstrapped by `GET /widget?website_token=…`. + * + * @property authToken JWT identifying the contact session; sent as `X-Auth-Token` on REST calls + * and persisted as the `cw_conversation` token so the contact survives restarts. + * @property pubsubToken The contact's ActionCable RoomChannel subscription token. + */ +internal data class WidgetSession( + val authToken: String, + val pubsubToken: String, +) + +/** + * The widget bootstrap endpoint returns HTML (there is no JSON variant); the session tokens are + * embedded as `window.authToken = '…'` and `window.chatwootPubsubToken = '…'` script globals. + */ +internal object WidgetPageParser { + private val authToken = Regex("""window\.authToken\s*=\s*['"]([^'"]+)['"]""") + private val pubsubToken = Regex("""window\.chatwootPubsubToken\s*=\s*['"]([^'"]+)['"]""") + + fun parse(html: String): WidgetSession? { + val auth = authToken.find(html)?.groupValues?.get(1) ?: return null + val pubsub = pubsubToken.find(html)?.groupValues?.get(1) ?: return null + return WidgetSession(authToken = auth, pubsubToken = pubsub) + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt new file mode 100644 index 0000000..4e88807 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/style/StyleConfig.kt @@ -0,0 +1,42 @@ +package com.chatwoot.android.sdk.style + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +/** + * Visual customisation for [com.chatwoot.android.sdk.ChatPage]. All theming flows through + * this one object; pass a copy of [DefaultStyle] with overrides. + * + * @property primaryColor Brand color — fills the header bar and tints the send button. + * @property onPrimaryColor Content (title, close icon) drawn on top of [primaryColor]. + * @property backgroundColor Background of the message list area. + * @property surfaceColor Background of the input bar at the bottom. + * @property outgoingBubbleColor Bubble fill for messages sent by the contact (the app user). + * @property onOutgoingBubbleColor Text color inside outgoing bubbles. + * @property incomingBubbleColor Bubble fill for agent/bot messages. + * @property onIncomingBubbleColor Text color inside incoming bubbles. + * @property textColor Primary text color, used for the message input. + * @property secondaryTextColor De-emphasised text: input placeholder, typing indicator, + * activity messages (e.g. "Conversation was resolved"). + * @property bubbleShape Shape clipping every message bubble. + * @property title Header title of the chat page. + */ +public data class StyleConfig( + val primaryColor: Color = Color(0xFF1F93FF), + val onPrimaryColor: Color = Color.White, + val backgroundColor: Color = Color(0xFFF7F8FA), + val surfaceColor: Color = Color.White, + val outgoingBubbleColor: Color = Color(0xFF1F93FF), + val onOutgoingBubbleColor: Color = Color.White, + val incomingBubbleColor: Color = Color.White, + val onIncomingBubbleColor: Color = Color(0xFF1B1B33), + val textColor: Color = Color(0xFF1B1B33), + val secondaryTextColor: Color = Color(0xFF6E7191), + val bubbleShape: Shape = RoundedCornerShape(12.dp), + val title: String = "Chat with us", +) + +/** The stock Chatwoot look. */ +public val DefaultStyle: StyleConfig = StyleConfig() diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt new file mode 100644 index 0000000..55be205 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt @@ -0,0 +1,67 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.style.StyleConfig + +@Composable +internal fun ChatHeader(style: StyleConfig, connected: Boolean, onFinish: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(style.primaryColor) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = style.title, + color = style.onPrimaryColor, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + ) + if (!connected) { + Text( + text = "connecting…", + color = style.onPrimaryColor.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + } + } + IconButton(onClick = onFinish) { + Text(text = "✕", color = style.onPrimaryColor, fontSize = 18.sp) + } + } +} + +@Composable +internal fun ErrorBanner(error: String, style: StyleConfig, onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(androidx.compose.ui.graphics.Color(0xFFFFE5E5)) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = error, + color = androidx.compose.ui.graphics.Color(0xFFB3261E), + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Text(text = "✕", color = androidx.compose.ui.graphics.Color(0xFFB3261E), fontSize = 14.sp) + } + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt new file mode 100644 index 0000000..7e7a67e --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt @@ -0,0 +1,87 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.chatwoot.android.sdk.ChatViewModel +import com.chatwoot.android.sdk.style.StyleConfig + +@Composable +internal fun ChatScreen( + onFinish: () -> Unit, + style: StyleConfig, + viewModel: ChatViewModel = viewModel { ChatViewModel() }, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(style.backgroundColor) + .safeDrawingPadding() + .imePadding(), + ) { + ChatHeader(style = style, connected = state.connected, onFinish = onFinish) + + state.error?.let { error -> + ErrorBanner(error = error, style = style, onDismiss = viewModel::dismissError) + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (state.loading) { + CircularProgressIndicator( + color = style.primaryColor, + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(count = state.messages.size, key = { state.messages[it].id }) { index -> + MessageBubble(message = state.messages[index], style = style) + } + } + } + } + + if (state.agentTyping) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + text = "typing…", + color = style.secondaryTextColor, + fontSize = 13.sp, + ) + } + } + + InputBar(style = style, enabled = !state.loading, onSend = viewModel::send) + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt new file mode 100644 index 0000000..89bf8ab --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -0,0 +1,61 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.style.StyleConfig + +@Composable +internal fun InputBar(style: StyleConfig, enabled: Boolean, onSend: (String) -> Unit) { + var text by rememberSaveable { mutableStateOf("") } + + fun submit() { + val value = text.trim() + if (value.isEmpty() || !enabled) return + onSend(value) + text = "" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(style.surfaceColor) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = text, + onValueChange = { text = it }, + enabled = enabled, + textStyle = TextStyle(color = style.textColor, fontSize = 15.sp), + decorationBox = { innerTextField -> + if (text.isEmpty()) { + Text(text = "Type your message…", color = style.secondaryTextColor, fontSize = 15.sp) + } + innerTextField() + }, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + IconButton(onClick = ::submit, enabled = enabled && text.isNotBlank()) { + Text( + text = "➤", + color = if (text.isNotBlank()) style.primaryColor else style.secondaryTextColor, + fontSize = 20.sp, + ) + } + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt new file mode 100644 index 0000000..0c1e9a4 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt @@ -0,0 +1,51 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.data.ChatMessage +import com.chatwoot.android.sdk.style.StyleConfig + +@Composable +internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { + if (message.isActivity) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = message.content, + color = style.secondaryTextColor, + fontSize = 12.sp, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + return + } + + val fromContact = message.fromContact + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (fromContact) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .clip(style.bubbleShape) + .background(if (fromContact) style.outgoingBubbleColor else style.incomingBubbleColor) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = message.content, + color = if (fromContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor, + fontSize = 15.sp, + ) + } + } +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/CableProtocolTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/CableProtocolTest.kt new file mode 100644 index 0000000..d89cfb1 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/CableProtocolTest.kt @@ -0,0 +1,69 @@ +package com.chatwoot.android.sdk.net + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CableProtocolTest { + + @Test + fun subscribeCommandNestsIdentifierAsJsonString() { + val command = Json.parseToJsonElement(CableProtocol.subscribeCommand("tok123")).jsonObject + assertEquals("subscribe", command["command"]?.jsonPrimitive?.content) + + // ActionCable requires the identifier to be a JSON-encoded *string*, not an object. + val identifier = Json.parseToJsonElement( + command["identifier"]!!.jsonPrimitive.content + ).jsonObject + assertEquals("RoomChannel", identifier["channel"]?.jsonPrimitive?.content) + assertEquals("tok123", identifier["pubsub_token"]?.jsonPrimitive?.content) + } + + @Test + fun presenceCommandCarriesUpdatePresenceAction() { + val command = Json.parseToJsonElement(CableProtocol.presenceCommand("tok123")).jsonObject + assertEquals("message", command["command"]?.jsonPrimitive?.content) + val data = Json.parseToJsonElement(command["data"]!!.jsonPrimitive.content).jsonObject + assertEquals("update_presence", data["action"]?.jsonPrimitive?.content) + } + + @Test + fun parsesControlFrames() { + assertEquals(CableFrame.Welcome, CableProtocol.parseFrame("""{"type":"welcome"}""")) + assertEquals(CableFrame.Ping, CableProtocol.parseFrame("""{"type":"ping","message":1781250000}""")) + assertEquals( + CableFrame.ConfirmSubscription, + CableProtocol.parseFrame("""{"type":"confirm_subscription","identifier":"{\"channel\":\"RoomChannel\"}"}"""), + ) + assertEquals(CableFrame.Disconnect, CableProtocol.parseFrame("""{"type":"disconnect","reason":"unauthorized"}""")) + } + + @Test + fun parsesBroadcastEventFrame() { + val frame = CableProtocol.parseFrame( + """ + { + "identifier": "{\"channel\":\"RoomChannel\",\"pubsub_token\":\"tok\"}", + "message": { + "event": "message.created", + "data": {"id": 42, "content": "hello", "message_type": 1, "created_at": 1781250000} + } + } + """.trimIndent() + ) + val event = assertIs(frame) + assertEquals("message.created", event.name) + assertEquals("42", event.data.jsonObject["id"]?.jsonPrimitive?.content) + } + + @Test + fun unknownPayloadsDoNotThrow() { + assertEquals(CableFrame.Unknown, CableProtocol.parseFrame("not json at all")) + assertEquals(CableFrame.Unknown, CableProtocol.parseFrame("""{"something":"else"}""")) + assertTrue(CableProtocol.parseFrame("""{"message":{"no_event":true}}""") is CableFrame.Unknown) + } +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt new file mode 100644 index 0000000..7da7690 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt @@ -0,0 +1,53 @@ +package com.chatwoot.android.sdk.net + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DtoTest { + + @Test + fun decodesMessagePayloadLeniently() { + val payload = ChatwootJson.decodeFromString( + MessagesPayloadDto.serializer(), + """ + { + "payload": [ + { + "id": 1, + "content": "Hi there", + "message_type": 1, + "content_type": "text", + "created_at": 1781250000, + "conversation_id": 7, + "status": "sent", + "sender": {"id": 3, "name": "Pranav", "type": "user", "some_new_field": {"nested": true}}, + "unknown_future_field": [1, 2, 3] + }, + {"id": 2, "message_type": 2, "content": "Conversation was resolved", "created_at": 1781250100} + ] + } + """.trimIndent() + ) + + assertEquals(2, payload.payload.size) + val first = payload.payload.first() + assertEquals(1L, first.id) + assertEquals("Hi there", first.content) + assertEquals(1, first.messageType) + assertEquals("Pranav", first.sender?.name) + assertFalse(first.private) + assertEquals(2, payload.payload[1].messageType) + } + + @Test + fun encodesSendMessageRequestWithSnakeCaseKeys() { + val body = ChatwootJson.encodeToString( + SendMessageRequest.serializer(), + SendMessageRequest(OutgoingMessageDto(content = "hello", timestamp = "2026-06-12T00:00:00Z", refererUrl = "")), + ) + assertTrue("\"referer_url\"" in body, body) + assertTrue("\"content\":\"hello\"" in body, body) + } +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/WidgetPageParserTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/WidgetPageParserTest.kt new file mode 100644 index 0000000..9cb27cd --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/WidgetPageParserTest.kt @@ -0,0 +1,30 @@ +package com.chatwoot.android.sdk.net + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WidgetPageParserTest { + + @Test + fun extractsTokensFromWidgetHtml() { + // Mirrors the real script block served by GET /widget on app.chatwoot.com. + val html = """ + + """.trimIndent() + + val session = WidgetPageParser.parse(html) + assertEquals("eyJhbGciOiJIUzI1NiJ9.payload.sig", session?.authToken) + assertEquals("czoAyeaR79j2BQdaNqy9ajFJ", session?.pubsubToken) + } + + @Test + fun returnsNullWhenTokensMissing() { + assertNull(WidgetPageParser.parse("404")) + assertNull(WidgetPageParser.parse("window.authToken = 'only-auth'")) + } +} diff --git a/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/ChatPageViewController.kt b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/ChatPageViewController.kt new file mode 100644 index 0000000..627668d --- /dev/null +++ b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/ChatPageViewController.kt @@ -0,0 +1,17 @@ +package com.chatwoot.android.sdk + +import androidx.compose.ui.window.ComposeUIViewController +import com.chatwoot.android.sdk.style.DefaultStyle +import com.chatwoot.android.sdk.style.StyleConfig +import platform.UIKit.UIViewController + +/** + * UIKit entry point for Swift/SwiftUI hosts: a view controller rendering [ChatPage]. + * Embed it directly or via `UIViewControllerRepresentable`. + */ +public fun ChatPageViewController( + onFinish: () -> Unit = {}, + styleConfig: StyleConfig = DefaultStyle, +): UIViewController = ComposeUIViewController { + ChatPage(show = true, onFinish = onFinish, styleConfig = styleConfig) +} diff --git a/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/Chatwoot.ios.kt b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/Chatwoot.ios.kt new file mode 100644 index 0000000..f7860ef --- /dev/null +++ b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/Chatwoot.ios.kt @@ -0,0 +1,10 @@ +package com.chatwoot.android.sdk + +import platform.Foundation.NSBundle + +internal actual fun platformDefaultConfig(): ChatwootConfig? { + val bundle = NSBundle.mainBundle + val baseUrl = bundle.objectForInfoDictionaryKey("ChatwootBaseUrl") as? String ?: return null + val token = bundle.objectForInfoDictionaryKey("ChatwootWebsiteToken") as? String ?: return null + return ChatwootConfig(baseUrl, token) +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b492aa5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "android-sdk" +include(":sdk") +include(":sample-app") From 91169ea8212aa90a514512fc54a5b02f0044ded3 Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Mon, 15 Jun 2026 19:29:10 +0530 Subject: [PATCH 04/10] Add attachment support (Phase 1: media + files) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send and receive image, video, audio and generic file attachments in the Compose ChatPage. Send: multipart POST to /api/v1/widget/messages with message[attachments][] (verified against the Chatwoot widget source) as a caption-less message; optimistic local bubble with upload progress, reconciled with the server message (websocket echo deduped by id). First-message-as-attachment uses a multipart POST /conversations + refetch. Receive: MessageDto gains an attachments array; both REST history and the message.created/updated websocket events flow through unchanged. Attachment-only messages (blank content) are no longer dropped by toChatMessage. Render: images inline (Coil 3), video plays on tap, audio as a voice-note row (play/pause + seek + duration), files open externally. Video/audio playback and url opening are expect/actual — Media3 on Android, AVPlayer on iOS. The file picker uses FileKit. Voice-note recording is deferred to Phase 2. Docs: CONTEXT.md records the attachment wire contract; ADR 0003 the media stack. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 16 ++ docs/adr/0003-media-stack-for-attachments.md | 34 +++ gradle/libs.versions.toml | 11 +- sdk/build.gradle.kts | 8 + .../sdk/media/PlatformMedia.android.kt | 96 ++++++++ .../com/chatwoot/android/sdk/ChatViewModel.kt | 10 + .../android/sdk/data/ChatRepository.kt | 127 ++++++++++- .../chatwoot/android/sdk/data/PickedFile.kt | 45 ++++ .../android/sdk/media/PlatformMedia.kt | 29 +++ .../com/chatwoot/android/sdk/net/Dto.kt | 14 ++ .../com/chatwoot/android/sdk/net/WidgetApi.kt | 54 +++++ .../android/sdk/ui/AttachmentContent.kt | 207 ++++++++++++++++++ .../com/chatwoot/android/sdk/ui/ChatScreen.kt | 17 +- .../com/chatwoot/android/sdk/ui/InputBar.kt | 51 ++++- .../chatwoot/android/sdk/ui/MessageBubble.kt | 30 ++- .../android/sdk/data/AttachmentTest.kt | 107 +++++++++ .../android/sdk/media/PlatformMedia.ios.kt | 94 ++++++++ 17 files changed, 933 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0003-media-stack-for-attachments.md create mode 100644 sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.android.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/PickedFile.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt create mode 100644 sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.ios.kt diff --git a/CONTEXT.md b/CONTEXT.md index dd07491..8d1a608 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,6 +27,10 @@ them in code, docs, and discussion. 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. +- **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) @@ -51,10 +55,22 @@ Omitting `cw_conversation` creates a fresh contact; passing it resumes the sessi |---|---| | `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"}}` — only for an **existing** conversation. 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. Returns the created Message (parse for the real `id` + attachment URLs). | | `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. | | `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. + +> **Unverified edge:** sending the session's *first* message as an attachment uses multipart +> `POST /conversations` (same `message[attachments][]` fields). The JSON create path is verified; +> the multipart create is not yet probed against app.chatwoot.com — verify during e2e. The +> create response's `message_type` is a string (as with the text path), so the SDK refetches +> `GET /messages` instead of parsing it. + ### Realtime (`wss:///cable`, ActionCable wire protocol) 1. On connect the server sends `{"type":"welcome"}`, then `{"type":"ping"}` every ~3s. diff --git a/docs/adr/0003-media-stack-for-attachments.md b/docs/adr/0003-media-stack-for-attachments.md new file mode 100644 index 0000000..250fe8f --- /dev/null +++ b/docs/adr/0003-media-stack-for-attachments.md @@ -0,0 +1,34 @@ +# 0003 — Media stack for attachments: Coil + native players + FileKit + +Date: 2026-06-15 · Status: accepted + +## Context + +Phase 1 of attachments needs to render images inline, play video on tap, play audio as +voice notes, and let the contact pick a file — across Android and iOS from `commonMain`. +There is no single mature Kotlin Multiplatform library covering image loading, video/audio +playback, and file picking, so each capability is chosen separately. (Voice-note *recording* +is deferred to Phase 2.) + +## Decision + +- **Images:** Coil 3 (`coil-compose` + `coil-network-ktor3`), loading over the SDK's existing + Ktor stack. A `SingletonImageLoader` factory registers the Ktor fetcher so iOS works too + (it has no JVM ServiceLoader auto-registration). +- **Video & audio playback:** hand-rolled `expect`/`actual` players — Media3/ExoPlayer + (`media3-exoplayer` + `media3-ui`) on Android, `AVPlayer`/`AVPlayerViewController` on iOS. + No third-party multiplayer dependency; keeps `commonMain` thin (ADR 0002). +- **Picker:** FileKit (`filekit-dialogs-compose`) — one `commonMain` call returns the picked + file, using PHPicker/UIDocumentPicker on iOS and the Photo Picker/OpenDocument on Android, + all permission-free. The alternative (rolling our own `expect`/`actual` PHPicker delegate + glue) was rejected as the fiddliest, lowest-value platform code. + +## Consequences + +- A future reader sees AVPlayer/Media3 code in the platform source rather than one shared + player abstraction — that's deliberate; native players give the most control with no media + lock-in, at the cost of two implementations to maintain. +- Three new dependencies enter the consumer's app (Coil, FileKit on both platforms; Media3 on + Android). Each is independently swappable. +- Picked files are read fully into memory before upload (Phase 1); large media is bounded by + Chatwoot's server limit (default 40 MB). Streaming uploads are a later optimization. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a026322..bdda3e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,15 @@ [versions] kotlin = "2.4.0" -agp = "9.2.0" +agp = "9.2.1" vanniktechPublish = "0.33.0" composeMultiplatform = "1.11.1" ktor = "3.5.0" coroutines = "1.11.0" serializationJson = "1.11.0" multiplatformSettings = "1.3.0" +coil = "3.5.0" +filekit = "0.14.2" +media3 = "1.10.1" jbLifecycle = "2.10.0" androidxComposeBom = "2026.05.00" activityCompose = "1.13.0" @@ -36,6 +39,12 @@ coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines- multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings", version.ref = "multiplatformSettings" } multiplatform-settings-no-arg = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-ktor3 = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" } +filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } + jb-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "jbLifecycle" } jb-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "jbLifecycle" } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index d6144a7..65c7d73 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -53,6 +53,11 @@ kotlin { implementation(libs.multiplatform.settings.no.arg) implementation(libs.jb.lifecycle.viewmodel.compose) implementation(libs.jb.lifecycle.runtime.compose) + + // Attachments: image loading (over the existing Ktor stack) + the file/media picker. + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor3) + implementation(libs.filekit.dialogs.compose) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -61,6 +66,9 @@ kotlin { androidMain.dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.androidx.startup) + // Video + audio playback. iOS uses AVFoundation, which ships with Kotlin/Native. + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.android.kt b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.android.kt new file mode 100644 index 0000000..1d590b0 --- /dev/null +++ b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.android.kt @@ -0,0 +1,96 @@ +package com.chatwoot.android.sdk.media + +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import com.chatwoot.android.sdk.appContext +import kotlinx.coroutines.delay + +@Composable +internal actual fun VideoPlayer(url: String, modifier: Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val player = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = true + } + } + DisposableEffect(player) { onDispose { player.release() } } + AndroidView( + modifier = modifier, + factory = { PlayerView(it).apply { this.player = player } }, + ) +} + +@Composable +internal actual fun rememberAudioPlayback(url: String): AudioPlayback { + val context = androidx.compose.ui.platform.LocalContext.current + val player = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + } + } + val playback = remember(player) { ExoAudioPlayback(player) } + + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { playback.playing = isPlaying } + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_READY) playback.duration = player.duration.coerceAtLeast(0) + } + } + player.addListener(listener) + onDispose { player.release() } + } + LaunchedEffect(player) { + while (true) { + playback.position = player.currentPosition.coerceAtLeast(0) + delay(200) + } + } + return playback +} + +private class ExoAudioPlayback(private val player: ExoPlayer) : AudioPlayback { + var playing by mutableStateOf(false) + var position by mutableLongStateOf(0L) + var duration by mutableLongStateOf(0L) + + override val isPlaying: Boolean get() = playing + override val positionMs: Long get() = position + override val durationMs: Long get() = duration + + override fun playPause() { + if (player.isPlaying) player.pause() + else { + if (player.playbackState == Player.STATE_ENDED) player.seekTo(0) + player.play() + } + } + + override fun seekToFraction(fraction: Float) { + val d = player.duration + if (d > 0) player.seekTo((d * fraction.coerceIn(0f, 1f)).toLong()) + } +} + +internal actual fun openExternally(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt index 8fc5682..3d1891a 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.chatwoot.android.sdk.data.ChatRepository import com.chatwoot.android.sdk.data.ChatUiState +import com.chatwoot.android.sdk.data.PickedFile import com.chatwoot.android.sdk.net.defaultHttpClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -46,6 +47,15 @@ internal class ChatViewModel : ViewModel() { } } + fun sendAttachment(file: PickedFile) { + viewModelScope.launch { + runCatching { repository.sendAttachment(file) } + .onFailure { e -> + _state.value = _state.value.copy(error = e.message ?: "Attachment failed to send") + } + } + } + fun dismissError() { _state.value = _state.value.copy(error = null) } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt index ef37b4a..66410d3 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -1,6 +1,7 @@ package com.chatwoot.android.sdk.data import com.chatwoot.android.sdk.ChatwootConfig +import com.chatwoot.android.sdk.net.AttachmentDto import com.chatwoot.android.sdk.net.CableClient import com.chatwoot.android.sdk.net.CableEvent import com.chatwoot.android.sdk.net.MessageDto @@ -13,6 +14,21 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +internal enum class AttachmentType { Image, Video, Audio, File } + +/** A rendered attachment. [url] is the original; [thumbUrl] is a preview when the server has one. */ +internal data class ChatAttachment( + val type: AttachmentType, + val url: String, + val thumbUrl: String?, + val fileName: String?, + val fileSize: Long?, + val width: Int?, + val height: Int?, +) /** A chat entry as rendered by the UI. */ internal data class ChatMessage( @@ -22,6 +38,12 @@ internal data class ChatMessage( val isActivity: Boolean, val senderName: String?, val createdAt: Long, + val attachments: List = emptyList(), + // Optimistic upload state — only set on the local placeholder while a send is in flight. + val pending: Boolean = false, + val failed: Boolean = false, + val uploadProgress: Float? = null, + val localPreview: PickedFile? = null, ) internal data class ChatUiState( @@ -49,6 +71,9 @@ internal class ChatRepository( private var session: WidgetSession? = null private var hasConversation = false + // Optimistic placeholders get descending negative ids so they never collide with real ones. + private var nextTempId = -1L + suspend fun connect(scope: CoroutineScope) { val s = api.fetchSession(tokenStore.conversationToken(config.websiteToken)) tokenStore.saveConversationToken(config.websiteToken, s.authToken) @@ -75,6 +100,38 @@ internal class ChatRepository( } } + /** + * Uploads [file] as an attachment-only message. Shows an optimistic local bubble + * immediately, then reconciles it with the server's response (the websocket echo of the + * same id is deduped by [upsert]). On failure the placeholder is marked [ChatMessage.failed]. + */ + @OptIn(ExperimentalTime::class) + suspend fun sendAttachment(file: PickedFile) { + val s = session ?: error("ChatRepository.sendAttachment called before connect") + val tempId = nextTempId-- + val onProgress = { progress: Float -> + _state.update { it.copy(messages = it.messages.withProgress(tempId, progress)) } + } + _state.update { it.copy(messages = it.messages.withUpserted(optimistic(tempId, file))) } + try { + if (hasConversation) { + // POST /messages returns the created Message — reconcile directly. + val real = api.sendAttachment(s.authToken, file, onProgress).toChatMessage() + _state.update { it.copy(messages = it.messages.reconcilingTemp(tempId, real)) } + } else { + // The create response can't be parsed (string message_type) — drop the + // placeholder and refetch so the stored attachment loads from GET /messages. + api.createConversationWithAttachment(s.authToken, file, onProgress) + hasConversation = true + _state.update { it.copy(messages = it.messages.reconcilingTemp(tempId, null)) } + refreshMessages(s) + } + } catch (e: Throwable) { + _state.update { it.copy(messages = it.messages.withFailed(tempId)) } + throw e + } + } + private suspend fun onCableEvent(event: CableEvent, s: WidgetSession) { when (event) { is CableEvent.MessageCreated -> upsert(event.message) @@ -100,23 +157,77 @@ internal class ChatRepository( private fun upsert(dto: MessageDto) { val message = dto.toChatMessage() ?: return - _state.update { state -> - state.copy(messages = (state.messages.filter { it.id != message.id } + message).sorted()) - } + _state.update { state -> state.copy(messages = state.messages.withUpserted(message)) } } +} - private fun List.sorted() = sortedWith(compareBy({ it.createdAt }, { it.id })) +// --- Pure message-list reducers (kept top-level so they're unit-testable without HTTP) --- + +private fun List.sorted() = sortedWith(compareBy({ it.createdAt }, { it.id })) + +/** Replaces any same-id entry with [message], keeping the list sorted. */ +internal fun List.withUpserted(message: ChatMessage): List = + (filter { it.id != message.id } + message).sorted() + +/** Removes the optimistic [tempId] placeholder and upserts the reconciled [real] message. */ +internal fun List.reconcilingTemp(tempId: Long, real: ChatMessage?): List { + val withoutTemp = filter { it.id != tempId } + return if (real == null) withoutTemp.sorted() else withoutTemp.withUpserted(real) } -private fun MessageDto.toChatMessage(): ChatMessage? { +internal fun List.withProgress(tempId: Long, progress: Float): List = + map { if (it.id == tempId) it.copy(uploadProgress = progress.coerceIn(0f, 1f)) else it } + +internal fun List.withFailed(tempId: Long): List = + map { if (it.id == tempId) it.copy(failed = true, pending = false) else it } + +@OptIn(ExperimentalTime::class) +internal fun optimistic(tempId: Long, file: PickedFile): ChatMessage = ChatMessage( + id = tempId, + content = "", + fromContact = true, + isActivity = false, + senderName = null, + createdAt = Clock.System.now().epochSeconds, + attachments = emptyList(), + pending = true, + uploadProgress = 0f, + localPreview = file, +) + +internal fun MessageDto.toChatMessage(): ChatMessage? { if (private) return null - val text = content?.takeIf { it.isNotBlank() } ?: return null + val text = content?.takeIf { it.isNotBlank() } + val isActivity = messageType == 2 + val mapped = attachments.mapNotNull { it.toChatAttachment() } + // Drop empty noise, but never an attachment- or activity-carrying message. + if (text == null && mapped.isEmpty() && !isActivity) return null return ChatMessage( id = id, - content = text, + content = text.orEmpty(), fromContact = messageType == 0, - isActivity = messageType == 2, + isActivity = isActivity, senderName = sender?.availableName ?: sender?.name, createdAt = createdAt, + attachments = mapped, + ) +} + +private fun AttachmentDto.toChatAttachment(): ChatAttachment? { + val url = dataUrl ?: thumbUrl ?: return null + val type = when (fileType?.lowercase()) { + "image" -> AttachmentType.Image + "video" -> AttachmentType.Video + "audio" -> AttachmentType.Audio + else -> AttachmentType.File + } + return ChatAttachment( + type = type, + url = url, + thumbUrl = thumbUrl, + fileName = extension?.let { "file.$it" }, + fileSize = fileSize, + width = width, + height = height, ) } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/PickedFile.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/PickedFile.kt new file mode 100644 index 0000000..c1d2442 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/PickedFile.kt @@ -0,0 +1,45 @@ +package com.chatwoot.android.sdk.data + +/** + * A file chosen by the contact, decoded into bytes in `commonMain`. The platform picker + * (FileKit) produces these; [com.chatwoot.android.sdk.net.WidgetApi] uploads them as + * `multipart/form-data`. + */ +internal data class PickedFile( + val name: String, + val mimeType: String, + val bytes: ByteArray, +) { + val attachmentType: AttachmentType + get() = when { + mimeType.startsWith("image/") -> AttachmentType.Image + mimeType.startsWith("video/") -> AttachmentType.Video + mimeType.startsWith("audio/") -> AttachmentType.Audio + else -> AttachmentType.File + } + + // Data classes with array members need explicit equals/hashCode for value semantics. + override fun equals(other: Any?): Boolean = + this === other || (other is PickedFile && name == other.name && mimeType == other.mimeType && bytes.contentEquals(other.bytes)) + + override fun hashCode(): Int = (name.hashCode() * 31 + mimeType.hashCode()) * 31 + bytes.contentHashCode() +} + +/** Best-effort MIME from a file extension; the server re-derives its own from the bytes. */ +internal fun mimeTypeForExtension(extension: String): String = when (extension.lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "heic" -> "image/heic" + "mp4" -> "video/mp4" + "mov" -> "video/quicktime" + "webm" -> "video/webm" + "mp3" -> "audio/mpeg" + "m4a", "aac" -> "audio/mp4" + "wav" -> "audio/wav" + "ogg", "oga" -> "audio/ogg" + "pdf" -> "application/pdf" + "txt" -> "text/plain" + else -> "application/octet-stream" +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.kt new file mode 100644 index 0000000..33e9011 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.kt @@ -0,0 +1,29 @@ +package com.chatwoot.android.sdk.media + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Plays [url] inline with the platform's native player and built-in transport controls + * (Media3 `PlayerView` on Android, `AVPlayer` on iOS). Used for video attachments. + */ +@Composable +internal expect fun VideoPlayer(url: String, modifier: Modifier) + +/** + * A lightweight audio player for voice-note bubbles, exposing Compose-observable state so a + * custom play/pause + progress row can drive it. Backed by Media3 (Android) / AVPlayer (iOS). + */ +internal interface AudioPlayback { + val isPlaying: Boolean + val positionMs: Long + val durationMs: Long + fun playPause() + fun seekToFraction(fraction: Float) +} + +@Composable +internal expect fun rememberAudioPlayback(url: String): AudioPlayback + +/** Opens [url] in the system handler (browser / viewer). Used for generic file attachments. */ +internal expect fun openExternally(url: String) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt index 9f61f85..511ace4 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -24,6 +24,20 @@ internal data class MessageDto( val status: String? = null, val private: Boolean = false, val sender: SenderDto? = null, + val attachments: List = emptyList(), +) + +@Serializable +internal data class AttachmentDto( + val id: Long? = null, + // image | audio | video | file | … — anything other than the first three renders as a file. + @SerialName("file_type") val fileType: String? = null, + @SerialName("data_url") val dataUrl: String? = null, + @SerialName("thumb_url") val thumbUrl: String? = null, + @SerialName("file_size") val fileSize: Long? = null, + val width: Int? = null, + val height: Int? = null, + val extension: String? = null, ) @Serializable diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt index 1817fa1..4919958 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt @@ -1,11 +1,15 @@ package com.chatwoot.android.sdk.net import com.chatwoot.android.sdk.ChatwootConfig +import com.chatwoot.android.sdk.data.PickedFile import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.onUpload import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter @@ -13,6 +17,7 @@ import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType +import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json @@ -62,6 +67,22 @@ internal class WidgetApi( setBody(SendMessageRequest(outgoing(content))) }.body() + /** + * Uploads an attachment as a (caption-less) message into an existing conversation. Mirrors + * the widget's `sendAttachment` multipart shape (`message[attachments][]`, `referer_url`, + * `timestamp`); returns the created Message so the caller can reconcile its optimistic bubble. + */ + suspend fun sendAttachment( + authToken: String, + file: PickedFile, + onProgress: (Float) -> Unit = {}, + ): MessageDto = + client.post("$base/api/v1/widget/messages") { + authenticated(authToken) + setBody(attachmentForm(file)) + onUpload { sent, total -> if (total != null && total > 0) onProgress(sent.toFloat() / total) } + }.body() + /** First message of a session goes through conversation creation. */ suspend fun createConversation(authToken: String, content: String) { client.post("$base/api/v1/widget/conversations") { @@ -71,6 +92,39 @@ internal class WidgetApi( } } + /** + * Creates the session's first conversation carrying an attachment (multipart). The create + * response's `message_type` is a string (see CONTEXT.md), so — like [createConversation] — + * we don't parse it; the caller refetches `GET /messages` to pick up the stored attachment. + */ + suspend fun createConversationWithAttachment( + authToken: String, + file: PickedFile, + onProgress: (Float) -> Unit = {}, + ) { + client.post("$base/api/v1/widget/conversations") { + authenticated(authToken) + setBody(attachmentForm(file)) + onUpload { sent, total -> if (total != null && total > 0) onProgress(sent.toFloat() / total) } + } + } + + @OptIn(ExperimentalTime::class) + private fun attachmentForm(file: PickedFile) = MultiPartFormDataContent( + formData { + append( + "message[attachments][]", + file.bytes, + Headers.build { + append(HttpHeaders.ContentType, file.mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") + }, + ) + append("message[referer_url]", "") + append("message[timestamp]", Clock.System.now().toString()) + }, + ) + suspend fun getAgents(): List = client.get("$base/api/v1/widget/inbox_members") { parameter("website_token", config.websiteToken) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt new file mode 100644 index 0000000..a263ac0 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/AttachmentContent.kt @@ -0,0 +1,207 @@ +package com.chatwoot.android.sdk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import coil3.compose.AsyncImage +import com.chatwoot.android.sdk.data.ChatAttachment +import com.chatwoot.android.sdk.data.ChatMessage +import com.chatwoot.android.sdk.data.AttachmentType +import com.chatwoot.android.sdk.media.VideoPlayer +import com.chatwoot.android.sdk.media.rememberAudioPlayback +import com.chatwoot.android.sdk.style.StyleConfig + +private val MediaWidth = 240.dp +private val MediaShape = RoundedCornerShape(10.dp) + +@Composable +internal fun AttachmentContent(message: ChatMessage, style: StyleConfig, onContact: Boolean) { + if (message.pending || message.failed) { + UploadingAttachment(message, style, onContact) + return + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + message.attachments.forEach { attachment -> + when (attachment.type) { + AttachmentType.Image -> ImageAttachment(attachment) + AttachmentType.Video -> VideoAttachment(attachment) + AttachmentType.Audio -> AudioAttachment(attachment, style, onContact) + AttachmentType.File -> FileAttachment(attachment, style, onContact) + } + } + } +} + +@Composable +private fun ImageAttachment(attachment: ChatAttachment) { + var fullScreen by remember { mutableStateOf(false) } + AsyncImage( + model = attachment.thumbUrl ?: attachment.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(MediaWidth) + .aspectRatio(attachment.aspectRatio()) + .clip(MediaShape) + .clickable { fullScreen = true }, + ) + if (fullScreen) { + FullScreenViewer(onDismiss = { fullScreen = false }) { + AsyncImage( + model = attachment.url, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun VideoAttachment(attachment: ChatAttachment) { + var playing by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .width(MediaWidth) + .aspectRatio(attachment.aspectRatio()) + .clip(MediaShape) + .background(Color.Black) + .clickable { playing = true }, + contentAlignment = Alignment.Center, + ) { + attachment.thumbUrl?.let { + AsyncImage(model = it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } + Box( + modifier = Modifier.size(48.dp).clip(RoundedCornerShape(50)).background(Color(0x99000000)), + contentAlignment = Alignment.Center, + ) { + Text("▶", color = Color.White, fontSize = 22.sp) + } + } + if (playing) { + FullScreenViewer(onDismiss = { playing = false }) { + VideoPlayer(url = attachment.url, modifier = Modifier.fillMaxWidth().aspectRatio(attachment.aspectRatio(landscape = true))) + } + } +} + +@Composable +private fun AudioAttachment(attachment: ChatAttachment, style: StyleConfig, onContact: Boolean) { + val playback = rememberAudioPlayback(attachment.url) + val tint = if (onContact) style.onOutgoingBubbleColor else style.primaryColor + val fraction = if (playback.durationMs > 0) playback.positionMs.toFloat() / playback.durationMs else 0f + Row( + modifier = Modifier.width(MediaWidth), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (playback.isPlaying) "⏸" else "▶", + color = tint, + fontSize = 22.sp, + modifier = Modifier.clickable { playback.playPause() }.padding(4.dp), + ) + Slider( + value = fraction, + onValueChange = { playback.seekToFraction(it) }, + modifier = Modifier.weight(1f), + ) + Text(formatDuration(if (playback.isPlaying || fraction > 0f) playback.positionMs else playback.durationMs), color = tint, fontSize = 12.sp) + } +} + +@Composable +private fun FileAttachment(attachment: ChatAttachment, style: StyleConfig, onContact: Boolean) { + val content = if (onContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor + Row( + modifier = Modifier.width(MediaWidth).clickable { com.chatwoot.android.sdk.media.openExternally(attachment.url) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("📄", fontSize = 26.sp) + Column { + Text(attachment.fileName ?: "Attachment", color = content, fontSize = 14.sp, maxLines = 1) + attachment.fileSize?.let { Text(formatBytes(it), color = content.copy(alpha = 0.7f), fontSize = 12.sp) } + } + } +} + +@Composable +private fun UploadingAttachment(message: ChatMessage, style: StyleConfig, onContact: Boolean) { + val preview = message.localPreview + val tint = if (onContact) style.onOutgoingBubbleColor else style.primaryColor + Box( + modifier = Modifier.width(MediaWidth).aspectRatio(1.4f).clip(MediaShape).background(Color(0x22000000)), + contentAlignment = Alignment.Center, + ) { + if (preview != null && preview.attachmentType == AttachmentType.Image) { + AsyncImage(model = preview.bytes, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } + if (message.failed) { + Text("Upload failed", color = tint, fontSize = 13.sp) + } else { + CircularProgressIndicator( + progress = { message.uploadProgress ?: 0f }, + color = tint, + modifier = Modifier.size(36.dp), + ) + } + } +} + +@Composable +private fun FullScreenViewer(onDismiss: () -> Unit, content: @Composable () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier.fillMaxSize().background(Color(0xEE000000)).clickable(onClick = onDismiss), + contentAlignment = Alignment.Center, + ) { content() } + } +} + +private fun ChatAttachment.aspectRatio(landscape: Boolean = false): Float { + val w = width + val h = height + return if (w != null && h != null && w > 0 && h > 0) w.toFloat() / h else if (landscape) 16f / 9f else 4f / 3f +} + +private fun formatDuration(ms: Long): String { + if (ms <= 0) return "0:00" + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + +private fun formatBytes(bytes: Long): String = when { + bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" + bytes >= 1_000 -> "${bytes / 1_000} KB" + else -> "$bytes B" +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt index 7e7a67e..bc2cb90 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.network.ktor3.KtorNetworkFetcherFactory import com.chatwoot.android.sdk.ChatViewModel import com.chatwoot.android.sdk.style.StyleConfig @@ -32,6 +35,13 @@ internal fun ChatScreen( style: StyleConfig, viewModel: ChatViewModel = viewModel { ChatViewModel() }, ) { + // Coil loads attachment images over the same Ktor stack the SDK already uses (KMP-wide). + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(KtorNetworkFetcherFactory()) } + .build() + } + val state by viewModel.state.collectAsStateWithLifecycle() val listState = rememberLazyListState() @@ -82,6 +92,11 @@ internal fun ChatScreen( } } - InputBar(style = style, enabled = !state.loading, onSend = viewModel::send) + InputBar( + style = style, + enabled = !state.loading, + onSend = viewModel::send, + onPickAttachment = viewModel::sendAttachment, + ) } } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt index 89bf8ab..bc6af2f 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -1,15 +1,20 @@ package com.chatwoot.android.sdk.ui import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -17,11 +22,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.data.PickedFile +import com.chatwoot.android.sdk.data.mimeTypeForExtension import com.chatwoot.android.sdk.style.StyleConfig +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.extension +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.launch @Composable -internal fun InputBar(style: StyleConfig, enabled: Boolean, onSend: (String) -> Unit) { +internal fun InputBar( + style: StyleConfig, + enabled: Boolean, + onSend: (String) -> Unit, + onPickAttachment: (PickedFile) -> Unit, +) { var text by rememberSaveable { mutableStateOf("") } + var menuOpen by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() fun submit() { val value = text.trim() @@ -30,6 +51,17 @@ internal fun InputBar(style: StyleConfig, enabled: Boolean, onSend: (String) -> text = "" } + fun consume(file: PlatformFile?) { + file ?: return + scope.launch { + val bytes = file.readBytes() + onPickAttachment(PickedFile(file.name, mimeTypeForExtension(file.extension), bytes)) + } + } + + val mediaPicker = rememberFilePickerLauncher(type = FileKitType.ImageAndVideo) { consume(it) } + val filePicker = rememberFilePickerLauncher(type = FileKitType.File()) { consume(it) } + Row( modifier = Modifier .fillMaxWidth() @@ -37,6 +69,21 @@ internal fun InputBar(style: StyleConfig, enabled: Boolean, onSend: (String) -> .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { + Box { + IconButton(onClick = { menuOpen = true }, enabled = enabled) { + Text(text = "📎", color = style.secondaryTextColor, fontSize = 20.sp) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Photo or video") }, + onClick = { menuOpen = false; mediaPicker.launch() }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { menuOpen = false; filePicker.launch() }, + ) + } + } BasicTextField( value = text, onValueChange = { text = it }, @@ -48,7 +95,7 @@ internal fun InputBar(style: StyleConfig, enabled: Boolean, onSend: (String) -> } innerTextField() }, - modifier = Modifier.weight(1f).padding(end = 8.dp), + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) IconButton(onClick = ::submit, enabled = enabled && text.isNotBlank()) { Text( diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt index 0c1e9a4..bd302c5 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt @@ -1,7 +1,9 @@ package com.chatwoot.android.sdk.ui import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -12,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.chatwoot.android.sdk.data.AttachmentType import com.chatwoot.android.sdk.data.ChatMessage import com.chatwoot.android.sdk.style.StyleConfig @@ -30,10 +33,20 @@ internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { } val fromContact = message.fromContact + val hasText = message.content.isNotBlank() + val hasAttachments = message.attachments.isNotEmpty() || message.pending || message.failed + // Standalone images/video render without bubble chrome; text, audio and files keep the bubble. + val bareMedia = !hasText && hasAttachments && (message.pending || message.failed || + message.attachments.all { it.type == AttachmentType.Image || it.type == AttachmentType.Video }) + Box( modifier = Modifier.fillMaxWidth(), contentAlignment = if (fromContact) Alignment.CenterEnd else Alignment.CenterStart, ) { + if (bareMedia) { + AttachmentContent(message = message, style = style, onContact = fromContact) + return@Box + } Box( modifier = Modifier .widthIn(max = 300.dp) @@ -41,11 +54,18 @@ internal fun MessageBubble(message: ChatMessage, style: StyleConfig) { .background(if (fromContact) style.outgoingBubbleColor else style.incomingBubbleColor) .padding(horizontal = 14.dp, vertical = 10.dp), ) { - Text( - text = message.content, - color = if (fromContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor, - fontSize = 15.sp, - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (hasText) { + Text( + text = message.content, + color = if (fromContact) style.onOutgoingBubbleColor else style.onIncomingBubbleColor, + fontSize = 15.sp, + ) + } + if (hasAttachments) { + AttachmentContent(message = message, style = style, onContact = fromContact) + } + } } } } diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt new file mode 100644 index 0000000..6ac7117 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt @@ -0,0 +1,107 @@ +package com.chatwoot.android.sdk.data + +import com.chatwoot.android.sdk.net.ChatwootJson +import com.chatwoot.android.sdk.net.MessageDto +import com.chatwoot.android.sdk.net.MessagesPayloadDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AttachmentTest { + + @Test + fun parsesAttachmentsArrayFromMessagePayload() { + val payload = ChatwootJson.decodeFromString( + MessagesPayloadDto.serializer(), + """ + {"payload":[{ + "id": 42, "message_type": 0, "created_at": 1781250000, "content": null, + "attachments": [{ + "id": 7, "file_type": "image", + "data_url": "https://cdn/x.jpg", "thumb_url": "https://cdn/x-thumb.jpg", + "file_size": 2048, "width": 800, "height": 600, "extension": "jpg", + "unexpected_field": true + }] + }]} + """.trimIndent(), + ) + val att = payload.payload.single().attachments.single() + assertEquals("image", att.fileType) + assertEquals("https://cdn/x.jpg", att.dataUrl) + assertEquals(800, att.width) + } + + @Test + fun attachmentOnlyMessageWithBlankContentIsNotDropped() { + val dto = MessageDto( + id = 1, content = "", messageType = 0, createdAt = 1, + attachments = listOf(com.chatwoot.android.sdk.net.AttachmentDto(fileType = "audio", dataUrl = "https://cdn/a.m4a")), + ) + val message = assertNotNull(dto.toChatMessage()) + assertEquals("", message.content) + assertEquals(AttachmentType.Audio, message.attachments.single().type) + assertTrue(message.fromContact) + } + + @Test + fun emptyMessageWithNoAttachmentsIsDropped() { + assertNull(MessageDto(id = 1, content = " ", messageType = 0, createdAt = 1).toChatMessage()) + } + + @Test + fun fileTypeMapsUnknownToFileAndPrefersDataUrl() { + val dto = MessageDto( + id = 2, messageType = 1, createdAt = 1, + attachments = listOf( + com.chatwoot.android.sdk.net.AttachmentDto(fileType = "share", dataUrl = "https://cdn/doc.pdf"), + com.chatwoot.android.sdk.net.AttachmentDto(fileType = "video", thumbUrl = "https://cdn/v-thumb.jpg"), + ), + ) + val atts = assertNotNull(dto.toChatMessage()).attachments + assertEquals(AttachmentType.File, atts[0].type) + assertEquals("https://cdn/doc.pdf", atts[0].url) + // No data_url → falls back to thumb_url for the URL. + assertEquals(AttachmentType.Video, atts[1].type) + assertEquals("https://cdn/v-thumb.jpg", atts[1].url) + } + + @Test + fun optimisticSendReconcilesWithRealMessageById() { + val tempId = -1L + val file = PickedFile("voice.m4a", "audio/mp4", byteArrayOf(1, 2, 3)) + var messages = emptyList().withUpserted(optimistic(tempId, file)) + + assertEquals(1, messages.size) + assertTrue(messages.single().pending) + assertEquals(file, messages.single().localPreview) + + messages = messages.withProgress(tempId, 0.5f) + assertEquals(0.5f, messages.single().uploadProgress) + + val real = ChatMessage( + id = 99, content = "", fromContact = true, isActivity = false, + senderName = null, createdAt = 1781250000, + attachments = listOf(ChatAttachment(AttachmentType.Audio, "https://cdn/a.m4a", null, null, 3, null, null)), + ) + messages = messages.reconcilingTemp(tempId, real) + // Temp gone, real present, not pending. + assertEquals(listOf(99L), messages.map { it.id }) + assertTrue(messages.single().attachments.isNotEmpty()) + + // A websocket echo of the same real id must not duplicate. + messages = messages.withUpserted(real) + assertEquals(1, messages.size) + } + + @Test + fun failedUploadKeepsPlaceholderAndMarksFailed() { + val tempId = -1L + val messages = emptyList() + .withUpserted(optimistic(tempId, PickedFile("x.jpg", "image/jpeg", byteArrayOf(0)))) + .withFailed(tempId) + assertTrue(messages.single().failed) + assertTrue(!messages.single().pending) + } +} diff --git a/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.ios.kt b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.ios.kt new file mode 100644 index 0000000..521ba17 --- /dev/null +++ b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/PlatformMedia.ios.kt @@ -0,0 +1,94 @@ +package com.chatwoot.android.sdk.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitViewController +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVPlayer +import platform.AVFoundation.addPeriodicTimeObserverForInterval +import platform.AVFoundation.currentItem +import platform.AVFoundation.currentTime +import platform.AVFoundation.duration +import platform.AVFoundation.pause +import platform.AVFoundation.play +import platform.AVFoundation.rate +import platform.AVFoundation.removeTimeObserver +import platform.AVFoundation.seekToTime +import platform.AVKit.AVPlayerViewController +import platform.CoreMedia.CMTimeGetSeconds +import platform.CoreMedia.CMTimeMakeWithSeconds +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +@OptIn(ExperimentalForeignApi::class) +@Composable +internal actual fun VideoPlayer(url: String, modifier: Modifier) { + val player = remember(url) { AVPlayer(uRL = NSURL.URLWithString(url) ?: NSURL()) } + DisposableEffect(player) { + player.play() + onDispose { player.pause() } + } + UIKitViewController( + factory = { AVPlayerViewController().apply { this.player = player } }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalForeignApi::class) +@Composable +internal actual fun rememberAudioPlayback(url: String): AudioPlayback { + val player = remember(url) { AVPlayer(uRL = NSURL.URLWithString(url) ?: NSURL()) } + val playback = remember(player) { AvAudioPlayback(player) } + DisposableEffect(player) { + val interval = CMTimeMakeWithSeconds(0.2, preferredTimescale = 600) + val observer = player.addPeriodicTimeObserverForInterval(interval, queue = null) { _ -> + playback.refresh() + } + onDispose { + player.removeTimeObserver(observer) + player.pause() + } + } + return playback +} + +@OptIn(ExperimentalForeignApi::class) +private class AvAudioPlayback(private val player: AVPlayer) : AudioPlayback { + var playing by mutableStateOf(false) + var position by mutableLongStateOf(0L) + var duration by mutableLongStateOf(0L) + + override val isPlaying: Boolean get() = playing + override val positionMs: Long get() = position + override val durationMs: Long get() = duration + + fun refresh() { + playing = player.rate != 0f + position = (CMTimeGetSeconds(player.currentTime()) * 1000).toLongOrZero() + player.currentItem?.let { duration = (CMTimeGetSeconds(it.duration) * 1000).toLongOrZero() } + } + + override fun playPause() { + if (player.rate != 0f) player.pause() else player.play() + playing = player.rate != 0f + } + + override fun seekToFraction(fraction: Float) { + val seconds = player.currentItem?.let { CMTimeGetSeconds(it.duration) } ?: return + if (seconds.isNaN() || seconds <= 0) return + player.seekToTime(CMTimeMakeWithSeconds(seconds * fraction.coerceIn(0f, 1f), preferredTimescale = 600)) + } +} + +private fun Double.toLongOrZero(): Long = if (isNaN() || isInfinite()) 0L else toLong() + +internal actual fun openExternally(url: String) { + val nsUrl = NSURL.URLWithString(url) ?: return + UIApplication.sharedApplication.openURL(nsUrl, options = emptyMap(), completionHandler = null) +} From 96eea48141df4c8a081286059f5cc896eb3c7c5a Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Mon, 15 Jun 2026 19:43:05 +0530 Subject: [PATCH 05/10] Add visitor identity (setUser/setCustomAttributes/reset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host apps can now identify the contact instead of leaving it anonymous. New imperative API on the Chatwoot singleton: - setUser(identifier, name, email, phoneNumber, avatarUrl, customAttributes, identifierHash) — flushed to the server right after bootstrap and on later changes, routing to PATCH /widget/contact/set_user when an identifier is present and PATCH /widget/contact otherwise. - setCustomAttributes(map) — merge inbox-defined attributes. - reset() — clear identity + persisted session on logout. Identity validation stays server-side: identifierHash is host-supplied (HMAC-SHA256 of the identifier with the per-inbox secret); the SDK never holds the secret. A changed identifier clears the stored session so the next bootstrap creates a fresh contact, preventing cross-user conversation leakage. TokenStore now persists the active identifier alongside the cw_conversation token and can clear a session. Adds ContactRequest DTO and WidgetApi setUser/updateContact calls. Docs: CONTEXT.md glossary + endpoints, ADR 0004, README, sample app. Tests: ContactRequest serialization, set_user vs contact routing (MockEngine), TokenStore round-trip/clear (MapSettings). Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 13 ++- README.md | 25 ++++++ docs/adr/0004-visitor-identity.md | 40 ++++++++++ gradle/libs.versions.toml | 2 + .../chatwoot/android/sample/MainActivity.kt | 10 +++ sdk/build.gradle.kts | 2 + .../com/chatwoot/android/sdk/Chatwoot.kt | 79 +++++++++++++++++++ .../android/sdk/data/ChatRepository.kt | 35 ++++++++ .../chatwoot/android/sdk/data/TokenStore.kt | 17 ++++ .../com/chatwoot/android/sdk/net/Dto.kt | 15 ++++ .../com/chatwoot/android/sdk/net/WidgetApi.kt | 23 ++++++ .../android/sdk/data/TokenStoreTest.kt | 45 +++++++++++ .../android/sdk/net/ContactApiTest.kt | 78 ++++++++++++++++++ .../com/chatwoot/android/sdk/net/DtoTest.kt | 20 +++++ 14 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0004-visitor-identity.md create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/TokenStoreTest.kt create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt diff --git a/CONTEXT.md b/CONTEXT.md index 8d1a608..76b63bc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,7 +26,16 @@ them in code, docs, and discussion. - **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. + "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`; 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 @@ -58,6 +67,8 @@ Omitting `cw_conversation` creates a fresh contact; passing it resumes the sessi | `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. Returns the created Message (parse for the real `id` + attachment URLs). | | `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. | | `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, diff --git a/README.md b/README.md index f37394b..7408d58 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,31 @@ ChatPageViewControllerKt.ChatPageViewController( Everything else — anonymous contact creation, conversation persistence across launches, history, live agent replies, typing indicators, reconnection — is handled inside. +## Identifying the visitor + +By default the contact is anonymous. If your app knows who the user is, identify them so agents +see a named contact and conversations follow the user across reinstalls/devices. Call it any +time (typically after your own login), before or while `ChatPage` is shown: + +```kotlin +Chatwoot.setUser( + identifier = "your-user-id", // stable id; recognises the same person later + name = "Ada Lovelace", + email = "ada@example.com", + phoneNumber = "+15551234567", + customAttributes = mapOf("plan" to "pro"), + identifierHash = serverComputedHash, // only if the inbox enforces identity validation +) + +Chatwoot.setCustomAttributes(mapOf("plan" to "enterprise")) // merge more attributes later +Chatwoot.reset() // on logout — forgets the session +``` + +**Identity validation (optional):** if you enable it on the inbox, `identifierHash` is required. +It must be computed **on your backend** as `HMAC-SHA256(hmacToken, identifier)` — the HMAC token +is a secret and must never ship inside the app. Passing a different `identifier` than the active +session starts a fresh contact + conversation on the next `ChatPage` open. + ## Theming All visual customisation flows through one object: diff --git a/docs/adr/0004-visitor-identity.md b/docs/adr/0004-visitor-identity.md new file mode 100644 index 0000000..73565ba --- /dev/null +++ b/docs/adr/0004-visitor-identity.md @@ -0,0 +1,40 @@ +# 0004 — Visitor identity via `Chatwoot.setUser`, HMAC stays server-side + +Date: 2026-06-15 · Status: accepted + +## Context + +The SDK was anonymous-only: every contact Chatwoot created was an unnamed record, so agents +couldn't see who they were talking to and conversations couldn't be tied to a known user across +reinstalls/devices. Host apps that already authenticate their users need to forward name / email / +phone / custom attributes, and — to prevent impersonation — optionally a stable **identifier** +validated by HMAC. Chatwoot's website widget exposes this through `setUser`, backed by +`PATCH /api/v1/widget/contact` and `/contact/set_user` (see CONTEXT.md). + +## Decision + +- **Surface:** imperative `Chatwoot.setUser(...)`, `Chatwoot.setCustomAttributes(...)`, and + `Chatwoot.reset()` on the existing singleton — not parameters on `configure()` or `ChatPage()`. + Identity is usually known only after the host's own login, which happens after `configure()` and + independently of when the chat UI is shown; an imperative call decouples the two and mirrors + Chatwoot's official JS/mobile SDK shape. Identity lives in a `StateFlow` the repository observes, + so it is flushed right after bootstrap and again on later changes. +- **HMAC stays server-side:** `setUser` accepts an optional **precomputed** `identifierHash`; the + SDK never takes the inbox's HMAC secret. The secret is per-inbox and computing the hash in the + app would require shipping it in the binary, where it is trivially extractable — defeating the + point of identity validation. The host's backend computes `HMAC-SHA256(hmac_token, identifier)`. +- **Identifier switch auto-resets:** the active identifier is persisted next to the conversation + JWT (`TokenStore`). If `setUser`'s identifier differs from the stored one, the session is cleared + before bootstrap so a second user can't resume the first user's conversation. `reset()` clears it + explicitly on logout. + +## Consequences + +- `setUser` taking a precomputed hash (rather than "just working") pushes a server-side + integration step onto adopters when identity validation is enabled — accepted as the only secure + option. With validation off, the hash is omitted and attributes still flow. +- Because the singleton holds no reference to a live session, `reset()` / an identifier switch take + effect on the **next** `ChatPage` open; a session already on screen continues until dismissed. + A mid-session identifier change therefore can't re-key the current conversation — documented, not + worked around. +- Two new contact endpoints are now part of the SDK's verified contract (recorded in CONTEXT.md). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdda3e0..eac4668 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = [libraries] ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } @@ -38,6 +39,7 @@ coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines- multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings", version.ref = "multiplatformSettings" } multiplatform-settings-no-arg = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } +multiplatform-settings-test = { group = "com.russhwolf", name = "multiplatform-settings-test", version.ref = "multiplatformSettings" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-ktor3 = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" } diff --git a/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt b/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt index 2b8ddab..c9c9efe 100644 --- a/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt +++ b/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt @@ -26,6 +26,16 @@ class MainActivity : ComponentActivity() { websiteToken = BuildConfig.CHATWOOT_WEBSITE_TOKEN, ) + // Identify the visitor so agents see a named contact instead of an anonymous one. + // Call after your app's own login; pass `identifierHash` (computed server-side) only if + // the inbox enforces identity validation. On logout, call Chatwoot.reset(). + Chatwoot.setUser( + identifier = "sample-user-1", + name = "Sample User", + email = "sample@example.com", + customAttributes = mapOf("plan" to "free"), + ) + setContent { MaterialTheme { var showChat by remember { mutableStateOf(false) } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 65c7d73..98434c8 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -62,6 +62,8 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.coroutines.test) + implementation(libs.ktor.client.mock) + implementation(libs.multiplatform.settings.test) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt index cf0a780..b8c3c31 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt @@ -1,5 +1,10 @@ package com.chatwoot.android.sdk +import com.chatwoot.android.sdk.data.TokenStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + /** * Connection settings for a Chatwoot website inbox. * @@ -18,6 +23,30 @@ public data class ChatwootConfig( internal val normalizedBaseUrl: String = baseUrl.trimEnd('/') } +/** + * The visitor whose conversations Chatwoot should attribute to a known person. + * + * @property identifier A stable, host-defined id (e.g. your user id). When set, the SDK + * associates the contact via `set_user`; changing it starts a fresh contact session. + * @property identifierHash HMAC-SHA256 of [identifier] keyed with the inbox's identity-validation + * secret, computed **server-side** by the host's backend. Required only when the inbox enforces + * identity validation; the SDK never holds the secret. + * @property customAttributes Inbox-defined custom attributes; numbers/dates are passed as strings. + */ +internal data class ChatwootIdentity( + val identifier: String? = null, + val name: String? = null, + val email: String? = null, + val phoneNumber: String? = null, + val avatarUrl: String? = null, + val customAttributes: Map = emptyMap(), + val identifierHash: String? = null, +) { + val isEmpty: Boolean + get() = identifier == null && name == null && email == null && + phoneNumber == null && avatarUrl == null && customAttributes.isEmpty() +} + /** * SDK entry point. Either call [configure] once (e.g. in `Application.onCreate` / * `application(_:didFinishLaunchingWithOptions:)`) or declare the platform fallback: @@ -33,6 +62,56 @@ public object Chatwoot { explicit = ChatwootConfig(baseUrl, websiteToken) } + private val _identity = MutableStateFlow(ChatwootIdentity()) + + /** The current visitor identity; observed by the repository to push updates to the server. */ + internal val identity: StateFlow get() = _identity + + /** + * Identifies the current visitor so agents see a known contact instead of an anonymous one. + * Call any time (e.g. after the host app's own login), before or while [ChatPage] is shown. + * + * Pass an [identifier] to recognise the same person across reinstalls/devices; supplying a + * different [identifier] than the active session starts a **fresh** contact + conversation on + * the next [ChatPage] open (no cross-user leakage). [identifierHash] is only needed when the + * inbox enforces identity validation and must be computed server-side. Unverified attribute-only + * updates (no [identifier]) are also supported. + */ + public fun setUser( + identifier: String? = null, + name: String? = null, + email: String? = null, + phoneNumber: String? = null, + avatarUrl: String? = null, + customAttributes: Map = emptyMap(), + identifierHash: String? = null, + ) { + _identity.value = ChatwootIdentity( + identifier = identifier, + name = name, + email = email, + phoneNumber = phoneNumber, + avatarUrl = avatarUrl, + customAttributes = customAttributes, + identifierHash = identifierHash, + ) + } + + /** Merges inbox-defined custom attributes into the current visitor identity. */ + public fun setCustomAttributes(attributes: Map) { + _identity.update { it.copy(customAttributes = it.customAttributes + attributes) } + } + + /** + * Clears the visitor identity and the persisted session for the configured inbox — call on + * logout. Takes effect on the next [ChatPage] open; a session already on screen continues + * until it is dismissed (`show = false`). + */ + public fun reset() { + _identity.value = ChatwootIdentity() + TokenStore().clearSession(config.websiteToken) + } + internal val config: ChatwootConfig get() = explicit ?: platformDefaultConfig() ?: error( "Chatwoot SDK is not configured. Call Chatwoot.configure(baseUrl, websiteToken) " + diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt index 66410d3..00791e5 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -1,9 +1,12 @@ package com.chatwoot.android.sdk.data +import com.chatwoot.android.sdk.Chatwoot import com.chatwoot.android.sdk.ChatwootConfig +import com.chatwoot.android.sdk.ChatwootIdentity import com.chatwoot.android.sdk.net.AttachmentDto import com.chatwoot.android.sdk.net.CableClient import com.chatwoot.android.sdk.net.CableEvent +import com.chatwoot.android.sdk.net.ContactRequest import com.chatwoot.android.sdk.net.MessageDto import com.chatwoot.android.sdk.net.WidgetApi import com.chatwoot.android.sdk.net.WidgetSession @@ -12,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.time.Clock @@ -75,10 +79,22 @@ internal class ChatRepository( private var nextTempId = -1L suspend fun connect(scope: CoroutineScope) { + // A different identified user than the persisted session means we must not resume the + // previous contact's conversation — drop the stored token so bootstrap creates a fresh one. + val wanted = Chatwoot.identity.value + if (wanted.identifier != null && + wanted.identifier != tokenStore.activeIdentifier(config.websiteToken) + ) { + tokenStore.clearSession(config.websiteToken) + } + val s = api.fetchSession(tokenStore.conversationToken(config.websiteToken)) tokenStore.saveConversationToken(config.websiteToken, s.authToken) + tokenStore.saveActiveIdentifier(config.websiteToken, wanted.identifier) session = s + runCatching { flushIdentity(s.authToken, wanted) } + refreshMessages(s) _state.update { it.copy(loading = false) } @@ -87,6 +103,25 @@ internal class ChatRepository( onCableEvent(event, s) } } + // Push later attribute changes (the value we just flushed is dropped). + scope.launch { + Chatwoot.identity.drop(1).collect { runCatching { flushIdentity(s.authToken, it) } } + } + } + + /** Sends the current identity to Chatwoot: `set_user` when identified, plain update otherwise. */ + private suspend fun flushIdentity(authToken: String, id: ChatwootIdentity) { + if (id.isEmpty) return + val body = ContactRequest( + identifier = id.identifier, + identifierHash = id.identifierHash, + name = id.name, + email = id.email, + phoneNumber = id.phoneNumber, + avatarUrl = id.avatarUrl, + customAttributes = id.customAttributes.ifEmpty { null }, + ) + if (id.identifier != null) api.setUser(authToken, body) else api.updateContact(authToken, body) } suspend fun send(content: String) { diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt index e9a66ea..4f44439 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt @@ -16,5 +16,22 @@ internal class TokenStore(private val settings: Settings = Settings()) { settings.putString(key(websiteToken), token) } + /** The host-supplied identifier the persisted session belongs to, if any. */ + fun activeIdentifier(websiteToken: String): String? = + settings.getStringOrNull(identifierKey(websiteToken)) + + fun saveActiveIdentifier(websiteToken: String, identifier: String?) { + if (identifier == null) settings.remove(identifierKey(websiteToken)) + else settings.putString(identifierKey(websiteToken), identifier) + } + + /** Forgets the persisted session so the next bootstrap creates a fresh contact. */ + fun clearSession(websiteToken: String) { + settings.remove(key(websiteToken)) + settings.remove(identifierKey(websiteToken)) + } + private fun key(websiteToken: String) = "cw_conversation_$websiteToken" + + private fun identifierKey(websiteToken: String) = "cw_identifier_$websiteToken" } diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt index 511ace4..4c1afe8 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -87,3 +87,18 @@ internal data class SendMessageRequest( internal data class CreateConversationRequest( val message: OutgoingMessageDto, ) + +/** + * Flat contact body for `PATCH /api/v1/widget/contact[/set_user]`. Null fields are dropped during + * encoding (`explicitNulls = false`), so each call sends only what the host supplied. + */ +@Serializable +internal data class ContactRequest( + val identifier: String? = null, + @SerialName("identifier_hash") val identifierHash: String? = null, + val name: String? = null, + val email: String? = null, + @SerialName("phone_number") val phoneNumber: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, + @SerialName("custom_attributes") val customAttributes: Map? = null, +) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt index 4919958..bcebee1 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt @@ -13,6 +13,7 @@ import io.ktor.client.request.forms.formData import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -125,6 +126,28 @@ internal class WidgetApi( }, ) + /** + * Associates the contact with a stable [ContactRequest.identifier] (`set_user`). When the inbox + * enforces identity validation the server checks [ContactRequest.identifierHash]; otherwise it's + * optional. Use [updateContact] for attribute-only updates with no identifier. + */ + suspend fun setUser(authToken: String, body: ContactRequest) { + client.patch("$base/api/v1/widget/contact/set_user") { + authenticated(authToken) + contentType(ContentType.Application.Json) + setBody(body) + } + } + + /** Updates the (possibly anonymous) contact's attributes. No identity validation. */ + suspend fun updateContact(authToken: String, body: ContactRequest) { + client.patch("$base/api/v1/widget/contact") { + authenticated(authToken) + contentType(ContentType.Application.Json) + setBody(body) + } + } + suspend fun getAgents(): List = client.get("$base/api/v1/widget/inbox_members") { parameter("website_token", config.websiteToken) diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/TokenStoreTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/TokenStoreTest.kt new file mode 100644 index 0000000..980fc1b --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/TokenStoreTest.kt @@ -0,0 +1,45 @@ +package com.chatwoot.android.sdk.data + +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TokenStoreTest { + + @Test + fun persistsTokenAndIdentifierPerWebsiteToken() { + val store = TokenStore(MapSettings()) + store.saveConversationToken("wt-a", "jwt-a") + store.saveActiveIdentifier("wt-a", "user-1") + store.saveConversationToken("wt-b", "jwt-b") + + assertEquals("jwt-a", store.conversationToken("wt-a")) + assertEquals("user-1", store.activeIdentifier("wt-a")) + // A different inbox must not see the first inbox's session. + assertEquals("jwt-b", store.conversationToken("wt-b")) + assertNull(store.activeIdentifier("wt-b")) + } + + @Test + fun clearSessionForgetsTokenAndIdentifier() { + val store = TokenStore(MapSettings()) + store.saveConversationToken("wt-a", "jwt-a") + store.saveActiveIdentifier("wt-a", "user-1") + + store.clearSession("wt-a") + + assertNull(store.conversationToken("wt-a")) + assertNull(store.activeIdentifier("wt-a")) + } + + @Test + fun savingNullIdentifierRemovesAnyStoredValue() { + val store = TokenStore(MapSettings()) + store.saveActiveIdentifier("wt-a", "user-1") + + store.saveActiveIdentifier("wt-a", null) + + assertNull(store.activeIdentifier("wt-a")) + } +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt new file mode 100644 index 0000000..0afb7b0 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt @@ -0,0 +1,78 @@ +package com.chatwoot.android.sdk.net + +import com.chatwoot.android.sdk.ChatwootConfig +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondOk +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpMethod +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ContactApiTest { + + private val config = ChatwootConfig("https://app.chatwoot.com", "wt-1") + + private fun apiCapturing(captured: MutableList): WidgetApi { + val engine = MockEngine { request -> + captured += RecordedRequest( + method = request.method, + fullUrl = request.url.toString(), + authToken = request.headers["X-Auth-Token"], + body = request.body.asText(), + ) + respondOk() + } + val client = HttpClient(engine) { install(ContentNegotiation) { json(ChatwootJson) } } + return WidgetApi(config, client) + } + + @Test + fun setUserHitsSetUserEndpointWithIdentityBody() = runTest { + val requests = mutableListOf() + val api = apiCapturing(requests) + + api.setUser( + "jwt-token", + ContactRequest(identifier = "u-1", identifierHash = "h", name = "Ada"), + ) + + val req = requests.single() + assertEquals(HttpMethod.Patch, req.method) + assertTrue(req.fullUrl.startsWith("https://app.chatwoot.com/api/v1/widget/contact/set_user"), req.fullUrl) + assertTrue("website_token=wt-1" in req.fullUrl, req.fullUrl) + assertEquals("jwt-token", req.authToken) + assertTrue("\"identifier\":\"u-1\"" in req.body, req.body) + assertTrue("\"identifier_hash\":\"h\"" in req.body, req.body) + } + + @Test + fun updateContactHitsPlainContactEndpoint() = runTest { + val requests = mutableListOf() + val api = apiCapturing(requests) + + api.updateContact("jwt-token", ContactRequest(email = "ada@example.com")) + + val req = requests.single() + assertEquals(HttpMethod.Patch, req.method) + assertTrue(req.fullUrl.startsWith("https://app.chatwoot.com/api/v1/widget/contact?"), req.fullUrl) + assertTrue("/set_user" !in req.fullUrl, req.fullUrl) + assertTrue("\"email\":\"ada@example.com\"" in req.body, req.body) + } + + private data class RecordedRequest( + val method: HttpMethod, + val fullUrl: String, + val authToken: String?, + val body: String, + ) +} + +private fun io.ktor.http.content.OutgoingContent.asText(): String = when (this) { + is io.ktor.http.content.TextContent -> text + is io.ktor.http.content.ByteArrayContent -> bytes().decodeToString() + else -> "" +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt index 7da7690..d6a736d 100644 --- a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt @@ -50,4 +50,24 @@ class DtoTest { assertTrue("\"referer_url\"" in body, body) assertTrue("\"content\":\"hello\"" in body, body) } + + @Test + fun encodesContactRequestWithSnakeCaseKeysAndOmitsNulls() { + val body = ChatwootJson.encodeToString( + ContactRequest.serializer(), + ContactRequest( + identifier = "u-123", + identifierHash = "deadbeef", + name = "Ada", + email = "ada@example.com", + customAttributes = mapOf("plan" to "pro"), + ), + ) + assertTrue("\"identifier\":\"u-123\"" in body, body) + assertTrue("\"identifier_hash\":\"deadbeef\"" in body, body) + assertTrue("\"custom_attributes\":{\"plan\":\"pro\"}" in body, body) + // phone_number / avatar_url were null — dropped during encoding. + assertFalse("phone_number" in body, body) + assertFalse("avatar_url" in body, body) + } } From 560b0a46195d51d419c00c8f266e15f109d07e7b Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Mon, 15 Jun 2026 19:53:36 +0530 Subject: [PATCH 06/10] Document visitor identity in CLAUDE.md Public surface now includes Chatwoot.setUser/setCustomAttributes/reset; note the identity flush flow and server-side HMAC invariant, the active-identifier in TokenStore, and refresh the ADR list (now four). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8c3e25..0f73f48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,15 +5,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview A Kotlin Multiplatform (Android + iOS) chat SDK for Chatwoot, published as -`com.chatwoot.android:sdk`. The entire public surface is `ChatPage(show, onFinish, -styleConfig = DefaultStyle)` (Compose Multiplatform) plus `Chatwoot.configure()` and, on -iOS, the `ChatPageViewController()` wrapper — everything else is `internal` (the module -uses `explicitApi()`). +`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 two load-bearing decisions (Maven coordinates, KMP+CMP). +the wiki. `docs/adr/` records the load-bearing decisions (Maven coordinates, KMP+CMP, +media stack, visitor identity). ## Commands @@ -45,7 +47,8 @@ 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 per website token) + → TokenStore (multiplatform-settings; persists the cw_conversation JWT + active + identifier per website token) ``` Key invariants: @@ -59,6 +62,11 @@ Key invariants: 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`. From 601348aa6c48a815cdb960ded2abbd8facbd2335 Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Mon, 15 Jun 2026 20:02:38 +0530 Subject: [PATCH 07/10] Add voice-note recording (attachments Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record and send voice notes from the chat input bar, reusing the Phase 1 attachment pipeline end to end. UX: when the text field is empty the send arrow becomes a 🎤 button (📎 file picker stays). Tapping it turns the input row into an inline recording bar (live timer • cancel ✕ • send ➤); send stops the recorder and uploads, cancel discards. The clip is wrapped as a PickedFile(audio/mp4) and routed through the existing ChatViewModel.sendAttachment path, so it shows the optimistic bubble and renders via the existing AudioAttachment player. Recording is native expect/actual: MediaRecorder (AAC/m4a) on Android, AVAudioRecorder on iOS. Mic permission is requested at first tap; on denial the mic button is silently hidden (no error). RECORD_AUDIO is declared in the SDK manifest (auto-merged); iOS hosts must add NSMicrophoneUsageDescription — documented in the README. ADR 0003 updated. Also pins Coil to 3.4.0 and FileKit to 0.13.0: their latest releases ship Kotlin/Native klibs built with the 2.4.0 compiler, whose ABI the project's pinned Kotlin 2.3.21 cannot consume (iOS link fails otherwise). This bundles the in-flight Kotlin 2.3.21 pin (Hilt metadata compatibility) that the dep downgrade depends on. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 ++ docs/adr/0003-media-stack-for-attachments.md | 7 +- gradle/libs.versions.toml | 14 ++- iosApp/iosApp/Info.plist | 2 + sdk/build.gradle.kts | 2 + sdk/src/androidMain/AndroidManifest.xml | 2 + .../sdk/media/AudioRecorder.android.kt | 107 ++++++++++++++++ .../android/sdk/media/AudioRecorder.kt | 31 +++++ .../com/chatwoot/android/sdk/ui/InputBar.kt | 66 +++++++++- .../android/sdk/media/AudioRecorder.ios.kt | 116 ++++++++++++++++++ 10 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.android.kt create mode 100644 sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.kt create mode 100644 sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.ios.kt diff --git a/README.md b/README.md index 7408d58..e6d9818 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ ChatPageViewControllerKt.ChatPageViewController( Everything else — anonymous contact creation, conversation persistence across launches, history, live agent replies, typing indicators, reconnection — is handled inside. +## Permissions (voice notes) + +Recording a voice note needs the microphone. The SDK requests it at first use and silently +hides the mic button if it's unavailable. + +- **Android** — `RECORD_AUDIO` is declared in the SDK manifest and merges into your app + automatically; nothing to add. +- **iOS** — you must add `NSMicrophoneUsageDescription` (with a user-facing reason) to your + app's `Info.plist`. iOS aborts at permission-request time if the key is missing, and the SDK + cannot supply it on your behalf. + +Picking image/video/file attachments needs no runtime permission on either platform. + ## Identifying the visitor By default the contact is anonymous. If your app knows who the user is, identify them so agents diff --git a/docs/adr/0003-media-stack-for-attachments.md b/docs/adr/0003-media-stack-for-attachments.md index 250fe8f..3856a82 100644 --- a/docs/adr/0003-media-stack-for-attachments.md +++ b/docs/adr/0003-media-stack-for-attachments.md @@ -7,8 +7,8 @@ Date: 2026-06-15 · Status: accepted Phase 1 of attachments needs to render images inline, play video on tap, play audio as voice notes, and let the contact pick a file — across Android and iOS from `commonMain`. There is no single mature Kotlin Multiplatform library covering image loading, video/audio -playback, and file picking, so each capability is chosen separately. (Voice-note *recording* -is deferred to Phase 2.) +playback, and file picking, so each capability is chosen separately. Phase 2 adds voice-note +*recording* on the same principle. ## Decision @@ -18,6 +18,9 @@ is deferred to Phase 2.) - **Video & audio playback:** hand-rolled `expect`/`actual` players — Media3/ExoPlayer (`media3-exoplayer` + `media3-ui`) on Android, `AVPlayer`/`AVPlayerViewController` on iOS. No third-party multiplayer dependency; keeps `commonMain` thin (ADR 0002). +- **Voice-note recording (Phase 2):** same `expect`/`actual` approach — `MediaRecorder` (AAC/m4a) + on Android, `AVAudioRecorder` on iOS; the clip is sent through the existing attachment path. + Mic permission is requested at first use; on denial the SDK silently hides the mic affordance. - **Picker:** FileKit (`filekit-dialogs-compose`) — one `commonMain` call returns the picked file, using PHPicker/UIDocumentPicker on iOS and the Photo Picker/OpenDocument on Android, all permission-free. The alternative (rolling our own `expect`/`actual` PHPicker delegate diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eac4668..5bab926 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,9 @@ [versions] -kotlin = "2.4.0" +# Pinned to 2.3.x: Kotlin 2.4.0 writes @Metadata that Hilt's bundled +# kotlin-metadata-jvm (2.3.21) cannot read, which breaks Hilt annotation +# processing in consuming apps. Bump back to the latest Kotlin once Hilt ships +# a release built against kotlin-metadata-jvm >= 2.4.0. +kotlin = "2.3.21" agp = "9.2.1" vanniktechPublish = "0.33.0" composeMultiplatform = "1.11.1" @@ -7,8 +11,12 @@ ktor = "3.5.0" coroutines = "1.11.0" serializationJson = "1.11.0" multiplatformSettings = "1.3.0" -coil = "3.5.0" -filekit = "0.14.2" +# Pinned below their latest: the newest Coil (3.5.0) and FileKit (0.14.2) ship Kotlin/Native +# klibs built with the 2.4.0 compiler, whose ABI the project's pinned Kotlin 2.3.21 cannot +# consume (iOS link fails with "incompatible ABI version '2.4.0'"). 3.4.0 / 0.13.0 are the +# newest releases built with Kotlin <= 2.3.x. Bump both when Kotlin moves back to >= 2.4.0. +coil = "3.4.0" +filekit = "0.13.0" media3 = "1.10.1" jbLifecycle = "2.10.0" androidxComposeBom = "2026.05.00" diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 73bfc34..bcb4443 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -20,6 +20,8 @@ 1 UILaunchScreen + NSMicrophoneUsageDescription + Record and send voice notes in the chat. CADisableMinimumFrameDurationOnPhone diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 98434c8..41634ea 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -71,6 +71,8 @@ kotlin { // Video + audio playback. iOS uses AVFoundation, which ships with Kotlin/Native. implementation(libs.media3.exoplayer) implementation(libs.media3.ui) + // Runtime mic-permission request for voice-note recording. + implementation(libs.androidx.activity.compose) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/sdk/src/androidMain/AndroidManifest.xml b/sdk/src/androidMain/AndroidManifest.xml index 60c916a..70083b3 100644 --- a/sdk/src/androidMain/AndroidManifest.xml +++ b/sdk/src/androidMain/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + = Build.VERSION_CODES.S) MediaRecorder(appContext) else MediaRecorder() + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioEncodingBitRate(64_000) + rec.setAudioSamplingRate(44_100) + rec.setOutputFile(file.absolutePath) + rec.prepare() + rec.start() + recorder = rec + outputFile = file + } + + override suspend fun stop(): PickedFile? { + val rec = recorder ?: return null + val file = outputFile + recorder = null + outputFile = null + val stopped = runCatching { rec.stop() }.isSuccess + runCatching { rec.release() } + if (!stopped || file == null || !file.exists()) { + file?.delete() + return null + } + return withContext(Dispatchers.IO) { + val bytes = file.readBytes() + file.delete() + if (bytes.isEmpty()) null else PickedFile(file.name, "audio/mp4", bytes) + } + } + + override fun cancel() { + recorder?.let { rec -> + runCatching { rec.stop() } + runCatching { rec.release() } + } + recorder = null + outputFile?.delete() + outputFile = null + } +} + +@Composable +internal actual fun rememberMicPermission(): MicPermission { + val controller = remember { AndroidMicPermission() } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + controller.onResult(it) + } + SideEffect { controller.launcher = launcher } + return controller +} + +private class AndroidMicPermission : MicPermission { + var launcher: ActivityResultLauncher? = null + + private var grantedState by mutableStateOf( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED, + ) + private var deniedState by mutableStateOf(false) + + override val granted: Boolean get() = grantedState + override val denied: Boolean get() = deniedState + + override fun request() { + if (grantedState) return + launcher?.launch(Manifest.permission.RECORD_AUDIO) + } + + fun onResult(granted: Boolean) { + grantedState = granted + deniedState = !granted + } +} diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.kt new file mode 100644 index 0000000..f4a9ba4 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.kt @@ -0,0 +1,31 @@ +package com.chatwoot.android.sdk.media + +import androidx.compose.runtime.Composable +import com.chatwoot.android.sdk.data.PickedFile + +/** + * Records a single voice note with the platform's native recorder (`MediaRecorder` on Android, + * `AVAudioRecorder` on iOS), producing AAC/m4a. [stop] returns the clip ready to upload through + * the existing attachment path, or null if nothing usable was captured. + */ +internal interface AudioRecorder { + fun start() + suspend fun stop(): PickedFile? + fun cancel() +} + +@Composable +internal expect fun rememberAudioRecorder(): AudioRecorder + +/** + * The microphone permission, surfaced as Compose-observable state. [request] triggers the system + * prompt; the SDK never shows its own UI for a denial — the caller just hides the mic affordance. + */ +internal interface MicPermission { + val granted: Boolean + val denied: Boolean + fun request() +} + +@Composable +internal expect fun rememberMicPermission(): MicPermission diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt index bc6af2f..fdd2082 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -5,12 +5,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,11 +22,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chatwoot.android.sdk.data.PickedFile import com.chatwoot.android.sdk.data.mimeTypeForExtension +import com.chatwoot.android.sdk.media.rememberAudioRecorder +import com.chatwoot.android.sdk.media.rememberMicPermission import com.chatwoot.android.sdk.style.StyleConfig import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitType @@ -31,6 +38,7 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.extension import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -42,7 +50,21 @@ internal fun InputBar( ) { var text by rememberSaveable { mutableStateOf("") } var menuOpen by remember { mutableStateOf(false) } + var recording by remember { mutableStateOf(false) } + var elapsedMs by remember { mutableStateOf(0L) } val scope = rememberCoroutineScope() + val recorder = rememberAudioRecorder() + val mic = rememberMicPermission() + + LaunchedEffect(recording) { + if (recording) { + elapsedMs = 0 + while (true) { + delay(200) + elapsedMs += 200 + } + } + } fun submit() { val value = text.trim() @@ -69,6 +91,26 @@ internal fun InputBar( .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (recording) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935))) + Text( + text = formatElapsed(elapsedMs), + color = style.textColor, + fontSize = 15.sp, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + ) + IconButton(onClick = { recording = false; recorder.cancel() }) { + Text(text = "✕", color = style.secondaryTextColor, fontSize = 20.sp) + } + IconButton(onClick = { + recording = false + scope.launch { recorder.stop()?.let(onPickAttachment) } + }) { + Text(text = "➤", color = style.primaryColor, fontSize = 20.sp) + } + return@Row + } + Box { IconButton(onClick = { menuOpen = true }, enabled = enabled) { Text(text = "📎", color = style.secondaryTextColor, fontSize = 20.sp) @@ -97,12 +139,24 @@ internal fun InputBar( }, modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) - IconButton(onClick = ::submit, enabled = enabled && text.isNotBlank()) { - Text( - text = "➤", - color = if (text.isNotBlank()) style.primaryColor else style.secondaryTextColor, - fontSize = 20.sp, - ) + when { + text.isNotBlank() -> IconButton(onClick = ::submit, enabled = enabled) { + Text(text = "➤", color = style.primaryColor, fontSize = 20.sp) + } + // Mic shows while permission is grantable; a denial hides it (no error surfaced). + !mic.denied -> IconButton( + onClick = { if (mic.granted) recorder.start().also { recording = true } else mic.request() }, + enabled = enabled, + ) { + Text(text = "🎤", color = style.secondaryTextColor, fontSize = 20.sp) + } } } } + +private fun formatElapsed(ms: Long): String { + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.ios.kt b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.ios.kt new file mode 100644 index 0000000..74fe17e --- /dev/null +++ b/sdk/src/iosMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.ios.kt @@ -0,0 +1,116 @@ +package com.chatwoot.android.sdk.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.chatwoot.android.sdk.data.PickedFile +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.AVFAudio.AVAudioRecorder +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.AVAudioSessionRecordPermissionDenied +import platform.AVFAudio.AVAudioSessionRecordPermissionGranted +import platform.AVFAudio.AVEncoderAudioQualityKey +import platform.AVFAudio.AVFormatIDKey +import platform.AVFAudio.AVNumberOfChannelsKey +import platform.AVFAudio.AVSampleRateKey +import platform.AVFAudio.setActive +import platform.AVFAudio.AVAudioQualityHigh +import platform.CoreAudioTypes.kAudioFormatMPEG4AAC +import platform.Foundation.NSData +import platform.Foundation.NSNumber +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import platform.posix.memcpy + +@Composable +internal actual fun rememberAudioRecorder(): AudioRecorder = remember { IosAudioRecorder() } + +@OptIn(ExperimentalForeignApi::class) +private class IosAudioRecorder : AudioRecorder { + private var recorder: AVAudioRecorder? = null + private var url: NSURL? = null + + override fun start() { + cancel() + val session = AVAudioSession.sharedInstance() + runCatching { + session.setCategory(AVAudioSessionCategoryPlayAndRecord, null) + session.setActive(true, null) + } + // NSTemporaryDirectory() ends with '/'. A fixed name is safe: start() cancels any prior take. + val fileUrl = NSURL.fileURLWithPath(NSTemporaryDirectory() + "cw_voice_note.m4a") + val settings = mapOf( + AVFormatIDKey to NSNumber(unsignedInt = kAudioFormatMPEG4AAC), + AVSampleRateKey to NSNumber(double = 44_100.0), + AVNumberOfChannelsKey to NSNumber(int = 1), + AVEncoderAudioQualityKey to NSNumber(long = AVAudioQualityHigh), + ) + val rec = AVAudioRecorder(fileUrl, settings, null) + rec.record() + recorder = rec + url = fileUrl + } + + override suspend fun stop(): PickedFile? { + val rec = recorder ?: return null + val fileUrl = url + recorder = null + url = null + rec.stop() + runCatching { AVAudioSession.sharedInstance().setActive(false, null) } + val bytes = fileUrl?.let { readBytes(it) } + return if (bytes == null || bytes.isEmpty()) null else PickedFile("voice_note.m4a", "audio/mp4", bytes) + } + + override fun cancel() { + recorder?.stop() + recorder = null + url = null + runCatching { AVAudioSession.sharedInstance().setActive(false, null) } + } + + private fun readBytes(fileUrl: NSURL): ByteArray? { + val data: NSData = NSData.dataWithContentsOfURL(fileUrl) ?: return null + val length = data.length.toInt() + if (length == 0) return null + val bytes = ByteArray(length) + bytes.usePinned { pinned -> memcpy(pinned.addressOf(0), data.bytes, length.toULong()) } + return bytes + } +} + +@Composable +internal actual fun rememberMicPermission(): MicPermission { + val controller = remember { IosMicPermission() } + return controller +} + +private class IosMicPermission : MicPermission { + private var grantedState by mutableStateOf( + AVAudioSession.sharedInstance().recordPermission == AVAudioSessionRecordPermissionGranted, + ) + private var deniedState by mutableStateOf( + AVAudioSession.sharedInstance().recordPermission == AVAudioSessionRecordPermissionDenied, + ) + + override val granted: Boolean get() = grantedState + override val denied: Boolean get() = deniedState + + override fun request() { + if (grantedState) return + AVAudioSession.sharedInstance().requestRecordPermission { allowed -> + dispatch_async(dispatch_get_main_queue()) { + grantedState = allowed + deniedState = !allowed + } + } + } +} From 5b9890dc8317d841613f28993083ba922df2805c Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Tue, 16 Jun 2026 14:18:05 +0530 Subject: [PATCH 08/10] Fix attachment/layout/scroll bugs in the chat UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Attachments: upload the picked file's real platform MIME (FileKit mimeType()) instead of guessing from a frequently-empty extension, so Chatwoot classifies images/videos correctly (they previously arrived as generic files in both the app and the Chatwoot web UI). Also parse the widget API's integer `file_type` enum (image=0, audio=1, video=2, file=3, …) via a tolerant serializer so received/echoed attachments render inline. - Layout: stop double-counting the IME inset (safeDrawing already maxes the keyboard) which pushed the whole screen up while typing; the header now paints behind the status bar, removing the gap above it; cap the input field at 5 lines. - Trim surrounding whitespace and trailing blank lines from message content (sent and received). - Auto-scroll to the newest message on receive/reconcile, not just on count changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/sdk/data/ChatRepository.kt | 3 +- .../com/chatwoot/android/sdk/net/Dto.kt | 41 ++++++++++++++++++- .../com/chatwoot/android/sdk/ui/ChatHeader.kt | 5 +++ .../com/chatwoot/android/sdk/ui/ChatScreen.kt | 16 +++++--- .../com/chatwoot/android/sdk/ui/InputBar.kt | 8 +++- .../android/sdk/data/AttachmentTest.kt | 24 +++++++++++ 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt index 00791e5..5464a56 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -232,7 +232,8 @@ internal fun optimistic(tempId: Long, file: PickedFile): ChatMessage = ChatMessa internal fun MessageDto.toChatMessage(): ChatMessage? { if (private) return null - val text = content?.takeIf { it.isNotBlank() } + // Trim surrounding whitespace and trailing blank lines for display (sent and received alike). + val text = content?.trim()?.takeIf { it.isNotEmpty() } val isActivity = messageType == 2 val mapped = attachments.mapNotNull { it.toChatAttachment() } // Drop empty noise, but never an attachment- or activity-carrying message. diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt index 4c1afe8..2a68a53 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -1,8 +1,18 @@ package com.chatwoot.android.sdk.net +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull /** Lenient by design: Chatwoot payloads carry many fields the SDK doesn't need. */ internal val ChatwootJson: Json = Json { @@ -31,7 +41,9 @@ internal data class MessageDto( internal data class AttachmentDto( val id: Long? = null, // image | audio | video | file | … — anything other than the first three renders as a file. - @SerialName("file_type") val fileType: String? = null, + // The widget API serialises this Rails enum as an INT (image=0, audio=1, video=2, file=3, …); + // [FileTypeSerializer] normalises both the int and string forms to the canonical name. + @SerialName("file_type") @Serializable(with = FileTypeSerializer::class) val fileType: String? = null, @SerialName("data_url") val dataUrl: String? = null, @SerialName("thumb_url") val thumbUrl: String? = null, @SerialName("file_size") val fileSize: Long? = null, @@ -40,6 +52,33 @@ internal data class AttachmentDto( val extension: String? = null, ) +/** + * Reads Chatwoot's `file_type` whether it arrives as the Rails enum's integer (image=0, audio=1, + * video=2, file=3, …) — as the widget API sends it — or as a string name, normalising to the + * canonical lowercase name so attachment rendering can switch on it. + */ +internal object FileTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("file_type", PrimitiveKind.STRING) + + private val byOrdinal = listOf( + "image", "audio", "video", "file", "location", "fallback", "share", + "story_mention", "contact", "ig_reel", "ig_post", "ig_story", "embed", + ) + + override fun deserialize(decoder: Decoder): String? { + val json = decoder as? JsonDecoder ?: return decoder.decodeString() + val element = json.decodeJsonElement() + if (element is JsonNull) return null + val primitive = element as? JsonPrimitive ?: return null + primitive.intOrNull?.let { return byOrdinal.getOrNull(it) ?: "file" } + return primitive.content + } + + override fun serialize(encoder: Encoder, value: String?) { + encoder.encodeString(value ?: "") + } +} + @Serializable internal data class SenderDto( val id: Long? = null, diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt index 55be205..e1cc8a1 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt @@ -3,8 +3,11 @@ package com.chatwoot.android.sdk.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +24,8 @@ internal fun ChatHeader(style: StyleConfig, connected: Boolean, onFinish: () -> modifier = Modifier .fillMaxWidth() .background(style.primaryColor) + // Fill behind the status bar (no gap above the header); content sits below it. + .windowInsetsPadding(WindowInsets.statusBars) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt index bc2cb90..3d6e57b 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt @@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator @@ -45,7 +48,8 @@ internal fun ChatScreen( val state by viewModel.state.collectAsStateWithLifecycle() val listState = rememberLazyListState() - LaunchedEffect(state.messages.size) { + // Scroll to the newest message whenever one is appended or replaced (sent OR received). + LaunchedEffect(state.messages.size, state.messages.lastOrNull()?.id) { if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) } @@ -53,8 +57,10 @@ internal fun ChatScreen( modifier = Modifier .fillMaxSize() .background(style.backgroundColor) - .safeDrawingPadding() - .imePadding(), + // Header paints its own status-bar inset (edge-to-edge); here we only inset the + // sides + bottom. safeDrawing's bottom already maxes the nav bar and IME, so the + // input bar rises with the keyboard without double-counting. + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)), ) { ChatHeader(style = style, connected = state.connected, onFinish = onFinish) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt index fdd2082..69c115e 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -36,6 +36,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.extension +import io.github.vinceglb.filekit.mimeType import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.delay @@ -77,7 +78,11 @@ internal fun InputBar( file ?: return scope.launch { val bytes = file.readBytes() - onPickAttachment(PickedFile(file.name, mimeTypeForExtension(file.extension), bytes)) + // Use the platform's real MIME (content resolver / UTType) so Chatwoot classifies the + // attachment correctly; gallery content:// URIs often have no usable extension. + val mime = file.mimeType()?.let { "${it.primaryType}/${it.subtype}" } + ?: mimeTypeForExtension(file.extension) + onPickAttachment(PickedFile(file.name, mime, bytes)) } } @@ -130,6 +135,7 @@ internal fun InputBar( value = text, onValueChange = { text = it }, enabled = enabled, + maxLines = 5, textStyle = TextStyle(color = style.textColor, fontSize = 15.sp), decorationBox = { innerTextField -> if (text.isEmpty()) { diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt index 6ac7117..fb2e780 100644 --- a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt @@ -33,6 +33,30 @@ class AttachmentTest { assertEquals(800, att.width) } + @Test + fun parsesIntegerFileTypeFromWidgetApi() { + // The widget API serialises file_type as the Rails enum int (image=0, video=2). + val payload = ChatwootJson.decodeFromString( + MessagesPayloadDto.serializer(), + """ + {"payload":[ + {"id": 1, "message_type": 0, "created_at": 1, "attachments": [ + {"file_type": 0, "data_url": "https://cdn/x.jpg"}]}, + {"id": 2, "message_type": 0, "created_at": 2, "attachments": [ + {"file_type": 2, "data_url": "https://cdn/v.mp4"}]} + ]} + """.trimIndent(), + ) + assertEquals(AttachmentType.Image, payload.payload[0].toChatMessage()!!.attachments.single().type) + assertEquals(AttachmentType.Video, payload.payload[1].toChatMessage()!!.attachments.single().type) + } + + @Test + fun trimsSurroundingWhitespaceAndTrailingBlankLines() { + val message = MessageDto(id = 5, content = " hi there\n\n \n", messageType = 1, createdAt = 1).toChatMessage() + assertEquals("hi there", assertNotNull(message).content) + } + @Test fun attachmentOnlyMessageWithBlankContentIsNotDropped() { val dto = MessageDto( From 8062a5c197aac5791e50e71df0ac58acfbd1fd1c Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Tue, 16 Jun 2026 16:43:25 +0530 Subject: [PATCH 09/10] Route attachment-first sends via /messages; adopt set_user token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attachment uploads now always go through POST /api/v1/widget/messages, never POST /conversations — verified against app.chatwoot.com that the endpoint lazily creates the conversation and returns a fully parseable Message (int message_type + populated attachments). The attachment-first branch and createConversationWithAttachment are gone; the optimistic bubble reconciles directly from the response (no refetch). set_user now parses widget_auth_token from the response and, when the server mints a fresh session JWT (identifying merged/swapped the contact), adopts it as the new active+persisted X-Auth-Token. session is now the single source of truth for the token: flushIdentity and the history refetch read it at call time rather than from a captured copy, so later REST calls follow the contact the server resolved. The realtime channel can't follow (no new pubsub_token) — documented. Docs: refreshed CONTEXT.md REST table + attachment note, amended ADR 0004. Tests: setUser token parse (present/absent) + repository attachment-first and token-adoption coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 16 +-- docs/adr/0004-visitor-identity.md | 7 + .../android/sdk/data/ChatRepository.kt | 63 +++++---- .../com/chatwoot/android/sdk/net/Dto.kt | 10 ++ .../com/chatwoot/android/sdk/net/WidgetApi.kt | 30 +---- .../android/sdk/data/ChatRepositoryTest.kt | 127 ++++++++++++++++++ .../android/sdk/net/ContactApiTest.kt | 42 +++++- 7 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/ChatRepositoryTest.kt diff --git a/CONTEXT.md b/CONTEXT.md index 76b63bc..cfe5e78 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,12 +63,12 @@ Omitting `cw_conversation` creates a fresh contact; passing it resumes the sessi | 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"}}` — only for an **existing** conversation. 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. Returns the created Message (parse for the real `id` + attachment URLs). | +| `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. | +| `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, @@ -76,11 +76,11 @@ file_size, width, height, extension}`. Received attachments need no special hand arrive on `GET /messages` and on the `message.created`/`message.updated` websocket events like any other field. -> **Unverified edge:** sending the session's *first* message as an attachment uses multipart -> `POST /conversations` (same `message[attachments][]` fields). The JSON create path is verified; -> the multipart create is not yet probed against app.chatwoot.com — verify during e2e. The -> create response's `message_type` is a string (as with the text path), so the SDK refetches -> `GET /messages` instead of parsing it. +> **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:///cable`, ActionCable wire protocol) diff --git a/docs/adr/0004-visitor-identity.md b/docs/adr/0004-visitor-identity.md index 73565ba..976341f 100644 --- a/docs/adr/0004-visitor-identity.md +++ b/docs/adr/0004-visitor-identity.md @@ -38,3 +38,10 @@ validated by HMAC. Chatwoot's website widget exposes this through `setUser`, bac A mid-session identifier change therefore can't re-key the current conversation — documented, not worked around. - Two new contact endpoints are now part of the SDK's verified contract (recorded in CONTEXT.md). +- `set_user` may return a fresh `widget_auth_token` when identifying merges/swaps the underlying + contact. The repository adopts it as the new active+persisted `X-Auth-Token` so subsequent REST + calls follow the contact the server resolved — `session` is the single source of truth for the + token, read at call time rather than captured. The realtime channel can't follow (no new + `pubsub_token` is returned), so it stays on the original contact's `RoomChannel` until the next + `ChatPage` open. This is the REST-side counterpart to the "can't re-key the current conversation" + consequence above. diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt index 5464a56..85cf24c 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -93,25 +93,32 @@ internal class ChatRepository( tokenStore.saveActiveIdentifier(config.websiteToken, wanted.identifier) session = s - runCatching { flushIdentity(s.authToken, wanted) } + // May adopt a server-minted token (set_user merge/swap), updating `session` in place — so + // it runs before the refetch/collectors below, which all read the current `session`. + runCatching { flushIdentity(wanted) } - refreshMessages(s) + refreshMessages() _state.update { it.copy(loading = false) } scope.launch { CableClient(client, config.normalizedBaseUrl, s.pubsubToken).events().collect { event -> - onCableEvent(event, s) + onCableEvent(event) } } // Push later attribute changes (the value we just flushed is dropped). scope.launch { - Chatwoot.identity.drop(1).collect { runCatching { flushIdentity(s.authToken, it) } } + Chatwoot.identity.drop(1).collect { runCatching { flushIdentity(it) } } } } - /** Sends the current identity to Chatwoot: `set_user` when identified, plain update otherwise. */ - private suspend fun flushIdentity(authToken: String, id: ChatwootIdentity) { + /** + * Sends the current identity to Chatwoot: `set_user` when identified, plain update otherwise. + * Reads the active token from [session] (never a captured copy) so a previously-adopted + * swapped token is honoured. + */ + private suspend fun flushIdentity(id: ChatwootIdentity) { if (id.isEmpty) return + val authToken = session?.authToken ?: return val body = ContactRequest( identifier = id.identifier, identifierHash = id.identifierHash, @@ -121,7 +128,19 @@ internal class ChatRepository( avatarUrl = id.avatarUrl, customAttributes = id.customAttributes.ifEmpty { null }, ) - if (id.identifier != null) api.setUser(authToken, body) else api.updateContact(authToken, body) + if (id.identifier != null) { + // set_user mints a fresh session JWT when identifying changes the underlying contact — + // adopt it so later REST calls follow the contact the server resolved (ADR 0004). + api.setUser(authToken, body).widgetAuthToken?.let(::adoptAuthToken) + } else { + api.updateContact(authToken, body) + } + } + + /** Replaces the active+persisted `X-Auth-Token` with a server-minted one. */ + private fun adoptAuthToken(token: String) { + session = session?.copy(authToken = token) + tokenStore.saveConversationToken(config.websiteToken, token) } suspend fun send(content: String) { @@ -131,7 +150,7 @@ internal class ChatRepository( } else { api.createConversation(s.authToken, content) hasConversation = true - refreshMessages(s) + refreshMessages() } } @@ -140,7 +159,6 @@ internal class ChatRepository( * immediately, then reconciles it with the server's response (the websocket echo of the * same id is deduped by [upsert]). On failure the placeholder is marked [ChatMessage.failed]. */ - @OptIn(ExperimentalTime::class) suspend fun sendAttachment(file: PickedFile) { val s = session ?: error("ChatRepository.sendAttachment called before connect") val tempId = nextTempId-- @@ -149,25 +167,19 @@ internal class ChatRepository( } _state.update { it.copy(messages = it.messages.withUpserted(optimistic(tempId, file))) } try { - if (hasConversation) { - // POST /messages returns the created Message — reconcile directly. - val real = api.sendAttachment(s.authToken, file, onProgress).toChatMessage() - _state.update { it.copy(messages = it.messages.reconcilingTemp(tempId, real)) } - } else { - // The create response can't be parsed (string message_type) — drop the - // placeholder and refetch so the stored attachment loads from GET /messages. - api.createConversationWithAttachment(s.authToken, file, onProgress) - hasConversation = true - _state.update { it.copy(messages = it.messages.reconcilingTemp(tempId, null)) } - refreshMessages(s) - } + // POST /messages lazily creates the conversation when none exists yet and returns a + // parseable Message — reconcile the optimistic bubble directly. Attachments never go + // through POST /conversations, so this single path covers attachment-first too. + val real = api.sendAttachment(s.authToken, file, onProgress).toChatMessage() + hasConversation = true + _state.update { it.copy(messages = it.messages.reconcilingTemp(tempId, real)) } } catch (e: Throwable) { _state.update { it.copy(messages = it.messages.withFailed(tempId)) } throw e } } - private suspend fun onCableEvent(event: CableEvent, s: WidgetSession) { + private suspend fun onCableEvent(event: CableEvent) { when (event) { is CableEvent.MessageCreated -> upsert(event.message) is CableEvent.MessageUpdated -> upsert(event.message) @@ -175,15 +187,16 @@ internal class ChatRepository( CableEvent.Connected -> { _state.update { it.copy(connected = true) } // Catch up on anything broadcast while we were offline. - runCatching { refreshMessages(s) } + runCatching { refreshMessages() } } CableEvent.Disconnected -> _state.update { it.copy(connected = false, agentTyping = false) } } } - private suspend fun refreshMessages(s: WidgetSession) { - val history = api.getMessages(s.authToken).mapNotNull { it.toChatMessage() } + private suspend fun refreshMessages() { + val authToken = session?.authToken ?: return + val history = api.getMessages(authToken).mapNotNull { it.toChatMessage() } hasConversation = hasConversation || history.isNotEmpty() _state.update { state -> state.copy(messages = (history + state.messages.filter { m -> history.none { it.id == m.id } }).sorted()) diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt index 2a68a53..eccdcd1 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -127,6 +127,16 @@ internal data class CreateConversationRequest( val message: OutgoingMessageDto, ) +/** + * `PATCH /contact/set_user` response. Mostly a contact summary the SDK ignores; the load-bearing + * field is [widgetAuthToken], which the server mints **only** when identifying changes the + * underlying contact (a merge/swap). When present it supersedes the session's `X-Auth-Token`. + */ +@Serializable +internal data class SetUserResponse( + @SerialName("widget_auth_token") val widgetAuthToken: String? = null, +) + /** * Flat contact body for `PATCH /api/v1/widget/contact[/set_user]`. Null fields are dropped during * encoding (`explicitNulls = false`), so each call sends only what the host supplied. diff --git a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt index bcebee1..d3b7fc0 100644 --- a/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt @@ -69,9 +69,11 @@ internal class WidgetApi( }.body() /** - * Uploads an attachment as a (caption-less) message into an existing conversation. Mirrors - * the widget's `sendAttachment` multipart shape (`message[attachments][]`, `referer_url`, - * `timestamp`); returns the created Message so the caller can reconcile its optimistic bubble. + * Uploads an attachment as a (caption-less) message. Mirrors the widget's `sendAttachment` + * multipart shape (`message[attachments][]`, `referer_url`, `timestamp`). The endpoint lazily + * creates the conversation when none exists yet, so this is also the attachment-*first* path — + * there is no multipart `POST /conversations`. Returns the created Message (parseable: int + * `message_type` + populated `attachments`) so the caller can reconcile its optimistic bubble. */ suspend fun sendAttachment( authToken: String, @@ -93,23 +95,6 @@ internal class WidgetApi( } } - /** - * Creates the session's first conversation carrying an attachment (multipart). The create - * response's `message_type` is a string (see CONTEXT.md), so — like [createConversation] — - * we don't parse it; the caller refetches `GET /messages` to pick up the stored attachment. - */ - suspend fun createConversationWithAttachment( - authToken: String, - file: PickedFile, - onProgress: (Float) -> Unit = {}, - ) { - client.post("$base/api/v1/widget/conversations") { - authenticated(authToken) - setBody(attachmentForm(file)) - onUpload { sent, total -> if (total != null && total > 0) onProgress(sent.toFloat() / total) } - } - } - @OptIn(ExperimentalTime::class) private fun attachmentForm(file: PickedFile) = MultiPartFormDataContent( formData { @@ -131,13 +116,12 @@ internal class WidgetApi( * enforces identity validation the server checks [ContactRequest.identifierHash]; otherwise it's * optional. Use [updateContact] for attribute-only updates with no identifier. */ - suspend fun setUser(authToken: String, body: ContactRequest) { + suspend fun setUser(authToken: String, body: ContactRequest): SetUserResponse = client.patch("$base/api/v1/widget/contact/set_user") { authenticated(authToken) contentType(ContentType.Application.Json) setBody(body) - } - } + }.body() /** Updates the (possibly anonymous) contact's attributes. No identity validation. */ suspend fun updateContact(authToken: String, body: ContactRequest) { diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/ChatRepositoryTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/ChatRepositoryTest.kt new file mode 100644 index 0000000..57516a7 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/ChatRepositoryTest.kt @@ -0,0 +1,127 @@ +package com.chatwoot.android.sdk.data + +import com.chatwoot.android.sdk.Chatwoot +import com.chatwoot.android.sdk.ChatwootConfig +import com.chatwoot.android.sdk.net.ChatwootJson +import com.russhwolf.settings.MapSettings +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Drives [ChatRepository] against a [MockEngine] backend. The websocket plugin is intentionally + * not installed, so [com.chatwoot.android.sdk.net.CableClient] fails fast and reconnect-loops + * harmlessly inside [backgroundScope] (cancelled when the test ends). + */ +class ChatRepositoryTest { + + private val config = ChatwootConfig("https://app.chatwoot.com", "wt-test") + + private val bootstrapHtml = + "" + + private data class Recorded(val method: HttpMethod, val path: String, val authToken: String?) + + @BeforeTest + fun resetIdentity() { + // Identity is global singleton state — start each test from anonymous. + Chatwoot.setUser() + } + + @AfterTest + fun clearIdentity() { + Chatwoot.setUser() + } + + private fun repo( + recorded: MutableList, + store: TokenStore, + handler: (path: String, method: HttpMethod) -> Pair = { _, _ -> "{}" to ContentType.Application.Json }, + ): ChatRepository { + val engine = MockEngine { request -> + val path = request.url.encodedPath + recorded += Recorded(request.method, path, request.headers["X-Auth-Token"]) + val (body, type) = when { + path.endsWith("/widget") -> bootstrapHtml to ContentType.Text.Html + else -> handler(path, request.method) + } + respond(body, headers = headersOf(HttpHeaders.ContentType, type.toString())) + } + val client = HttpClient(engine) { install(ContentNegotiation) { json(ChatwootJson) } } + return ChatRepository(config, client, tokenStore = store) + } + + @Test + fun attachmentFirstSessionPostsToMessagesNotConversations() = runTest { + val recorded = mutableListOf() + val attachmentResponse = """ + {"id":700,"content":null,"message_type":0,"created_at":1781606671,"conversation_id":5, + "attachments":[{"id":78,"file_type":"image","data_url":"https://x/probe.png", + "thumb_url":"https://x/thumb.png","file_size":67,"extension":"png"}]} + """.trimIndent() + val repo = repo(recorded, TokenStore(MapSettings())) { path, method -> + when { + path.endsWith("/messages") && method == HttpMethod.Post -> + attachmentResponse to ContentType.Application.Json + path.endsWith("/messages") -> """{"payload":[]}""" to ContentType.Application.Json + else -> "{}" to ContentType.Application.Json + } + } + repo.connect(backgroundScope) + + repo.sendAttachment(PickedFile("probe.png", "image/png", byteArrayOf(1, 2, 3))) + + // Requirement: attachment-first must go through POST /messages, never POST /conversations. + assertTrue( + recorded.any { it.method == HttpMethod.Post && it.path.endsWith("/messages") }, + "expected a POST /messages; got $recorded", + ) + assertFalse( + recorded.any { it.path.endsWith("/conversations") }, + "must not POST /conversations for an attachment; got $recorded", + ) + // Optimistic bubble reconciled directly from the parseable response — no lingering placeholder. + val messages = repo.state.value.messages + assertEquals(1, messages.size, "expected one reconciled message; got $messages") + assertTrue(messages.single().attachments.isNotEmpty()) + assertFalse(messages.single().pending) + } + + @Test + fun setUserAdoptsAndPersistsReturnedWidgetAuthToken() = runTest { + Chatwoot.setUser(identifier = "user-x", name = "X") + val recorded = mutableListOf() + val store = TokenStore(MapSettings()) + val repo = repo(recorded, store) { path, method -> + when { + path.endsWith("/contact/set_user") -> + """{"id":42,"widget_auth_token":"jwt-swapped"}""" to ContentType.Application.Json + path.endsWith("/messages") -> """{"payload":[]}""" to ContentType.Application.Json + else -> "{}" to ContentType.Application.Json + } + } + + repo.connect(backgroundScope) + + // The swapped token replaces the persisted cw_conversation JWT. + assertEquals("jwt-swapped", store.conversationToken("wt-test")) + // And the history refetch that follows set_user authenticates with the new token. + val messagesAfterSetUser = recorded + .dropWhile { !(it.path.endsWith("/contact/set_user")) } + .firstOrNull { it.path.endsWith("/messages") && it.method == HttpMethod.Get } + assertEquals("jwt-swapped", messagesAfterSetUser?.authToken, "got $recorded") + } +} diff --git a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt index 0afb7b0..5d8aca4 100644 --- a/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt @@ -3,20 +3,28 @@ package com.chatwoot.android.sdk.net import com.chatwoot.android.sdk.ChatwootConfig import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respondOk +import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType import io.ktor.http.HttpMethod +import io.ktor.http.headersOf +import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class ContactApiTest { private val config = ChatwootConfig("https://app.chatwoot.com", "wt-1") - private fun apiCapturing(captured: MutableList): WidgetApi { + /** Captures requests and replies with [responseJson] (sent as application/json). */ + private fun apiCapturing( + captured: MutableList, + responseJson: String = "{}", + ): WidgetApi { val engine = MockEngine { request -> captured += RecordedRequest( method = request.method, @@ -24,7 +32,7 @@ class ContactApiTest { authToken = request.headers["X-Auth-Token"], body = request.body.asText(), ) - respondOk() + respond(responseJson, headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())) } val client = HttpClient(engine) { install(ContentNegotiation) { json(ChatwootJson) } } return WidgetApi(config, client) @@ -49,6 +57,34 @@ class ContactApiTest { assertTrue("\"identifier_hash\":\"h\"" in req.body, req.body) } + @Test + fun setUserParsesWidgetAuthTokenWhenServerSwapsContact() = runTest { + val requests = mutableListOf() + // The server mints a fresh session JWT when identifying changes the underlying contact. + val api = apiCapturing( + requests, + responseJson = """{"id":42,"has_email":true,"widget_auth_token":"jwt-swapped"}""", + ) + + val response = api.setUser("jwt-old", ContactRequest(identifier = "u-1")) + + assertEquals("jwt-swapped", response.widgetAuthToken) + } + + @Test + fun setUserReturnsNullTokenWhenServerOmitsIt() = runTest { + val requests = mutableListOf() + // Inboxes without identity validation return only the contact summary, no token. + val api = apiCapturing( + requests, + responseJson = """{"id":42,"has_email":true,"has_name":true,"has_phone_number":false}""", + ) + + val response = api.setUser("jwt-old", ContactRequest(identifier = "u-1")) + + assertNull(response.widgetAuthToken) + } + @Test fun updateContactHitsPlainContactEndpoint() = runTest { val requests = mutableListOf() From 97741185e585d7ec74c3f79f4151219736f04120 Mon Sep 17 00:00:00 2001 From: Rohit T P Date: Fri, 19 Jun 2026 22:43:16 +0530 Subject: [PATCH 10/10] CI: pin macOS runners to macos-26 (Xcode 26) for Compose 1.11 iOS link build.yml has been red on master since the SDK landed: linking the iOS-simulator test binary (linkDebugTestIosSimulatorArm64) fails with an undefined _OBJC_CLASS_$_UIViewLayoutRegion and a missing auto-linked private framework UIUtilities, coming from compose.ui:ui-uikit. Root cause is the runner's Xcode, not our code. macos-latest still resolves to macos-15 / Xcode 16.4, whose iOS SDK can't resolve the UIKit symbols Compose Multiplatform 1.11 emits. The same task links cleanly on Xcode 26 locally. Pin both CI workflows to macos-26 (default Xcode 26.2) to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 7 +++++-- .github/workflows/publish.yml | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 572fbf0..b1e655c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,8 +8,11 @@ on: jobs: build: name: Build and test - # macOS so the iOS targets compile too. - runs-on: macos-latest + # 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0090d7..b493d84 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,8 +38,10 @@ jobs: name: Publish sdk ${{ needs.detect.outputs.version }} needs: detect if: needs.detect.outputs.publish == 'true' - # macOS required: the KMP publication includes the iOS targets. - runs-on: macos-latest + # 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: