Android Activity Feed Tutorial

Build an Android activity feed with Kotlin and Jetpack Compose

Implement real-time activity feeds and timelines in your Android app with Stream's Activity Feed V3 SDK - user and timeline feeds, posting, reactions, comments, a follow graph, and a "For You" discovery feed.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your Android feed app.

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 Activity class.
  • 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

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.

# 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:

/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 .md to any Stream docs URL for a clean Markdown version. A condensed index for the Android Feeds SDK is at https://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 the FeedsClient(...) factory. It is bound to one user for its lifetime (the User + tokenProvider are fixed at construction). Built once, at app launch, and connect()ed before any feed call.
  • Feed - a handle to a single feed, obtained with client.feed(group, id) (e.g. client.feed("user", "alice")). Call getOrCreate() to load it.
  • FeedState / ActivityState - observable StateFlows (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 — compileSdk 36, minSdk 24, 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):

curl -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.

getstream 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.

getstream env --target android

4. Mint a user token (never-expiring by default; add a TTL for production-like testing):

getstream 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.

# 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:

./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:

dependencies {
    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.

Credentials.kt
object 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.

ClientProvider.kt
import 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) },
    )
}

FeedsClient does not auto-connect - always connect() 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:

FeedsViewModel.kt
import 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):

ui/ActivityItem.kt
import 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:

HomeScreen.kt
import 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:

ui/ActivityComposer.kt
import 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:

item { ActivityComposer(onPost = onPost) }

(remember to import io.getstream.feedstutorial.ui.ActivityComposer.)

Now implement posting in FeedsViewModel - replace the onPost stub:

FeedsViewModel.kt
import 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:

FeedsViewModel.kt
data 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):

ExploreScreen.kt
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 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 foryou feed 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:

FeedsViewModel.kt
import 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):

ui/ActivityItem.kt
@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.unfollow follow/unfollow a target feed; we reload with getOrCreate() to reflect the change immediately.
  • activity.currentFeed?.ownFollows tells us whether the current user's timeline already follows the feed. activity.currentFeed carries 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:

FeedsViewModel.kt
import 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:

ui/ActivityItem.kt
// 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 / deleteActivityReaction toggle reactions. "like" is arbitrary - any string works.
  • The SDK's reactive state updates the UI automatically. Read activity.ownReactions and activity.reactionGroups for 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:

CommentsViewModel.kt
import 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:

CommentsScreen.kt
import 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:

ui/ActivityItem.kt
ActionButton(
    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 as Feed.
  • activity.addComment(...) posts a comment; activity.commentCount is 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):

ui/ActivityComposer.kt
@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):

FeedsViewModel.kt
fun 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:

ui/ActivityItem.kt
activity.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:

./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. Run getstream init in 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 and ClientProvider calls connect() 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 timeline feed doesn't follow your user feed. followSelfIfNeeded (Step 4) sets this up; confirm it ran.
  • Empty Explore feed - foryou shows popular content and needs activity in the app; it won't populate on a brand-new app.
  • FeedId group 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 real File before addActivity (Step 10); confirm copyToCache ran and the temp file exists.

Next steps

Activity Feed V3 has much more:

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, then getstream skills --universal and getstream skills stream-android --universal for the Android pack (use --claude instead 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 to local.properties) -> getstream token <user_id> (mint a token); feed groups are configured in the dashboard
  • Android integration skill: invoke /stream-android in your agent for Feeds setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/activity-feeds/docs/android/llms.txt
  • Markdown endpoints: append .md to 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

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.
Thank you for your feedback

We appreciate your feedback and will get back to you within 5 working days.

Start building in-app feeds for free.

Add Chat to my app: getstream.io/SKILL.md

The fastest way to build with Stream. Start a new project or improve an existing one. Full CLI and documentation integration out of the box.


Ask your agent:

/stream Build me a Social App with Feeds and Moderation.
/stream Any livestream calls running?