Android MVVM Architecture for A Production Ready App

Janishar Ali
17 min readJul 8, 2024

--

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.

  1. 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.
  2. data — It holds the code to handle network, database, datastore, and repositories.
  3. di — Dependency provider modules
  4. utils — Common utility functions
  5. analytics — Events to be sent for the analytics
  6. fcm — Notification system
  7. work — Workers and Alarm Manager
  8. 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

  1. entity — Contains the models for the database tables
  2. dao — Contains the queries for the database tables
  3. Converter — Defines the model to table type converters
  4. 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:

  1. request: contains the additional API models
  2. [Group]Api: Retrofit interface for the API calls
  3. 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

  1. ImageHeaderInterceptor: Adds access token header to fetch protected images
  2. 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.
  3. 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.
  4. NetworkInterceptor: It makes the network call only when the App is connected to the internet
  5. 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

  1. extensions: Add extension function to Throwable for converting it into ApiErrorResponse
  2. ForcedLogout: It broadcasts a logout event through SharedFlow
  3. NetworkHelper: Check internet connectivity
  4. 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

  1. module — It contains the Module classes to provide the instances
  2. 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.

  1. MainActivity — This is the single activity in the App. It mounts the WimmApp compose which creates Navigation routes.
  2. MainViewModel — Each UI screen component has its own viewmodel. MainViewModel helps in handling forcedLogout, and storing deeplink data.
  3. 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.

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

  1. Color — App theme colors
  2. Typography — Defines custom fonts for different UI
  3. 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.

  1. launchNetwork. It helps in launching coroutine, showing loader ui, and displaying error messages to the user when making an API call.
  2. Loader — Sends event to show loading ui defined inside ui/common/progress
  3. Messenger — Send event to show snackbar defined inside ui/common/snackbar
  4. 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

  1. Notification — Defines the interface for sending notifications
  2. Defaults — Class to hold default values for any notification
  3. PendingIntents — Helper class that provides pending intents for launching a particular screen via deeplink, when the user clicks the notification.
  4. Payload — Stores the notification information
  5. 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

Let’s become friends on Twitter, Linkedin, and Github

--

--

Janishar Ali

Coder 🐱‍💻 Founder 🧑‍🚀 Teacher 👨‍🎨 Learner 📚 https://janisharali.com