Hands taking a photo with a smartphone

This guide walks through building a picture messaging app from scratch: users capture or select photos, send them to friends, and recipients interact with the image before viewing it. The stack works for any social photo app built on Kotlin, Jetpack Compose, MVVM with Hilt, Firebase Auth and Firestore for real-time sync, Cloud Storage for images, and FCM for push notifications.

Follow these steps and commands to ship your own app on Google Play.

What You’ll Need

  • Android Studio (Ladybug or newer)
  • JDK 17
  • A Google account for Firebase and Google Play Console
  • An Android device or emulator (API 26+)

Step 1: Create the Android Project

Open Android Studio and create a new project with the Empty Activity (Compose) template. Set minimum SDK to API 26, language to Kotlin, and build configuration to Kotlin DSL (build.gradle.kts).

Or scaffold from the command line if you have the Android SDK installed:

sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
# Then use Android Studio's New Project wizard for Compose + Kotlin

Verify the project builds:

./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk

Step 2: Set Up the Architecture (MVVM + Hilt)

Use MVVM with a repository layer and Hilt for dependency injection. Add these to your version catalog or root build.gradle.kts:

// gradle/libs.versions.toml (key versions)
[versions]
hilt = "2.51"
navigation = "2.8.0"
coil = "2.6.0"
camerax = "1.4.0"

// app/build.gradle.kts plugins
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.dagger.hilt.android")
    id("com.google.gms.google-services")
    id("com.google.devtools.ksp")  // KSP — Hilt's current annotation processor (faster than kapt)
}

Apply Hilt in your Application class and main Activity:

@HiltAndroidApp
class MyApp : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { AppTheme { AppNavHost() } }
    }
}

Organize packages by feature:

  • data/ — Firestore repositories, DTOs
  • domain/ — models and use cases
  • ui/ — Compose screens and ViewModels
  • di/ — Hilt modules for Firebase, repositories

Step 3: Connect Firebase

Create a project in the Firebase Console, add an Android app with your package name (e.g. com.example.picturemessages), and download google-services.json into app/.

# Install Firebase CLI (optional, for rules deployment)
curl -sL https://firebase.tools | bash
firebase login
firebase init firestore storage

Add Firebase dependencies:

implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
implementation("com.google.firebase:firebase-auth-ktx")
implementation("com.google.firebase:firebase-firestore-ktx")
implementation("com.google.firebase:firebase-storage-ktx")
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-appcheck-playintegrity")

Enable Email/Password (or phone) auth, create a Firestore database in production mode, and enable Cloud Storage. Deploy security rules that scope reads/writes to authenticated users.

Step 4: Build the Message Content Layer

Define how photos are transformed before delivery. For a puzzle-style app, split the image into an N×N grid, shuffle tile indices (keeping the puzzle solvable), and track swap moves. In Compose, render each tile as a clipped Image offset within a grid. For a simpler app, you might apply filters, captions, or expiry timers instead.

Message data model

Store messages in Firestore with fields like senderId, recipientId, imageUrl, gridSize, tileOrder (array of indices), status (pending/viewed), moveCount, and viewTimeMs. Use a Firestore snapshot listener so both users see live updates.

// Repository pattern — listen for incoming messages
fun observeInbox(userId: String): Flow<List<Message>> = callbackFlow {
    val reg = firestore.collection("messages")
        .whereEqualTo("recipientId", userId)
        .addSnapshotListener { snap, err ->
            if (err != null) { close(err); return@addSnapshotListener }
            trySend(snap?.documents?.map { it.toMessage() } ?: emptyList())
        }
    awaitClose { reg.remove() }
}

Step 5: Camera and Image Upload

Add CameraX for capture and Coil for loading images from Firebase Storage URLs:

implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
implementation("io.coil-kt:coil-compose:2.6.0")

Upload flow: compress the bitmap, upload to messages/{uuid}.jpg in Storage, get the download URL, then write the Firestore document.

suspend fun uploadMessageImage(bytes: ByteArray, id: String): String {
    val ref = storage.reference.child("messages/$id.jpg")
    ref.putBytes(bytes).await()
    return ref.downloadUrl.await().toString()
}

Step 6: Contacts and Usernames

Most messaging apps need a way to find friends. On registration, reserve a unique username in a usernames/{name} collection (document ID = username, field uid). Contacts are stored as subcollections or an array of user IDs on each user document.

Step 7: Push Notifications (FCM)

Create a FirebaseMessagingService subclass to handle incoming message notifications. Save each device’s FCM token to the user document on login.

class MessageMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(msg: RemoteMessage) {
        val title = msg.notification?.title ?: "New message!"
        showNotification(title, msg.notification?.body)
    }
}

When a message is sent, use a Cloud Function or your backend to push to the recipient’s token:

firebase functions:config:set app.url="https://yourdomain.com"
# Cloud Function on message create → admin.messaging().send(...)

Step 8: Navigation and Material 3 UI

Use Navigation Compose for screens: Login, Home/Inbox, Compose Message, View Message, Contacts, Profile. Apply Material 3 theming with dynamic color optional.

implementation("androidx.navigation:navigation-compose:2.8.0")
implementation("androidx.compose.material3:material3")

Step 9: Security with App Check

Enable Firebase App Check with Play Integrity to reduce API abuse:

FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
    PlayIntegrityAppCheckProviderFactory.getInstance()
)

Step 10: Test and Ship to Google Play

Run instrumented tests, enable Crashlytics, then build a signed release:

# Generate a keystore (once)
keytool -genkey -v -keystore app-release.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias myapp

# Configure signing in app/build.gradle.kts, then:
./gradlew bundleRelease

Upload the AAB to Google Play Console. You’ll need a privacy policy URL on your website, a content rating questionnaire, and store listing assets.

Tech Stack Summary

Kotlin • Jetpack Compose • Material 3 • MVVM • Hilt • Coroutines • Firebase Auth • Firestore • Storage • FCM • Crashlytics • App Check • CameraX • Coil • Navigation Compose

This is the architecture behind Phuzzles, our photo-puzzle messenger on Google Play. If you want an app like this shipped without building it yourself, talk to us.