diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b1e655c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build + +on: + pull_request: + push: + branches: [master] + +jobs: + build: + name: Build and test + # macos-26 (Xcode 26.x), not macos-latest: Compose Multiplatform 1.11 references + # UIKit symbols (e.g. UIViewLayoutRegion, auto-linking the private UIUtilities + # framework) that the older Xcode on macos-latest/macos-15 can't resolve when + # linking the iOS-simulator test binary (linkDebugTestIosSimulatorArm64). + runs-on: macos-26 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != 'refs/heads/master' }} + + - name: Build and test + run: ./gradlew build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b493d84 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,102 @@ +name: Publish + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + detect: + name: Detect version bump + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.changed.outputs.publish }} + version: ${{ steps.changed.outputs.version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Check for sdk version change + id: changed + run: | + version=$(grep -E '^version\s*=' sdk/build.gradle.kts | sed -E 's/.*"(.+)".*/\1/') + echo "version=$version" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Only publish when the `version = "..."` line changed in this push — Maven Central + # rejects republishing the same GAV anyway. + if git diff HEAD~1 HEAD -- sdk/build.gradle.kts | grep -qE '^[+-]version\s*=\s*"'; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + + publish: + name: Publish sdk ${{ needs.detect.outputs.version }} + needs: detect + if: needs.detect.outputs.publish == 'true' + # macos-26 (Xcode 26.x) required: macOS for the iOS targets in the KMP publication, + # and pinned to 26 (not macos-latest/macos-15) so Compose Multiplatform 1.11's UIKit + # symbols resolve when the build links the iOS-simulator test binary. + runs-on: macos-26 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build, test and publish to Maven Central + run: ./gradlew :sdk:build :sdk:publishToMavenCentral + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + + - name: Assemble XCFramework + run: ./gradlew :sdk:assembleChatwootSDKReleaseXCFramework + + - name: Zip XCFramework and compute SPM checksum + id: xcframework + run: | + cd sdk/build/XCFrameworks/release + zip -r -X ChatwootSDK.xcframework.zip ChatwootSDK.xcframework + checksum=$(swift package compute-checksum ChatwootSDK.xcframework.zip) + echo "checksum=$checksum" >> "$GITHUB_OUTPUT" + + - name: Create GitHub release with XCFramework + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.detect.outputs.version }} + run: | + gh release create "sdk-v$VERSION" \ + --title "sdk v$VERSION" \ + --notes "Chatwoot SDK $VERSION — Maven: com.chatwoot.android:sdk:$VERSION" \ + sdk/build/XCFrameworks/release/ChatwootSDK.xcframework.zip + + - name: Point Package.swift at the new release + env: + VERSION: ${{ needs.detect.outputs.version }} + CHECKSUM: ${{ steps.xcframework.outputs.checksum }} + run: | + sed -i '' \ + -e "s|url: \".*\"|url: \"https://github.com/${{ github.repository }}/releases/download/sdk-v$VERSION/ChatwootSDK.xcframework.zip\"|" \ + -e "s|checksum: \".*\"|checksum: \"$CHECKSUM\"|" \ + Package.swift + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Package.swift + git commit -m "Point Package.swift at sdk-v$VERSION" + git push diff --git a/.gitignore b/.gitignore index 39b6783..83e2faf 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. @@ -63,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 new file mode 100644 index 0000000..0f73f48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Kotlin Multiplatform (Android + iOS) chat SDK for Chatwoot, published as +`com.chatwoot.android:sdk`. The public surface is `ChatPage(show, onFinish, +styleConfig = DefaultStyle)` (Compose Multiplatform), the `Chatwoot` singleton +(`configure()`, plus the visitor-identity calls `setUser()` / `setCustomAttributes()` / +`reset()`), and, on iOS, the `ChatPageViewController()` wrapper — everything else is +`internal` (the module uses `explicitApi()`). + +**Read `CONTEXT.md` first** for the domain glossary and the *verified* Chatwoot protocol +contract (REST + ActionCable websocket). The upstream wiki this project started from is +stale — `CONTEXT.md` reflects reality as probed against app.chatwoot.com; trust it over +the wiki. `docs/adr/` records the load-bearing decisions (Maven coordinates, KMP+CMP, +media stack, visitor identity). + +## Commands + +```bash +./gradlew build # all targets + tests (requires macOS for iOS) +./gradlew :sdk:testAndroidHostTest # JVM unit tests only (fastest loop) +./gradlew :sdk:iosSimulatorArm64Test # common tests on the iOS simulator +./gradlew :sdk:publishToMavenLocal # local publish, no signing needed +./gradlew :sample-app:installDebug # Android demo on a connected device/emulator +./gradlew :sdk:assembleChatwootSDKReleaseXCFramework # XCFramework for Swift consumers +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES build # iOS demo (or open in Xcode) +# NB: generic simulator destinations fail — they request ios_x64, which :sdk deliberately +# doesn't target (arm64-only). Use a concrete simulator or ARCHS=arm64. +``` + +Manual e2e testing needs Chatwoot credentials in `local.properties` (not committed): +`chatwoot.baseUrl=…` and `chatwoot.websiteToken=…` — the sample app injects them via +BuildConfig. + +## Architecture + +One KMP module, `:sdk` (targets: `androidLibrary` via AGP's +`com.android.kotlin.multiplatform.library`, `iosArm64`, `iosSimulatorArm64`). Data flows: + +``` +ChatPage → ChatScreen → ChatViewModel (multiplatform lifecycle ViewModel) + → ChatRepository (session lifecycle, message StateFlow, send; dedupes REST + ws) + → WidgetApi (Ktor REST; bootstrap parses HTML via WidgetPageParser) + → CableClient (websocket: subscribe/keepalive/backoff; pure frame logic in CableProtocol) + → TokenStore (multiplatform-settings; persists the cw_conversation JWT + active + identifier per website token) +``` + +Key invariants: +- The bootstrap endpoint returns **HTML**, not JSON — session tokens are regex-parsed + (`WidgetPageParser`). There is no JSON alternative. +- First send of a fresh session goes through `POST /conversations`, later sends through + `POST /messages`; after conversation creation the repository *refetches* messages rather + than parsing the create response (its `message_type` is a string there, an int elsewhere). +- `CableProtocol` is pure functions (frame parse/build) so it stays unit-testable; + `CableClient` owns the connection loop, 30s `update_presence` keepalive, and reconnect + backoff — on reconnect the repository refreshes history to catch up missed events. +- `private: true` messages are agent notes — filter, never render. `message_type`: 0 + contact, 1 agent, 2 activity, 3 template. +- Visitor identity lives in `Chatwoot.identity` (a `StateFlow`); the repository flushes it + after bootstrap (and on later changes) via `PATCH /widget/contact/set_user` when an + identifier is set, else `PATCH /widget/contact`. HMAC `identifier_hash` is **host-supplied** + (computed server-side) — never compute it in the SDK. A changed identifier clears the stored + session so the next bootstrap starts a fresh contact. See ADR 0004. +- Platform code is minimal by design: expect/actual for the config fallback + (manifest meta-data / Info.plist) and the Ktor engine comes from the classpath + (OkHttp on Android, Darwin on iOS). Keep new code in `commonMain`. + +## Build system conventions (mirrors the sibling `dependables` repo) + +- Root `build.gradle.kts` centralises group + vanniktech maven-publish + (`publishToMavenCentral(automaticRelease = true)`, signing only when + `ORG_GRADLE_PROJECT_signingInMemoryKey` is set, config-cache opt-out for publish tasks). + The `:sdk` module owns its own `version` and `pom {}`. +- Repositories only in `settings.gradle.kts` (`FAIL_ON_PROJECT_REPOS`); versions only in + `gradle/libs.versions.toml`. JVM target 21. The Compose compiler plugin version is the + Kotlin version. +- `kotlin.daemon.jvmargs`/`kotlin.native.jvmArgs` are raised in `gradle.properties` — + Kotlin/Native linking of Compose OOMs at the default heap; don't remove them. +- CI (`.github/workflows/`): `build.yml` runs `./gradlew build` on macOS; + `publish.yml` publishes **only when `version = "…"` changes** in `sdk/build.gradle.kts` + on master, then builds the XCFramework, attaches it to a `sdk-vX.Y.Z` GitHub release and + rewrites `Package.swift` (url + checksum) for SPM consumers. +- Publishing to Maven Central requires Chatwoot's verified Sonatype namespace + (`com.chatwoot`) — expected to stay red until upstreaming (ADR 0001). diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..cfe5e78 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,98 @@ +# Context + +Domain language and protocol contract for the Chatwoot SDK. Terms here are canonical — use +them in code, docs, and discussion. + +## Glossary + +- **Website inbox** — the Chatwoot inbox type this SDK talks to. Created in the Chatwoot + dashboard; identified by its **website token**. +- **Website token** — public token identifying a website inbox (`website_token` query param + on every API call). Not a secret. +- **Conversation token** (`cw_conversation`) — a JWT identifying one contact session + (claims: `source_id`, `inbox_id`). Returned by the widget bootstrap as `window.authToken`, + sent as the `X-Auth-Token` header on REST calls, and passed back as `?cw_conversation=` on + re-bootstrap to resume the same contact + conversation. Persisted by `TokenStore` + (SharedPreferences / NSUserDefaults), keyed per website token. +- **Pubsub token** — the contact's ActionCable subscription credential + (`window.chatwootPubsubToken`). A *contact* pubsub token only receives events for that + contact's own session; *user* (agent) tokens see account-wide events — the SDK only ever + holds contact tokens. +- **RoomChannel** — the single ActionCable channel Chatwoot broadcasts on. Contact + subscriptions identify as `{"channel":"RoomChannel","pubsub_token":"…"}` (no + account_id/user_id — those are for agent connections). +- **ChatPage** — the SDK's entire public UI surface: + `ChatPage(show, onFinish, styleConfig = DefaultStyle)`. +- **StyleConfig / DefaultStyle** — the one theming object; all visual customisation flows + through it. No other styling knobs exist. +- **Contact** — the end user chatting. Chatwoot auto-creates an anonymous contact (e.g. + "weathered-shape-813") on first bootstrap. The host names it via `Chatwoot.setUser(...)`. +- **Identifier** — a stable, host-defined id for the contact (e.g. the host app's user id). + Supplied via `setUser`; lets Chatwoot recognise the same person across reinstalls/devices. + Persisted next to the conversation token by `TokenStore`; changing it starts a fresh contact. +- **Identity validation / HMAC** — optional impersonation protection. When an inbox enables it, + associating an **identifier** requires an `identifier_hash` = `HMAC-SHA256(inbox_hmac_token, + identifier)`. The `hmac_token` is a per-inbox **secret** computed **server-side** by the host's + backend; the SDK only ever forwards a precomputed hash and never holds the secret. +- **Custom attributes** — inbox-defined key/value fields on the contact (`custom_attributes`). + The SDK takes a `Map`; numbers/dates are passed as strings. +- **Attachment** — a file carried by a Message. Each has a `file_type` (`image`, `audio`, + `video`, `file`, plus rarer kinds — anything other than the first three renders as a generic + file), a `data_url` (original) and optional `thumb_url` (preview). Sent one-per-message and + caption-less (the upload carries no `content`). _Avoid_: media, upload. + +## Protocol contract (verified against app.chatwoot.com, June 2026) + +> The upstream wiki (`Steps-to-build-the-integration`) is stale: `GET /widgets.json` no +> longer exists, and message POSTs authenticate with `X-Auth-Token`, not bare query params. + +### Bootstrap + +`GET /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"}}`. **Lazily creates the conversation** if none exists yet (verified: posting to a fresh session returns the created Message with `conversation_id` set and `message_type` as an **int**). The SDK still routes the *first text* send through `POST /conversations` (below); attachments always use this endpoint. Returns the created Message. | +| `POST /messages` (multipart) | **Attachment** upload (`multipart/form-data`): `message[attachments][]` (the file, with filename) + `message[referer_url]` + `message[timestamp]`. No `content`, no `echo_id` — one file, caption-less. Like the JSON form it **lazily creates the conversation** when none exists, so attachment-*first* sends use this — never `POST /conversations`. Returns the created Message with a parseable int `message_type` and a populated `attachments[]` (parse for the real `id` + attachment URLs; no refetch needed). | +| `POST /conversations` | First message of a session: `{"message":{…}}` (optional `contact:{name,email,phone_number}`). Creates conversation + message. Caveat: in *this* response `message_type` is a string (`"template"`); the SDK refetches `GET /messages` instead of parsing it. | +| `GET /conversations` | `{}` when no conversation exists yet. | +| `PATCH /contact` | Updates the (possibly anonymous) contact. **Flat** body (not nested under `contact`): `{name,email,phone_number,avatar_url,custom_attributes:{},additional_attributes:{}}`. No identity validation. Also the `setCustomAttributes` path (`{custom_attributes:{}}`). | +| `PATCH /contact/set_user` | Associates a stable `identifier`: body `{identifier, …same fields…, identifier_hash}`. Server runs `validate_hmac` (`HMAC-SHA256(hmac_token, identifier)`) — enforced only when the inbox has identity validation on; otherwise `identifier_hash` is optional. Response is `{id, has_email, has_name, has_phone_number}`, and **conditionally** a `widget_auth_token` — the server mints a fresh session JWT when identifying changes the underlying contact (a merge/swap). When present, the SDK must adopt it as the new active+persisted `X-Auth-Token` (it replaces the `cw_conversation` JWT). Not returned on inboxes without identity validation (unverified against such an inbox; implemented defensively as optional). No new `pubsub_token` is returned, so the realtime channel stays on the original contact's `RoomChannel`. | +| `GET /inbox_members` | `{"payload":[{id,name,avatar_url,availability_status}]}` — no auth header needed. | + +A Message carries an `attachments` array; each entry: `{id, file_type, data_url, thumb_url, +file_size, width, height, extension}`. Received attachments need no special handling — they +arrive on `GET /messages` and on the `message.created`/`message.updated` websocket events like +any other field. + +> **Attachment-first sends** (verified against app.chatwoot.com): the session's *first* message +> when it carries a file goes through multipart `POST /messages`, **not** `POST /conversations`. +> The server lazily creates the conversation and returns a fully parseable Message (int +> `message_type` + populated `attachments[]`), so the SDK reconciles its optimistic bubble +> directly from the response — no refetch, no separate "create with attachment" path. + +### Realtime (`wss:///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..e6d9818 100644 --- a/README.md +++ b/README.md @@ -1 +1,125 @@ -# 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. + +## 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 +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: + +```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/docs/adr/0003-media-stack-for-attachments.md b/docs/adr/0003-media-stack-for-attachments.md new file mode 100644 index 0000000..3856a82 --- /dev/null +++ b/docs/adr/0003-media-stack-for-attachments.md @@ -0,0 +1,37 @@ +# 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. Phase 2 adds voice-note +*recording* on the same principle. + +## 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). +- **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 + 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/docs/adr/0004-visitor-identity.md b/docs/adr/0004-visitor-identity.md new file mode 100644 index 0000000..976341f --- /dev/null +++ b/docs/adr/0004-visitor-identity.md @@ -0,0 +1,47 @@ +# 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). +- `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/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..5bab926 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,71 @@ +[versions] +# 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" +ktor = "3.5.0" +coroutines = "1.11.0" +serializationJson = "1.11.0" +multiplatformSettings = "1.3.0" +# 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" +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-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" } +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" } +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" } +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" } + +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 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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..bcb4443 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,34 @@ + + + + + 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 + + NSMicrophoneUsageDescription + Record and send voice notes in the chat. + + 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..c9c9efe --- /dev/null +++ b/sample-app/src/main/kotlin/com/chatwoot/android/sample/MainActivity.kt @@ -0,0 +1,55 @@ +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, + ) + + // 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) } + + 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..41634ea --- /dev/null +++ b/sdk/build.gradle.kts @@ -0,0 +1,111 @@ +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) + + // 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) + implementation(libs.coroutines.test) + implementation(libs.ktor.client.mock) + implementation(libs.multiplatform.settings.test) + } + 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) + // Runtime mic-permission request for voice-note recording. + implementation(libs.androidx.activity.compose) + } + 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..70083b3 --- /dev/null +++ b/sdk/src/androidMain/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + 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/androidMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.android.kt b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.android.kt new file mode 100644 index 0000000..4f72330 --- /dev/null +++ b/sdk/src/androidMain/kotlin/com/chatwoot/android/sdk/media/AudioRecorder.android.kt @@ -0,0 +1,107 @@ +package com.chatwoot.android.sdk.media + +import android.Manifest +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import com.chatwoot.android.sdk.appContext +import com.chatwoot.android.sdk.data.PickedFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +@Composable +internal actual fun rememberAudioRecorder(): AudioRecorder = remember { AndroidAudioRecorder() } + +private class AndroidAudioRecorder : AudioRecorder { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + override fun start() { + cancel() + val file = File(appContext.cacheDir, "voice_${System.currentTimeMillis()}.m4a") + @Suppress("DEPRECATION") + val rec = if (Build.VERSION.SDK_INT >= 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/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/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..3d1891a --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ChatViewModel.kt @@ -0,0 +1,66 @@ +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.data.PickedFile +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 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) + } + + 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..b8c3c31 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/Chatwoot.kt @@ -0,0 +1,125 @@ +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. + * + * @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('/') +} + +/** + * 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: + * + * - 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) + } + + 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) " + + "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..85cf24c --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/ChatRepository.kt @@ -0,0 +1,282 @@ +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 +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.drop +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( + val id: Long, + val content: String, + val fromContact: Boolean, + 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( + 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 + + // Optimistic placeholders get descending negative ids so they never collide with real ones. + 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 + + // 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() + _state.update { it.copy(loading = false) } + + scope.launch { + CableClient(client, config.normalizedBaseUrl, s.pubsubToken).events().collect { event -> + onCableEvent(event) + } + } + // Push later attribute changes (the value we just flushed is dropped). + scope.launch { + Chatwoot.identity.drop(1).collect { runCatching { flushIdentity(it) } } + } + } + + /** + * 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, + name = id.name, + email = id.email, + phoneNumber = id.phoneNumber, + avatarUrl = id.avatarUrl, + customAttributes = id.customAttributes.ifEmpty { null }, + ) + 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) { + 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() + } + } + + /** + * 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]. + */ + 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 { + // 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) { + 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() } + } + CableEvent.Disconnected -> + _state.update { it.copy(connected = false, agentTyping = false) } + } + } + + 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()) + } + } + + private fun upsert(dto: MessageDto) { + val message = dto.toChatMessage() ?: return + _state.update { state -> state.copy(messages = state.messages.withUpserted(message)) } + } +} + +// --- 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) +} + +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 + // 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. + if (text == null && mapped.isEmpty() && !isActivity) return null + return ChatMessage( + id = id, + content = text.orEmpty(), + fromContact = messageType == 0, + 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/data/TokenStore.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt new file mode 100644 index 0000000..4f44439 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/data/TokenStore.kt @@ -0,0 +1,37 @@ +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) + } + + /** 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/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/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/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..eccdcd1 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/Dto.kt @@ -0,0 +1,153 @@ +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 { + 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, + 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. + // 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, + val width: Int? = null, + val height: Int? = null, + 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, + 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, +) + +/** + * `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. + */ +@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 new file mode 100644 index 0000000..d3b7fc0 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/net/WidgetApi.kt @@ -0,0 +1,148 @@ +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 +import io.ktor.client.request.patch +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 +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() + + /** + * 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, + 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") { + authenticated(authToken) + contentType(ContentType.Application.Json) + setBody(CreateConversationRequest(outgoing(content))) + } + } + + @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()) + }, + ) + + /** + * 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): 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) { + 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) + }.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/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/ChatHeader.kt b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt new file mode 100644 index 0000000..e1cc8a1 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatHeader.kt @@ -0,0 +1,72 @@ +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 +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) + // 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, + ) { + 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..3d6e57b --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/ChatScreen.kt @@ -0,0 +1,108 @@ +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +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 +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 coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.network.ktor3.KtorNetworkFetcherFactory +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() }, +) { + // 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() + + // 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) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(style.backgroundColor) + // 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) + + 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, + 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 new file mode 100644 index 0000000..69c115e --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/InputBar.kt @@ -0,0 +1,168 @@ +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.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 +import androidx.compose.runtime.rememberCoroutineScope +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 +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 +import kotlinx.coroutines.launch + +@Composable +internal fun InputBar( + style: StyleConfig, + enabled: Boolean, + onSend: (String) -> Unit, + onPickAttachment: (PickedFile) -> Unit, +) { + 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() + if (value.isEmpty() || !enabled) return + onSend(value) + text = "" + } + + fun consume(file: PlatformFile?) { + file ?: return + scope.launch { + val bytes = file.readBytes() + // 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)) + } + } + + val mediaPicker = rememberFilePickerLauncher(type = FileKitType.ImageAndVideo) { consume(it) } + val filePicker = rememberFilePickerLauncher(type = FileKitType.File()) { consume(it) } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(style.surfaceColor) + .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) + } + 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 }, + enabled = enabled, + maxLines = 5, + 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(horizontal = 8.dp), + ) + 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/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..bd302c5 --- /dev/null +++ b/sdk/src/commonMain/kotlin/com/chatwoot/android/sdk/ui/MessageBubble.kt @@ -0,0 +1,71 @@ +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 +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.AttachmentType +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 + 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) + .clip(style.bubbleShape) + .background(if (fromContact) style.outgoingBubbleColor else style.incomingBubbleColor) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + 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..fb2e780 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/data/AttachmentTest.kt @@ -0,0 +1,131 @@ +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 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( + 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/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/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/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/ContactApiTest.kt b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt new file mode 100644 index 0000000..5d8aca4 --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/ContactApiTest.kt @@ -0,0 +1,114 @@ +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.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") + + /** 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, + fullUrl = request.url.toString(), + authToken = request.headers["X-Auth-Token"], + body = request.body.asText(), + ) + respond(responseJson, headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())) + } + 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 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() + 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 new file mode 100644 index 0000000..d6a736d --- /dev/null +++ b/sdk/src/commonTest/kotlin/com/chatwoot/android/sdk/net/DtoTest.kt @@ -0,0 +1,73 @@ +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) + } + + @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) + } +} 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/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 + } + } + } +} 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) +} 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")