DEV Community

Cover image for I Had 3 Abandoned Apps, Then Kotlin Pulled Me Back In
freerave
freerave

Posted on

I Had 3 Abandoned Apps, Then Kotlin Pulled Me Back In

How I went from abandoning 3 Android apps to shipping DotReminder v0.0.5 — a full reminder app with AI, biometrics, 2FA, and real-time sessions — in just a few months of Kotlin fun.

Look at this screenshot. Three apps. Three ideas. Three moments of excitement that all faded into the "I'll finish it later" pile.

Google Play Console showing 3 apps: DotBurn, DotReminder, shredzilla

DotBurn — a fitness tracker. Abandoned.

shredzilla — a gym app. Also abandoned.

DotReminder — a reminder app. This one I kept going.

Not because it was my best idea. Not because it had the most users. But because one quiet evening in November 2025, I opened Android Studio, looked at the Kotlin syntax, and thought:

"Wait... this is actually fun."

And that was it. I was hooked again.


The "Just Playing Around" Phase (v0.0.1 → v0.0.2)

When I first started DotReminder back in November 2025, I wasn't building a product. I was playing. Jetpack Compose was new to me, Hilt felt like magic, and Firebase was just... there, ready to do the heavy lifting.

The first two versions were pure chaos — in the best way:

  • AI chat powered by Google Gemini for natural language reminders
  • Location-based reminders with Geofencing
  • 5 custom themes (Ocean Blue, Forest Green, Sunset Orange, Midnight Purple, Candy)
  • Biometric auth, 2FA, Google + Facebook sign-in
  • Widgets for home screen quick access

Was the code clean? Absolutely not. Was it working? Mostly. Was I having fun? 100%.


The Turning Point — v0.0.3 (November → March)

By the time I reached v0.0.3, the "playing around" phase was over. Real bugs started showing up. Real crashes. Real users (well, 2 of them 😄).

The biggest crisis? Biometric auth was completely broken.

W AccountSecurityScreen: FragmentActivity not found
Enter fullscreen mode Exit fullscreen mode

The error looked simple. The fix was... embarrassing.

// ❌ The problem — MainActivity inheriting from the wrong class
class MainActivity : ComponentActivity() { ... }

// ✅ The fix — AppCompatActivity extends FragmentActivity
// which is what BiometricPrompt actually needs
class MainActivity : AppCompatActivity() { ... }
Enter fullscreen mode Exit fullscreen mode

That's it. One word. AppCompatActivity instead of ComponentActivity. BiometricPrompt internally uses the Fragment system, which only exists in FragmentActivity. ComponentActivity doesn't have it.

The lesson? Always read what your dependencies actually need.

Real Active Sessions

The other big feature in v0.0.3 was replacing the mock "Active Sessions" screen with real Firestore-backed data.

The Firestore structure:

users/
  {userId}/
    sessions/
      {sessionId}/
        deviceName: "samsung SM-G991B"
        deviceModel: "SM-G991B"
        loginTime: Timestamp
        lastActive: Timestamp
Enter fullscreen mode Exit fullscreen mode

The Session model:

