Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ dependencies {
compileOnly(libs.androidx.room.gradle.plugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin)
// KMP Product Flavors - for cross-platform flavor support
implementation(libs.kmpProductFlavors.gradlePlugin)
}

tasks {
Expand Down Expand Up @@ -90,6 +92,11 @@ gradlePlugin {
id = "org.convention.kmp.library"
implementationClass = "KMPLibraryConventionPlugin"
}
register("kmpFlavors") {
id = "org.convention.kmp.flavors"
implementationClass = "KMPFlavorsConventionPlugin"
description = "Configures KMP Product Flavors for cross-platform flavor support"
}

register("kmpCoreBaseLibrary") {
id = "org.convention.kmp.core.base.library"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import org.gradle.kotlin.dsl.dependencies

/**
* Plugin that applies the Android library and Kotlin multiplatform plugins and configures them.
*
* This plugin includes KMP Product Flavors support for cross-platform flavor configuration
* that aligns with Android application flavors (demo/prod).
*/
class KMPCoreBaseLibraryConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
Expand All @@ -19,6 +22,7 @@ class KMPCoreBaseLibraryConventionPlugin: Plugin<Project> {
apply("com.android.library")
apply("org.jetbrains.kotlin.multiplatform")
apply("org.convention.kmp.koin")
apply("org.convention.kmp.flavors") // KMP cross-platform flavors
apply("org.convention.detekt.plugin")
apply("org.jetbrains.kotlin.plugin.serialization")
apply("org.jetbrains.kotlin.plugin.parcelize")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import com.mobilebytelabs.kmpflavors.KmpFlavorExtension
import com.mobilebytelabs.kmpflavors.KmpFlavorPlugin
import org.convention.KmpFlavors
import org.convention.configureKmpFlavors
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure

/**
* Convention plugin that applies and configures KMP Product Flavors plugin.
*
* This plugin provides cross-platform flavor support for Kotlin Multiplatform modules,
* allowing consistent flavor configuration across Android, iOS, Desktop, and Web targets.
*
* Usage:
* ```kotlin
* plugins {
* id("org.convention.kmp.flavors")
* }
* ```
*
* This will configure:
* - Demo/Prod flavors aligned with Android application flavors
* - BuildConfig generation with flavor-specific constants
* - Proper source set wiring for all platforms
*/
class KMPFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Apply the KMP Product Flavors plugin by class
pluginManager.apply(KmpFlavorPlugin::class.java)

// Configure flavors using centralized configuration
extensions.configure<KmpFlavorExtension> {
configureKmpFlavors(
extension = this,
dimensions = KmpFlavors.defaultDimensions,
flavors = KmpFlavors.defaultFlavors,
generateBuildConfig = true,
buildConfigPackage = inferBuildConfigPackage(target),
)
}
}
}

/**
* Infers the BuildConfig package from the project's group or path.
*/
private fun inferBuildConfigPackage(project: Project): String {
// Try to use the project's group if available
val group = project.group.toString()
if (group.isNotEmpty() && group != "unspecified") {
return "$group.${project.name.replace("-", ".")}"
}

// Fall back to path-based package name
val pathParts = project.path
.removePrefix(":")
.split(":")
.filter { it.isNotEmpty() }

return if (pathParts.isNotEmpty()) {
"org.mifos.${pathParts.joinToString(".") { it.replace("-", ".") }}"
} else {
"org.mifos.${project.name.replace("-", ".")}"
}
}
Comment on lines +49 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Package name inference may produce invalid Kotlin/Java packages.

A few concerns with inferBuildConfigPackage:

  1. Line 53: project.name.replace("-", ".") doesn't sanitize characters that are invalid in Java/Kotlin package identifiers (e.g., names starting with a digit, or containing other special characters). A module named 3d-renderer would produce a segment starting with 3d.

  2. Line 63: Same issue with pathParts.joinToString(".") — path segments could begin with digits or contain other invalid chars.

  3. Lines 63, 65: The "org.mifos." prefix is hardcoded. Since this is a project template intended for forking, downstream users will inherit org.mifos BuildConfig packages unless they set an explicit group. Consider deriving this from a configurable property or at minimum documenting that users should set project.group.

Suggested defensive sanitization
+    /**
+     * Sanitizes a string segment to be a valid Java/Kotlin package identifier.
+     */
+    private fun sanitizePackageSegment(segment: String): String =
+        segment.replace("-", ".")
+            .split(".")
+            .joinToString(".") { part ->
+                if (part.firstOrNull()?.isDigit() == true) "_$part" else part
+            }
+
     private fun inferBuildConfigPackage(project: Project): String {
         val group = project.group.toString()
         if (group.isNotEmpty() && group != "unspecified") {
-            return "$group.${project.name.replace("-", ".")}"
+            return "$group.${sanitizePackageSegment(project.name)}"
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build-logic/convention/src/main/kotlin/KMPFlavorsConventionPlugin.kt` around
lines 49 - 67, inferBuildConfigPackage currently builds package segments from
project.name and project.path without sanitizing invalid Java/Kotlin identifiers
and uses a hardcoded "org.mifos" prefix; update inferBuildConfigPackage to (1)
read a configurable base package first (e.g., from a project property like
"buildConfigPackageBase" or fallback to project.group if set), (2) sanitize
every segment derived from project.name and pathParts by replacing
non-alphanumeric/underscore characters with underscores, lowercasing, and if a
segment starts with a digit prefixing it with an underscore so it becomes a
valid identifier, and (3) join the sanitized segments with dots and prepend the
configurable base (instead of the hardcoded "org.mifos") before returning the
package string; refer to inferBuildConfigPackage, project.name, pathParts, and
the "org.mifos" prefix when locating places to change.

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import org.gradle.kotlin.dsl.dependencies

/**
* Plugin that applies the Android library and Kotlin multiplatform plugins and configures them.
*
* This plugin now includes KMP Product Flavors support for cross-platform flavor configuration
* that aligns with Android application flavors (demo/prod).
*/
class KMPLibraryConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
Expand All @@ -19,6 +22,7 @@ class KMPLibraryConventionPlugin: Plugin<Project> {
apply("com.android.library")
apply("org.jetbrains.kotlin.multiplatform")
apply("org.convention.kmp.koin")
apply("org.convention.kmp.flavors") // KMP cross-platform flavors
apply("org.convention.detekt.plugin")
apply("org.convention.spotless.plugin")
apply("org.jetbrains.kotlin.plugin.serialization")
Expand Down
195 changes: 195 additions & 0 deletions build-logic/convention/src/main/kotlin/org/convention/KmpFlavors.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package org.convention

import com.mobilebytelabs.kmpflavors.FlavorConfig
import com.mobilebytelabs.kmpflavors.KmpFlavorExtension
import org.gradle.api.NamedDomainObjectContainer

/**
* KMP Product Flavors configuration for kmp-project-template.
*
* This provides cross-platform flavor support that aligns with the existing
* Android application flavors (demo/prod).
*
* ## Flavor Dimensions
* - **contentType**: Demo vs Production content source
*
* ## Build Variants
* - demo: Uses local/mock data for development and testing
* - prod: Uses production backend services
*
* ## Usage in Modules
* ```kotlin
* plugins {
* id("org.convention.kmp.flavors")
* }
* ```
*/
object KmpFlavors {

/**
* Flavor dimension definitions.
* Currently matches Android's contentType dimension.
*/
enum class Dimension(val priority: Int) {
CONTENT_TYPE(0)
}

/**
* Available flavors aligned with Android application flavors.
*/
enum class Flavor(
val dimension: Dimension,
val isDefault: Boolean = false,
val applicationIdSuffix: String? = null,
val bundleIdSuffix: String? = null,
) {
DEMO(
dimension = Dimension.CONTENT_TYPE,
isDefault = true, // Demo is default for development
applicationIdSuffix = ".demo",
bundleIdSuffix = ".demo",
),
PROD(
dimension = Dimension.CONTENT_TYPE,
isDefault = false,
);

val flavorName: String = name.lowercase()
}

/**
* Default dimension configurations for kmp-product-flavors.
*/
val defaultDimensions: List<DimensionConfig>
get() = Dimension.values().map { dimension ->
DimensionConfig(
name = dimension.name.lowercase().replace("_", ""),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find and read the KmpFlavors.kt file
find . -name "KmpFlavors.kt" -type f

Repository: openMF/kmp-project-template

Length of output: 140


🏁 Script executed:

#!/bin/bash
# Read the KmpFlavors.kt file to see lines around 66 and 78
cat -n ./build-logic/convention/src/main/kotlin/org/convention/KmpFlavors.kt

Repository: openMF/kmp-project-template

Length of output: 7080


🏁 Script executed:

#!/bin/bash
# Search for contentType, contenttype, CONTENT_TYPE usage in the codebase
rg -i "contenttype|content_type" --type=kt -C 3

Repository: openMF/kmp-project-template

Length of output: 97


🏁 Script executed:

#!/bin/bash
# Search for contentType, contenttype, CONTENT_TYPE usage in the codebase
rg -i "contenttype|content_type" -C 3

Repository: openMF/kmp-project-template

Length of output: 5960


🏁 Script executed:

#!/bin/bash
# Let's look at the AppFlavor.kt file more closely to understand the dimension naming
cat -n ./build-logic/convention/src/main/kotlin/org/convention/AppFlavor.kt

Repository: openMF/kmp-project-template

Length of output: 2182


🏁 Script executed:

#!/bin/bash
# Check if there's any test or usage that validates the dimension names match
rg "contentType\|contenttype" --type-list | head -20
find . -name "*.gradle.kts" -o -name "build.gradle" | xargs grep -l "contentType\|contenttype\|kmp.*flavor" 2>/dev/null | head -10

Repository: openMF/kmp-project-template

Length of output: 519


🏁 Script executed:

#!/bin/bash
# Check the build.gradle.kts file for kmp-product-flavors configuration
cat -n ./build-logic/convention/build.gradle.kts | head -80

Repository: openMF/kmp-project-template

Length of output: 3032


🏁 Script executed:

#!/bin/bash
# Search for how kmp-product-flavors is actually used and if there's any configuration or test
find . -type f \( -name "*.kt" -o -name "*.kts" \) | xargs grep -l "kmpProductFlavors\|KmpFlavorExtension\|configureKmpFlavors" 2>/dev/null

Repository: openMF/kmp-project-template

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Read the KMPFlavorsConventionPlugin.kt to see how it's being used
cat -n ./build-logic/convention/src/main/kotlin/KMPFlavorsConventionPlugin.kt

Repository: openMF/kmp-project-template

Length of output: 2902


🏁 Script executed:

#!/bin/bash
# Search for any test files that might validate dimension names
find . -type f -name "*Test*.kt" -o -name "*test*.kts" | xargs grep -l "contenttype\|contentType\|dimension" 2>/dev/null | head -5

Repository: openMF/kmp-project-template

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Search for any gradle configuration files that might show how dimension names are used
find . -type f -name "*.gradle.kts" | xargs grep -A5 -B5 "dimension\|flavorDimension" 2>/dev/null | head -50

Repository: openMF/kmp-project-template

Length of output: 53


Dimension name transform produces "contenttype" instead of "contentType" — this mismatches Android configuration.

Android's FlavorDimension.contentType (line 33 of AppFlavor.kt) registers the dimension as "contentType" (camelCase). However, lines 66 and 78 of KmpFlavors.kt transform CONTENT_TYPE to "contenttype" (all lowercase), causing a mismatch.

The transform should convert CONTENT_TYPE to contentType to align with Android's expected dimension name:

Suggested fix for camelCase conversion
-                name = dimension.name.lowercase().replace("_", ""),
+                name = dimension.name.lowercase()
+                    .replace(Regex("_([a-z])")) { it.groupValues[1].uppercase() },

Apply the same to line 78.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name = dimension.name.lowercase().replace("_", ""),
name = dimension.name.lowercase()
.replace(Regex("_([a-z])")) { it.groupValues[1].uppercase() },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build-logic/convention/src/main/kotlin/org/convention/KmpFlavors.kt` at line
66, The dimension name transform in KmpFlavors (the expression that builds the
flavor dimension name from dimension.name) lowercases and strips underscores
producing "contenttype" which mismatches Android's FlavorDimension.contentType
(camelCase); update the transformation used where name =
dimension.name.lowercase().replace("_", "") (and the analogous occurrence later
in the same file) to produce camelCase (e.g., convert tokens split by '_' to
lower-firstToken + capitalized subsequent tokens) so CONTENT_TYPE becomes
contentType and matches AppFlavor/FlavorDimension.

priority = dimension.priority,
)
}

/**
* Default flavor configurations for kmp-product-flavors.
*/
val defaultFlavors: List<FlavorConfigData>
get() = Flavor.values().map { flavor ->
FlavorConfigData(
name = flavor.flavorName,
dimension = flavor.dimension.name.lowercase().replace("_", ""),
isDefault = flavor.isDefault,
applicationIdSuffix = flavor.applicationIdSuffix,
bundleIdSuffix = flavor.bundleIdSuffix,
)
}

/**
* Checks if a specific flavor is currently active.
*/
fun isFlavorActive(flavor: Flavor, activeVariant: String): Boolean =
activeVariant.contains(flavor.flavorName, ignoreCase = true)

/**
* Gets the base URL for the current flavor.
*/
fun getBaseUrl(flavor: Flavor): String = when (flavor) {
Flavor.DEMO -> "https://demo-api.mifos.org"
Flavor.PROD -> "https://api.mifos.org"
}

/**
* Gets the analytics enabled flag for the current flavor.
*/
fun isAnalyticsEnabled(flavor: Flavor): Boolean = when (flavor) {
Flavor.DEMO -> false
Flavor.PROD -> true
}
}

