8 min read Guides Android

How to Add a Feedback Board to an Android App with Kotlin

By the end of this guide your Android app has a screen where users read every feature request other people have already filed, vote on the ones they want, open one to read its comments, and file a new one from a form — all without leaving the app. You write none of the list, none of the form and none of the vote handling; those are Jetpack Compose composables that ship in the SDK. What you write is roughly one Gradle line, one configuration block, and the navigation glue that decides where the screen lives in your app.

On the other side, each submission becomes a row on a board your team triages, moves through a status pipeline, and can push into the issue tracker you already use. That half is covered near the end, because it changes what you should build on the client.

What the SDK actually gives you

The Android SDK is Kotlin, coroutines and Jetpack Compose. The pieces you compose with are FeedbackList, FeedbackDetailView, SubmitFeedbackView, FeedbackCard, VoteButton, StatusBadge and CategoryBadge, plus a FeedbackKitProvider that pushes the SDK instance and a theme down the composition. Underneath them sit suspend-function APIs — feedback, votes, comments, users, events — that you can call directly if you would rather draw your own UI.

Requirements are minSdk 24, Java/Kotlin JVM target 17, and a Compose setup on BOM 2024.01.00 or later. The library brings OkHttp, kotlinx.serialization, DataStore and Material 3 with it as transitive dependencies.

1. Add the dependency

One line in your module-level build.gradle.kts. The group is com.getfeedbackkit and the artifact is feedbackkit. Take the version from the Kotlin SDK docs rather than from this page — a version number copied out of an article ages badly.

dependencies {
implementation("com.getfeedbackkit:feedbackkit:1.1.1")
}

There is no manifest entry to add. The library declares the INTERNET permission in its own manifest and the manifest merger folds it into your app.

2. Configure once, at app start

Configuration is a single call with a DSL block. Do it in your Application class rather than an Activity: the call is idempotent — the second call returns the instance the first one created — so configuring from an Activity that gets recreated is harmless but pointless, and a key changed at runtime will not take effect.

import android.app.Application
import com.swiftlydeveloped.feedbackkit.Environment
import com.swiftlydeveloped.feedbackkit.FeedbackKit
import com.swiftlydeveloped.feedbackkit.configure

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

FeedbackKit.configure(this) {
    apiKey = BuildConfig.FEEDBACKKIT_API_KEY
    environment = Environment.PRODUCTION
    debug = BuildConfig.DEBUG
}
}
}

Note the second import: configure with a trailing lambda is an extension function on the companion, so it needs importing alongside the class itself. Environment is an enum — PRODUCTION, STAGING, LOCAL for the Android emulator, LOCAL_DEVICE for a physical device — and setting it sets the base URL. Everything the SDK sends then carries an X-API-Key header holding your key; there is no login step.

Where the key should live

Be clear-eyed about what kind of secret this is. An API key compiled into an APK is not secret: anyone can unzip and decompile a shipped app and read it. The FeedbackKit SDK key is a project-scoped client credential designed to be handed out to clients, and that is the only kind of credential that belongs in a mobile binary. Never ship a key that can administer your project.

What you should still avoid is committing the key to your repository, where it ends up in every fork and every CI log. The conventional Android answer is local.properties, which is git-ignored by default, read into a BuildConfig field at build time:

import java.util.Properties

val localProps = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) }
}

android {
buildFeatures {
buildConfig = true
}

defaultConfig {
buildConfigField(
    "String",
    "FEEDBACKKIT_API_KEY",
    "\"${localProps.getProperty("feedbackkit.apiKey") ?: ""}\""
)
}
}

The buildConfig flag matters: recent Android Gradle Plugin versions do not generate BuildConfig unless you ask. On CI, read the same value from an environment variable instead of a file, and if you run separate projects for staging and production, give each build type its own key here rather than switching one by hand.

3. Render the list and the submit form

The smallest useful screen is a list, a button that opens the submit form, and a detail view. This mirrors the example app that ships with the SDK.

@Composable
fun FeedbackScreen() {
val theme = if (isSystemInDarkTheme()) FeedbackKitTheme.Dark else FeedbackKitTheme.Light

FeedbackKitProvider(theme = theme) {
val navController = rememberNavController()
val listState = rememberFeedbackListState()
var selected by remember { mutableStateOf<Feedback?>(null) }

NavHost(navController = navController, startDestination = "list") {
    composable("list") {
        Scaffold(
            floatingActionButton = {
                FloatingActionButton(onClick = { navController.navigate("submit") }) {
                    Icon(Icons.Default.Add, contentDescription = "Submit feedback")
                }
            }
        ) { padding ->
            FeedbackList(
                modifier = Modifier.fillMaxSize().padding(padding),
                state = listState,
                onFeedbackClick = { feedback ->
                    selected = feedback
                    navController.navigate("detail")
                }
            )
        }
    }

    composable("detail") {
        selected?.let { feedback ->
            FeedbackDetailView(
                feedback = feedback,
                onBack = { navController.popBackStack() },
                onVoteChange = { response ->
                    selected = feedback.withVote(response.hasVoted, response.voteCount)
                    listState.updateFeedback(selected!!)
                }
            )
        }
    }

    composable("submit") {
        SubmitFeedbackView(
            onBack = { navController.popBackStack() },
            onSubmitSuccess = { newFeedback ->
                listState.addFeedback(newFeedback)
                navController.popBackStack()
            }
        )
    }
}
}
}