data class Session(
    val sessionId: String = "",
    val deviceName: String = "",
    val deviceModel: String = "",
    val loginTime: Timestamp = Timestamp.now(),
    val lastActive: Timestamp = Timestamp.now()
) {
    companion object {
        fun createForCurrentDevice(sessionId: String) = Session(
            sessionId = sessionId,
            deviceName = "${Build.MANUFACTURER} ${Build.MODEL}",
            deviceModel = Build.MODEL,
            loginTime = Timestamp.now(),
            lastActive = Timestamp.now()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The SessionRepository using real-time Flow:

fun getSessions(userId: String): Flow<List<Session>> = callbackFlow {
    val listener = sessionsCollection(userId)
        .orderBy("lastActive", Query.Direction.DESCENDING)
        .addSnapshotListener { snapshot, error ->
            if (error != null) { close(error); return@addSnapshotListener }
            val sessions = snapshot?.documents?.mapNotNull { doc ->
                Session(
                    sessionId = doc.getString("sessionId") ?: doc.id,
                    deviceName = doc.getString("deviceName") ?: "Unknown Device",
                    loginTime = doc.getTimestamp("loginTime") ?: Timestamp.now(),
                    lastActive = doc.getTimestamp("lastActive") ?: Timestamp.now()
                )
            } ?: emptyList()
            trySend(sessions)
        }
    awaitClose { listener.remove() }
}
Enter fullscreen mode Exit fullscreen mode

And of course — the Firestore Rules were missing the sessions subcollection. Classic.

match /users/{userId} {
  allow read, write: if request.auth != null && request.auth.uid == userId;

  // ✅ Don't forget subcollections!
  match /sessions/{sessionId} {
    allow read, write: if request.auth != null && request.auth.uid == userId;
  }
}
Enter fullscreen mode Exit fullscreen mode

The AGP 9.0 Migration — v0.0.4

March 2026. AGP 9.0 dropped and broke basically everything.

Here's what I had to change in libs.versions.toml:

[versions]
agp = "9.1.0"
kotlin = "2.3.10"
ksp = "2.3.6"  # First version compatible with AGP 9.0

[plugins]
# ❌ Remove these — they're built-in since AGP 9.0
# kotlin-android = { id = "org.jetbrains.kotlin.android" }
# kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose" }
# kotlin-kapt = { id = "org.jetbrains.kotlin.kapt" }

# ✅ Keep only these
android-application = { id = "com.android.application", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Enter fullscreen mode Exit fullscreen mode

And in app/build.gradle.kts:

// ❌ Old kapt
kapt(libs.hilt.compiler)

// ✅ New KSP — faster and AGP 9.0 compatible
ksp(libs.hilt.compiler)
Enter fullscreen mode Exit fullscreen mode

Also had to add this to gradle.properties:

android.disallowKotlinSourceSets=false
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64
Enter fullscreen mode Exit fullscreen mode

The KSP migration alone cut my build time by roughly 30%.


The Big Cleanup — v0.0.5

This is where things got interesting. The app was 95 MB. Not acceptable.

$ du -sh app/src/main/assets/backgrounds/home/*
6.4M  candy_home.mp4
17M   forest_green_home.mp4
1.9M  midnight_purple_home.mp4
17M   ocean_blue_home.mp4
9.2M  sunset_orange_home.mp4
Enter fullscreen mode Exit fullscreen mode

74MB of video backgrounds. I was using ExoPlayer to play them on the home screen.

The fix was simple: delete them, replace with pure Compose gradients.

Before:

// ❌ 74MB of videos + ExoPlayer dependency + Cloudinary CDN
VideoBackgroundPlayer(
    videoPath = "backgrounds/home/ocean_blue_home.mp4",
    modifier = Modifier.fillMaxSize()
)
Enter fullscreen mode Exit fullscreen mode

After:

// ✅ Zero bytes, works offline, renders instantly
val OceanBlueDarkGradient = Brush.radialGradient(
    colors = listOf(
        Color(0xFF0D47A1), // Deep navy
        Color(0xFF1565C0), // Royal blue
        Color(0xFF1976D2), // Ocean blue
        Color(0xFF42A5F5), // Light ocean blue
        Color(0xFF0D47A1)  // Back to deep navy
    ),
    center = Offset(0.7f, 0.3f),
    radius = 1000f
)
Enter fullscreen mode Exit fullscreen mode

Result: 95 MB → 20.5 MB. An 78% reduction in one commit.

Upgrading the AI Brain

While I was at it, I upgraded the Gemini model from 2.0-flash to 2.5-flash:

// Before
generativeModel = GenerativeModel(
    modelName = "gemini-2.0-flash",
    apiKey = BuildConfig.GEMINI_API_KEY
)

// After — faster, smarter, Arabic + English native support
generativeModel = GenerativeModel(
    modelName = "gemini-2.5-flash",
    apiKey = BuildConfig.GEMINI_API_KEY
)
Enter fullscreen mode Exit fullscreen mode

And the most satisfying addition — a JSON extractor that turns natural language into structured reminder data:

suspend fun parseReminderToJSON(userInput: String): String? {
    val currentMillis = System.currentTimeMillis()

    val prompt = """
        You are a strict Data Extraction AI for a Reminder App.
        You must support both Arabic and English natively.

        Current Time in Milliseconds: $currentMillis

        Output ONLY a valid JSON object:
        {
          "title": "Cleaned up task title",
          "reminderTime": 1712345678000,
          "isRecurring": false,
          "category": "Work"
        }

        User Input: "$userInput"
    """.trimIndent()

    return try {
        withContext(Dispatchers.IO) {
            val response = generativeModel!!.generateContent(prompt)
            response.text
                ?.replace("```
{% endraw %}
json", "")
                ?.replace("
{% raw %}
```", "")
                ?.trim()
        }
    } catch (e: Exception) { null }
}
Enter fullscreen mode Exit fullscreen mode

Now in AiViewModel, instead of regex-based NLP parsing:

// ❌ Old NLP approach — fragile regex
val parsedInfo = NLPUtils.parseReminderText(command)

// ✅ New Gemini JSON approach — understands context and Arabic
val jsonString = geminiService.parseReminderToJSON(userMessageText)

if (!jsonString.isNullOrBlank()) {
    val cleanJson = jsonString.replace("```
{% endraw %}
json", "").replace("
{% raw %}
```", "").trim()
    val json = Gson().fromJson(cleanJson, JsonObject::class.java)

    val title = if (json.has("title")) json.get("title").asString else ""
    val reminderTime = if (json.has("reminderTime") && !json.get("reminderTime").isJsonNull)
        json.get("reminderTime").asLong else null
    val category = if (json.has("category")) json.get("category").asString else "Other"

    // Save to Firestore
    val reminder = Reminder(
        id = UUID.randomUUID().toString(),
        title = title,
        category = category,
        reminderTime = reminderTime!!,
        userId = currentUserId
    )
    reminderService.addReminder(reminder)
    _chatMessages.add(ChatMessage(text = "Done! '$title' added ✅", sender = SenderType.AI))
}
Enter fullscreen mode Exit fullscreen mode

What's Next — v0.0.6

The roadmap is clear:

  1. Real Change Password — Still using a mock implementation. Time to fix it.
// 🚧 Current — shameful mock
_changePasswordState.value = ChangePasswordState.Success("Password changed (mock).")

// ✅ Planned — real Firebase re-auth
firebaseAuth.currentUser?.reauthenticateAndRetrieveData(credential)?.await()
firebaseAuth.currentUser?.updatePassword(newPassword)?.await()
Enter fullscreen mode Exit fullscreen mode
  1. Calendar Feature — The screen exists, it just needs the reminders mapped to dates.

  2. AI in AddReminderScreen — Using parseReminderToJSON not just in chat but in the add reminder flow too.


The Real Takeaway

Looking at that Play Console screenshot — 3 apps, 2 abandoned — I could feel bad about DotBurn and shredzilla. But I don't.

Because every line of Kotlin I wrote in those apps taught me something. The AGP migration, the BiometricPrompt quirks, the Firestore Rules gotchas, the ExoPlayer RAM usage — all of it was lessons paid in abandoned projects.

DotReminder is what happens when those lessons finally compound.

versionCode 6. versionName 0.0.5. Still going. 🚀


Links


If you're also sitting on 3 abandoned projects right now — pick one. Just one. And keep going.

Top comments (0)