Skip to content
Open
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)
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

Run Dependency Guard to baseline the new implementation dependency.

kmpProductFlavors.gradlePlugin is added as implementation (correctly, since KmpFlavorPlugin is applied by class reference), but this adds the artifact to the runtime dependency set of all convention plugin consumers. The Dependency Guard baseline must be updated to reflect this change.

As per coding guidelines: Use Dependency Guard to validate dependencies in Kotlin Multiplatform projects.

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

In `@build-logic/convention/build.gradle.kts` at line 36, Add the new
implementation dependency to the Dependency Guard baseline: run the Dependency
Guard task to update the baseline after adding
implementation(libs.kmpProductFlavors.gradlePlugin) so the new artifact is
accepted for consumers of the convention plugin (KmpFlavorPlugin applied by
class reference); commit the updated baseline file produced by Dependency Guard
to the repo.

}

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,73 @@
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.
* Ensures the package name is valid Kotlin (no hyphens).
*/
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") {
// Replace hyphens with dots to ensure valid Kotlin package name
val sanitizedGroup = group.replace("-", ".")
val sanitizedName = project.name.replace("-", ".")
return "$sanitizedGroup.$sanitizedName"
}

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

return if (pathParts.isNotEmpty()) {
// Replace hyphens with underscores to ensure valid Kotlin identifiers
"org.mifos.${pathParts.joinToString(".") { it.replace("-", "_") }}"
} else {
"org.mifos.${project.name.replace("-", "_")}"
}
}
Comment on lines +50 to +72
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

Inconsistent hyphen sanitization strategy between the two package-inference code paths.

The group-based path (Line 55-56) replaces - with ., splitting a component like feature-auth into two sub-packages (feature.auth). The path-based fallback (Line 68) replaces - with _, producing a single identifier (feature_auth). The two paths can therefore generate different package names for the same module, depending solely on whether group is set.

Using underscores uniformly (valid Kotlin package identifiers) in both paths avoids this split-vs-collapse mismatch:

♻️ Proposed fix
     if (group.isNotEmpty() && group != "unspecified") {
-        val sanitizedGroup = group.replace("-", ".")
-        val sanitizedName = project.name.replace("-", ".")
+        val sanitizedGroup = group.replace("-", "_")
+        val sanitizedName = project.name.replace("-", "_")
         return "$sanitizedGroup.$sanitizedName"
     }
🤖 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 50 - 72, The inferBuildConfigPackage function uses inconsistent hyphen
sanitization: sanitizedGroup currently replaces "-" with "." while the fallback
path replaces "-" with "_" which can produce different package names; change the
group-based branch so both sanitizedGroup and sanitizedName replace hyphens with
underscores (use group.replace("-", "_") and project.name.replace("-", "_")) so
the produced package ("$sanitizedGroup.$sanitizedName") matches the fallback's
hyphen-to-underscore strategy and yields valid Kotlin identifiers.

}
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 ->
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace deprecated values() with entries (Kotlin 1.9+).

Dimension.values(), Flavor.values(), and KmpFlavors.Flavor.values() are all deprecated as of Kotlin 1.9. The project targets Kotlin 2.2.21, so entries is available and preferred.

♻️ Proposed fix
-        get() = Dimension.values().map { dimension ->
+        get() = Dimension.entries.map { dimension ->
-        get() = Flavor.values().map { flavor ->
+        get() = Flavor.entries.map { flavor ->
-    val flavor = KmpFlavors.Flavor.values().find { it.flavorName == flavorData.name }
+    val flavor = KmpFlavors.Flavor.entries.find { it.flavorName == flavorData.name }

Also applies to: 75-75, 183-183

🤖 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
64, The code uses deprecated EnumClass.values() calls; replace all occurrences
of Dimension.values(), Flavor.values(), and KmpFlavors.Flavor.values() with the
Kotlin 1.9+ enum entries property (e.g., Dimension.entries, Flavor.entries,
KmpFlavors.Flavor.entries) so the getters and any map/iteration logic (such as
the get() = Dimension.values().map { ... } expression and similar occurrences
around the Flavor usages) iterate over entries instead of values().

DimensionConfig(
name = dimension.name.lowercase().replace("_", ""),
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.1"

# 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" }

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