Three things are worth pointing out. rememberFeedbackListState issues its first load when it is created, so there is no manual fetch call; it also exposes loading, refreshing and error state. FeedbackDetailView takes a whole Feedback object, not an id, which is why the list hands you the item that was clicked. And sharing one list state lets a vote cast in the detail view, or a newly submitted item, update the list behind it — that is what updateFeedback and addFeedback are for.

Navigation-compose is your app's dependency, not the SDK's. FeedbackList also has a simpler overload that manages its own state and takes filters directly, which is what you want if the board should only show triaged items:

FeedbackList(
statusFilter = FeedbackStatus.APPROVED,
onFeedbackClick = { feedback -> /* open detail */ }
)

Theming is a data class: FeedbackKitTheme.Light and .Dark are presets, and you can copy either with your own primary colour, background, corner radius and per-status colours so the board does not look bolted on.

4. Where the entry point belongs

Three placements cover almost every app, and they are not equivalent.

  • A settings row. Lowest friction to build, lowest traffic. Good when feedback is a secondary concern and you mostly want a place to point support replies at.
  • A first-class navigation destination — a tab, or an item in the drawer. Right when the roadmap is part of the product story and you want people browsing what others asked for.
  • A modal bottom sheet, opened from wherever frustration actually happens. Highest intent, because the user writes while the problem is in front of them.

The sheet is worth a snippet, because SubmitFeedbackView takes a showAppBar flag that lets it sit inside a container that already has its own chrome:

var showSubmit by remember { mutableStateOf(false) }

if (showSubmit) {
ModalBottomSheet(onDismissRequest = { showSubmit = false }) {
SubmitFeedbackView(
    showAppBar = false,
    onSubmitSuccess = { showSubmit = false }
)
}
}

Depending on your Material 3 version, ModalBottomSheet may still require an @OptIn(ExperimentalMaterial3Api::class) annotation.

5. Identify the user, or voting will not work

Reading the board works anonymously. Voting does not: the votes API requires a user id and throws a validation error without one. You can set it at configuration time, or register a user and persist the id so it survives a restart.

val user = FeedbackKit.shared.users.register(
email = "user@example.com",
name = "Ada",
externalId = "your-own-user-id"
)

FeedbackKit.shared.setUserIdAndPersist(user.id)

The id is stored with DataStore and can be reloaded on the next launch; logout clears it. The externalId is the hook that lets you reconcile a FeedbackKit user with your own account system later.

6. What happens on the server

A submitted item lands in a status pipeline with six states: pending, approved, in_progress, testflight, completed and rejected. Two of those close voting — completed and rejected — and the SDK knows it, so the vote button renders disabled on those items rather than firing a request that fails.

Submitting also casts a vote for the person who submitted. That is the correct default (someone who files a request obviously wants it) but it means a brand-new item shows one vote, not zero, and you should not read that as a stranger having endorsed it.

From the board, an item can be pushed into the tracker your team already works in. Nine ticket providers are supported — GitHub, Notion, ClickUp, Linear, Monday.com, Trello, Airtable, Asana and Basecamp — alongside Slack for notifications and HubSpot, Salesforce and email campaign sync on the CRM side. Capabilities are deliberately not uniform, because the provider APIs are not uniform: GitHub, for one, has no comment sync. Check the integrations page for the per-provider matrix before you build a workflow that assumes a capability exists.

Troubleshooting

These are the failures that actually happen, rather than the ones that sound plausible.

Symptom Cause
Voting throws a validation error mentioning a required user id No user id is set. Set one in configure, or register a user and persist the id.
Every request fails with an authentication error A 401 maps to AuthenticationError. Usually the wrong key, or the right key pointed at the wrong environment.
Works against production, fails against a local server Environment.LOCAL is plain HTTP on 10.0.2.2. Android blocks cleartext traffic by default, so permit that host in a debug-only network security config.
Release build fails to parse responses, debug build is fine An R8 problem. The library ships consumer rules that keep its classes and serializers, so check nothing in your own configuration is overriding them.
Submissions succeed but the list stays the same length Either a status or category filter is applied, or you are over the visible item cap on the free plan — see below.

That last one deserves the detail, because it is genuinely confusing the first time. On the free plan a project shows a limited number of feedback items. Submissions past that limit are still accepted and still return successfully to your app — they are simply not returned by list reads, so the client sees a success and the board does not grow. If the counts stop adding up, check the plan on pricing before you go hunting through your Compose state.

One thing you will be told to check that you probably do not need to: the INTERNET permission is declared by the library manifest and merged into yours automatically, so it is only worth investigating if you strip permissions deliberately. And note that FeedbackList renders a designed empty state rather than an error, which looks identical whether the board is genuinely empty or a filter excluded everything — rule out the filter first.

Where to go next

The Kotlin SDK reference has the full method surface, including comments, event tracking and the individual badge composables. To draw your own UI entirely, use the suspend APIs the composables call, with the OpenAPI description for the HTTP layer underneath. And if this app is one of several, the docs index lists the other SDKs — the models, statuses and board are shared, so an Android user and an iOS user vote on the same row.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.