/**
* Data class for dimension configuration.
*/
data class DimensionConfig(
val name: String,
val priority: Int,
)

/**
* Data class for flavor configuration.
*/
data class FlavorConfigData(
val name: String,
val dimension: String,
val isDefault: Boolean = false,
val applicationIdSuffix: String? = null,
val bundleIdSuffix: String? = null,
val buildConfigFields: Map<String, BuildConfigFieldData> = emptyMap(),
)

/**
* Data class for BuildConfig field.
*/
data class BuildConfigFieldData(
val type: String,
val value: String,
)

/**
* Extension function to configure KMP flavors using centralized configuration.
*/
fun configureKmpFlavors(
extension: KmpFlavorExtension,
dimensions: List<DimensionConfig>,
flavors: List<FlavorConfigData>,
generateBuildConfig: Boolean = true,
buildConfigPackage: String? = null,
) {
extension.apply {
this.generateBuildConfig.set(generateBuildConfig)
buildConfigPackage?.let { this.buildConfigPackage.set(it) }

// Configure dimensions
flavorDimensions {
dimensions.forEach { dim ->
register(dim.name) {
priority.set(dim.priority)
}
}
}

// Configure flavors
this.flavors {
flavors.forEach { flavorData ->
register(flavorData.name) {
dimension.set(flavorData.dimension)
isDefault.set(flavorData.isDefault)
flavorData.applicationIdSuffix?.let { applicationIdSuffix.set(it) }
flavorData.bundleIdSuffix?.let { bundleIdSuffix.set(it) }

// Add standard BuildConfig fields
addStandardBuildConfigFields(this, flavorData)
}
}
}
}
}

