A robust, production-ready template for modern Android development that takes the pain out of setting up a new project. Built on the foundation of Now In Android's architecture, this template provides a comprehensive starting point for both new and experienced Android developers.
- Production-Ready Authentication: Firebase authentication with Google Sign-In and email/password, including secure credential management
- Clean Architecture: Clear separation of concerns with a modular, scalable architecture
- Modern Tech Stack: Leverages the latest Android development tools and libraries including Jetpack Compose, Kotlin Coroutines, and Dagger Hilt
- Type-Safe Navigation: Fully typed navigation using Kotlin serialization
- Robust Data Management: Complete data layer with Repository pattern, Room database, and Preferences DataStore
- Network Communication: Retrofit + OkHttp setup with proper error handling and interceptors
- Firebase Integration: Full Firebase suite including Authentication, Firestore, and Analytics
- Background Sync: Robust data synchronization system using WorkManager
- CI/CD: Automate build, release and Play Store deployment using GitHub actions and Fastlane
Note
The codebase follows a set of conventions that prioritize simplicity and maintainability. Understanding these patterns will help you develop effectively.
Check out the whole list here.
- UI: Jetpack Compose, Material3, Navigation Compose
- DI: Dagger Hilt
- Async: Kotlin Coroutines & Flow
- Network: Retrofit, OkHttp, Kotlinx Serialization
- Storage: Room DB, DataStore Preferences
- Firebase: Auth, Firestore, Analytics, Crashlytics
- Background Processing: WorkManager
- Images: Coil
- Kotlin 2.0
- Gradle 8.11.1 with Version Catalogs
- Java 21
- Custom Gradle Convention Plugins
- Dokka and MKDocs for documentation
- Spotless for code formatting
- MVVM with Clean Architecture
- Repository Pattern
- Modular design with feature isolation
- Firebase Authentication & Firestore
- Single Activity
- DataStore for preferences
- Kotlinx Serialization for JSON
- Type-safe navigation
- Background sync with WorkManager
- Debug/Release variants
- Firebase Crashlytics integration
- GitHub Actions CI/CD
- Automatic dependency updates with Renovate
- Code documentation with Dokka
graph TD
A[App] --> B[Firebase: Analytics]
A --> C[Features: Auth, Home, Profile, Settings]
A --> D[Sync]
A --> I[Core: UI]
C --> I
C --> E[Data]
D --> E
E --> F[Firebase: Auth, Firestore]
E --> G[Core: Network, Preferences, Room]
F --> H[Core: Android]
G --> H
B --> H
The codebase follows a clean architecture pattern with clear separation of concerns across different layers. Each layer has specific responsibilities and dependencies flow inward, with the domain layer at the center.
The data layer is responsible for handling data operations and is organized into the following components:
- Data Sources: Located in
*DataSource
classes (e.g.,NetworkDataSource
,AuthDataSource
)- Handle raw data operations with external systems (API, database, etc.)
- Perform data transformations and mapping
- Example:
AuthDataSourceImpl
in the auth module handles raw Firebase authentication operations
Note
Data sources should expose Flow for observable data and suspend functions for one-shot operations:
interface DataSource {
fun observeData(): Flow<Data>
suspend fun updateData(data: Data)
}
- Models: Found in
models
packages across modules- Define data structures for external data sources
- Contain serialization/deserialization logic
- Example:
NetworkPost
in the network module represents raw API responses
Important
Always keep data models immutable and use data classes:
data class NetworkResponse(
val id: Int,
val data: String
)
The data layer is implemented across several modules:
network/
: Handles remote API communicationstorage/preferences/
: Manages local data persistence using DataStorestorage/room/
: Handles SQLite database operations using Room
Warning
Don't expose data source interfaces directly to ViewModels. Always go through repositories:
// DO THIS
class MyViewModel(
private val repository: MyRepository
)
// DON'T DO THIS
class MyViewModel(
private val dataSource: MyDataSource
)
The repository layer acts as a single source of truth and mediates between data sources:
- Repositories: Found in
repository
packages (e.g.,AuthRepository
)- Coordinate between multiple data sources
- Implement business logic for data operations
- Abstract data sources from the UI layer
- Handle caching strategies
- Example:
AuthRepositoryImpl
coordinates between Firebase Auth and local preferences
Key characteristics:
- Uses Kotlin Result type for error handling
- Implements caching where appropriate
- Exposes Kotlin Flow for reactive data updates
Important
Always return Result<T>
from repository methods. This ensures consistent error handling across the app:
suspend fun getData(): Result<Data> = suspendRunCatching {
dataSource.getData()
}
The UI layer follows an MVVM pattern and consists of:
-
ViewModels: Located in
ui
packages- Manage UI state and business logic
- Handle user interactions
- Communicate with repositories
- Example:
AuthViewModel
manages authentication state and user actions
-
Screens: Found in
ui
packages alongside their ViewModels- Compose UI components
- Handle UI layouts and styling
- Observe ViewModel state
- Example:
SignInScreen
displays login form and handles user input
-
State Management:
- Uses
UiState<T>
data class for managing loading, error, and success states - Employs
StateFlow
for reactive UI updates - Handles one-time events using
OneTimeEvent<T>
- Uses
Tip
Always use UiState
wrapper for ViewModel states. This ensures consistent error and loading handling across the app.
data class UiState<T : Any>(
val data: T,
val loading: Boolean = false,
val error: OneTimeEvent<Throwable?> = OneTimeEvent(null)
)
Warning
Don't create custom loading or error handling in individual screens. Use StatefulComposable instead:
// DON'T DO THIS
if (isLoading) {
CircularProgressIndicator()
}
// DO THIS
StatefulComposable(state = uiState) { data ->
// Your UI content
}
The typical data flow follows this pattern:
-
UI Layer:
User Action β ViewModel β Repository
-
Repository Layer:
Repository β Data Sources β External Systems
-
Data Flow Back:
External Systems β Data Sources β Repository β ViewModel β UI
The codebase uses several key data structures for state management:
-
UiState:
data class UiState<T : Any>( val data: T, val loading: Boolean = false, val error: OneTimeEvent<Throwable?> = OneTimeEvent(null) )
- Wraps UI data with loading and error states
- Used by ViewModels to communicate state to UI
-
Result:
- Used by repositories to handle success/failure
- Propagates errors up the stack
- Example:
Result<AuthUser>
for authentication operations
-
StateFlow:
- Used for reactive state management
- Provides hot, stateful event streams
- Example:
_authUiState: MutableStateFlow<UiState<AuthScreenData>>
-
OneTimeEvent:
- Errors propagate up through Result
- They get converted to OneTimeEvent when reaching the UI layer
- This ensures error Snackbars only show once and don't reappear on recomposition
Important
Use StatefulComposable
for screens that need loading or error handling. This component handles these states automatically, reducing boilerplate and ensuring consistent behavior.
StatefulComposable(
state = viewModel.state,
onShowSnackbar = { msg, action -> /* ... */ }
) { data ->
// Your UI content here
}
Tip
Use the provided extension functions for updating state:
// For regular state updates
_uiState.updateState { copy(value = newValue) }
// For async operations
_uiState.updateStateWith(viewModelScope) {
repository.someAsyncOperation()
}
This codebase prioritizes pragmatic simplicity over theoretical purity, making conscious tradeoffs that favor maintainability and readability over absolute correctness or flexibility. Here are some key examples of this philosophy:
Instead of implementing error and loading states individually for each screen, we handle these centrally through the StatefulComposable
:
@Composable
fun <T : Any> StatefulComposable(
state: UiState<T>,
onShowSnackbar: suspend (String, String?) -> Boolean,
content: @Composable (T) -> Unit
) {
content(state.data)
if (state.loading) {
// Centralized loading indicator
}
state.error.getContentIfNotHandled()?.let { error ->
// Centralized error handling
}
}
Tradeoff:
- β Simplicity: UI components only need to focus on their happy path
- β Consistency: Error and loading states behave uniformly across the app
- β Flexibility: Less control over specific error/loading UI for individual screens
While the NowInAndroid codebase promotes a functional approach using Flow operators and transformations, we opt for a more direct approach using MutableStateFlow:
// Our simplified approach
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _authUiState = MutableStateFlow(UiState(AuthScreenData()))
val authUiState = _authUiState.asStateFlow()
fun updateEmail(email: String) {
_authUiState.updateState {
copy(
email = TextFiledData(
value = email,
errorMessage = if (email.isEmailValid()) null else "Email Not Valid"
)
)
}
}
}
Tradeoff:
- β Readability: State changes are explicit and easy to trace
- β Simplicity: Easier to manage multiple UI events and loading states
- β Debuggability: Direct state mutations are easier to debug
- β Purity: Less adherence to functional programming principles
- β Resource Management: No automatic cleanup of subscribers when the app is in background (compared to
SharingStarted.WhileSubscribed(5_000)
)
Note
These patterns are guidelines, not rules. The goal is to make the codebase more maintainable and easier to understand, not to restrict flexibility where it's truly needed.
- Android Studio Hedgehog or newer
- JDK 21
- Firebase account for authentication, firestore and analytics
- Clone and open project. The depth flag is added to reduce the clone size:
git clone --depth 1 -b main https://github.com/atick-faisal/Jetpack-Compose-Starter.git
- Firebase setup:
- Create project in Firebase Console
- Add Authentication and Firestore and add the following rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper function to check if user is authenticated
function isAuthenticated() {
return request.auth != null;
}
// Helper function to check if the user is accessing their own data
function isUserOwner(userId) {
return isAuthenticated() && request.auth.uid == userId;
}
// Helper function to validate Jetpack data structure
function isValidJetpack(jetpack) {
return jetpack.size() == 7
&& 'id' in jetpack && jetpack.id is string
// ... other validations
}
// Match the specific path structure: dev.atick.jetpack/{userId}/jetpacks/{jetpackId}
match /dev.atick.jetpack/{userId}/jetpacks/{jetpackId} {
allow read: if isUserOwner(userId);
allow create: if isUserOwner(userId)
&& isValidJetpack(request.resource.data);
allow update: if isUserOwner(userId)
&& isValidJetpack(request.resource.data)
&& request.resource.data.id == resource.data.id;
allow delete: if isUserOwner(userId);
}
// Deny access to all other documents by default
match /{document=**} {
allow read, write: if false;
}
}
}
- Download
google-services.json
toapp/
- Add SHA fingerprint to Firebase Console for Google Sign-In:
./gradlew signingReport
Or, you can run the signingReport
task from the run configurations.
Then get the SHA-1 fingerprint for the debug and release builds of the app
module and add them to Firebase Console.
Note
Firebase authentication, firestore and crashlytics requires Firebase console setup and the google-services.json
file. I have provided a template to ensure a successful build. However, you need to provide your own in order to use all the functionalities.
- Running the App:
To run the app, select
app
from the run configurations and hit run.
- Create
keystore.properties
in project root:
storePassword=****
keyPassword=****
keyAlias=****
storeFile=keystore-file-name.jks
-
Place keystore file in
app/
-
To get the release apk file:
./gradlew assembleRelease
Or, to get the bundle file:
./gradlew bundleRelease
The template contains 3 GitHub actions workflows:
- Build: Runs the build and tests on every push
- Docs: Generates the documentation and deploys to GitHub Pages
- Release: Builds and deploys the app to the Play Store
The release workflow requires the following secrets:
KEYSTORE
: Base64 encoded keystore fileKEYSTORE_PROPERTIES
: Base64 encoded keystore.properties fileGOOGLE_SERVICES_JSON
: Base64 encoded google-services.json file
Important
The release workflow is set up to deploy to the Play Store. Make sure to update the fastlane/Appfile
and fastlane/Fastfile
with your own app details.
Also, get the play store service account json file and add it to the fastlane
directory. The instructions can be found here.
This guide walks through the process of adding a new feature to the app, following the established patterns and conventions.
Start by defining your data models in the appropriate layer:
- Data Source Models: These are the raw data models from your network, database or other sources. You should put them in
core:<data-source>/models/src/main/kotlin/dev/atick/core/<data-source>/models/
. For example:
// core/network/src/main/kotlin/dev/atick/network/models/
@Serializable
data class NetworkFeatureData(
val id: Int,
val title: String
)
- Repository Models: These are the domain models (this template doesn't use domain layer, but you are free to add one) that your repository will work with. Put them in
core:<feature>/models/src/main/kotlin/dev/atick/core/<feature>/models/
. For example:
2. **UI Models** (what your screen will display):
```kotlin
// feature/feature-name/src/main/kotlin/dev/atick/feature/models/
data class FeatureScreenData(
val title: String,
val description: String = "",
// ... other UI state
)
- Define the interface:
// feature/src/main/kotlin/dev/atick/feature/data/
interface FeatureDataSource {
suspend fun getFeatureData(): List<NetworkFeatureData>
fun observeFeatureData(): Flow<List<NetworkFeatureData>>
}
- Implement the data source:
class FeatureDataSourceImpl @Inject constructor(
private val api: FeatureApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : FeatureDataSource {
override suspend fun getFeatureData(): List<NetworkFeatureData> {
return withContext(ioDispatcher) {
api.getFeatureData()
}
}
override fun observeFeatureData(): Flow<List<NetworkFeatureData>> {
return flow {
// Implementation
}.flowOn(ioDispatcher)
}
}
- Define repository interface:
// feature/src/main/kotlin/dev/atick/feature/repository/
interface FeatureRepository {
suspend fun getFeatureData(): Result<List<FeatureData>>
}
- Implement repository:
class FeatureRepositoryImpl @Inject constructor(
private val dataSource: FeatureDataSource
) : FeatureRepository {
override suspend fun getFeatureData(): Result<List<FeatureData>> =
suspendRunCatching {
dataSource.getFeatureData().map { it.toFeatureData() }
}
}
// feature/src/main/kotlin/dev/atick/feature/ui/
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(FeatureScreenData()))
val uiState = _uiState.asStateFlow()
init {
loadData()
}
private fun loadData() {
_uiState.updateStateWith(viewModelScope) {
repository.getFeatureData()
.map { data -> /* transform to screen data */ }
}
}
fun onUserAction(/* params */) {
_uiState.updateState {
copy(/* update state */)
}
}
}
- Create screen composable:
// feature/src/main/kotlin/dev/atick/feature/ui/
@Composable
fun FeatureRoute(
onShowSnackbar: suspend (String, String?) -> Boolean,
viewModel: FeatureViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar
) { screenData ->
FeatureScreen(
screenData = screenData,
onAction = viewModel::onUserAction
)
}
}
@Composable
private fun FeatureScreen(
screenData: FeatureScreenData,
onAction: () -> Unit
) {
// UI implementation
}
- Add preview:
@DevicePreviews
@Composable
private fun FeatureScreenPreview() {
FeatureScreen(
screenData = FeatureScreenData(/* sample data */),
onAction = {}
)
}
- Define navigation endpoints:
// feature/src/main/kotlin/dev/atick/feature/navigation/
@Serializable
data object FeatureNavGraph
@Serializable
data object Feature
- Add navigation extensions:
fun NavController.navigateToFeature(navOptions: NavOptions? = null) {
navigate(Feature, navOptions)
}
fun NavGraphBuilder.featureScreen(
onShowSnackbar: suspend (String, String?) -> Boolean
) {
composable<Feature> {
FeatureRoute(
onShowSnackbar = onShowSnackbar
)
}
}
fun NavGraphBuilder.featureNavGraph(
nestedGraphs: NavGraphBuilder.() -> Unit
) {
navigation<FeatureNavGraph>(
startDestination = Feature
) {
nestedGraphs()
}
}
- Add module for data source:
@Module
@InstallIn(SingletonComponent::class)
abstract class DataSourceModule {
@Binds
@Singleton
abstract fun bindFeatureDataSource(
impl: FeatureDataSourceImpl
): FeatureDataSource
}
- Add module for repository:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindFeatureRepository(
impl: FeatureRepositoryImpl
): FeatureRepository
}
β
Data models defined
β
Data source interface and implementation created
β
Repository interface and implementation created
β
ViewModel handling state and user actions
β
UI components with previews
β
Navigation setup
β
Dependency injection modules