diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2acfd96..b644736 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ kase-change = { group = "net.pearx.kasechange", name = "kasechange", version.ref ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" } +serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } [plugins] android-library = { id = "com.android.library", version.ref = "android" } diff --git a/sekret-gradle-plugin/build.gradle.kts b/sekret-gradle-plugin/build.gradle.kts index 16aaddb..c88afb3 100644 --- a/sekret-gradle-plugin/build.gradle.kts +++ b/sekret-gradle-plugin/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.kotlin.poet) implementation(libs.kase.change) implementation(libs.serialization) + implementation(libs.serialization.json) } val generateVersion = tasks.create("generateVersion") { diff --git a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/extension/PropertiesExtension.kt b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/extension/PropertiesExtension.kt index c80a1d0..3be112c 100644 --- a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/extension/PropertiesExtension.kt +++ b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/extension/PropertiesExtension.kt @@ -38,6 +38,12 @@ open class PropertiesExtension(objectFactory: ObjectFactory) { */ open val propertiesFile: RegularFileProperty = objectFactory.fileProperty() + /** + * Change where the google-services.json file for your secrets is located. + * Default is "google-services.json" in the **androidMain** target of the module the plugin is applied. + */ + open val googleServicesFile: RegularFileProperty = objectFactory.fileProperty() + /** * Configuration for handling copy process of native binaries. */ @@ -65,6 +71,7 @@ open class PropertiesExtension(objectFactory: ObjectFactory) { }) encryptionKey.convention(packageName) propertiesFile.convention(project.layout.projectDirectory.file(sekretFileName)) + googleServicesFile.convention(project.layout.projectDirectory.file("src/androidMain/$googleServicesFileName")) nativeCopy = NativeCopyExtension(project.objects).also { it.setupConvention(project) @@ -100,5 +107,6 @@ open class PropertiesExtension(objectFactory: ObjectFactory) { companion object { internal const val sekretFileName = "sekret.properties" internal const val sekretPackageName = "dev.datlag.sekret" + internal const val googleServicesFileName = "google-services.json" } } \ No newline at end of file diff --git a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/helper/Encoder.kt b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/helper/Encoder.kt index c580d99..ebd1f67 100644 --- a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/helper/Encoder.kt +++ b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/helper/Encoder.kt @@ -1,6 +1,7 @@ package dev.datlag.sekret.gradle.helper import dev.datlag.sekret.gradle.EncodedProperty +import dev.datlag.sekret.gradle.model.GoogleServices import net.pearx.kasechange.toCamelCase import net.pearx.kasechange.universalWordSplitter import java.security.MessageDigest @@ -10,17 +11,80 @@ import kotlin.experimental.xor object Encoder { fun encodeProperties( - properties: Properties, + properties: Properties?, + googleServices: GoogleServices?, password: String ): Iterable { val props = mutableSetOf() - properties.entries.forEach { entry -> + properties?.entries?.forEach { entry -> val keyName = (entry.key as String).toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim() val secretValue = encode(entry.value as String, password) props.add(EncodedProperty(keyName, secretValue)) } + googleServices?.let { json -> + props.add(EncodedProperty( + key = GoogleServices.PROJECT_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(json.project.id, password) + )) + json.project.number?.let { + props.add(EncodedProperty( + key = GoogleServices.PROJECT_NUMBER_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + json.project.firebaseUrl?.let { + props.add(EncodedProperty( + key = GoogleServices.PROJECT_FIREBASE_URL_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + json.project.storageBucket?.let { + props.add(EncodedProperty( + key = GoogleServices.PROJECT_STORAGE_BUCKET_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + json.firebase?.let { firebase -> + firebase.info?.appId?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_APP_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + firebase.currentApiKey?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_API_KEY_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + firebase.androidClient?.clientId?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_ANDROID_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + firebase.iOSClient?.clientId?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_IOS_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + firebase.webClientOrFirebaseAuth?.clientId?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_WEB_OR_AUTH_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + firebase.adminClient?.clientId?.let { + props.add(EncodedProperty( + key = GoogleServices.FIREBASE_ADMIN_ID_KEY.toCamelCase(universalWordSplitter(treatDigitsAsUppercase = false)).trim(), + secret = encode(it, password) + )) + } + } + } return props } diff --git a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/model/GoogleServices.kt b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/model/GoogleServices.kt new file mode 100644 index 0000000..d9dca8d --- /dev/null +++ b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/model/GoogleServices.kt @@ -0,0 +1,141 @@ +package dev.datlag.sekret.gradle.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.gradle.api.logging.LogLevel +import org.gradle.api.logging.Logger +import java.io.File + +@Serializable +data class GoogleServices( + @SerialName("project_info") val project: Project, + @SerialName("client") val client: List +) { + @Transient + val firebase: Client? = client.maxByOrNull { + it.firebaseFactor + } ?: client.firstOrNull() + + @Serializable + data class Project( + @SerialName("project_id") val id: String, + @SerialName("project_number") private val _number: String, + @SerialName("firebase_url") private val _firebaseUrl: String? = null, + @SerialName("storage_bucket") private val _storageBucket: String? = null, + ) { + @Transient + val number = _number.ifBlank { null } + + @Transient + val firebaseUrl = _firebaseUrl?.ifBlank { null } + + @Transient + val storageBucket = _storageBucket?.ifBlank { null } + } + + @Serializable + data class Client( + @SerialName("client_info") val info: Info? = null, + @SerialName("oauth_client") val oauthClient: List = emptyList(), + @SerialName("api_key") val apiKey: List = emptyList(), + @SerialName("services") val services: Services? = null + ) { + @Transient + val androidClient: OAuthClient? = clientOfType(1) + + @Transient + val iOSClient: OAuthClient? = clientOfType(2) + + @Transient + val webClientOrFirebaseAuth: OAuthClient? = clientOfType(3) + + @Transient + val adminClient: OAuthClient? = clientOfType(4) + + @Transient + val currentApiKey: String? = apiKey.firstNotNullOfOrNull { it.current } + + @Transient + internal val firebaseFactor: Int = listOfNotNull( + androidClient?.let { 1 }, + iOSClient?.let { 1 }, + webClientOrFirebaseAuth?.let { 2 }, + adminClient?.let { 1 } + ).sum() + + private fun clientOfType(type: Int): OAuthClient? = oauthClient.firstOrNull { + it.clientType == type + } ?: services?.appInvite?.otherPlatformOAuthClient?.firstOrNull { + it.clientType == type + } + + @Serializable + data class Info( + @SerialName("mobilesdk_app_id") private val _appId: String?, + ) { + @Transient + val appId: String? = _appId?.ifBlank { null } + } + + @Serializable + data class OAuthClient( + @SerialName("client_id") private val _clientId: String?, + @SerialName("client_type") val clientType: Int = -1, + ) { + @Transient + val clientId: String? = _clientId?.ifBlank { null } + } + + @Serializable + data class ApiKey( + @SerialName("current_key") private val _current: String? = null, + ) { + @Transient + val current: String? = _current?.ifBlank { null } + } + + @Serializable + data class Services( + @SerialName("appinvite_service") val appInvite: AppInvite? = null, + ) { + @Serializable + data class AppInvite( + @SerialName("other_platform_oauth_client") val otherPlatformOAuthClient: List = emptyList(), + ) + } + } + + companion object { + internal const val PROJECT_ID_KEY = "PROJECT_ID" + internal const val PROJECT_NUMBER_KEY = "PROJECT_NUMBER" + internal const val PROJECT_FIREBASE_URL_KEY = "PROJECT_FIREBASE_URL" + internal const val PROJECT_STORAGE_BUCKET_KEY = "PROJECT_STORAGE_BUCKET" + + internal const val FIREBASE_APP_ID_KEY = "FIREBASE_APP_ID" + internal const val FIREBASE_ANDROID_ID_KEY = "FIREBASE_ANDROID_ID" + internal const val FIREBASE_IOS_ID_KEY = "FIREBASE_IOS_ID" + internal const val FIREBASE_WEB_OR_AUTH_ID_KEY = "FIREBASE_WEB_OR_AUTH_ID" + internal const val FIREBASE_ADMIN_ID_KEY = "FIREBASE_ADMIN_ID" + internal const val FIREBASE_API_KEY_KEY = "FIREBASE_API_KEY" + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + @OptIn(ExperimentalSerializationApi::class) + fun from(file: File, logger: Logger): GoogleServices? { + return file.inputStream().use { inputStream -> + runCatching { + json.decodeFromStream(inputStream) + }.onFailure { + logger.log(LogLevel.ERROR, "Seems like your google-services.json is malformed, could not parse.", it) + }.getOrNull() + } + } + } +} diff --git a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/CreateSekretValueTask.kt b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/CreateSekretValueTask.kt index bbc05c6..6c42f00 100644 --- a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/CreateSekretValueTask.kt +++ b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/CreateSekretValueTask.kt @@ -44,7 +44,7 @@ open class CreateSekretValueTask : DefaultTask() { val name = key.orNull ?: throw IllegalArgumentException("Missing property 'key'") val data = value.orNull ?: throw IllegalArgumentException("Missing property 'value'") - val propFile = propertiesFile.asFile.orNull ?: throw IllegalStateException("No sekret properties file found.") + val propFile = propertiesFile.orNull?.asFile ?: throw IllegalStateException("No sekret properties file found.") val properties = Utils.propertiesFromFile(propFile) properties[name] = data diff --git a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/GenerateSekretTask.kt b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/GenerateSekretTask.kt index 4578d98..55f2a62 100644 --- a/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/GenerateSekretTask.kt +++ b/sekret-gradle-plugin/src/main/java/dev/datlag/sekret/gradle/tasks/GenerateSekretTask.kt @@ -11,6 +11,7 @@ import dev.datlag.sekret.gradle.generator.ModuleGenerator import dev.datlag.sekret.gradle.generator.SekretGenerator import dev.datlag.sekret.gradle.helper.Encoder import dev.datlag.sekret.gradle.helper.Utils +import dev.datlag.sekret.gradle.model.GoogleServices import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty @@ -49,6 +50,9 @@ open class GenerateSekretTask : DefaultTask() { @get:InputFile open val propertiesFile: RegularFileProperty = project.objects.fileProperty() + @get:InputFile + open val googleServicesFile: RegularFileProperty = project.objects.fileProperty() + private val outputDir: File get() = outputDirectory.asFile.orNull ?: projectLayout.projectDirectory.dir("sekret").asFile @@ -89,15 +93,26 @@ open class GenerateSekretTask : DefaultTask() { targets = requiredTargets ) - val propFile = propertiesFile.asFile.orNull ?: throw IllegalStateException("No sekret properties file found.") - val properties = Utils.propertiesFromFile(propFile) + val propFile = propertiesFile.orNull?.asFile + val googleServicesFile = googleServicesFile.orNull?.asFile + + if (propFile == null && googleServicesFile == null) { + throw IllegalStateException("No sekret.properties file or google-services.json found.") + } + + val properties = propFile?.let(Utils::propertiesFromFile) + val googleServices = googleServicesFile?.let { GoogleServices.from(it, logger) } + + if (properties == null && googleServices == null) { + throw IllegalStateException("No sekret.properties file or google-services.json could not be parsed.") + } val generator = SekretGenerator.createAllForTargets( packageName = packageName.getOrElse(PropertiesExtension.sekretPackageName), structure = structure ) - val encodedProperties = Encoder.encodeProperties(properties, encryptionKey.get()) + val encodedProperties = Encoder.encodeProperties(properties, googleServices, encryptionKey.get()) SekretGenerator.generate(encodedProperties, *generator.toTypedArray()) } @@ -118,10 +133,30 @@ open class GenerateSekretTask : DefaultTask() { return null } - return resolveFile(config.propertiesFile.asFile.getOrElse(project.file(PropertiesExtension.sekretFileName))) + return resolveFile(config.propertiesFile.orNull?.asFile ?: project.file(PropertiesExtension.sekretFileName)) ?: resolveFile(project.projectDir) } + private fun googleServicesFile(project: Project, config: PropertiesExtension): File? { + val defaultName = PropertiesExtension.googleServicesFileName + + fun resolveFile(file: File): File? { + if (file.existsSafely() && file.canReadSafely()) { + val googleServicesFile = if (file.isDirectorySafely()) { + File(file, defaultName) + } else { + file + } + if (googleServicesFile.existsSafely() && googleServicesFile.canReadSafely()) { + return googleServicesFile + } + } + return null + } + + return config.googleServicesFile.orNull?.asFile?.let(::resolveFile) + } + fun apply(project: Project, extension: SekretPluginExtension = project.sekretExtension) { enabled.set(extension.properties.enabled) packageName.set(extension.properties.packageName) @@ -134,6 +169,7 @@ open class GenerateSekretTask : DefaultTask() { encryptionKey.set(extension.properties.encryptionKey) outputDirectory.set(project.findProject("sekret")?.projectDir ?: File(project.projectDir, "sekret")) propertiesFile.set(propertiesFile(project, extension.properties)) + googleServicesFile.set(googleServicesFile(project, extension.properties)) } companion object {