Skip to content
Back to skills

Cupthread Android Sdk

ASecurity

Guide for integrating and using the CupThread Android SDK in Kotlin and Jetpack Compose applications.

  • 2 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 19, 2026
ai-agentsrustgojavakotlingitapibackenddocumentation

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Scanned October 7, 2026

npx -y skills add CupThread/CupThreadAgenticCoding --skill cupthread-android-sdk --agent claude-code

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.

Security grade badge for Cupthread Android Sdk
[![Security: A β€” Skills Directory](https://www.skillsdirectory.com/api/skills/cupthread-cupthread-android-sdk/badge)](https://www.skillsdirectory.com/skills/cupthread-cupthread-android-sdk)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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 (`![alt](url)`, 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/).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.