This guide will walk you through building a small application step-by-step, focusing on integrating several powerful tools and concepts essential for modern Android development.
What We’ll Cover:
- Jetpack Compose: Building the UI declaratively.
- NoSQL Database (Firestore): Storing and retrieving data in the cloud.
- WorkManager: Running reliable background tasks.
- Build Flavors: Creating different versions of the app (e.g., dev vs. prod).
- Proguard/R8: Shrinking and obfuscating your code for release.
- Firebase App Distribution: Distributing test builds easily.
- CI/CD (GitHub Actions): Automating the build and distribution process.
The Goal: Build a “Task Reporter” app. Users can add simple task descriptions. These tasks are saved to Firestore. A background worker will periodically “report” (log a message or update a counter in Firestore) that the app is active. We’ll have dev
and prod
flavors pointing to different Firestore collections/data and distribute the dev
build for testing.
Prerequisites:
- Android Studio (latest stable version recommended).
- Basic understanding of Kotlin and Android development fundamentals.
- Familiarity with Jetpack Compose basics (Composable functions, State).
- A Google account to use Firebase.
- A GitHub account (for CI/CD).
Let’s get started!
Step 0: Project Setup
- Create New Project: Open Android Studio -> New Project -> Empty Activity (choose Compose).
- Name:
AdvancedConceptsApp
(or your choice). - Package Name: Your preferred package name (e.g.,
com.yourcompany.advancedconceptsapp
). - Language: Kotlin.
- Minimum SDK: API 24 or higher.
- Build Configuration Language: Kotlin DSL (
build.gradle.kts
). - Click Finish.
Step 1: Firebase Integration (Firestore & App Distribution)
- Connect to Firebase: In Android Studio: Tools -> Firebase.
- In the Assistant panel, find Firestore. Click “Get Started with Cloud Firestore”. Click “Connect to Firebase”. Follow the prompts to create a new Firebase project or connect to an existing one.
- Click “Add Cloud Firestore to your app”. Accept changes to your
build.gradle.kts
(orbuild.gradle
) files. This adds the necessary dependencies. - Go back to the Firebase Assistant, find App Distribution. Click “Get Started”. Add the App Distribution Gradle plugin by clicking the button. Accept changes.
- Enable Services in Firebase Console:
- Go to the Firebase Console and select your project.
- Enable Firestore Database (start in Test mode).
- In the left menu, go to Build -> Firestore Database. Click “Create database”.
- Start in Test mode for easier initial development (we’ll secure it later if needed). Choose a location close to your users. Click “Enable”.
- Ensure App Distribution is accessible (no setup needed here yet).
- Download Initial
google-services.json
:- In Firebase Console -> Project Settings (gear icon) -> Your apps.
- Ensure your Android app (using the base package name like
com.yourcompany.advancedconceptsapp
) is registered. If not, add it. - Download the
google-services.json
file. - Switch Android Studio to the Project view and place the file inside the
app/
directory. - Note: We will likely replace this file in Step 4 after configuring build flavors.
Step 2: Building the Basic UI with Compose
Let’s create a simple UI to add and display tasks.
- Dependencies: Ensure necessary dependencies for Compose, ViewModel, Firestore, and WorkManager are in
app/build.gradle.kts
.
app/build.gradle.ktsdependencies { // Core & Lifecycle & Activity implementation("androidx.core:core-ktx:1.13.1") // Use latest versions implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1") implementation("androidx.activity:activity-compose:1.9.0") // Compose implementation(platform("androidx.compose:compose-bom:2024.04.01")) // Check latest BOM implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1") // Firebase implementation(platform("com.google.firebase:firebase-bom:33.0.0")) // Check latest BOM implementation("com.google.firebase:firebase-firestore-ktx") // WorkManager implementation("androidx.work:work-runtime-ktx:2.9.0") // Check latest version }
Sync Gradle files.
- Task Data Class: Create
data/Task.kt
.
data/Task.ktpackage com.yourcompany.advancedconceptsapp.data import com.google.firebase.firestore.DocumentId data class Task( @DocumentId val id: String = "", val description: String = "", val timestamp: Long = System.currentTimeMillis() ) { constructor() : this("", "", 0L) // Firestore requires a no-arg constructor }
- ViewModel: Create
ui/TaskViewModel.kt
. (We’ll update the collection name later).
ui/TaskViewModel.ktpackage com.yourcompany.advancedconceptsapp.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.firestore.ktx.firestore import com.google.firebase.firestore.ktx.toObjects import com.google.firebase.ktx.Firebase import com.yourcompany.advancedconceptsapp.data.Task // Import BuildConfig later when needed import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await // Temporary placeholder - will be replaced by BuildConfig field const val TEMPORARY_TASKS_COLLECTION = "tasks" class TaskViewModel : ViewModel() { private val db = Firebase.firestore // Use temporary constant for now private val tasksCollection = db.collection(TEMPORARY_TASKS_COLLECTION) private val _tasks = MutableStateFlow<List<Task>>(emptyList()) val tasks: StateFlow<List<Task>> = _tasks private val _error = MutableStateFlow<String?>(null) val error: StateFlow<String?> = _error init { loadTasks() } fun loadTasks() { viewModelScope.launch { try { tasksCollection.orderBy("timestamp", com.google.firebase.firestore.Query.Direction.DESCENDING) .addSnapshotListener { snapshots, e -> if (e != null) { _error.value = "Error listening: ${e.localizedMessage}" return@addSnapshotListener } _tasks.value = snapshots?.toObjects<Task>() ?: emptyList() _error.value = null } } catch (e: Exception) { _error.value = "Error loading: ${e.localizedMessage}" } } } fun addTask(description: String) { if (description.isBlank()) { _error.value = "Task description cannot be empty." return } viewModelScope.launch { try { val task = Task(description = description, timestamp = System.currentTimeMillis()) tasksCollection.add(task).await() _error.value = null } catch (e: Exception) { _error.value = "Error adding: ${e.localizedMessage}" } } } }
- Main Screen Composable: Create
ui/TaskScreen.kt
.
ui/TaskScreen.ktpackage com.yourcompany.advancedconceptsapp.ui // Imports: androidx.compose.*, androidx.lifecycle.viewmodel.compose.viewModel, java.text.SimpleDateFormat, etc. import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yourcompany.advancedconceptsapp.data.Task import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import androidx.compose.ui.res.stringResource import com.yourcompany.advancedconceptsapp.R // Import R class @OptIn(ExperimentalMaterial3Api::class) // For TopAppBar @Composable fun TaskScreen(taskViewModel: TaskViewModel = viewModel()) { val tasks by taskViewModel.tasks.collectAsState() val errorMessage by taskViewModel.error.collectAsState() var taskDescription by remember { mutableStateOf("") } Scaffold( topBar = { TopAppBar(title = { Text(stringResource(id = R.string.app_name)) }) // Use resource for flavor changes } ) { paddingValues -> Column(modifier = Modifier.padding(paddingValues).padding(16.dp).fillMaxSize()) { // Input Row Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = taskDescription, onValueChange = { taskDescription = it }, label = { Text("New Task Description") }, modifier = Modifier.weight(1f), singleLine = true ) Spacer(modifier = Modifier.width(8.dp)) Button(onClick = { taskViewModel.addTask(taskDescription) taskDescription = "" }) { Text("Add") } } Spacer(modifier = Modifier.height(16.dp)) // Error Message errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) } // Task List if (tasks.isEmpty() && errorMessage == null) { Text("No tasks yet. Add one!") } else { LazyColumn(modifier = Modifier.weight(1f)) { items(tasks, key = { it.id }) { task -> TaskItem(task) Divider() } } } } } } @Composable fun TaskItem(task: Task) { val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(task.description, style = MaterialTheme.typography.bodyLarge) Text("Added: ${dateFormat.format(Date(task.timestamp))}", style = MaterialTheme.typography.bodySmall) } } }
- Update
MainActivity.kt
: Set the content toTaskScreen
.
MainActivity.ktpackage com.yourcompany.advancedconceptsapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.yourcompany.advancedconceptsapp.ui.TaskScreen import com.yourcompany.advancedconceptsapp.ui.theme.AdvancedConceptsAppTheme // Imports for WorkManager scheduling will be added in Step 3 class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AdvancedConceptsAppTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { TaskScreen() } } } // TODO: Schedule WorkManager job in Step 3 } }
- Run the App: Test basic functionality. Tasks should appear and persist in Firestore’s `tasks` collection (initially).
Step 3: WorkManager Implementation
Create a background worker for periodic reporting.
- Create the Worker: Create
worker/ReportingWorker.kt
. (Collection name will be updated later).
worker/ReportingWorker.ktpackage com.yourcompany.advancedconceptsapp.worker import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.google.firebase.firestore.ktx.firestore import com.google.firebase.ktx.Firebase // Import BuildConfig later when needed import kotlinx.coroutines.tasks.await // Temporary placeholder - will be replaced by BuildConfig field const val TEMPORARY_USAGE_LOG_COLLECTION = "usage_logs" class ReportingWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { companion object { const val TAG = "ReportingWorker" } private val db = Firebase.firestore override suspend fun doWork(): Result { Log.d(TAG, "Worker started: Reporting usage.") return try { val logEntry = hashMapOf( "timestamp" to System.currentTimeMillis(), "message" to "App usage report.", "worker_run_id" to id.toString() ) // Use temporary constant for now db.collection(TEMPORARY_USAGE_LOG_COLLECTION).add(logEntry).await() Log.d(TAG, "Worker finished successfully.") Result.success() } catch (e: Exception) { Log.e(TAG, "Worker failed", e) Result.failure() } } }
- Schedule the Worker: Update
MainActivity.kt
‘sonCreate
method.
MainActivity.kt additions// Add these imports to MainActivity.kt import android.content.Context import android.util.Log import androidx.work.* import com.yourcompany.advancedconceptsapp.worker.ReportingWorker import java.util.concurrent.TimeUnit // Inside MainActivity class, after setContent { ... } block in onCreate override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { // ... existing code ... } // Schedule the worker schedulePeriodicUsageReport(this) } // Add this function to MainActivity class private fun schedulePeriodicUsageReport(context: Context) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val reportingWorkRequest = PeriodicWorkRequestBuilder<ReportingWorker>( 1, TimeUnit.HOURS // ~ every hour ) .setConstraints(constraints) .addTag(ReportingWorker.TAG) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( ReportingWorker.TAG, ExistingPeriodicWorkPolicy.KEEP, reportingWorkRequest ) Log.d("MainActivity", "Periodic reporting work scheduled.") }
- Test WorkManager:
- Run the app. Check Logcat for messages from
ReportingWorker
andMainActivity
about scheduling. - WorkManager tasks don’t run immediately, especially periodic ones. You can use ADB commands to force execution for testing:
- Find your package name:
com.yourcompany.advancedconceptsapp
- Force run jobs:
adb shell cmd jobscheduler run -f com.yourcompany.advancedconceptsapp 999
(The 999 is usually sufficient, it’s a job ID). - Or use Android Studio’s App Inspection tab -> Background Task Inspector to view and trigger workers.
- Find your package name:
- Check your Firestore Console for the
usage_logs
collection.
- Run the app. Check Logcat for messages from
Step 4: Build Flavors (dev vs. prod)
Create dev
and prod
flavors for different environments.
- Configure
app/build.gradle.kts
:
app/build.gradle.ktsandroid { // ... namespace, compileSdk, defaultConfig ... // ****** Enable BuildConfig generation ****** buildFeatures { buildConfig = true } // ******************************************* flavorDimensions += "environment" productFlavors { create("dev") { dimension = "environment" applicationIdSuffix = ".dev" // CRITICAL: Changes package name for dev builds versionNameSuffix = "-dev" resValue("string", "app_name", "Task Reporter (Dev)") buildConfigField("String", "TASKS_COLLECTION", ""tasks_dev"") buildConfigField("String", "USAGE_LOG_COLLECTION", ""usage_logs_dev"") } create("prod") { dimension = "environment" resValue("string", "app_name", "Task Reporter") buildConfigField("String", "TASKS_COLLECTION", ""tasks"") buildConfigField("String", "USAGE_LOG_COLLECTION", ""usage_logs"") } } // ... buildTypes, compileOptions, etc ... }
Sync Gradle files.
Important: We addedapplicationIdSuffix = ".dev"
. This means the actual package name for your development builds will become something likecom.yourcompany.advancedconceptsapp.dev
. This requires an update to your Firebase project setup, explained next. Also note thebuildFeatures { buildConfig = true }
block which is required to usebuildConfigField
. Handling Firebase for Suffixed Application IDs
Because the `dev` flavor now has a different application ID (`…advancedconceptsapp.dev`), the original `google-services.json` file (downloaded in Step 1) will not work for `dev` builds, causing a “No matching client found” error during build.
You must add this new Application ID to your Firebase project:
- Go to Firebase Console: Open your project settings (gear icon).
- Your apps: Scroll down to the “Your apps” card.
- Add app: Click “Add app” and select the Android icon (</>).
- Register dev app:
- Package name: Enter the exact suffixed ID:
com.yourcompany.advancedconceptsapp.dev
(replace `com.yourcompany.advancedconceptsapp` with your actual base package name). - Nickname (Optional): “Task Reporter Dev”.
- SHA-1 (Optional but Recommended): Add the debug SHA-1 key from `./gradlew signingReport`.
- Package name: Enter the exact suffixed ID:
- Register and Download: Click “Register app”. Crucially, download the new
google-services.json
file offered. This file now contains configurations for BOTH your base ID and the `.dev` suffixed ID. - Replace File: In Android Studio (Project view), delete the old
google-services.json
from theapp/
directory and replace it with the **newly downloaded** one. - Skip SDK steps: You can skip the remaining steps in the Firebase console for adding the SDK.
- Clean & Rebuild: Back in Android Studio, perform a Build -> Clean Project and then Build -> Rebuild Project.
Now your project is correctly configured in Firebase for both `dev` (with the `.dev` suffix) and `prod` (base package name) variants using a single `google-services.json`.- Create Flavor-Specific Source Sets:
- Switch to Project view in Android Studio.
- Right-click on
app/src
-> New -> Directory. Name itdev
. - Inside
dev
, createres/values/
directories. - Right-click on
app/src
-> New -> Directory. Name itprod
. - Inside
prod
, createres/values/
directories. - (Optional but good practice): You can now move the default
app_name
string definition fromapp/src/main/res/values/strings.xml
into bothapp/src/dev/res/values/strings.xml
andapp/src/prod/res/values/strings.xml
. Or, you can rely solely on theresValue
definitions in Gradle (as done above). UsingresValue
is often simpler for single strings likeapp_name
. If you had many different resources (layouts, drawables), you’d put them in the respectivedev/res
orprod/res
folders.
- Use Build Config Fields in Code:
- Update
TaskViewModel.kt
andReportingWorker.kt
to useBuildConfig
instead of temporary constants.
- Update
TaskViewModel.kt change
// Add this import import com.yourcompany.advancedconceptsapp.BuildConfig // Replace the temporary constant usage // const val TEMPORARY_TASKS_COLLECTION = "tasks" // Remove this line private val tasksCollection = db.collection(BuildConfig.TASKS_COLLECTION) // Use build config field
ReportingWorker.kt change
// Add this import import com.yourcompany.advancedconceptsapp.BuildConfig // Replace the temporary constant usage // const val TEMPORARY_USAGE_LOG_COLLECTION = "usage_logs" // Remove this line // ... inside doWork() ... db.collection(BuildConfig.USAGE_LOG_COLLECTION).add(logEntry).await() // Use build config field
Modify
TaskScreen.kt
to potentially use the flavor-specific app name (thoughresValue
handles this automatically if you referenced@string/app_name
correctly, whichTopAppBar
usually does). If you set the title directly, you would load it from resources:// In TaskScreen.kt (if needed)
import androidx.compose.ui.res.stringResource
import com.yourcompany.advancedconceptsapp.R // Import R class
// Inside Scaffold -> topBar
TopAppBar(title = { Text(stringResource(id = R.string.app_name)) }) // Use string resource
- Select Build Variant & Test:
- In Android Studio, go to Build -> Select Build Variant… (or use the “Build Variants” panel usually docked on the left).
- You can now choose between
devDebug
,devRelease
,prodDebug
, andprodRelease
. - Select
devDebug
. Run the app. The title should say “Task Reporter (Dev)”. Data should go totasks_dev
andusage_logs_dev
in Firestore. - Select
prodDebug
. Run the app. The title should be “Task Reporter”. Data should go totasks
andusage_logs
.
Step 5: Proguard/R8 Configuration (for Release Builds)
R8 is the default code shrinker and obfuscator in Android Studio (successor to Proguard). It’s enabled by default for release
build types. We need to ensure it doesn’t break our app, especially Firestore data mapping.
- Review
app/build.gradle.kts
Release Build Type:
app/build.gradle.ktsandroid { // ... buildTypes { release { isMinifyEnabled = true // Should be true by default for release isShrinkResources = true // R8 handles both proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" // Our custom rules file ) } debug { isMinifyEnabled = false // Usually false for debug proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } // ... debug build type ... } // ... }
isMinifyEnabled = true
enables R8 for therelease
build type. - Configure
app/proguard-rules.pro
:- Firestore uses reflection to serialize/deserialize data classes. R8 might remove or rename classes/fields needed for this process. We need to add “keep” rules.
- Open (or create) the
app/proguard-rules.pro
file. Add the following:
# Keep Task data class and its members for Firestore serialization -keep class com.yourcompany.advancedconceptsapp.data.Task { (...); *; } # Keep any other data classes used with Firestore similarly # -keep class com.yourcompany.advancedconceptsapp.data.AnotherFirestoreModel { (...); *; } # Keep Coroutine builders and intrinsics (often needed, though AGP/R8 handle some automatically) -keepnames class kotlinx.coroutines.intrinsics.** { *; } # Keep companion objects for Workers if needed (sometimes R8 removes them) -keepclassmembers class * extends androidx.work.Worker { public static ** Companion; } # Keep specific fields/methods if using reflection elsewhere # -keepclassmembers class com.example.SomeClass { # private java.lang.String someField; # public void someMethod(); # } # Add rules for any other libraries that require them (e.g., Retrofit, Gson, etc.) # Consult library documentation for necessary Proguard/R8 rules.
- Review
- Explanation:
-keep class ... { <init>(...); *; }
: Keeps theTask
class, its constructors (<init>
), and all its fields/methods (*
) from being removed or renamed. This is crucial for Firestore.-keepnames
: Prevents renaming but allows removal if unused.-keepclassmembers
: Keeps specific members within a class.
- Explanation:
3. Test the Release Build:
- Select the
prodRelease
build variant. - Go to Build -> Generate Signed Bundle / APK…. Choose APK.
- Create a new keystore or use an existing one (follow the prompts). Remember the passwords!
- Select
prodRelease
as the variant. Click Finish. - Android Studio will build the release APK. Find it (usually in
app/prod/release/
). - Install this APK manually on a device:
adb install app-prod-release.apk
. - Test thoroughly. Can you add tasks? Do they appear? Does the background worker still log to Firestore (check
usage_logs
)? If it crashes or data doesn’t save/load correctly, R8 likely removed something important. Check Logcat for errors (oftenClassNotFoundException
orNoSuchMethodError
) and adjust yourproguard-rules.pro
file accordingly.
- Select the
Step 6: Firebase App Distribution (for Dev Builds)
Configure Gradle to upload development builds to testers via Firebase App Distribution.
- Download private key: on Firebase console go to Project Overview at left top corner -> Service accounts -> Firebase Admin SDK -> Click on “Generate new private key” button ->
api-project-xxx-yyy.json
move this file to root project at the same level of app folder *Ensure that this file be in your local app, do not push it to the remote repository because it contains sensible data and will be rejected later - Configure App Distribution Plugin in
app/build.gradle.kts
:
app/build.gradle.kts// Apply the plugin at the top plugins { // ... other plugins id("com.android.application"), id("kotlin-android"), etc. alias(libs.plugins.google.firebase.appdistribution) } android { // ... buildFeatures, flavorDimensions, productFlavors ... buildTypes { getByName("release") { isMinifyEnabled = true // Should be true by default for release isShrinkResources = true // R8 handles both proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" // Our custom rules file ) } getByName("debug") { isMinifyEnabled = false // Usually false for debug proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } firebaseAppDistribution { artifactType = "APK" releaseNotes = "Latest build with fixes/features" testers = "briew@example.com, bri@example.com, cal@example.com" serviceCredentialsFile="$rootDir/
api-project-xxx-yyy.json
"//do not push this line to the remote repository or stablish as local variable } } }Add library version to libs.version.toml
[versions] googleFirebaseAppdistribution = "5.1.1" [plugins] google-firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "googleFirebaseAppdistribution" } Ensure the plugin classpath is in the
project-level
build.gradle.kts
:project build.gradle.kts
plugins { // ... alias(libs.plugins.google.firebase.appdistribution) apply false }
Sync Gradle files.
- Upload a Build Manually:
- Select the desired variant (e.g.,
devDebug
,devRelease
,prodDebug
,prodRelease
). - In Android Studio Terminal run each commmand to generate apk version for each environment:
./gradlew assembleRelease appDistributionUploadProdRelease
./gradlew assembleRelease appDistributionUploadDevRelease
./gradlew assembleDebug appDistributionUploadProdDebug
./gradlew assembleDebug appDistributionUploadDevDebug
- Check Firebase Console -> App Distribution -> Select .dev project . Add testers or use the configured group (`android-testers`).
- Select the desired variant (e.g.,
Step 7: CI/CD with GitHub Actions
Automate building and distributing the `dev` build on push to a specific branch.
- Create GitHub Repository. Create a new repository on GitHub and push your project code to it.
- Generate FIREBASE_APP_ID:
- on Firebase App Distribution go to Project Overview -> General -> App ID for com.yourcompany.advancedconceptsapp.dev environment (1:xxxxxxxxx:android:yyyyyyyyyy)
- In GitHub repository go to Settings -> Secrets and variables -> Actions -> New repository secret
- Set the name: FIREBASE_APP_ID and value: paste the App ID generated
- Add FIREBASE_SERVICE_ACCOUNT_KEY_JSON:
- open
api-project-xxx-yyy.json
located at root project and copy the content - In GitHub repository go to Settings -> Secrets and variables -> Actions -> New repository secret
- Set the name: FIREBASE_SERVICE_ACCOUNT_KEY_JSON and value: paste the json content
- open
- Create GitHub Actions Workflow File:
- In your project root, create the directories
.github/workflows/
. - Inside
.github/workflows/
, create a new file namedandroid_build_distribute.yml
. - Paste the following content:
- In your project root, create the directories
name: Android CI on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' cache: gradle - name: Grant execute permission for gradlew run: chmod +x ./gradlew - name: Build devRelease APK run: ./gradlew assembleRelease - name: upload artifact to Firebase App Distribution uses: wzieba/Firebase-Distribution-Github-Action@v1 with: appId: ${{ secrets.FIREBASE_APP_ID }} serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY_JSON }} groups: testers file: app/build/outputs/apk/dev/release/app-dev-release-unsigned.apk
- Generate FIREBASE_APP_ID:
- Commit and Push: Commit the
.github/workflows/android_build_distribute.yml
file and push it to yourmain
branch on GitHub.
- Commit and Push: Commit the
- Verify: Go to the “Actions” tab in your GitHub repository. You should see the workflow running. If it succeeds, check Firebase App Distribution for the new build. Your testers should get notified.
Step 8: Testing and Verification Summary
- Flavors: Switch between
devDebug
andprodDebug
in Android Studio. Verify the app name changes and data goes to the correct Firestore collections (tasks_dev
/tasks
,usage_logs_dev
/usage_logs
).
- Flavors: Switch between
- WorkManager: Use the App Inspection -> Background Task Inspector or ADB commands to verify the
ReportingWorker
runs periodically and logs data to the correct Firestore collection based on the selected flavor.
- WorkManager: Use the App Inspection -> Background Task Inspector or ADB commands to verify the
- R8/Proguard: Install and test the
prodRelease
APK manually. Ensure all features work, especially adding/viewing tasks (Firestore interaction). Check Logcat for crashes related to missing classes/methods.
- R8/Proguard: Install and test the
- App Distribution: Make sure testers receive invites for the
devDebug
(ordevRelease
) builds uploaded manually or via CI/CD. Ensure they can install and run the app.
- App Distribution: Make sure testers receive invites for the
- CI/CD: Check the GitHub Actions logs for successful builds and uploads after pushing to the
develop
branch. Verify the build appears in Firebase App Distribution.
- CI/CD: Check the GitHub Actions logs for successful builds and uploads after pushing to the
Conclusion
Congratulations! You’ve navigated complex Android topics including Firestore, WorkManager, Compose, Flavors (with correct Firebase setup), R8, App Distribution, and CI/CD.
This project provides a solid foundation. From here, you can explore:
- More complex WorkManager chains or constraints.
- Deeper R8/Proguard rule optimization.
- More sophisticated CI/CD pipelines (deploy signed apks/bundles, running tests, deploying to Google Play).
- Using different NoSQL databases or local caching with Room.
- Advanced Compose UI patterns and state management.
- Firebase Authentication, Cloud Functions, etc.
If you want to have access to the full code in my GitHub repository, contact me in the comments.
Project Folder Structure (Conceptual)
AdvancedConceptsApp/
├── .git/
├── .github/workflows/android_build_distribute.yml
├── .gradle/
├── app/
│ ├── build/
│ ├── libs/
│ ├── src/
│ │ ├── main/ # Common code, res, AndroidManifest.xml
│ │ │ └── java/com/yourcompany/advancedconceptsapp/
│ │ │ ├── data/Task.kt
│ │ │ ├── ui/TaskScreen.kt, TaskViewModel.kt, theme/
│ │ │ ├── worker/ReportingWorker.kt
│ │ │ └── MainActivity.kt
│ │ ├── dev/ # Dev flavor source set (optional overrides)
│ │ ├── prod/ # Prod flavor source set (optional overrides)
│ │ ├── test/ # Unit tests
│ │ └── androidTest/ # Instrumentation tests
│ ├── google-services.json # *** IMPORTANT: Contains configs for BOTH package names ***
│ ├── build.gradle.kts # App-level build script
│ └── proguard-rules.pro # R8/Proguard rules
├── api-project-xxx-yyy.json # Firebase service account key json
├── gradle/wrapper/
├── build.gradle.kts # Project-level build script
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
Source: Read MoreÂ