This tutorial teaches you how to build a fully featured Android activity feed app with Kotlin, Jetpack Compose, and the Stream Activity Feed V3 SDK - a timeline of activities from people you follow, an activity composer, reactions, comments, a follow graph, and a "For You" discovery feed. You can use it as the foundation for any social, timeline, or notification experience.
A complete sample app is available in our tutorial repository.
This page works for both humans and AI coding agents: every step is a file operation or shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
Your app runs on Stream's edge network for optimal performance, across multiple pricing tiers including a free maker plan.
Notes about this tutorial:
- Whenever we mention "activities" we mean items in a feed (such as posts) - not the Android
Activityclass.- Feeds V3 is a headless SDK: it owns the data layer and exposes observable
StateFlows, but ships no pre-built UI. You build the Composables; the SDK keeps them in sync.
Choose your path
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the Android integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Android pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-android --universal
Then ask your agent:
1234/stream-android Add Stream Activity Feeds to my Jetpack Compose app: connect a FeedsClient, create user + timeline feeds, render the timeline with a composer, and add reactions, comments, and follow/unfollow. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.
The agent pauses and hands back to you at two points: browser login during getstream init, and running the app.
Path B - Build it manually
Follow the steps below. Feeds is a multi-screen app, so Step 1 starts from a small starter project that already contains the navigation, theme, and placeholder screens - you fill in every Stream-touching file from the complete code blocks here (no fragments to assemble).
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Android Feeds SDK is athttps://getstream.io/activity-feeds/docs/android/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Four pieces, one mental model:
FeedsClient- the low-level client, created with theFeedsClient(...)factory. It is bound to one user for its lifetime (theUser+tokenProviderare fixed at construction). Built once, at app launch, andconnect()ed before any feed call.Feed- a handle to a single feed, obtained withclient.feed(group, id)(e.g.client.feed("user", "alice")). CallgetOrCreate()to load it.FeedState/ActivityState- observableStateFlows (state.activities,state.following,state.comments, ...) that drive your Compose UI and update in real time.- No pre-built UI - you write every Composable yourself. The SDK owns the data; your Composables collect its flows.
You'll connect a client, create feeds, render a timeline, then layer on posting, discovery, follows, reactions, comments, and images.
Prerequisites
- Android Studio (Meerkat 2024.3.1 or newer)
- The starter project (Step 1) comes pre-configured —
compileSdk36,minSdk24, and Kotlin 2.2.21. You don't set these yourself; they're listed here so you know what the SDK needs. - Kotlin 2.2.0 or higher is required — the Feeds SDK artifacts are compiled with Kotlin 2.2.x; an older Kotlin version (e.g. the 2.0.x some project templates ship) fails at compile time with an opaque "Internal compiler error ... metadata 2.2.0, expected 2.0.0". The starter already satisfies this.
- Stream Feeds Android SDK — you add this in Step 2.
Feeds V3 is pre-1.0. Public APIs may shift between releases - if a signature here differs from the version you install, check the Feeds Android docs or the SDK source.
Step 0 - Provision credentials with the Stream CLI
You need an API key, a user id, and a user token, all belonging to your own Stream app. The getstream CLI provisions them in one flow. Run these from your project directory.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The login flow creates your organization.
1getstream init
🧑 Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first - env, token, and api commands fail with "stream project is not initialized" until it runs.
3. Grab your API key. getstream env --target android writes it to local.properties (STREAM_API_KEY); copy the value from there (or open the app in the dashboard with getstream open). You paste it into Credentials.kt in Step 3.
1getstream env --target android
4. Mint a user token (never-expiring by default; add a TTL for production-like testing):
12getstream token tutorial_user getstream token tutorial_user --ttl 1d
5. Confirm your feed groups. This tutorial uses the built-in user, timeline, and foryou feed groups. New apps have the standard groups enabled; you can review or add feed groups in the dashboard (Feeds → Feed Groups). Unlike Chat channels, feed groups are configured on the dashboard, not created from the client.
✅ Checkpoint: you have an API key, a user id, and a token printed by the CLI, all belonging to your own app, and your app has the user / timeline / foryou feed groups.
Fallback: tutorial demo credentials
Want to skip account setup entirely? The Credentials.kt block in Step 3 is pre-filled with working credentials against Stream's shared, pre-seeded feeds tutorial environment (API key, a random user id + name, and a token). Copy it as-is and it runs. Swap in your own credentials from the CLI flow above before building anything real - the demo credentials are shared and short-lived.
Step 1 - Get the starter project
Feeds has no drop-in UI, so the app is several screens (Home, Explore, Comments) plus navigation and a theme. To keep the tutorial focused on Stream code, start from the prepared starter project - it has the navigation graph, Material theme, and placeholder screens/components already wired, with TODOs where Stream code goes.
1234# or download the zip from the repo's tutorial-start release git clone git@github.com:GetStream/stream-feeds-android-tutorial.git cd stream-feeds-android-tutorial git checkout tutorial-start
Open the project in Android Studio.
🧑 Human checkpoint: cloning and opening the project in Android Studio is a human step. Agents: detect the project shape first (find . -maxdepth 3 -name "settings.gradle*" -o -name "build.gradle*"). If there's no Gradle project, stop and ask the human to clone the starter above before continuing - don't scaffold it yourself.
✅ Checkpoint:
1./gradlew :app:assembleDebug
The app builds and launches to empty placeholder screens. It comes to life as you complete the steps.
Step 2 - Add the SDK dependency
In the app module's build.gradle.kts, add the Feeds v3 client. The code in this tutorial is verified against 0.7.0; newer releases may adjust APIs (see the note under Prerequisites) — check the releases page if you want the latest:
123dependencies { implementation("io.getstream:stream-feeds-android-client:0.7.0") }
The starter is already on compileSdk 36 and Kotlin 2.2.21, so no other Gradle changes are needed. Sync the project.
✅ Checkpoint: ./gradlew :app:assembleDebug succeeds and import io.getstream.feeds.android.client.api.FeedsClient resolves.
Step 3 - Add credentials and connect
Two files: Credentials.kt holds the values from Step 0, and ClientProvider.kt builds and connects the FeedsClient.
Replace Credentials.kt. Using Option 1 (the CLI)? Paste your API key, user id, name, and token. Using the demo fallback? The block is pre-filled with working tutorial credentials.
123456object Credentials { const val API_KEY = "REPLACE_WITH_API_KEY" const val USER_ID = "REPLACE_WITH_USER_ID" const val USER_NAME = "REPLACE_WITH_USER_NAME" const val USER_TOKEN = "REPLACE_WITH_TOKEN" }
Security note: in production, never ship your API secret or generate tokens on the client. Tokens are minted by your backend after login. The hardcoded token here is for development only.
Create ClientProvider.kt. It builds the FeedsClient once and connects it. FeedsClient is bound to a single user for its lifetime, so a singleton is fine for this tutorial; a real app builds a fresh client on sign-in.
1234567891011121314151617181920212223242526272829303132333435363738import android.content.Context import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamToken import io.getstream.feeds.android.client.api.FeedsClient import io.getstream.feeds.android.client.api.model.User import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object ClientProvider { private val mutex = Mutex() private var instance: FeedsClient? = null // In a real app, you'll likely have a factory to create a new client instance on user sign in. suspend fun get(context: Context): FeedsClient { instance?.let { return it } mutex.withLock { instance?.let { return@get it } val feedsClient = createClient(context) feedsClient.connect().fold( onSuccess = { /* Connected successfully */ }, onFailure = { /* Skipping error handling for tutorial purposes */ }, ) instance = feedsClient return feedsClient } } private fun createClient(context: Context): FeedsClient = FeedsClient( context = context.applicationContext, apiKey = StreamApiKey.fromString(Credentials.API_KEY), user = User(id = Credentials.USER_ID, name = Credentials.USER_NAME), tokenProvider = { StreamToken.fromString(Credentials.USER_TOKEN) }, ) }
FeedsClientdoes not auto-connect - alwaysconnect()before any feed call. For a production login/logout/user-switch flow, the Feeds docs recommend a session manager that owns the client; the singleton above keeps the tutorial simple.
✅ Checkpoint: the app builds and connects with no auth error in Logcat. (Nothing renders yet - that's the next step.)
Step 4 - Create feeds and render the timeline
Now create the feeds and display their activities. The two main screens (Home and Explore) are driven by FeedsViewModel, which creates a State holding the client and feeds and exposes it as a StateFlow.
Replace FeedsViewModel.kt:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.getstream.feeds.android.client.api.FeedsClient import io.getstream.feeds.android.client.api.model.ActivityData import io.getstream.feeds.android.client.api.state.Feed import io.getstream.feedstutorial.ClientProvider import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class FeedsViewModel(application: Application) : AndroidViewModel(application) { val state: StateFlow<State?> = flow { emit(createState()) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) data class State( val client: FeedsClient, val userFeed: Feed, val timelineFeed: Feed, ) private suspend fun createState(): State { val client = ClientProvider.get(getApplication()) val state = State( client = client, userFeed = client.feed("user", client.user.id), timelineFeed = client.feed("timeline", client.user.id), ) loadFeeds(state) return state } private suspend fun loadFeeds(state: State) { state.userFeed.getOrCreate() state.timelineFeed.getOrCreate() viewModelScope.launch { followSelfIfNeeded(state) } } // By default, `timeline:user_id` doesn't follow `user:user_id`, so the timeline doesn't // show the user's own posts. For tutorial purposes we create the follow relationship // here; in production this is usually done on the backend. private suspend fun followSelfIfNeeded(state: State) { val followsSelf = state.timelineFeed.state.following.first() .any { it.targetFeed.fid == state.userFeed.fid } if (!followsSelf) { state.timelineFeed.follow( targetFid = state.userFeed.fid, createNotificationActivity = false, ) } } // Filled in over the next steps. fun onPost(text: String, imageUri: Uri?) { /* TODO: Step 5 */ } fun onFollowClick(activity: ActivityData) { /* TODO: Step 7 */ } fun onLikeClick(activity: ActivityData) { /* TODO: Step 8 */ } }
Create the ActivityItem component that renders a single activity (we'll extend it with actions in later steps):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import coil3.compose.AsyncImage import io.getstream.feeds.android.client.api.model.ActivityData import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.UserData @Composable fun ActivityItem( activity: ActivityData, feedId: FeedId, currentUserId: String, navController: NavController, onFollowClick: (ActivityData) -> Unit, onLikeClick: (ActivityData) -> Unit, ) { Card { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { ActivityHeader(activity) Text( text = activity.text.orEmpty(), fontSize = 14.sp, lineHeight = 20.sp, modifier = Modifier.padding(start = 56.dp, bottom = 8.dp), ) } } } @Composable private fun ActivityHeader(activity: ActivityData) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Avatar(activity.user) Text( text = activity.user.name ?: "Unknown", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier .weight(1f) .padding(horizontal = 16.dp), ) } } @Composable private fun Avatar(user: UserData) { Box( modifier = Modifier.background(MaterialTheme.colorScheme.secondary, CircleShape), contentAlignment = Alignment.Center, ) { Text( text = user.name?.firstOrNull()?.uppercase() ?: "?", color = MaterialTheme.colorScheme.onSecondary, fontWeight = FontWeight.Bold, ) AsyncImage( modifier = Modifier .size(40.dp) .clip(CircleShape), model = user.image, contentDescription = null, contentScale = ContentScale.Crop, ) } }
Wire the timeline into HomeScreen.kt:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import android.net.Uri import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import io.getstream.feeds.android.client.api.model.ActivityData import io.getstream.feedstutorial.FeedsViewModel import io.getstream.feedstutorial.ui.ActivityItem import io.getstream.feedstutorial.ui.EmptyContent import io.getstream.feedstutorial.ui.LoadingScreen @Composable fun HomeScreen(navController: NavController) { val viewModel: FeedsViewModel = viewModel(LocalActivity.current as ComponentActivity) val state by viewModel.state.collectAsStateWithLifecycle() Surface { when (val state = state) { null -> LoadingScreen() else -> HomeContent( state = state, navController = navController, onLikeClick = { activity -> viewModel.onLikeClick(activity) }, onPost = { text, imageUri -> viewModel.onPost(text, imageUri) }, onFollowClick = { activity -> viewModel.onFollowClick(activity) }, ) } } } @Composable fun HomeContent( state: FeedsViewModel.State, navController: NavController, onLikeClick: (ActivityData) -> Unit, onPost: (String, Uri?) -> Unit, onFollowClick: (ActivityData) -> Unit, ) { val timelineActivities by state.timelineFeed.state.activities.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { if (timelineActivities.isEmpty()) { item { EmptyContent("Write something to start your timeline") } } else { items(timelineActivities) { activity -> ActivityItem( activity = activity, feedId = state.timelineFeed.fid, currentUserId = state.client.user.id, navController = navController, onLikeClick = onLikeClick, onFollowClick = onFollowClick, ) } } } }
✅ Checkpoint: run the app. The Home tab shows a loading spinner, then an empty-timeline message (you have no activities yet). No auth errors in Logcat. feed.state.activities is a StateFlow that updates automatically as activities change.
Step 5 - Post activities (the composer)
Add an ActivityComposer and make it the first item in the timeline. Users post to their user feed; posts appear in their timeline via the self-follow from Step 4.
Create ActivityComposer.kt:
1234567891011121314151617181920212223242526272829303132333435363738394041424344import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.OutlinedTextField 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.unit.dp @Composable fun ActivityComposer(onPost: (String, Uri?) -> Unit) { var text by remember { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { OutlinedTextField( value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxSize(), placeholder = { Text("What is happening?") }, ) Row(Modifier.align(Alignment.End), Arrangement.spacedBy(16.dp)) { Button( onClick = { onPost(text, null) text = "" }, enabled = text.isNotBlank(), content = { Text("Post") }, ) } HorizontalDivider(Modifier.fillMaxWidth()) } }
Add it as the first item of the LazyColumn in HomeContent (in HomeScreen.kt), before the if (timelineActivities.isEmpty()) block:
1item { ActivityComposer(onPost = onPost) }
(remember to import io.getstream.feedstutorial.ui.ActivityComposer.)
Now implement posting in FeedsViewModel - replace the onPost stub:
1234567891011121314import io.getstream.feeds.android.client.api.model.FeedAddActivityRequest fun onPost(text: String, imageUri: Uri?) { val state = state.value ?: return viewModelScope.launch { val request = FeedAddActivityRequest( type = "post", text = text.trim(), feeds = listOf(state.userFeed.fid.rawValue), ) state.userFeed.addActivity(request = request) } }
✅ Checkpoint: post something from the Home tab. It appears in the timeline immediately (posted to your user feed, fanned out to your timeline).
Step 6 - Explore ("For You") feed
The Explore tab uses the built-in foryou feed to surface popular activities.
Add an exploreFeed to FeedsViewModel.State and initialize it in createState/loadFeeds:
1234567891011121314151617181920212223242526data class State( val client: FeedsClient, val userFeed: Feed, val timelineFeed: Feed, val exploreFeed: Feed, ) private suspend fun createState(): State { val client = ClientProvider.get(getApplication()) val state = State( client = client, userFeed = client.feed("user", client.user.id), timelineFeed = client.feed("timeline", client.user.id), exploreFeed = client.feed("foryou", client.user.id), ) loadFeeds(state) return state } private suspend fun loadFeeds(state: State) { state.userFeed.getOrCreate() state.timelineFeed.getOrCreate() state.exploreFeed.getOrCreate() viewModelScope.launch { followSelfIfNeeded(state) } }
Fill in ExploreScreen.kt (same layout as Home, minus the composer):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import io.getstream.feeds.android.client.api.model.ActivityData import io.getstream.feedstutorial.FeedsViewModel import io.getstream.feedstutorial.ui.ActivityItem import io.getstream.feedstutorial.ui.EmptyContent import io.getstream.feedstutorial.ui.LoadingScreen @Composable fun ExploreScreen(navController: NavController) { val viewModel: FeedsViewModel = viewModel(LocalActivity.current as ComponentActivity) val state by viewModel.state.collectAsStateWithLifecycle() Surface { when (val state = state) { null -> LoadingScreen() else -> ExploreContent( state = state, navController = navController, onLikeClick = { activity -> viewModel.onLikeClick(activity) }, onFollowClick = { activity -> viewModel.onFollowClick(activity) }, ) } } } @Composable fun ExploreContent( state: FeedsViewModel.State, navController: NavController, onLikeClick: (ActivityData) -> Unit, onFollowClick: (ActivityData) -> Unit, ) { val activities by state.exploreFeed.state.activities.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { if (activities.isEmpty()) { item { EmptyContent("Popular activities will show up here once your app has more content") } } else { items(activities) { activity -> ActivityItem( activity = activity, feedId = state.exploreFeed.fid, currentUserId = state.client.user.id, navController = navController, onLikeClick = onLikeClick, onFollowClick = onFollowClick, ) } } } }
The
foryoufeed uses the "popular" activity selector, which doesn't support real-time updates. See how real-time updates work.
✅ Checkpoint: the Explore tab loads without error (it may be empty until your app has popular content).
Step 7 - Follow and unfollow
Let users follow feeds from Explore. Implement the operation in the ViewModel and add a button to the activity header.
Replace the onFollowClick stub in FeedsViewModel.kt:
123456789101112131415161718192021import io.getstream.feeds.android.client.api.model.FeedId fun onFollowClick(activity: ActivityData) { val state = state.value ?: return viewModelScope.launch { val targetFeedId = FeedId("user", activity.user.id) val isFollowing = activity.currentFeed?.ownFollows.isNullOrEmpty().not() val result = if (isFollowing) { state.timelineFeed.unfollow(targetFeedId) } else { state.timelineFeed.follow(targetFeedId) } // Refresh the feeds after the follow/unfollow so the UI reflects it. result.onSuccess { launch { state.timelineFeed.getOrCreate() } launch { state.exploreFeed.getOrCreate() } } } }
Extend ActivityHeader in ActivityItem.kt to take the current user id + follow callback and render a Follow/Unfollow button (add import androidx.compose.material3.TextButton). Update the ActivityHeader(activity) call in ActivityItem to ActivityHeader(activity, currentUserId, onFollowClick):
1234567891011121314151617181920212223242526272829@Composable private fun ActivityHeader( activity: ActivityData, currentUserId: String, onFollowClick: (ActivityData) -> Unit, ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Avatar(activity.user) Text( text = activity.user.name ?: "Unknown", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier .weight(1f) .padding(horizontal = 16.dp), ) if (activity.user.id != currentUserId) { TextButton(onClick = { onFollowClick(activity) }) { if (activity.currentFeed?.ownFollows.isNullOrEmpty()) { Text("Follow") } else { Text("Unfollow") } } } } }
Notes:
feed.follow/feed.unfollowfollow/unfollow a target feed; we reload withgetOrCreate()to reflect the change immediately.activity.currentFeed?.ownFollowstells us whether the current user's timeline already follows the feed.activity.currentFeedcarries info about the feed the activity was posted to - useful for Reddit-style apps where feeds aren't 1:1 with users.- The API also supports follow requests requiring owner approval.
✅ Checkpoint: on the Explore tab, tap Follow on another user's activity - the button flips to Unfollow, and their posts start appearing in your Home timeline.
Step 8 - Reactions
Add a "like" reaction toggle. Same pattern: operation in the ViewModel, button in ActivityItem.
Replace the onLikeClick stub in FeedsViewModel.kt:
123456789101112131415import io.getstream.feeds.android.network.models.AddReactionRequest fun onLikeClick(activity: ActivityData) { val state = state.value ?: return val hasOwnReaction = activity.ownReactions.any { it.type == "like" } viewModelScope.launch { if (hasOwnReaction) { state.timelineFeed.deleteActivityReaction(activity.id, "like") } else { val request = AddReactionRequest("like", createNotificationActivity = true) state.timelineFeed.addActivityReaction(activity.id, request) } } }
Add an actions row to ActivityItem with a like button, and an ActionButton helper (add imports androidx.compose.foundation.clickable, androidx.compose.material.icons.Icons, androidx.compose.material.icons.filled.Favorite, androidx.compose.material.icons.filled.FavoriteBorder, androidx.compose.material3.Icon, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.vector.ImageVector). Add this row after the activity Text inside ActivityItem's Column, and add the helper alongside:
1234567891011121314151617181920212223242526272829303132333435363738// Inside ActivityItem's Column, after the text Text(...): Row( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), ) { val reactionCount = activity.reactionGroups["like"]?.count ?: 0 val hasOwnReaction = activity.ownReactions.any { it.type == "like" } ActionButton( icon = if (hasOwnReaction) Icons.Default.Favorite else Icons.Default.FavoriteBorder, count = reactionCount, contentDescription = "Like", onClick = { onLikeClick(activity) }, ) } @Composable fun ActionButton( icon: ImageVector, count: Int, contentDescription: String, onClick: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(onClick = onClick) .padding(horizontal = 8.dp, vertical = 4.dp), ) { Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(20.dp)) Text( text = count.toString(), fontSize = 12.sp, color = Color.Gray, modifier = Modifier.padding(start = 4.dp), ) } }
Notes:
addActivityReaction/deleteActivityReactiontoggle reactions."like"is arbitrary - any string works.- The SDK's reactive state updates the UI automatically. Read
activity.ownReactionsandactivity.reactionGroupsfor real-time reaction data. Comments can have reactions too - see reactions.
✅ Checkpoint: tap the heart on an activity - it fills in and the count increments; tap again to remove.
Step 9 - Comments
Add a comments screen. Comments live on an Activity object (distinct from ActivityData): get the handle from the client, call get(), and observe activity.state.comments.
Create CommentsViewModel.kt:
123456789101112131415161718192021222324252627282930313233343536373839import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.request.ActivityAddCommentRequest import io.getstream.feeds.android.client.api.state.Activity import io.getstream.feedstutorial.ClientProvider import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class CommentsViewModel( private val activityId: String, private val feedId: FeedId, application: Application, ) : AndroidViewModel(application) { val activity: StateFlow<Activity?> = flow { emit(createActivity()) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) private suspend fun createActivity(): Activity = ClientProvider.get(getApplication()) .activity(activityId, feedId) .apply { get() } fun onComment(text: String) { val activity = activity.value ?: return viewModelScope.launch { val request = ActivityAddCommentRequest( comment = text, activityId = activity.activityId, ) activity.addComment(request) } } }
Create CommentsScreen.kt:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113import android.app.Application import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface 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.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.CreationExtras.Key import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.ThreadedCommentData import io.getstream.feeds.android.client.api.state.Activity import io.getstream.feedstutorial.ui.LoadingScreen @Composable fun CommentsScreen(activityId: String, feedId: FeedId) { val viewModel = viewModel { CommentsViewModel( activityId = activityId, feedId = feedId, application = get(APPLICATION_KEY)!!, ) } val activity by viewModel.activity.collectAsStateWithLifecycle() Surface { when (val activity = activity) { null -> LoadingScreen() else -> CommentsContent(activity = activity, onComment = { text -> viewModel.onComment(text) }) } } } @Composable private fun CommentsContent(activity: Activity, onComment: (String) -> Unit) { val comments by activity.state.comments.collectAsStateWithLifecycle() Column { LazyColumn( modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(comments) { comment -> CommentItem(comment) } } HorizontalDivider(Modifier.fillMaxWidth()) CommentComposer(onComment = onComment) } } @Composable private fun CommentItem(data: ThreadedCommentData) { Card { Column( Modifier .padding(16.dp) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text(data.user.name.orEmpty(), fontWeight = FontWeight.SemiBold) Text(data.text.orEmpty()) } } } @Composable fun CommentComposer(onComment: (String) -> Unit) { var text by remember { mutableStateOf("") } OutlinedTextField( value = text, onValueChange = { text = it }, modifier = Modifier .fillMaxWidth() .padding(16.dp), placeholder = { Text("Write a comment...") }, maxLines = 4, trailingIcon = { IconButton( onClick = { onComment(text) text = "" }, enabled = text.isNotBlank(), ) { Icon(Icons.AutoMirrored.Default.Send, contentDescription = "Send comment") } }, ) }
Add a comments button to ActivityItem's action row that navigates to the comments route (add import androidx.compose.material.icons.filled.ChatBubbleOutline). Put it before the like button:
123456ActionButton( icon = Icons.Default.ChatBubbleOutline, count = activity.commentCount, contentDescription = "Comments", onClick = { navController.navigate("comments/${feedId.rawValue}/${activity.id}") }, )
Notes:
client.activity(id, fid)+get()loads the activity and its comments - the same pattern asFeed.activity.addComment(...)posts a comment;activity.commentCountis the total. Comments can be threaded.
✅ Checkpoint: tap the comment icon on an activity, post a comment on the comments screen, and see it appear in the list.
Step 10 - Post images
Attach images to activities. Extend the composer to pick an image, update posting to upload it, and render attachments in ActivityItem.
Update ActivityComposer.kt to add an image picker and preview (add imports for rememberLauncherForActivityResult, PickVisualMediaRequest, ActivityResultContracts, size, RoundedCornerShape, Icons, Icons.Outlined.Image, Icon, IconButton, clip, ContentScale, and coil3.compose.AsyncImage):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748@Composable fun ActivityComposer(onPost: (String, Uri?) -> Unit) { var text by remember { mutableStateOf("") } var imageUri by remember { mutableStateOf<Uri?>(null) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { imageUri = it } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { OutlinedTextField( value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxSize(), placeholder = { Text("What is happening?") }, ) imageUri?.let { AsyncImage( model = it, contentDescription = "Selected image", modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(25)), contentScale = ContentScale.Crop, ) } Row(Modifier.align(Alignment.End), Arrangement.spacedBy(16.dp)) { IconButton( onClick = { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, content = { Icon(Icons.Outlined.Image, contentDescription = "Add image") }, ) Button( onClick = { onPost(text, imageUri) imageUri = null text = "" }, enabled = text.isNotBlank() || imageUri != null, content = { Text("Post") }, ) } HorizontalDivider(Modifier.fillMaxWidth()) } }
Update posting in FeedsViewModel.kt to upload the picked image (add imports android.content.Context, io.getstream.feeds.android.client.api.file.FeedUploadPayload, io.getstream.feeds.android.client.api.file.FileType, kotlinx.coroutines.Dispatchers, kotlinx.coroutines.withContext, java.io.File, java.io.FileOutputStream):
12345678910111213141516171819202122232425262728293031323334fun onPost(text: String, imageUri: Uri?) { val state = state.value ?: return viewModelScope.launch { val application = getApplication<Application>() val imageFile: File? = imageUri?.let { application.copyToCache(it) }?.getOrElse { return@launch } val attachment = imageFile?.let { listOf(FeedUploadPayload(it, FileType.Image)) } val request = FeedAddActivityRequest( type = "post", text = text.trim(), feeds = listOf(state.userFeed.fid.rawValue), attachmentUploads = attachment, ) state.userFeed.addActivity(request = request) imageFile?.let { deleteFile(it) } } } private suspend fun Context.copyToCache(uri: Uri) = withContext(Dispatchers.IO) { runCatching { val outputFile = File(cacheDir, "attachment_${System.currentTimeMillis()}.tmp") contentResolver.openInputStream(uri).use { inputStream -> checkNotNull(inputStream) { "Error opening input stream for URI: $uri" } FileOutputStream(outputFile).use(inputStream::copyTo) } outputFile } } private suspend fun deleteFile(file: File) = runCatching { withContext(Dispatchers.IO) { file.delete() } }
Render the attachment in ActivityItem.kt (add imports androidx.compose.foundation.layout.height, androidx.compose.foundation.shape.RoundedCornerShape, io.getstream.feeds.android.network.models.Attachment). Add this after the activity text, before the actions row, plus the helper:
123456789101112131415activity.attachments.firstOrNull()?.let { attachment -> ImageAttachment(attachment) } @Composable private fun ImageAttachment(attachment: Attachment) { AsyncImage( modifier = Modifier .fillMaxWidth() .height(240.dp) .padding(top = 8.dp) .clip(RoundedCornerShape(12.dp)), model = attachment.imageUrl, contentDescription = "Activity image", contentScale = ContentScale.Crop, ) }
✅ Checkpoint: post an activity with an image - it uploads and renders in the timeline. (You can also paste an image URL; the API auto-attaches URL metadata.)
Verify the whole build
Build and install to a running emulator or connected device:
1./gradlew :app:installDebug
🧑 Human checkpoint: launching the app and confirming the UI is a human step. Agents: install with the command above, then hand back to the human to run it. Confirm the full loop: Home shows your timeline with the composer -> post text and an image -> like an activity -> open comments and post one -> on Explore, follow another user and see their posts appear in Home.
Troubleshooting
stream project is not initialized- CLI onboarding not run. Rungetstream initin the project directory first.- Auth error / nothing loads - the API key and token belong to different apps, or
connect()wasn't called. Confirm both come from the same app andClientProvidercallsconnect()before any feed operation. Internal compiler error ... metadata 2.2.0, expected 2.0.0- your Kotlin version is older than the SDK's. Bump Kotlin to 2.2.0+ (Prerequisites).- Empty timeline even after posting - the
timelinefeed doesn't follow youruserfeed.followSelfIfNeeded(Step 4) sets this up; confirm it ran. - Empty Explore feed -
foryoushows popular content and needs activity in the app; it won't populate on a brand-new app. FeedIdgroup not found / empty feed - group names are case-sensitive and must match the feed groups on your dashboard (user,timeline,foryou). The wrong case silently creates a new empty group.- Images don't upload - the picked
content://URI must be copied to a realFilebeforeaddActivity(Step 10); confirmcopyToCacheran and the temp file exists.
Next steps
Activity Feed V3 has much more:
- Activity selectors and ranking for what content to show
- Notification feeds with aggregation, and stories
- Custom feed groups, visibility & permissions, moderation, and polls
- Sample app - the complete demo: stream-feeds-android-tutorial on GitHub
Pricing is flexible with multiple tiers, including a free maker plan.
Final Thoughts
You've built a fully functional Android activity feed with Kotlin and Jetpack Compose - user and timeline feeds, a composer with image uploads, a follow graph, reactions, and comments - all driven by the SDK's reactive StateFlows. The Feeds SDK for Android and the API support much more: notification feeds, stories, ranking, moderation, and polls.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skills --universalandgetstream skills stream-android --universalfor the Android pack (use--claudeinstead for Claude Code). (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream env --target android(API key tolocal.properties) ->getstream token <user_id>(mint a token); feed groups are configured in the dashboard - Android integration skill: invoke
/stream-androidin your agent for Feeds setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/activity-feeds/docs/android/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - Feeds V3 is pre-1.0; check the pinned version's source rather than assuming APIs from training data