/**
* Adds standard BuildConfig fields to a flavor.
*/
private fun addStandardBuildConfigFields(
flavorConfig: FlavorConfig,
flavorData: FlavorConfigData,
) {
val flavor = KmpFlavors.Flavor.values().find { it.flavorName == flavorData.name }
if (flavor != null) {
flavorConfig.apply {
buildConfigField("String", "BASE_URL", "\"${KmpFlavors.getBaseUrl(flavor)}\"")
buildConfigField("Boolean", "ANALYTICS_ENABLED", KmpFlavors.isAnalyticsEnabled(flavor).toString())
}
}

// Add any custom fields
flavorData.buildConfigFields.forEach { (name, field) ->
flavorConfig.buildConfigField(field.type, name, field.value)
}
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ spotlessVersion = "7.0.2"
turbine = "1.2.1"
twitter-detekt-compose = "0.0.26"
moduleGraph = "2.9.0"
kmpProductFlavors = "1.0.0"

# Kotlin KMP Dependencies
kotlin = "2.2.21"
Expand Down Expand Up @@ -251,6 +252,7 @@ kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }

ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
kmpProductFlavors-gradlePlugin = { group = "io.github.mobilebytelabs.kmpflavors", name = "flavor-plugin", version.ref = "kmpProductFlavors" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the artifact exists on Maven Central
echo "Checking Maven Central for io.github.mobilebytelabs.kmpflavors:flavor-plugin:1.0.0"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://repo1.maven.org/maven2/io/github/mobilebytelabs/kmpflavors/flavor-plugin/1.0.0/flavor-plugin-1.0.0.pom")
echo "HTTP Response Code: $HTTP_CODE"

if [ "$HTTP_CODE" == "200" ]; then
    echo "✓ Artifact is available on Maven Central"
elif [ "$HTTP_CODE" == "404" ]; then
    echo "✗ Artifact NOT found on Maven Central (404)"
else
    echo "? Unexpected response code: $HTTP_CODE"
fi

Repository: openMF/kmp-project-template

Length of output: 221


The artifact io.github.mobilebytelabs.kmpflavors:flavor-plugin:1.0.0 is not available on Maven Central. The dependency cannot be resolved with this configuration and the build will fail.

Confirm the artifact group ID, name, and version are correct. If the artifact is published to an alternative repository, add that repository to the project's Gradle configuration. If this is a custom or private artifact, ensure the appropriate repository and credentials are configured.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gradle/libs.versions.toml` at line 255, The dependency entry
kmpProductFlavors-gradlePlugin = { group =
"io.github.mobilebytelabs.kmpflavors", name = "flavor-plugin", version.ref =
"kmpProductFlavors" } references an artifact that isn't on Maven Central; verify
the coordinates (group/name/version referenced by kmpProductFlavors) and either
correct them or add the repository that hosts the artifact to the Gradle
repositories (e.g., maven { url "..." } or a private repo with credentials) so
Gradle can resolve it; if this is an internal artifact ensure the appropriate
credentials and repository are configured in the project’s build/repository
configuration or replace version.ref with a published version available on the
configured repositories.


ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktorVersion" }
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktorVersion" }
Expand Down Expand Up @@ -367,6 +369,7 @@ aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "abo
cmp-feature-convention = { id = "org.convention.cmp.feature", version = "unspecified" }
kmp-koin-convention = { id = "org.convention.kmp.koin", version = "unspecified" }
kmp-library-convention = { id = "org.convention.kmp.library", version = "unspecified" }
kmp-flavors-convention = { id = "org.convention.kmp.flavors", version = "unspecified" }
kmp-core-base-library-convention = { id = "org.convention.kmp.core.base.library", version = "unspecified" }

android-application-firebase = { id = "org.convention.android.application.firebase" }
Expand Down
Loading