Android MVVM Architecture for A Production Ready App
Android development has gone through a rapid change over the past couple of years. The journey has been both rewarding and bumpy at times. Key milestones include Kotlin, Architectural Components, Coroutines, Compose, AndroidX, Navigation, and DataStore. The introduction of new features and libraries required a revisit of the Android App architecture design. I have provided multiple versions of MVVM architectures over the years with great feedback from the community, receiving more than 10,000 stars on my GitHub repositories: MVP, MVVM, etc.
While my earlier attempts to simplify the concepts through sample projects worked, the implementation of MVVM in a real production app was still difficult to understand for many readers. A production app requires more considerations like Analytics, Work, Notification, Database, Proguard, Auto Token Refresh, Deeplinks, Share, etc.
To solve these challenges I have open source 2 projects, wimm-android-app (complete production-ready Android project) and wimm-node-app (API backend project supporting the App). In this article, I will explain the key concepts used in the Android project MVVM architecture.
About WhereIsMyMotivation project
Project Link: github.com/unusualcodeorg/wimm-android-app
WhereIsMyMotivation App provides videos and quotes to get inspiration every day. It also includes information on the life of great personalities. It helps to track an individual’s happiness level and write daily journals. Any interesting links can be shared and stored in the motivation box.
Let’s quickly deep dive into the project structure.
- ui — App UI code, view models, navigations. The key idea here is to keep most of the things related to a feature in the same feature directory.
- data — It holds the code to handle network, database, datastore, and repositories.
- di — Dependency provider modules
- utils — Common utility functions
- analytics — Events to be sent for the analytics
- fcm — Notification system
- work — Workers and Alarm Manager
- init — Initialization of libraries
Let’s first go through the data layer
data
├── local
│ ├── db
│ │ ├── Converter.kt
│ │ ├── DatabaseService.kt
│ │ ├── dao
│ │ │ ├── JournalDao.kt
│ │ │ └── MoodDao.kt
│ │ ├── entity
│ │ │ ├── Journal.kt
│ │ │ └── Mood.kt
│ │ └── migrations.kt
│ ├── file
│ │ └── LocalFiles.kt
│ └── prefs
│ ├── AppMetricPreferences.kt
│ ├── ContentPreferences.kt
│ └── UserPreferences.kt
├── model
│ ├── Auth.kt
│ ├── Content.kt
│ ├── DeepLinkData.kt
│ ├── Mentor.kt
│ ├── MetaContent.kt
│ ├── Role.kt
│ ├── SubscriptionInfo.kt
│ ├── Token.kt
│ ├── Topic.kt
│ ├── UniversalSearchResult.kt
│ └── User.kt
├── remote
│ ├── Networking.kt
│ ├── RequestHeaders.kt
│ ├── apis
│ │ ├── auth
│ │ │ ├── AuthApi.kt
│ │ │ ├── Endpoints.kt
│ │ │ ├── RefreshTokenApi.kt
│ │ │ └── request
│ │ │ ├── AuthRequest.kt
│ │ │ └── RefreshTokenRequest.kt
│ │ ├── content
│ │ │ ├── ContentApi.kt
│ │ │ ├── Endpoints.kt
│ │ │ └── request
│ │ │ ├── ContentBookmarkRequest.kt
│ │ │ └── ContentSubmissionRequest.kt
│ │ ├── mentor
│ │ │ ├── Endpoints.kt
│ │ │ └── MentorApi.kt
│ │ ├── subscription
│ │ │ ├── Endpoints.kt
│ │ │ ├── SubscriptionApi.kt
│ │ │ └── request
│ │ │ └── SubscriptionModifyRequest.kt
│ │ ├── topic
│ │ │ ├── Endpoints.kt
│ │ │ └── TopicApi.kt
│ │ └── user
│ │ ├── Endpoints.kt
│ │ ├── UserApi.kt
│ │ └── request
│ │ ├── FirebaseTokenRequest.kt
│ │ ├── JournalsRequest.kt
│ │ ├── MessageRequest.kt
│ │ └── MoodsRequest.kt
│ ├── interceptors
│ │ ├── ForcedCacheInterceptor.kt
│ │ ├── ImageHeaderInterceptor.kt
│ │ ├── LocalHostInterceptor.kt
│ │ ├── NetworkInterceptor.kt
│ │ ├── RefreshTokenInterceptor.kt
│ │ └── RequestHeaderInterceptor.kt
│ ├── response
│ │ ├── ApiDataResponse.kt
│ │ ├── ApiErrorResponse.kt
│ │ └── ApiGeneralResponse.kt
│ └── utils
│ ├── ForcedLogout.kt
│ ├── NetworkHelper.kt
│ ├── NoConnectivityException.kt
│ └── extensions.kt
└── repository
├── AppMetricRepository.kt
├── AuthRepository.kt
├── ContentRepository.kt
├── JournalRepository.kt
├── MentorRepository.kt
├── MoodRepository.kt
├── RemoteConfigRepository.kt
├── SearchRepository.kt
├── SubscriptionRepository.kt
├── TopicRepository.kt
└── UserRepository.kt
data/local/db
- entity — Contains the models for the database tables
- dao — Contains the queries for the database tables
- Converter — Defines the model to table type converters
- DatabaseService — Initializes Room database
data/local/db/entity/Journal
@Parcelize
@JsonClass(generateAdapter = true)
@Entity(tableName = "journals")
data class Journal(
@Json(name = "id")
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var id: Long = 0,
...
) : Parcelable
data/local/db/dao/JournalDao
@Dao
interface JournalDao {
@Delete
suspend fun delete(entity: Journal): Int
//emits an int value, indicating the number of rows removed from the database.
@Insert
suspend fun insert(entity: Journal): Long
// emits a long, which is the new rowId for the inserted item
@Query("SELECT * FROM journals WHERE createdBy = :userId ORDER BY createdAt DESC")
fun getAll(userId: String): List<Journal>
...
}
data/local/db/DatabaseService
@Singleton
@Database(
entities = [
Mood::class,
Journal::class
],
exportSchema = false,
version = 1
)
@TypeConverters(Converter::class)
abstract class DatabaseService : RoomDatabase() {
abstract fun moodDao(): MoodDao
abstract fun journalDao(): JournalDao
}
data/local/file
Provides functions to read raw JSON files
data/local/prefs
Read and write DataStore Preferences
data/local/prefs/ContentPreferences
class ContentPreferences @Inject constructor(private val dataStore: DataStore<Preferences>) {
companion object {
private val FEED_NEXT_PAGE_NUMBER = intPreferencesKey("FEED_NEXT_PAGE_NUMBER")
private val FEED_LAST_SEEN = longPreferencesKey("FEED_LAST_SEEN")
}
suspend fun getFeedNextPageNumber() =
dataStore.data.map { it[FEED_NEXT_PAGE_NUMBER] ?: 1 }.first()
...
}
data/model
It contains the API data models using Moshi
@Parcelize
@JsonClass(generateAdapter = true)
data class Auth(
@Json(name = "user")
val user: User,
@Json(name = "tokens")
val token: Token
) : Parcelable
data/remote
apis — It contains a group of related REST APIs. apis/auth contains the APIs related to login, logout, token refresh, etc. Each group has a structure:
- request: contains the additional API models
- [Group]Api: Retrofit interface for the API calls
- Endpoint: contains the API endpoints
data/remote/request/AuthRequest
@JsonClass(generateAdapter = true)
data class BasicAuthRequest(
@Json(name = "email")
val email: String,
@Json(name = "password")
val password: String
)
data/remote/AuthApi
interface AuthApi {
@POST(Endpoints.AUTH_LOGIN_BASIC)
@Headers(RequestHeaders.Key.AUTH_PUBLIC)
suspend fun basicLogin(
@Body request: BasicAuthRequest
): ApiDataResponse<Auth>
...
}
interceptors — It contains the okhttp interceptors
- ImageHeaderInterceptor: Adds access token header to fetch protected images
- RequestHeaderInterceptor: Adds x-api-key header, access token in the Authentication header for the protected APIs (it reads the custom header from the interface to know if it is a protected or public api), device id header, and android version header.
- RefreshTokenInterceptor: It is quite involved (I will write about it in a separate article). This Interceptor pauses all the APIs that fail due to access token expiry, refreshes the expired token, and finally calls the failed apis again with the new access token.
- NetworkInterceptor: It makes the network call only when the App is connected to the internet
- LocalHostInterceptor: This helps in calling the localhost on a given port when running backend project locally.
@Singleton
class RequestHeaderInterceptor @Inject constructor(private val requestHeaders: RequestHeaders) :
Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val builder = request.newBuilder()
val apiAuthType = request.header(RequestHeaders.Key.API_AUTH_TYPE)
builder.removeHeader(RequestHeaders.Key.API_AUTH_TYPE)
when (apiAuthType) {
RequestHeaders.Type.PROTECTED.value -> {
val accessToken = requestHeaders.accessTokenFetcher.fetch()
if (accessToken != null) {
builder.addHeader(
RequestHeaders.Param.ACCESS_TOKEN.value,
"Bearer $accessToken"
)
}
builder.addHeader(RequestHeaders.Param.API_KEY.value, requestHeaders.apiKey)
}
RequestHeaders.Type.PUBLIC.value ->
builder.addHeader(RequestHeaders.Param.API_KEY.value, requestHeaders.apiKey)
}
if (apiAuthType != null) {
requestHeaders.deviceIdFetcher.fetch()?.let {
builder.addHeader(RequestHeaders.Param.DEVICE_ID.value, it)
}
requestHeaders.appVersionCodeFetcher.fetch()?.toString()?.let {
builder.addHeader(RequestHeaders.Param.ANDROID_VERSION.value, it)
}
}
return chain.proceed(builder.build())
}
}
response — It holds the model for the API responses
data/remote/response/ApiDataResponse
@JsonClass(generateAdapter = true)
data class ApiDataResponse<T>(
@Json(name = "statusCode")
val statusCode: String,
@Json(name = "message")
val message: String,
@Json(name = "data")
val data: T
)
utils — It contains the code needed around API handling
- extensions: Add extension function to Throwable for converting it into ApiErrorResponse
- ForcedLogout: It broadcasts a logout event through SharedFlow
- NetworkHelper: Check internet connectivity
- NoConnectivityException: Custom Exception
RequestHeaders — Store the functions to fetch tokens and app info
class RequestHeaders @Inject constructor(
@ApiKeyInfo val apiKey: String,
@AccessTokenInfo val accessTokenFetcher: ResultFetcherBlocking<String>,
@DeviceIdInfo val deviceIdFetcher: ResultFetcherBlocking<String>,
@AppVersionCodeInfo val appVersionCodeFetcher: ResultFetcherBlocking<Long>,
) {
object Key {
const val API_AUTH_TYPE = "API_AUTH_TYPE"
const val AUTH_PUBLIC = "$API_AUTH_TYPE: public"
const val AUTH_PROTECTED = "$API_AUTH_TYPE: protected"
}
@Keep
enum class Type(val value: String) {
PUBLIC("public"),
PROTECTED("protected")
}
@Keep
enum class Param(val value: String) {
API_KEY("x-api-key"),
DEVICE_ID("x-device-id"),
ANDROID_VERSION("x-android-version"),
TIMEZONE_OFFSET("x-timezone-offset"),
ACCESS_TOKEN("Authorization")
}
}
Networking — Static functions to create instances of okhttp clients
object Networking {
private const val NETWORK_CALL_TIMEOUT = 60
fun <T> createService(baseUrl: String, client: OkHttpClient, service: Class<T>): T =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().build()
)
)
.build()
.create(service)
fun createOkHttpClientForApis(
networkInterceptor: NetworkInterceptor,
headerInterceptor: RequestHeaderInterceptor,
refreshTokenInterceptor: RefreshTokenInterceptor,
cacheDir: File,
cacheSize: Long
) = OkHttpClient.Builder()
.cache(Cache(cacheDir, cacheSize))
.addInterceptor(networkInterceptor)
.addInterceptor(headerInterceptor)
.addInterceptor(refreshTokenInterceptor)
.addInterceptor(getHttpLoggingInterceptor())
.readTimeout(NETWORK_CALL_TIMEOUT.toLong(), TimeUnit.SECONDS)
.writeTimeout(NETWORK_CALL_TIMEOUT.toLong(), TimeUnit.SECONDS)
.build()
private fun getHttpLoggingInterceptor() = HttpLoggingInterceptor()
.apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
...
}
data/repository
This structure acts as a mediator to access data for a given resource via api or local. We do not expose apis and datastore directly to other application components like ViewModel, but we access them via corresponding repositories. Example: UserRepository exposes functions related to user data in preferences and ContentRepository exposes functions to make API calls using ContentApi.
class ContentRepository @Inject constructor(
private val contentApi: ContentApi,
private val contentPreferences: ContentPreferences
) {
suspend fun fetchHomeFeedContents(
pageNumber: Int,
pageItemCount: Int,
empty: Boolean
): Flow<List<Content>> =
flow {
emit(contentApi.contents(pageNumber, pageItemCount, empty))
}.map { it.data }
suspend fun getFeedNextPageNumber(): Int = contentPreferences.getFeedNextPageNumber()
...
}
Now let’s move to the di package. The App uses Hilt for the dependency injection.
Dependency injection is a technique of lifting the instance creation out of the requiring classes into a configuration module. This enables to manage the instance scope and helps in testing. The Hilt is a very popular framework built on top of Dagger. You can learn more on this topic from my Dagger Article
- module — It contains the Module classes to provide the instances
- qualifier — It contains the annotations used in providing similar instance types
di/module/NetworkModule
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
...
@Provides
@Singleton
fun provideApiOkHttpClient(
@ApplicationContext context: Context,
networkInterceptor: NetworkInterceptor,
requestHeaderInterceptor: RequestHeaderInterceptor,
refreshTokenInterceptor: RefreshTokenInterceptor
): OkHttpClient = Networking.createOkHttpClientForApis(
networkInterceptor,
requestHeaderInterceptor,
refreshTokenInterceptor,
context.cacheDir,
50 * 1024 * 1024 // 50MB
)
@Provides
@Singleton
fun provideUserApi(
@BaseUrl baseUrl: String,
okHttpClient: OkHttpClient
): UserApi = Networking.createService(
baseUrl,
okHttpClient,
UserApi::class.java
)
@Provides
@Singleton
@BaseUrl
fun provideBaseUrl(): String = BuildConfig.BASE_URL
@Provides
@Singleton
@AccessTokenInfo
fun provideAccessToken(
userPreferences: UserPreferences
): ResultFetcherBlocking<String> = object : ResultFetcherBlocking<String> {
override fun fetch(): String? = runBlocking { userPreferences.getAccessToken() }
}
...
}
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class BaseUrl
Now it’s time for the most involved section concerning the ui package. The core idea here is to keep all the code related to a single-screen UI in a single package.
- MainActivity — This is the single activity in the App. It mounts the WimmApp compose which creates Navigation routes.
- MainViewModel — Each UI screen component has its own viewmodel. MainViewModel helps in handling forcedLogout, and storing deeplink data.
- WimmApp — It loads the AppTheme (material3 is used in the project), mounts the NavGraph, centralized Loader UI, and content sharing handler.
MainActivity — Initializes the App UI and receives the deeplink intent.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
val viewModel by viewModels<MainViewModel>()
companion object {
const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(
ContextCompat.getColor(this, R.color.immersive_sys_ui)
)
)
super.onCreate(savedInstanceState)
setContent {
WimmApp(
navigator = viewModel.navigator,
loader = viewModel.loader,
messenger = viewModel.messenger,
sharer = viewModel.sharer,
finish = { finish() }
)
}
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
intent?.run {
when (action) {
Intent.ACTION_SEND -> {
if (type == Constants.MIME_TYPE_TEXT_PLAIN) {
val text = getStringExtra(Intent.EXTRA_TEXT)
viewModel.storeMotivation(text)
}
}
Intent.ACTION_VIEW -> {
viewModel.handleDeepLink(data)
}
}
}
}
}
WimmApp — initializes the navigation and loads other central UI components like Snackbar
@Composable
fun WimmApp(
navigator: Navigator,
loader: Loader,
sharer: Sharer<Content>,
messenger: Messenger,
finish: () -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
AppTheme {
val navController = rememberNavController()
Scaffold(
modifier = Modifier.imePadding(),
snackbarHost = { AppSnackbar(snackbarHostState, messenger) },
// it will render bottom bar only in the home route
bottomBar = { HomeBottomBar(navController = navController) },
) { innerPaddingModifier ->
NavGraph(
navController = navController,
modifier = Modifier.padding(innerPaddingModifier),
navigator = navigator,
finish = finish
)
Loading(
modifier = Modifier
.padding(innerPaddingModifier)
.fillMaxWidth(),
loader = loader
)
ContentShareHandler(sharer)
}
}
}
ui/navigation
This package defines the handling of navigation inside the App.
- Destination — This class defines the routes and deeplinks
object Destination {
data object Splash : Screen("splash")
data object Login : Screen("login")
data object Onboarding : Screen("onboarding")
data object ServerUnreachable : Screen("server-unreachable")
data object ExploreMentors : Screen("explore-mentor")
data object Mentor : DynamicScreen("mentor", "mentorId")
data object Topic : DynamicScreen("topic", "topicId")
data object YouTube : DynamicScreen("youtube", "contentId")
data object Content : DynamicScreen("content", "contentId")
data object Home : Screen("home") {
data object Feed : Screen("home/feed")
data object Mentors : Screen("home/mentors")
data object MyBox : Screen("home/my_box")
data object Search : DynamicScreen("home/search", "searchMode")
data object Profile : DynamicScreen("home/profile", "profileTab")
}
abstract class Screen(baseRoute: String) {
companion object {
const val BASE_DEEPLINK_URL = "app://wimm"
}
open val route = baseRoute
open val deeplink = "${BASE_DEEPLINK_URL}/$baseRoute"
}
abstract class DynamicScreen(
private val baseRoute: String,
val routeArgName: String,
) : Screen(baseRoute) {
val navArguments = listOf(navArgument(routeArgName) { type = NavType.StringType })
override val route = "$baseRoute/{$routeArgName}"
override val deeplink = "${BASE_DEEPLINK_URL}/$baseRoute/{$routeArgName}"
fun dynamicRoute(param: String) = "$baseRoute/$param"
fun dynamicDeeplink(param: String) = "$BASE_DEEPLINK_URL/$baseRoute/${param}"
}
}
2. Navigator — This class add the events for rest of the App to trigger navigation.
@ActivityRetainedScoped
class Navigator @Inject constructor() {
private val _navigate =
MutableSharedFlow<NavTarget>(extraBufferCapacity = 1)
private val _deeplink =
MutableSharedFlow<NavDeeplink>(extraBufferCapacity = 1)
private val _back =
MutableSharedFlow<NavBack>(extraBufferCapacity = 1)
private val _end =
MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
val navigate = _navigate.asSharedFlow()
val deeplink = _deeplink.asSharedFlow()
val back = _back.asSharedFlow()
val end = _end.asSharedFlow()
fun navigateTo(route: String, popBackstack: Boolean = false) {
_navigate.tryEmit(NavTarget(route, popBackstack))
}
fun navigateTo(uri: Uri, popBackstack: Boolean = false) {
_deeplink.tryEmit(NavDeeplink(uri, popBackstack))
}
fun navigateBack(recreate: Boolean = false) {
_back.tryEmit(NavBack(recreate))
}
fun finish() {
_end.tryEmit(true)
}
data class NavTarget(val route: String, val popBackstack: Boolean = false)
data class NavDeeplink(val uri: Uri, val popBackstack: Boolean = false)
data class NavBack(val recreate: Boolean)
}
3. NavHandler — It adds listeners to the events from Navigator and processes those events accordingly. The main feature includes not duplicating the stack if the screen is already in view.
@Composable
internal fun NavHandler(
navController: NavController,
navigator: Navigator,
finish: () -> Unit
) {
LaunchedEffect("navigation") {
navigator.navigate.onEach {
if (it.popBackstack) navController.popBackStack()
navController.navigate(it.route)
}.launchIn(this)
navigator.deeplink.onEach {
if (navController.graph.hasDeepLink(it.uri)) {
val reached = navController.currentDestination?.hasDeepLink(it.uri) ?: false
if (!reached) {
if (it.popBackstack) navController.popBackStack()
navController.navigate(it.uri)
}
}
}.launchIn(this)
navigator.back.onEach {
if (it.recreate) {
navController.previousBackStackEntry?.destination?.route?.let { route ->
navController.navigate(route) {
popUpTo(route) { inclusive = true }
}
}
} else {
navController.navigateUp()
}
}.launchIn(this)
navigator.end.onEach {
finish()
}.launchIn(this)
}
}
4. NavGraph — This is the place to register the routes. hiltViewModel provides the instance of viewmodels. Deeplink has this structureapp://wimm/home/my_box
.
@Composable
fun NavGraph(
modifier: Modifier = Modifier,
navController: NavHostController,
startDestination: String = Destination.Splash.route,
navigator: Navigator,
finish: () -> Unit = {},
) {
NavHandler(
navController = navController,
navigator = navigator,
finish = finish
)
NavHost(
navController = navController,
startDestination = startDestination,
) {
// Splash
composable(Destination.Splash.route) {
val viewModel: SplashViewModel = hiltViewModel(key = SplashViewModel.TAG)
Splash(modifier, viewModel)
}
...
// Home
navigation(
route = Destination.Home.route,
startDestination = Destination.Home.Feed.route
) {
...
// Home.Profile
composable(
route = Destination.Home.Profile.route,
arguments = Destination.Home.Profile.navArguments,
deepLinks = listOf(navDeepLink {
uriPattern = Destination.Home.Profile.deeplink
}),
) {
val profileViewModel: ProfileViewModel = hiltViewModel(key = ProfileViewModel.TAG)
val moodsViewModel: MoodsViewModel = hiltViewModel(key = MoodsViewModel.TAG)
val journalsViewModel: JournalsViewModel =
hiltViewModel(key = JournalsViewModel.TAG)
Profile(modifier, profileViewModel, moodsViewModel, journalsViewModel)
}
}
...
}
}
Screen Flows
ui/theme
- Color — App theme colors
- Typography — Defines custom fonts for different UI
- Theme — Provides the MaterialTheme with support of dark and light mode
ui/common
It contains the reusable compose ui components that can be used inside any other ui.
I prefer to create wrappers around the external libraries so that in the future it is easier to replace them if needed.
common
├── appbar
│ ├── LogoAppBar.kt
│ ├── LogoUpAppBar.kt
│ └── NavAppBar.kt
├── browser
│ ├── ChromeTabHelper.kt
│ └── ContentBrowser.kt
├── image
│ ├── Lottie.kt
│ ├── NetworkImage.kt
│ └── OutlinedAvatar.kt
├── list
│ └── InfiniteList.kt
├── preview
│ └── Provider.kt
├── progress
│ ├── Loader.kt
│ └── Loading.kt
├── share
│ ├── ContentShareHandler.kt
│ ├── Payload.kt
│ └── Sharer.kt
├── snackbar
│ ├── AppSnackbar.kt
│ ├── Message.kt
│ ├── MessageHandler.kt
│ └── Messenger.kt
├── text
│ └── AutoSizeText.kt
└── utils
├── Lerp.kt
└── Scrim.kt
Feature UI
Each feature ui will have compose file and its viewmodel. Example: ui/mentor
@Composable
fun Mentor(modifier: Modifier, viewModel: MentorViewModel) {
val mentor = viewModel.mentor.collectAsStateWithLifecycle().value
?: return LoadingPlaceholder(loading = true)
val contents = viewModel.contents.collectAsStateWithLifecycle().value
MentorView(
modifier = modifier.fillMaxSize(),
mentor = mentor,
contents = contents,
selectContent = { viewModel.selectContent(it) },
upPress = { viewModel.upPress() },
)
}
...
Here MentorView is separately defined so that we can create previews easily.
In the compose file it is important to provide the previews. To render the previews based on data, we use PreviewParameterProvider which is added inside ui/common/preview/Provider.kt
class ContentPreviewParameterProvider : PreviewParameterProvider<Content> {
override val values = sequenceOf(
Content(
id = "5d3abb01d7e737505f6283a8",
title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt",
subtitle = "Lorem",
thumbnail = "https://i3.ytimg.com/vi/hVLM0BSqx5o/hqdefault.jpg",
extra = "https://www.youtube.com/watch?v=9TCMHVmNc5w",
creator = User(
id = "6569996fb0013db6a35f2f84",
name = "Janishar Ali",
email = null,
profilePicUrl = "https://avatars.githubusercontent.com/u/11065002?v=4",
),
views = 54,
category = Content.Category.YOUTUBE,
liked = false,
likes = 12456356,
shares = 974524
)
)
}
@Preview(showBackground = true)
@Composable
private fun MentorContentPreview(
@PreviewParameter(ContentPreviewParameterProvider::class, limit = 1) content: Content
) {
AppTheme {
MentorContent(
content = content,
selectContent = {}
)
}
}
ViewModel
This layer separates the logic from the UI. All the states and their mutation functions for a UI lives inside the viewmodel. The project defines the BaseViewModel which any other viewmodel should extends.
BaseViewModel helps in reducing the redundant code for viewmodel.
- launchNetwork. It helps in launching coroutine, showing loader ui, and displaying error messages to the user when making an API call.
- Loader — Sends event to show loading ui defined inside ui/common/progress
- Messenger — Send event to show snackbar defined inside ui/common/snackbar
- Navigator — Send event to show screens defined inside ui/navigation
abstract class BaseViewModel(
private val loader: Loader,
private val messenger: Messenger,
private val navigator: Navigator
) : ViewModel() {
companion object {
const val TAG = "BaseViewModel"
}
protected fun launchNetwork(
silent: Boolean = false,
error: (ApiErrorResponse) -> Unit = {},
block: suspend CoroutineScope.() -> Unit
) {
if (!silent) {
loader.start()
viewModelScope.launch {
try {
block()
} catch (e: Throwable) {
if (e is CancellationException) return@launch
val errorResponse = e.toApiErrorResponse()
handleNetworkError(errorResponse)
error(errorResponse)
Logger.d(TAG, e)
Logger.record(e)
} finally {
loader.stop()
}
}
} else {
viewModelScope.launch {
try {
block()
} catch (e: Throwable) {
if (e is CancellationException) return@launch
val errorResponse = e.toApiErrorResponse()
error(errorResponse)
Logger.d(TAG, e)
Logger.record(e)
}
}
}
}
private fun handleNetworkError(err: ApiErrorResponse) {
when (err.status) {
ApiErrorResponse.Status.HTTP_BAD_GATEWAY,
ApiErrorResponse.Status.REMOTE_CONNECTION_ERROR -> {
messenger.deliverRes(Message.error(R.string.server_connection_error))
navigator.navigateTo(Destination.ServerUnreachable.route)
}
ApiErrorResponse.Status.NETWORK_CONNECTION_ERROR ->
messenger.deliverRes(Message.error(R.string.no_internet_connection))
ApiErrorResponse.Status.HTTP_UNAUTHORIZED ->
messenger.deliver(Message.error(err.message))
ApiErrorResponse.Status.HTTP_FORBIDDEN ->
messenger.deliverRes(Message.error(R.string.permission_not_available))
ApiErrorResponse.Status.HTTP_BAD_REQUEST ->
err.message.let { messenger.deliver(Message.error(err.message)) }
ApiErrorResponse.Status.HTTP_NOT_FOUND ->
err.message.let { messenger.deliver(Message.error(err.message)) }
ApiErrorResponse.Status.HTTP_INTERNAL_ERROR ->
messenger.deliverRes(Message.error(R.string.network_internal_error))
ApiErrorResponse.Status.HTTP_UNAVAILABLE -> {
messenger.deliverRes(Message.error(R.string.network_server_not_available))
navigator.navigateTo(Destination.ServerUnreachable.route)
}
ApiErrorResponse.Status.UNKNOWN ->
messenger.deliverRes(Message.error(R.string.something_went_wrong))
}
}
}
Example: MentorViewModel
@HiltViewModel
class MentorViewModel @Inject constructor(
loader: Loader,
messenger: Messenger,
savedStateHandle: SavedStateHandle,
private val navigator: Navigator,
private val mentorRepository: MentorRepository,
private val contentBrowser: ContentBrowser
) : BaseViewModel(loader, messenger, navigator) {
companion object {
const val TAG = "MentorsViewModel"
}
init {
val mentorId: String = savedStateHandle.get<String>(Destination.Mentor.routeArgName)!!
loadMentor(mentorId)
loadMentorContents(mentorId)
}
private val _mentor = MutableStateFlow<Mentor?>(null)
private val _contents = MutableStateFlow<List<Content>?>(null)
val mentor = _mentor.asStateFlow()
val contents = _contents.asStateFlow()
fun upPress() {
navigator.navigateBack()
}
fun selectContent(content: Content) {
contentBrowser.show(content)
}
private fun loadMentor(mentorId: String) {
launchNetwork {
mentorRepository.fetchMentorDetails(mentorId)
.collect {
_mentor.value = it
}
}
}
private fun loadMentorContents(mentorId: String) {
launchNetwork {
mentorRepository.fetchMentorContents(mentorId, 1, 100)
.collect {
_contents.value = it
}
}
}
}
Analytics is very important for an App going into production. But it is often the case where we might have to switch the analytics provider or use multiple providers at once. For example, Moengage and Firebase together. To handle these cases we provide abstraction to the analytics client.
analytics
├── AnalyticsEvent.kt
├── AnalyticsEventParam.kt
├── FirebaseTrackingClient.kt
├── Tracker.kt
└── TrackingClient.kt
TrackingClient defines the interface for tracking analytics events, thus we are free to choose its implementation.
interface TrackingClient {
fun track(event: AnalyticsEvent, vararg parameters: AnalyticsEventParam<*>)
fun defaultProperties(): List<AnalyticsEventParam<*>>
}
FirebaseTrackingClient implements the TrackingClient
class FirebaseTrackingClient(private val analytics: FirebaseAnalytics) : TrackingClient {
override fun track(event: AnalyticsEvent, vararg parameters: AnalyticsEventParam<*>) {
val newParameters = mutableListOf(*parameters).apply {
addAll(defaultProperties())
}
val bundle = Bundle().apply {
if (newParameters.isNotEmpty()) {
for (param in parameters) {
when (param.value) {
is String -> putString(param.name, param.value)
is Int -> putInt(param.name, param.value)
is Long -> putLong(param.name, param.value)
is Double -> putDouble(param.name, param.value)
}
}
}
}
analytics.logEvent(event.name, bundle)
}
override fun defaultProperties(): List<AnalyticsEventParam<*>> {
return ArrayList()
}
}
We track the events using the Tracker class. We can configure this class to take multiple TrackingClients as well. Its instance is provided inside the App using Hilt as a singleton.
@Singleton
class Tracker @Inject constructor(private val client: TrackingClient) {
fun trackAppOpen() =
client.track(AnalyticsEvent(AnalyticsEvent.Name.APP_OPEN))
...
}
Notification is a very important tool for user engagement outside the App. It is quite messy to handle and send Notifications using Android Notification Manager. The project solves this by creating a framework for sending notifications inside fcm package.
fcm
├── NotificationHelper.kt
├── NotificationService.kt
├── core
│ ├── Defaults.kt
│ ├── Notification.kt
│ ├── Payload.kt
│ ├── PendingIntents.kt
│ └── Provider.kt
└── notifications
├── ContentNotification.kt
├── ImageNotification.kt
├── MoodNotification.kt
└── TextNotification.kt
fcm/core
- Notification — Defines the interface for sending notifications
- Defaults — Class to hold default values for any notification
- PendingIntents — Helper class that provides pending intents for launching a particular screen via deeplink, when the user clicks the notification.
- Payload — Stores the notification information
- Provider — Helper class that has defaults and common functions
Example: ContentNotification
class ContentNotification(
private val provider: Provider,
private val payload: Payload,
private val imageLoader: ImageLoader
) : Notification {
override suspend fun send() {
try {
if (payload.extra == null) return
val openPendingIntent = provider.pendingIntents.contentView(payload.extra)
val notificationBuilder = provider
.basicNotificationBuilder()
.setTicker(payload.ticker)
.setContentTitle(payload.title)
.setContentText(payload.subtitle)
.setContentIntent(openPendingIntent)
.addAction(provider.buildOpenAction(openPendingIntent))
if (payload.thumbnail != null && payload.thumbnail.isValidUrl()) {
val request = ImageRequest.Builder(provider.context)
.data(payload.thumbnail)
.allowHardware(true)
.build()
val result = imageLoader.execute(request)
if (result !is SuccessResult) return
val bitmap = (result.drawable as BitmapDrawable).bitmap
val style = NotificationCompat.BigPictureStyle().bigPicture(bitmap)
notificationBuilder.setStyle(style)
}
val notificationManager =
provider.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(
Notification.Type.CONTENT.unique(),
notificationBuilder.build()
)
} catch (e: Exception) {
Logger.record(e)
}
}
}
core/NotificationHelper — It abstracts the process of creating a Notification object and sending it. It makes the usage simpler since we just have to pass the Payload and the rest work is handled inside this helper.
core/NotificationService — It creates a service for firebase to receive notifications and the fcm token.
Notification when received by the NotificationService or triggered from the App is scheduled using a Worker.
val payload = Payload(
type = Notification.Type.MOOD,
ticker = context.getString(R.string.how_your_feeling),
title = context.getString(R.string.notification_mood_title),
subtitle = context.getString(R.string.notification_mood_text)
)
appWorkManager.addNotificationWork(payload)
Worker is the goto approach now in Android for executing background tasks in cases where the App may not be alive. The project uses workers to display notifications and sync mood and journal entries. In addition to the worker, the app also uses an alarm to show daily mood notifications.
work
├── AppAlarmManager.kt
├── AppWorkManager.kt
├── receiver
│ └── DailyMoodAlarmReceiver.kt
└── worker
├── DailyMoodAlarmWorker.kt
├── MoodAndJournalSyncWorker.kt
└── NotificationWorker.kt
AlarmManager is accompanied by DailyMoodAlarmReceiver — BroadcastReceier which is called when the Alarm is triggered.
For Notification and Alarm, we have to give the App appropriate permissions.
utils provide the common functions that are needed throughout the App.
.
├── common
│ ├── CalendarUtils.kt
│ ├── Constants.kt
│ ├── ContentUtils.kt
│ ├── Extensions.kt
│ ├── FileUtils.kt
│ ├── Formatter.kt
│ ├── PermissionUtils.kt
│ ├── ResultCallback.kt
│ ├── ResultFetcher.kt
│ ├── SystemUtils.kt
│ ├── TimeUtils.kt
│ └── UrlUtils.kt
├── config
│ └── RemoteKey.kt
├── coroutine
│ └── Dispatcher.kt
├── display
│ ├── FontUtils.kt
│ └── Toaster.kt
├── log
│ └── Logger.kt
└── share
└── ShareUtils.kt
init provide a way to initialize the external libraries consistantly.
init
├── CoilInit.kt
├── FirebaseInit.kt
├── Initializer.kt
├── MetricInit.kt
└── WorkInit.kt
interface Initializer {
suspend fun init()
}
@Singleton
class WorkInit @Inject constructor(
private val appWorkManager: AppWorkManager,
private val alarmManager: AppAlarmManager,
private val appMetricPreferences: AppMetricPreferences
) : Initializer {
override suspend fun init() {
scheduleWorks()
}
private suspend fun scheduleWorks() {
val time = appMetricPreferences.getDailyRecordAlarmTime()
alarmManager.setDailyMoodAlarm(time.first, time.second, time.third)
appWorkManager.addMoodAndJournalSyncWork()
}
}
WimmApplication — Is called when the app launches
@HiltAndroidApp
class WimmApplication : Application(), Configuration.Provider {
companion object {
const val TAG = "WimmApplication"
}
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var tracker: Tracker
@Inject
lateinit var firebaseInit: FirebaseInit
@Inject
lateinit var workInit: WorkInit
@Inject
lateinit var metricInit: MetricInit
@Inject
lateinit var coilInit: CoilInit
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
super.onCreate()
tracker.trackAppOpen()
GlobalScope.launch {
metricInit.init()
workInit.init()
firebaseInit.init()
coilInit.init()
}
}
}
Finally, the project used Version Catalog defined at grade/libs.versions.toml
Now, you can explore the repo in detail and I am sure you will find it a good time-spending exercise.
This is it, I will be writing in detail on individual topics like Hilt, WorkManger, etc in future articles.
Thanks for reading this article. Be sure to share this article if you found it helpful. It would let others get this article and spread the knowledge. Also, putting a clap will motivate me to write more such articles
Find more about me on janisharali.com