Installs into .claude/skills of the current project.
Are you the author of Cupthread Android Sdk?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/cupthread-cupthread-android-sdk)
---
name: cupthread-android-sdk
description: Guide for integrating and using the CupThread Android SDK in Kotlin and Jetpack Compose applications.
---
# CupThread Android SDK Guide
The **CupThread Android SDK** (`dev.cupthread:feedback`) provides native Jetpack Compose UI components and client APIs for integrating user feedback, feature requests, roadmaps, and changelogs into Android applications (Android 8+ / API 26+, Material 3).
## Documentation & Repository
- **Full API Documentation (KDoc)**: [https://cupthread.github.io/CupThreadAndroidSDK/](https://cupthread.github.io/CupThreadAndroidSDK/)
- **GitHub Repository**: [https://github.com/CupThread/CupThreadAndroidSDK](https://github.com/CupThread/CupThreadAndroidSDK)
---
## π€ Quick Prompt for Coding Agents
```text
Integrate the CupThread Android SDK (roadmap, changelog, and feedback screens) into this Compose app. Scaffold a dedicated configuration helper with a placeholder for the App Key, and at the end, remind me with step-by-step instructions on how to set my App Key safely (e.g. via local.properties).
```
---
## Installation
### 1. Add Maven Repository
Add the CupThread CDN Maven repository in your `settings.gradle.kts` (or root `build.gradle.kts`):
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
name = "CupThreadCDN"
url = uri("https://cdn.cupthread.com/maven")
}
}
}
```
### 2. Add Dependency
Add the feedback SDK dependency to your `app/build.gradle.kts`:
```kotlin
dependencies {
implementation("dev.cupthread:feedback:0.1.0")
}
```
---
## Setup & Initialization
```kotlin
import dev.cupthread.feedback.FeedbackClient
import dev.cupthread.feedback.FeedbackClientConfig
import dev.cupthread.feedback.UserTokenStore
// Initialize the client with your App Key from the CupThread Console
val client = FeedbackClient(
FeedbackClientConfig(
baseUrl = "https://api.cupthread.com",
appKey = "app_xxx"
)
)
// Obtain a persistent anonymous user token
val userToken = UserTokenStore.create(context).token
```
---
## Ready-Made Jetpack Compose Screens
Wrap your Compose UI hierarchy with `CupThreadTheme(client)` to inherit developer console skin and theme settings.
- **`RoadmapBoardScreen(client, userToken)`**: Kanban roadmap board with column chips and paged feedback cards.
- **`FeatureRequestsScreen(client, userToken)`**: Searchable feature requests list with optimistic voting, filtering, and creation dialog.
- **`WhatsNewScreen(client, userToken)`**: Changelog list with Markdown rendering and email updates subscription.
- **`ChangelogOverlay(client, visible, onDismiss)` / `client.presentLatestChangelog(activity)`**: In-app modal dialog announcing new releases.
- **`FeedbackComposer(client, userToken, onSubmit)`**: Structured feedback form with category selection, device metadata, and attachment uploads.
### Example Usage
```kotlin
@Composable
fun MyFeedbackScreen(client: FeedbackClient, userToken: String) {
CupThreadTheme(client = client) {
RoadmapBoardScreen(
client = client,
userToken = userToken
)
}
}
// Or present latest changelog modal on app launch:
client.presentLatestChangelog(activity)
```
---
## Payment-Attribute Signing (HMAC-SHA256, DATA-03)
> **β οΈ SDK status β the Android SDK cannot sign payment attributes.** `updateUserAttributes` accepts `isPaying`/`plan`/`mrr`/`currency` only and exposes no `signature`/`timestamp` parameters, so **every call containing a payment attribute β now including `currency` (DATA-07) β returns `422 payment_attributes_require_signature`** (the SDK's own KDoc example included) until [CupThreadAndroidSDK#28](https://github.com/CupThread/CupThreadAndroidSDK/issues/28) lands. Until then, either (a) keep the SDK for identity-only updates (currency-only updates need a signature too since DATA-07) and report payment attributes from a trusted backend, or (b) send the raw signed `PUT` yourself. The Kotlin snippet below is the **workaround** for option (b), not the normal integration path.
`PUT /api/v1/public/apps/{appKey}/user` only persists paying status, plan, MRR, and currency when the request is **signed with the app's SDK signing secret** (developer console: *App Access β App Credentials β SDK signing secret*). Whenever the body contains any of `isPaying`, `mrr`, `plan`, or `currency` (an explicit JSON `null` counts), it must also carry `signature` (64-char hex HMAC-SHA256, case-insensitive) and `timestamp` (epoch seconds) β both plain body fields. Since DATA-07, `currency` is a signed attribute too (it is the unit of `mrr`), so identity-only writes are the only unsigned path left; a signed update that omits `currency` preserves the stored currency instead of coercing it to `USD` β `USD` applies only when a profile is created with no stored currency. Rejections happen before any profile row is created:
- `422 payment_attributes_require_signature` β payment fields without `signature` + `timestamp`
- `422 sdk_signing_secret_not_configured` β the app has no signing secret yet
- `401 stale_signature` β `timestamp` more than Β±300 s from server time
- `401 invalid_signature` β wrong key or tampered values
**Canonical string** β HMAC-SHA256-sign the exact bytes of this newline-joined string (no trailing newline) and hex-encode the digest:
```
cpt-user-attrs-v1
<appKey>
<userToken>
<isPaying: true|false|unset>
<plan: value|null|unset>
<mrr: canonicalNumber|null|unset>
<currency: valueAsSent|unset>
<timestamp: epochSeconds>
```
- Absent fields sign as `unset`, explicit JSON `null` as `null` (omitting `plan` β sending `"plan": null`). `userToken` is the body value when present, else the `X-User-Token` header value.
- `canonicalNumber` follows JS `Number.prototype.toFixed(2)` with trailing zeros and a trailing `.` stripped (`1200.00 β "1200"`, `99.50 β "99.5"`); exact binary ties pick the larger n (`10.125 β "10.13"`). Compute it from the raw JSON value as sent.
- Sign immediately before sending (Β±300 s freshness window) and don't mutate signed values when appending `signature`/`timestamp` to the body.
**Workaround (b) β HMAC helper for the raw signed `PUT`** (the SDK call itself cannot sign):
```kotlin
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun userAttrsSignature(canonical: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
return mac.doFinal(canonical.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
```
**Cross-check your port** against this reference vector before shipping: secret `cpt_sk_test_secret_0123456789abcdef`, appKey `app_demo12345`, userToken `3fa85f64-5717-4562-b3fc-2c963f66afa6`, timestamp `1758000000`, body `{"isPaying":true,"plan":"pro","mrr":299.5,"currency":"USD"}` β canonical `cpt-user-attrs-v1\napp_demo12345\n3fa85f64-5717-4562-b3fc-2c963f66afa6\ntrue\npro\n299.5\nUSD\n1758000000` β signature `59bc1177751f4c36f9caeed8c763cde9ee18835196acd1b6d4ce3b1ea2dc0273`. `cupthread api sign-user-attrs` generates more vectors.
## Key Features Overview
1. **Structured Feedback Submission**: Gather bug reports and feedback with automatic device/package metadata and image/log attachments.
2. **Feature Request Voting & Ideation**: Community voting board with real-time optimistic state updates.
3. **Roadmap & Kanban Visibility**: Keep users engaged with public status columns and release targets.
4. **In-App Changelog & Announcements**: Present "What's New" release notes and let users subscribe for updates.
5. **User Attributes Sync**: Sync user payment status, plan name, MRR, and currency via `client.updateUserAttributes(...)`. The underlying `PUT /api/v1/public/apps/{appKey}/user` endpoint is rate limited per client IP (60 requests/minute, HTTP `429`) β syncs of many users behind one shared IP must retry with exponential backoff.
## Request Correlation IDs (X-Request-Id)
Every CupThread API response carries an `X-Request-Id` header (OPS-01): your ID when the request supplied a format-valid one (`^[A-Za-z0-9._-]{8,64}$`, e.g. a UUID), otherwise a server-generated UUID. Quote it verbatim in bug reports and support requests β it lets CupThread locate the exact request in server logs. When driving the API with a raw `OkHttp` call instead of the SDK, send your own `X-Request-Id` per request; values outside that charset/length budget are silently ignored and replaced. CORS exposes the header, so browser-based integrations can read it too.
## Feedback Image Restrictions (415 Unsupported Media Type)
The feedback image upload endpoint accepts only **PNG, JPEG, WebP, and GIF**. When integrating the attachment picker:
- **Drop SVG from image pickers / file-type allowlists.** `image/svg+xml` is rejected with `415` β declared via MIME type, or via a `.svg` filename when no content type is sent. SVG executes script in browsers and is never stored from end-user uploads.
- **The declared MIME type must match the file content.** The server sniffs magic bytes; mismatched or unrecognized files (e.g. HTML bytes named `.png`) fail with `415 {"error": "File content does not match declared image type (...)"}`.
- **Map HTTP `415` to a user-facing "unsupported image type" message** and let the user pick a different file. It is a deterministic client error β never retry automatically.
- **Image uploads are private attachments (PRIV-02).** `POST /api/v1/uploads/images` answers `200` `{kind: "image", uploadId, status: "uploaded", stored, filename, mimeType, size, sha256}` β no `url`, `key`, or `variants`. Do not fetch the file from a public CDN URL or `GET /api/v1/files/:key` (`403 private_attachment_forbidden`). After `POST /api/v1/feedback` finalizes the upload, an authorized caller downloads it from `/api/v1/feedback/attachments/:id/download`.
Console-configured app icons (developer-facing) are unaffected and may still use screened SVG.
## Self-Service Data Erasure (PRIV-01)
`POST /api/v1/me/erase` lets end users erase their own profile for one app (PRIV-01). Call it with a `{"appKey": "β¦"}` JSON body and the user's `X-User-Token: <uuid>` header (a Clerk session also works for signed-in users; developer `cpt_` tokens are not an identity here):
- **Effect**: the anonymous token is rotated immediately (the old token stops working; replays return `404`), stored PII (display name, email, IP, user agent) is cleared, and the user's feature requests, votes, and comments survive **without personal attribution** β aggregate counts are preserved.
- `200 {"erased": true, "endUserId": "β¦"}` on success β discard the stored `X-User-Token` locally and mint a fresh one for any further activity.
- `400` `{"error": "appKey is required"}` without `appKey`; `401` without any identity; `404 {"erased": false, "error": "β¦"}` when the app is unknown, no profile matches, or the profile was already erased; `429` when rate limited (first a per-IP budget of 10 requests/60 s answering `Too many submissionsβ¦`, then a per-token budget answering `Too many erasure requests for this tokenβ¦`).
- Erasure is immediate and **irreversible** β confirm with the user in UI (e.g. a settings/"delete my data" action) before calling. Never retry `404`; retry `429` with exponential backoff.
## Feedback Metadata Redaction (PRIV-01)
The `metadata` map attached to feedback submissions is **sanitized server-side before storage** β oversized or credential-looking payloads are shrunk, **never rejected**, so existing integrations keep working unchanged. Pre-sanitizing client-side with the same rules is encouraged so users get local feedback:
- Keys must match `[A-Za-z0-9_.:-]{1,64}` (max **24** keys); non-conforming or surplus keys are silently dropped.
- Values under credential-looking keys β `token`, `secret`, `password`, `apiKey`, `authorization`, `cookie`, `session`, β¦ (camelCase-aware, so `github_token`, `api-key`, and `sessionCookie` all hit) β are replaced with the literal `"[redacted]"`.
- Strings are truncated to 512 chars (suffixed `β¦[truncated]`), nesting is capped at depth 4 (deeper objects/arrays become `null`), and the serialized object is capped at **8 KB** (whole keys dropped in sorted order until it fits β never sliced mid-value).
## Feedback Attachment Upload Flow (Upload Sessions)
Attachment uploads go through **pre-allocated upload sessions** β feedback submissions referencing raw attachment URLs or storage keys are rejected (`400` `direct_attachment_forbidden`). For custom integrations outside the SDK composer, drive the three-step lifecycle directly:
1. **Create a session first**: `POST /api/v1/uploads/sessions` with `{ "appKey": "β¦", "files": [{ "clientFileId": "β¦", "filename": "β¦", "mimeType": "β¦", "size": 123 }] }` (1β8 files). Anonymous users must send the **same `X-User-Token`** they will use for feedback submit β the session is identity-bound (SEC-28). The `201` response provides `sessionToken` (`cpt_up_β¦`, ~1-hour TTL), `expiresAt`, and `uploads[]` slots with `uploadId` (`upl_β¦`), `uploadUrl`, and `maxBytes`.
2. **Upload each reserved slot**: `PUT /api/v1/uploads/{uploadId}` with `Authorization: Bearer <sessionToken>` and the raw file bytes (`Content-Type` set to the file's real MIME type; `multipart/form-data` with a `file` field also works). Slots are single-shot (`409` `already_uploaded`), over-cap bodies fail with `413` `file_too_large`, and failed content inspection fails with `415` β deterministic, never retry.
3. **Submit feedback with the ids**: pass the finalized `uploadId`s (max 8) in the `uploadIds` array of `POST /api/v1/feedback`. A scan-rejected attachment fails the whole submission with `422` `scan_rejected` β remove or replace that file and resubmit; the passing uploads stay reusable. Two concurrent submits of the same `uploadId` no longer race into a `500`: the loser gets `409` `already_finalized` and creates nothing β do not resubmit that id.
Use the per-file `maxBytes` from the session response to pre-validate file sizes client-side, and reuse one session for all files of a single composer submission.
## Feature-Request List Pagination (Cursor Keyset)
The public feature-request feed β `GET /api/v1/feature-requests` β is keyset-cursor-paginated (DATA-01). Every response carries `requests`, `total`, `hasMore`, and `nextCursor`; walk large boards by echoing `nextCursor` back as the `cursor` query parameter until it returns `null`:
- **Prefer `cursor` over incrementing `offset`** β offset pages scan and discard rows server-side, while the cursor jumps straight to the next key. A request that sends `cursor` ignores `offset`.
- **`offset` above 10000 is clamped to 10000** and still returns `200`. Deep pages belong on the cursor. Plain listings (no `q`) are rate limited per client IP at 60 requests/minute (generic `429` JSON).
- **Treat the cursor as opaque** β never parse, construct, or persist one beyond forwarding it back; malformed cursors fail with `400 {"error": "Invalid cursor"}`.
- **`hasMore` is exact** (the server fetches one extra row) and `total` stays constant across pages; `limit` is clamped to 1β200 (default 50), and `q`/`versionId` filters compose with the cursor.
- **The built-in roadmap/feedback screens need no changes** β the fields are additive; clients that only read `requests` keep working.
## Feature Request Vote Rate Limits (429 Too Many Requests)
The public vote endpoints β `POST` / `DELETE /api/v1/feature-requests/{id}/vote` β are rate limited **per client IP** to **20 requests per minute**. Throttled calls fail with `429 {"error": "Too many votes. Please try again shortly."}` (a vote-specific body, distinct from the generic `Too many requestsβ¦` text). When building custom voting UI on top of the client:
- **Optional `shipNotifyEmail`** (an email string, max 254) on `POST` enrolls that address in double-opt-in ship notifications ("email me when this ships"). Omitting it leaves the `{voted, voteCount}` response unchanged. On a sign-in-required board, an address that is not one of the session's verified emails still records the vote and adds `warning.code` `email_not_verified` with `Ship notifications on this board are bound to your signed-in email address`; no confirmation email is sent. Boards that allow anonymous voting accept any address.
- **Treat `429` as a recoverable, user-facing condition** β surface a friendly "you're voting too fast, try again in a minute" message rather than a generic error.
- **Never auto-retry `429` in a tight loop** β if you retry at all, back off for the remainder of the 60-second rate-limit window.
- **The built-in roadmap/voting screens need no changes** β normal usage (voting on a handful of feature requests) stays well under the limit.
## Public Board Read Limits and Shared Cache
These four public GETs share one per-IP budget of **60 requests per minute** and answer `429 {"error": "Too many requests. Please try again shortly."}` when it is spent:
- `GET /api/v1/feature-requests/{id}/comments`
- `GET /api/v1/public/apps/{appKey}/changelog`
- `GET /api/v1/public/columns/{appKey}`
- `GET /api/v1/public/versions/{appKey}`
A board or changelog screen that loads once stays under that budget. A poller, or a widget that refetches on a tight timer from one shared IP, will not.
- **Retry `429` after a short backoff.** The window is one minute. Treat it as transient. An SDK that already surfaces non-2xx needs no new status mapping.
- **Anonymous `200`s can be up to 30 seconds stale.** The server may serve them from a shared cache, and a cache hit carries `Cache-Control: public, max-age=30`. A comment just posted, a column just edited, a version just released, or a changelog just published can take that long to appear on an anonymous read. Polling faster spends the same 60/minute budget and does not expire the cache.
- **The changelog feed splits by session.** Only anonymous responses are cached. A Clerk session gets a fresh body, and linked feature-request titles stay visible only to callers who can see the roadmap. Columns, versions, and comment threads are the same for every caller, so those `200`s are shared.
- **A sign-in-required board still returns `401`** while an older `200` sits in the cache. The built-in roadmap and changelog screens need no code change for this.
## Public Profile Rate Limits & Unknown-User 404 (SEC-34)
The public profile page / hovercard reads `GET /api/v1/users/{userId}/profile`, which is rate limited **per client IP** to **60 requests per minute** β one bucket shared with the `PUT /api/v1/public/apps/{appKey}/user` attribute sync. Throttled calls fail with the generic `429 {"error": "Too many requests. Please try again shortly."}`. An unknown app-scoped `u_*` id (or a `u_*` id sent without `appKey`) returns `404 {"error": "User profile not found"}` instead of a placeholder:
- **Cache profile lookups client-side** and reuse them across renders. A card list resolving many users behind one shared IP (office NAT, CI farm) can exhaust the 60/minute budget β which also throttles attribute syncs from the same IP.
- **Handle `429` with exponential backoff** and render a friendly "try again shortly" state; never retry in a tight loop.
- **Treat `404` on a `u_*` id as "no public profile for this id"** and fall back to the placeholder UI β the server no longer scans for unmatched ids, so retrying cannot change the answer. Raw `user_*` ids from old `/u/` links keep the opt-in placeholder behavior.
## Request Body Limits & End-User Description Images (SEC-36 / PRIV-11)
Two server-side behaviors every integration should know:
- **Public JSON budget is 256 KB (SEC-36).** Public intake routes (feature requests, votes, comments, user-attribute upserts, feedback) parse the body through a bounded reader **before** rate limiting and validation; anything over the budget fails with `413 {"error": "Payload exceeds size limit", "code": "payload_too_large"}` without consuming a rate-limit slot. Keep request bodies β especially free-text description fields β well under 256 KB, truncating oversized input client-side instead of discovering the limit via `413`.
- **Image embeds in end-user feature-request descriptions do not render (PRIV-11).** Public boards strip image embeds (``, reference images, raw `<img>`) from end-user-submitted descriptions at ingest and render descriptions with images disabled β anti tracking-pixel/phishing hardening. Links still render (tagged `rel="nofollow ugc noopener noreferrer"`); submitting an image embed is not an error, it just will not display. Developer-authored content (changelog entries, console notifications) keeps full Markdown rendering.
For complete method signatures, customization options, and advanced architecture, consult the [KDoc API Documentation](https://cupthread.github.io/CupThreadAndroidSDK/).