A live streaming Android app built with Jetpack Compose, Agora RTC, and Firebase. Hosts can broadcast in real time; viewers can join, watch, and chat.
This is a personal project I built to push my own bar on what a production-shaped Android codebase should look like: clean architecture, Compose-first UI, real-time data, and the kind of details I'd want a teammate to find when they open a PR.
Download and install the APK directly on your Android device. You may see an "Install from unknown sources" prompt, this is normal for APKs installed outside the Play Store.
Minimum SDK: Android 7.0 (API 24) | Target SDK: Android 14 (API 34)
A complete walkthrough of hosting a live stream, viewer experience, real-time chat, PiP mode, and profile stats.
If the video doesn't load inline, click here to watch the demo
- Sign in: with email/password or Google (Credential Manager + Firebase Auth)
- Go live: start an Agora RTC broadcast, toggle on/off camera/mic, rotate camera, end the stream cleanly
- Watch live: join a stream as a viewer, send comments in real time, see the host's mute/camera state update live
- PiP mode: broadcaster and viewer both enter picture-in-picture by pressing home or back, keeping the stream alive while using other apps
- Browse streams: on a Home tab that observes Firestore for live status changes
- Profile: with lifetime stats (streams, total stream time, avg viewers) and a recent streams list, all driven by a Firestore snapshot listener so it updates the moment a new stream ends
- Dark / light theme: with persistent user preference, falls back to system theme if unset, custom Material 3 color scheme tokens
| Layer | Tech |
|---|---|
| UI | Jetpack Compose, Material 3, Coil (images + SVG) |
| Architecture | Clean Architecture, MVVM, Hilt (DI) |
| Async | Kotlin Coroutines & Flow |
| Firebase | Firebase Firestore + Firebase Auth |
| Security | EncryptedSharedPreferences (AES-256) |
| Navigation | Type-safe Compose Navigation |
| Auth | Firebase Auth + Google Identity (Credential Manager) |
| RTC | Agora SDK |
| Media upload | Cloudinary (profile photos) |
| Background | Foreground Service keeps stream alive and handles app removal from recent apps |
| PiP | Android PictureInPicture API with custom RemoteAction buttons |
Clean Architecture (data → domain → presentation) with MVVM at the presentation layer.
com/livestreaming/streamly/
├── base/ , BaseViewModel<U, E>, SuspendUseCase / FlowUseCase contracts
├── config/ , theme, navigation, reusable components, utils
├── core/ , domain models, encrypted prefs, Firestore collections
└── ui/
├── auth/ , login / register, Google + email auth, profile photo upload
├── home/ , live streams list, real-time Firestore observer
├── broadcast/ , host a stream via Agora RTC, mic/camera controls
├── watch/ , join a stream as viewer, real-time chat
├── profile/ , lifetime stats, recent streams, sign-out
├── dashboard/ , bottom-nav hosting Home + Profile
└── splash/ , landing / session
Every feature under ui/ follows the same internal shape:
ui/<feature>/
├── data/
│ ├── local/ , encrypted prefs / cache
│ ├── remote/ , Firestore + Agora calls
│ └── repository/ , implements domain interface
├── domain/
│ ├── repository/ , interface
│ └── usecase/ , SuspendUseCase / FlowUseCase
├── presentation/
│ ├── <Feature>Screen.kt
│ ├── <Feature>ViewModel.kt , extends BaseViewModel<UiState, Events>
│ ├── <Feature>UiState.kt
│ ├── <Feature>Events.kt , one-shot events (errors, navigation)
│ └── component/ , composables
└── di/ , Hilt @Module per feature
The View observes a MutableStateFlow<UiState> and MutableSharedFlow<Events> exposed by the ViewModel.
ViewModels never reach into data sources, they call use cases (SuspendUseCase<T, P> returning
Result<T>, FlowUseCase<T, P> returning Flow<T>), which call repositories, which call local/remote
sources. One-way data flow, single source of truth per screen.
Surgical list diffing: the home screen doesn't replace the whole list on every Firestore update. It diffs incoming changes against the current list and only touches what changed. One viewer count increment doesn't recompose everything.
Lifecycle-aware camera: preview and channel join are split intentionally. You see yourself before
going live. ON_STOP is used instead of ON_PAUSE to stop the preview so it doesn't fire during PiP
transitions, no flags needed.
Atomic viewer presence: joining increments the viewer count and writes the viewer document in a single Firestore batch. One network call, one listener trigger, always in sync.
Ghost viewer cleanup: onTaskRemoved in the foreground service handles app kills using
NonCancellable context.
PiP with custom controls: RemoteAction buttons for mic, camera, end/leave. Icons update live via
setPictureInPictureParams. A BroadcastReceiver routes taps back to the ViewModel through a
SharedFlow event bus, no direct Activity reference required.
Comments as a derived flow: flatMapLatest + distinctUntilChanged on stream ID means the Firestore
listener only restarts if you switch streams, not on every field update.
Custom NavType<T>: in config/utils/extension/AppExtension.kt for type-safe,
kotlinx.serialization-backed navigation arguments.
Per-feature Hilt modules: scoped to ViewModelComponent, feature dependencies live and die with the screen.
Reusable composables: in config/components/ (buttons, switches, dialogs, snackbars), every screen
uses the same primitives.
Responsive scaling: with sdp/ssp so layouts hold up across screen sizes.
Typed error model: sealed class ApiException (NetworkException, UnknownException) wraps every
data-source failure with a context-resolved message. ViewModels surface it through Events.OnError,
screens render it via the global SnackbarUtils (typed Success/Error/Warning visuals).
Form validation: GenericValidators + per-field FieldState(value, error) pattern across
login/register; PhoneNumberVisualTransformation backed by libphonenumber for live phone formatting.
Runtime permissions: PermissionUtils driving the camera/mic flow on the broadcast screen with
denied-once and permanently-denied states handled separately.
- Clone the repo and open in Android Studio
- Drop your own
google-services.jsoninto/app - Add your Cloudinary keys to
local.properties - Add your Agora App ID to
Constants.kt(optional, app runs without it but streaming won't work) - Sync Gradle and hit Run
No test suite yet, that's the next thing I'd add. No CI or ProGuard rules yet. Both are on the roadmap.
Built by Khawaja Moiz, Senior Android Engineer with 6 years of production experience.
📧 khwajamoiz406@gmail.com | GitHub | LinkedIn
MIT, see LICENSE for details.



