diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9220db..717e47e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ moshi = "1.15.0" retrofit = "2.9.0" junit = "4.13.2" ksp = "1.9.23-1.0.19" +play-publish = "v3-rev20231115-2.0.0" +google-auth = "1.20.0" [libraries] agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -29,6 +31,8 @@ kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version. ktlint-plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } detekt-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } ksp-plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +play-publish = { module = "com.google.apis:google-api-services-androidpublisher", version.ref = "play-publish" } +google-auth = { module = "com.google.auth:google-auth-library-oauth2-http", version.ref = "google-auth" } [plugins] agp = { id = "com.android.application", version.ref = "agp" } diff --git a/plugin-build/plugin/build.gradle.kts b/plugin-build/plugin/build.gradle.kts index 1509f7b..734ff52 100644 --- a/plugin-build/plugin/build.gradle.kts +++ b/plugin-build/plugin/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { implementation(libs.retrofitMoshi) implementation(libs.grgitCore) implementation(libs.grgitGradle) + implementation(libs.play.publish) + implementation(libs.google.auth) testImplementation(libs.junit) diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/BuildPublishPlugin.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/BuildPublishPlugin.kt index c84ef3f..6fd4e6b 100644 --- a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/BuildPublishPlugin.kt +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/BuildPublishPlugin.kt @@ -2,6 +2,7 @@ package ru.kode.android.build.publish.plugin +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.impl.VariantOutputImpl import com.android.build.gradle.AppExtension @@ -33,11 +34,13 @@ import ru.kode.android.build.publish.plugin.extension.config.ChangelogConfig import ru.kode.android.build.publish.plugin.extension.config.FirebaseAppDistributionConfig import ru.kode.android.build.publish.plugin.extension.config.JiraConfig import ru.kode.android.build.publish.plugin.extension.config.OutputConfig +import ru.kode.android.build.publish.plugin.extension.config.PlayConfig import ru.kode.android.build.publish.plugin.extension.config.SlackConfig import ru.kode.android.build.publish.plugin.extension.config.TelegramConfig import ru.kode.android.build.publish.plugin.task.appcenter.AppCenterDistributionTask import ru.kode.android.build.publish.plugin.task.changelog.GenerateChangelogTask import ru.kode.android.build.publish.plugin.task.jira.JiraAutomationTask +import ru.kode.android.build.publish.plugin.task.play.PlayDistributionTask import ru.kode.android.build.publish.plugin.task.slack.changelog.SendSlackChangelogTask import ru.kode.android.build.publish.plugin.task.slack.distribution.SlackDistributionTask import ru.kode.android.build.publish.plugin.task.tag.GetLastTagTask @@ -59,6 +62,7 @@ internal const val DEFAULT_VERSION_CODE = 1 internal const val DEFAULT_BASE_FILE_NAME = "dev-build" internal const val CHANGELOG_FILENAME = "changelog.txt" internal const val APP_CENTER_DISTRIBUTION_UPLOAD_TASK_PREFIX = "appCenterDistributionUpload" +internal const val PLAY_DISTRIBUTION_UPLOAD_TASK_PREFIX = "playUpload" internal const val SLACK_DISTRIBUTION_UPLOAD_TASK_PREFIX = "slackDistributionUpload" internal const val JIRA_AUTOMATION_TASK = "jiraAutomation" internal const val DEFAULT_CONTAINER_NAME = "default" @@ -93,17 +97,20 @@ abstract class BuildPublishPlugin : Plugin { as? VariantOutputImpl if (output != null) { val buildVariant = BuildVariant(variant.name, variant.flavorName, variant.buildType) - val outputFileName = output.outputFileName.get() + val apkOutputFileName = output.outputFileName.get() + + val bundleFile = variant.artifacts.get(SingleArtifact.BUNDLE) val outputProviders = project.registerVariantTasks( buildPublishExtension, buildVariant, changelogFile, - outputFileName, + apkOutputFileName, + bundleFile, grgitService, ) output.versionCode.set(outputProviders.versionCode) - output.outputFileName.set(outputProviders.outputFileName) + output.outputFileName.set(outputProviders.apkOutputFileName) output.versionName.set(outputProviders.versionName) } }, @@ -128,7 +135,8 @@ abstract class BuildPublishPlugin : Plugin { buildPublishExtension: BuildPublishExtension, buildVariant: BuildVariant, changelogFile: Provider, - outputFileName: String, + apkOutputFileName: String, + bundleFile: Provider, grgitService: Provider, ): OutputProviders { val outputConfig = @@ -150,15 +158,15 @@ abstract class BuildPublishPlugin : Plugin { project.provider { DEFAULT_VERSION_CODE } } } - val outputFileNameProvider = + val apkOutputFileNameProvider = useVersionsFromTagProvider.flatMap { useVersionsFromTag -> if (useVersionsFromTag) { outputConfig.baseFileName.zip(tagBuildProvider) { baseFileName, tagBuildFile -> - mapToOutputFileName(tagBuildFile, outputFileName, baseFileName) + mapToOutputApkFileName(tagBuildFile, apkOutputFileName, baseFileName) } } else { outputConfig.baseFileName.map { baseFileName -> - createDefaultOutputFileName(baseFileName, outputFileName) + createDefaultOutputFileName(baseFileName, apkOutputFileName) } } } @@ -172,9 +180,9 @@ abstract class BuildPublishPlugin : Plugin { project.provider { DEFAULT_VERSION_NAME } } } - val outputFileProvider = - outputFileNameProvider.flatMap { fileName -> - mapToOutputFile(buildVariant, fileName) + val apkOutputFileProvider = + apkOutputFileNameProvider.flatMap { fileName -> + mapToOutputApkFile(buildVariant, fileName) } tasks.registerPrintLastIncreasedTagTask( buildVariant, @@ -220,7 +228,7 @@ abstract class BuildPublishPlugin : Plugin { buildVariant, generateChangelogFileProvider, tagBuildProvider, - outputFileProvider, + apkOutputFileProvider, ) } val appCenterDistributionConfig = @@ -233,12 +241,27 @@ abstract class BuildPublishPlugin : Plugin { config = appCenterDistributionConfig, buildVariant = buildVariant, changelogFileProvider = generateChangelogFileProvider, - buildVariantOutputFileProvider = outputFileProvider, + apkOutputFileProvider = apkOutputFileProvider, tagBuildProvider = tagBuildProvider, outputConfig = outputConfig, ) tasks.registerAppCenterDistributionTask(params) } + val playConfig = + with(buildPublishExtension.play) { + findByName(buildVariant.name) ?: findByName(DEFAULT_CONTAINER_NAME) + } + if (playConfig != null) { + val params = + PlayTaskParams( + config = playConfig, + buildVariant = buildVariant, + bundleOutputFileProvider = bundleFile, + tagBuildProvider = tagBuildProvider, + outputConfig = outputConfig, + ) + tasks.registerPlayTask(params) + } val jiraConfig = with(buildPublishExtension.jira) { findByName(buildVariant.name) ?: findByName(DEFAULT_CONTAINER_NAME) @@ -256,7 +279,7 @@ abstract class BuildPublishPlugin : Plugin { return OutputProviders( versionName = versionNameProvider, versionCode = versionCodeProvider, - outputFileName = outputFileNameProvider, + apkOutputFileName = apkOutputFileNameProvider, ) } @@ -298,7 +321,7 @@ abstract class BuildPublishPlugin : Plugin { buildVariant: BuildVariant, generateChangelogFileProvider: Provider, tagBuildProvider: Provider, - outputFileProvider: Provider, + apkOutputFileProvider: Provider, ) { registerSendSlackChangelogTask( outputConfig, @@ -316,7 +339,7 @@ abstract class BuildPublishPlugin : Plugin { slackConfig.uploadApiTokenFile, slackConfig.uploadChannels, buildVariant, - outputFileProvider, + apkOutputFileProvider, ) } } @@ -325,13 +348,13 @@ abstract class BuildPublishPlugin : Plugin { apiTokenFile: RegularFileProperty, channels: SetProperty, buildVariant: BuildVariant, - outputFileProvider: Provider, + apkOutputFileProvider: Provider, ) { register( "$SLACK_DISTRIBUTION_UPLOAD_TASK_PREFIX${buildVariant.capitalizedName()}", SlackDistributionTask::class.java, ) { - it.buildVariantOutputFile.set(outputFileProvider) + it.buildVariantOutputFile.set(apkOutputFileProvider) it.apiTokenFile.set(apiTokenFile) it.channels.set(channels) } @@ -449,7 +472,7 @@ abstract class BuildPublishPlugin : Plugin { AppCenterDistributionTask::class.java, ) { it.tagBuildFile.set(params.tagBuildProvider) - it.buildVariantOutputFile.set(params.buildVariantOutputFileProvider) + it.buildVariantOutputFile.set(params.apkOutputFileProvider) it.changelogFile.set(params.changelogFileProvider) it.apiTokenFile.set(config.apiTokenFile) it.ownerName.set(config.ownerName) @@ -461,6 +484,23 @@ abstract class BuildPublishPlugin : Plugin { it.uploadStatusRequestDelayCoefficient.set(config.uploadStatusRequestDelayCoefficient) } } + + private fun TaskContainer.registerPlayTask(params: PlayTaskParams): TaskProvider { + val buildVariant = params.buildVariant + val config = params.config + + return register( + "$PLAY_DISTRIBUTION_UPLOAD_TASK_PREFIX${buildVariant.capitalizedName()}", + PlayDistributionTask::class.java, + ) { + it.tagBuildFile.set(params.tagBuildProvider) + it.buildVariantOutputFile.set(params.bundleOutputFileProvider) + it.apiTokenFile.set(config.apiTokenFile) + it.appId.set(config.appId) + it.trackId.set(config.trackId) + it.updatePriority.set(config.updatePriority) + } + } } private fun Project.configurePlugins( @@ -540,14 +580,22 @@ private fun AppDistributionExtension.configure( private data class OutputProviders( val versionName: Provider, val versionCode: Provider, - val outputFileName: Provider, + val apkOutputFileName: Provider, ) private data class AppCenterDistributionTaskParams( val config: AppCenterDistributionConfig, val buildVariant: BuildVariant, val changelogFileProvider: Provider, - val buildVariantOutputFileProvider: Provider, + val apkOutputFileProvider: Provider, + val tagBuildProvider: Provider, + val outputConfig: OutputConfig, +) + +private data class PlayTaskParams( + val config: PlayConfig, + val buildVariant: BuildVariant, + val bundleOutputFileProvider: Provider, val tagBuildProvider: Provider, val outputConfig: OutputConfig, ) @@ -561,7 +609,7 @@ private fun mapToVersionCode(tagBuildFile: RegularFile): Int { } } -private fun mapToOutputFileName( +private fun mapToOutputApkFileName( tagBuildFile: RegularFile, outputFileName: String, baseFileName: String?, @@ -600,7 +648,7 @@ private fun mapToVersionName( } } -private fun Project.mapToOutputFile( +private fun Project.mapToOutputApkFile( buildVariant: BuildVariant, fileName: String, ): Provider { diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/BuildPublishExtension.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/BuildPublishExtension.kt index 77b72b4..4c74087 100644 --- a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/BuildPublishExtension.kt +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/BuildPublishExtension.kt @@ -7,6 +7,7 @@ import ru.kode.android.build.publish.plugin.extension.config.ChangelogConfig import ru.kode.android.build.publish.plugin.extension.config.FirebaseAppDistributionConfig import ru.kode.android.build.publish.plugin.extension.config.JiraConfig import ru.kode.android.build.publish.plugin.extension.config.OutputConfig +import ru.kode.android.build.publish.plugin.extension.config.PlayConfig import ru.kode.android.build.publish.plugin.extension.config.SlackConfig import ru.kode.android.build.publish.plugin.extension.config.TelegramConfig import javax.inject.Inject @@ -31,4 +32,6 @@ abstract class BuildPublishExtension objectFactory.domainObjectContainer(FirebaseAppDistributionConfig::class.java) val appCenterDistribution: NamedDomainObjectContainer = objectFactory.domainObjectContainer(AppCenterDistributionConfig::class.java) + val play: NamedDomainObjectContainer = + objectFactory.domainObjectContainer(PlayConfig::class.java) } diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/config/PlayConfig.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/config/PlayConfig.kt new file mode 100644 index 0000000..e56f55c --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/config/PlayConfig.kt @@ -0,0 +1,36 @@ +package ru.kode.android.build.publish.plugin.extension.config + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile + +interface PlayConfig { + val name: String + + /** + * The path to file with token for Google Play project + */ + @get:InputFile + val apiTokenFile: RegularFileProperty + + /** + * appId in Google Play + */ + @get:Input + val appId: Property + + /** + * Track name of target app. Defaults to "internal" + */ + @get:Input + val trackId: Property + + /** + * Test groups for app distribution + * + * For example: [android-testers] + */ + @get:Input + val updatePriority: Property +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayDistributionTask.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayDistributionTask.kt new file mode 100644 index 0000000..d1e2654 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayDistributionTask.kt @@ -0,0 +1,95 @@ +package ru.kode.android.build.publish.plugin.task.play + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.plugins.BasePlugin +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import org.gradle.workers.WorkQueue +import org.gradle.workers.WorkerExecutor +import ru.kode.android.build.publish.plugin.enity.mapper.fromJson +import ru.kode.android.build.publish.plugin.task.play.work.PlayUploadWork +import javax.inject.Inject + +/** + * Task to publish app at given release track in google play console with set priority + * Contains basic functionality, for the extensions reference to original implementation: + * https://github.com/Triple-T/gradle-play-publisher + */ +abstract class PlayDistributionTask + @Inject + constructor( + private val workerExecutor: WorkerExecutor, + ) : DefaultTask() { + init { + description = "Task to send apk to Google Play" + group = BasePlugin.BUILD_GROUP + } + + @get:InputFile + @get:Option( + option = "buildVariantOutputFile", + description = "Artifact output file (absolute path is expected)", + ) + abstract val buildVariantOutputFile: RegularFileProperty + + @get:InputFile + @get:Option(option = "tagBuildFile", description = "Json contains info about tag build") + abstract val tagBuildFile: RegularFileProperty + + @get:InputFile + @get:Option( + option = "apiTokenFile", + description = "API token for google play console", + ) + abstract val apiTokenFile: RegularFileProperty + + @get:Input + @get:Option( + option = "appId", + description = "appId from Play Console", + ) + abstract val appId: Property + + @get:Input + @get:Option( + option = "trackId", + description = "Track name of target app. Defaults to internal", + ) + abstract val trackId: Property + + @get:Input + @get:Optional + @get:Option( + option = "updatePriority", + description = "Update priority (0..5)", + ) + abstract val updatePriority: Property + + @TaskAction + fun upload() { + val outputFile = buildVariantOutputFile.asFile.get() + if (outputFile.extension != "aab") throw GradleException("file ${outputFile.path} is not bundle") + val tag = fromJson(tagBuildFile.asFile.get()) + val releaseName = "${tag.name}(${tag.buildVersion}.${tag.buildNumber})" + val apiTokenFile = apiTokenFile.asFile.get() + val workQueue: WorkQueue = workerExecutor.noIsolation() + val trackId = trackId.orNull ?: "internal" + val appId = appId.get() + val updatePriority = updatePriority.orNull ?: 0 + + workQueue.submit(PlayUploadWork::class.java) { parameters -> + parameters.appId.set(appId) + parameters.apiToken.set(apiTokenFile) + parameters.trackId.set(trackId) + parameters.updatePriority.set(updatePriority) + parameters.releaseName.set(releaseName) + parameters.outputFile.set(outputFile) + } + } + } diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayPublisher.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayPublisher.kt new file mode 100644 index 0000000..5acafd1 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayPublisher.kt @@ -0,0 +1,91 @@ +package ru.kode.android.build.publish.plugin.task.play + +import ru.kode.android.build.publish.plugin.task.play.publisher.CommitResponse +import ru.kode.android.build.publish.plugin.task.play.publisher.EditResponse +import ru.kode.android.build.publish.plugin.task.play.publisher.GppProduct +import ru.kode.android.build.publish.plugin.task.play.publisher.UpdateProductResponse +import ru.kode.android.build.publish.plugin.task.play.publisher.UploadInternalSharingArtifactResponse +import java.io.File + +/** + * Proxy for the AndroidPublisher API. Separate the build side configuration from API dependencies + * to make testing easier. + * + * For the full API docs, see [here](https://developers.google.com/android-publisher/api-ref). + */ +interface PlayPublisher { + /** + * Creates a new edit. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/edits/insert). + */ + fun insertEdit(): EditResponse + + /** + * Retrieves an existing edit with the given [id]. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/edits/get). + */ + fun getEdit(id: String): EditResponse + + /** + * Commits an edit with the given [id]. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/edits/commit). + */ + fun commitEdit( + id: String, + sendChangesForReview: Boolean = true, + ): CommitResponse + + /** + * Validates an edit with the given [id]. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/edits/validate). + */ + fun validateEdit(id: String) + + /** + * Uploads the given [bundleFile] as an Internal Sharing artifact. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/internalappsharingartifacts/uploadbundle). + */ + fun uploadInternalSharingBundle(bundleFile: File): UploadInternalSharingArtifactResponse + + /** + * Uploads the given [apkFile] as an Internal Sharing artifact. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/internalappsharingartifacts/uploadapk). + */ + fun uploadInternalSharingApk(apkFile: File): UploadInternalSharingArtifactResponse + + /** + * Get all current products. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/inappproducts/list). + */ + fun getInAppProducts(): List + + /** + * Creates a new product from the given [productFile]. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/inappproducts/insert). + */ + fun insertInAppProduct(productFile: File) + + /** + * Updates an existing product from the given [productFile]. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/inappproducts/update). + */ + fun updateInAppProduct(productFile: File): UpdateProductResponse +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/AndroidPublisher.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/AndroidPublisher.kt new file mode 100644 index 0000000..f4315cc --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/AndroidPublisher.kt @@ -0,0 +1,108 @@ +package ru.kode.android.build.publish.plugin.task.play.publisher + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler +import com.google.api.client.http.HttpRequest +import com.google.api.client.http.HttpTransport +import com.google.api.client.http.apache.v2.ApacheHttpTransport +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.ExponentialBackOff +import com.google.api.services.androidpublisher.AndroidPublisher +import com.google.api.services.androidpublisher.AndroidPublisherScopes +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.GoogleCredentials +import org.apache.http.auth.AuthScope +import org.apache.http.auth.UsernamePasswordCredentials +import org.apache.http.impl.client.BasicCredentialsProvider +import org.apache.http.impl.client.ProxyAuthenticationStrategy +import java.io.FileInputStream +import java.io.InputStream +import java.lang.IllegalStateException +import java.security.KeyStore +import java.util.concurrent.TimeUnit + +@Suppress("TooGenericExceptionCaught") // Exception is rethrown with proper message +internal fun createPublisher(credentials: InputStream): AndroidPublisher { + val transport = buildTransport() + val credential = + try { + GoogleCredentials.fromStream(credentials) { transport } + } catch (e: Exception) { + throw IllegalStateException( + "Credential parsing may have failed. " + + "Ensure credential files supplied in the DSL contain valid JSON " + + "and/or the ANDROID_PUBLISHER_CREDENTIALS envvar contains valid JSON " + + "(not a file path).", + e, + ) + }.createScoped(listOf(AndroidPublisherScopes.ANDROIDPUBLISHER)) + + return AndroidPublisher.Builder( + transport, + GsonFactory.getDefaultInstance(), + AndroidPublisherAdapter(credential), + ).setApplicationName("PLUGIN_NAME").build() +} + +internal infix fun GoogleJsonResponseException.has(error: String) = details?.errors.orEmpty().any { it.reason == error } + +private fun buildTransport(): HttpTransport { + val trustStore: String? = System.getProperty("javax.net.ssl.trustStore", null) + val trustStorePassword: String? = + System.getProperty("javax.net.ssl.trustStorePassword", null) + + return if (trustStore == null) { + createHttpTransport() + } else { + val ks = KeyStore.getInstance(KeyStore.getDefaultType()) + FileInputStream(trustStore).use { fis -> + ks.load(fis, trustStorePassword?.toCharArray()) + } + NetHttpTransport.Builder().trustCertificates(ks).build() + } +} + +private fun createHttpTransport(): HttpTransport { + val protocols = arrayOf("http", "https") + for (protocol in protocols) { + val proxyHost = System.getProperty("$protocol.proxyHost") + val proxyUser = System.getProperty("$protocol.proxyUser") + val proxyPassword = System.getProperty("$protocol.proxyPassword") + if (proxyHost != null && proxyUser != null && proxyPassword != null) { + val defaultProxyPort = if (protocol == "http") "80" else "443" + val proxyPort = Integer.parseInt(System.getProperty("$protocol.proxyPort", defaultProxyPort)) + val credentials = BasicCredentialsProvider() + credentials.setCredentials( + AuthScope(proxyHost, proxyPort), + UsernamePasswordCredentials(proxyUser, proxyPassword), + ) + val httpClient = + ApacheHttpTransport.newDefaultHttpClientBuilder() + .setProxyAuthenticationStrategy(ProxyAuthenticationStrategy.INSTANCE) + .setDefaultCredentialsProvider(credentials) + .build() + return ApacheHttpTransport(httpClient) + } + } + return GoogleNetHttpTransport.newTrustedTransport() +} + +private class AndroidPublisherAdapter( + credential: GoogleCredentials, +) : HttpCredentialsAdapter(credential) { + override fun initialize(request: HttpRequest) { + val backOffHandler = + HttpBackOffUnsuccessfulResponseHandler( + ExponentialBackOff.Builder() + .setMaxElapsedTimeMillis(TimeUnit.MINUTES.toMillis(3).toInt()) + .build(), + ) + + super.initialize( + request.setReadTimeout(0) + .setUnsuccessfulResponseHandler(backOffHandler), + ) + } +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/DefaultPlayPublisher.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/DefaultPlayPublisher.kt new file mode 100644 index 0000000..6fba296 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/DefaultPlayPublisher.kt @@ -0,0 +1,234 @@ +package ru.kode.android.build.publish.plugin.task.play.publisher + +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.client.googleapis.media.MediaHttpUploader +import com.google.api.client.googleapis.services.AbstractGoogleClientRequest +import com.google.api.client.http.FileContent +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.androidpublisher.AndroidPublisher +import com.google.api.services.androidpublisher.model.Apk +import com.google.api.services.androidpublisher.model.Bundle +import com.google.api.services.androidpublisher.model.DeobfuscationFilesUploadResponse +import com.google.api.services.androidpublisher.model.ExpansionFile +import com.google.api.services.androidpublisher.model.InAppProduct +import com.google.api.services.androidpublisher.model.Track +import java.io.File +import kotlin.math.roundToInt + +internal class DefaultPlayPublisher( + private val publisher: AndroidPublisher, + override val appId: String, +) : InternalPlayPublisher { + override fun insertEdit(): EditResponse { + return try { + EditResponse.Success(publisher.edits().insert(appId, null).execute().id) + } catch (e: GoogleJsonResponseException) { + EditResponse.Failure(e) + } + } + + override fun getEdit(id: String): EditResponse { + return try { + EditResponse.Success(publisher.edits().get(appId, id).execute().id) + } catch (e: GoogleJsonResponseException) { + EditResponse.Failure(e) + } + } + + override fun commitEdit( + id: String, + sendChangesForReview: Boolean, + ): CommitResponse { + return try { + publisher.edits().commit(appId, id) + .setChangesNotSentForReview(!sendChangesForReview) + .execute() + CommitResponse.Success + } catch (e: GoogleJsonResponseException) { + CommitResponse.Failure(e) + } + } + + override fun validateEdit(id: String) { + publisher.edits().validate(appId, id).execute() + } + + override fun getTrack( + editId: String, + track: String, + ): Track { + return try { + publisher.edits().tracks().get(appId, editId, track).execute() + } catch (e: GoogleJsonResponseException) { + if (e has "notFound") { + Track().setTrack(track) + } else { + throw e + } + } + } + + override fun listTracks(editId: String): List { + return publisher.edits().tracks().list(appId, editId).execute()?.tracks.orEmpty() + } + + override fun updateTrack( + editId: String, + track: Track, + ) { + println( + "Updating ${track.releases.map { it.status }.distinct()} release " + + "($appId:${track.releases.flatMap { it.versionCodes.orEmpty() }}) " + + "in track '${track.track}'", + ) + publisher.edits().tracks().update(appId, editId, track.track, track).execute() + } + + override fun uploadBundle( + editId: String, + bundleFile: File, + ): Bundle { + val content = FileContent(MIME_TYPE_STREAM, bundleFile) + return publisher.edits().bundles().upload(appId, editId, content) + .trackUploadProgress("App Bundle", bundleFile) + .execute() + } + + override fun uploadApk( + editId: String, + apkFile: File, + ): Apk { + val content = FileContent(MIME_TYPE_APK, apkFile) + return publisher.edits().apks().upload(appId, editId, content) + .trackUploadProgress("APK", apkFile) + .execute() + } + + override fun attachObb( + editId: String, + type: String, + appVersion: Int, + obbVersion: Int, + ) { + val obb = ExpansionFile().also { it.referencesVersion = obbVersion } + publisher.edits().expansionfiles() + .update(appId, editId, appVersion, type, obb) + .execute() + } + + override fun uploadDeobfuscationFile( + editId: String, + file: File, + versionCode: Int, + type: String, + ): DeobfuscationFilesUploadResponse { + val mapping = FileContent(MIME_TYPE_STREAM, file) + val humanFileName = + when (type) { + "proguard" -> "mapping" + "nativeCode" -> "native debug symbols" + else -> type + } + return publisher.edits().deobfuscationfiles() + .upload(appId, editId, versionCode, type, mapping) + .trackUploadProgress("$humanFileName file", file) + .execute() + } + + override fun uploadInternalSharingBundle(bundleFile: File): UploadInternalSharingArtifactResponse { + val bundle = + publisher.internalappsharingartifacts() + .uploadbundle(appId, FileContent(MIME_TYPE_STREAM, bundleFile)) + .trackUploadProgress("App Bundle", bundleFile) + .execute() + + return UploadInternalSharingArtifactResponse(bundle.toPrettyString(), bundle.downloadUrl) + } + + override fun uploadInternalSharingApk(apkFile: File): UploadInternalSharingArtifactResponse { + val apk = + publisher.internalappsharingartifacts() + .uploadapk(appId, FileContent(MIME_TYPE_APK, apkFile)) + .trackUploadProgress("APK", apkFile) + .execute() + + return UploadInternalSharingArtifactResponse(apk.toPrettyString(), apk.downloadUrl) + } + + override fun getInAppProducts(): List { + fun AndroidPublisher.Inappproducts.List.withToken(token: String?) = + apply { + this.token = token + } + + val products = mutableListOf() + + var token: String? = null + do { + val response = publisher.inappproducts().list(appId).withToken(token).execute() + products += response.inappproduct.orEmpty() + token = response.tokenPagination?.nextPageToken + } while (token != null) + + return products.map { + GppProduct(it.sku, it.toPrettyString()) + } + } + + override fun insertInAppProduct(productFile: File) { + publisher.inappproducts().insert(appId, readProductFile(productFile)) + .apply { autoConvertMissingPrices = true } + .execute() + } + + override fun updateInAppProduct(productFile: File): UpdateProductResponse { + val product = readProductFile(productFile) + try { + publisher.inappproducts().update(appId, product.sku, product) + .apply { autoConvertMissingPrices = true } + .execute() + } catch (e: GoogleJsonResponseException) { + if (e.statusCode == 404) { + return UpdateProductResponse(true) + } else { + throw e + } + } + + return UpdateProductResponse(false) + } + + private fun readProductFile(product: File) = + product.inputStream().use { + GsonFactory.getDefaultInstance() + .createJsonParser(it) + .parse(InAppProduct::class.java) + } + + private fun > R.trackUploadProgress( + thing: String, + file: File, + ): R { + mediaHttpUploader?.setProgressListener { + when (it.uploadState) { + MediaHttpUploader.UploadState.INITIATION_STARTED -> + println("Starting $thing upload: $file") + + MediaHttpUploader.UploadState.MEDIA_IN_PROGRESS -> + println("Uploading $thing: ${(it.progress * 100).roundToInt()}% complete") + + MediaHttpUploader.UploadState.MEDIA_COMPLETE -> + println("${thing.capitalize()} upload complete") + + else -> {} + } + } + return this + } + + private companion object { + const val MIME_TYPE_STREAM = "application/octet-stream" + const val MIME_TYPE_APK = "application/vnd.android.package-archive" + const val MIME_TYPE_IMAGE = "image/*" + } +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/InternalPlayPublisher.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/InternalPlayPublisher.kt new file mode 100644 index 0000000..ec40730 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/InternalPlayPublisher.kt @@ -0,0 +1,51 @@ +package ru.kode.android.build.publish.plugin.task.play.publisher + +import com.google.api.services.androidpublisher.model.Apk +import com.google.api.services.androidpublisher.model.Bundle +import com.google.api.services.androidpublisher.model.DeobfuscationFilesUploadResponse +import com.google.api.services.androidpublisher.model.Track +import ru.kode.android.build.publish.plugin.task.play.PlayPublisher +import java.io.File +import java.io.IOException + +internal interface InternalPlayPublisher : PlayPublisher { + val appId: String + + fun getTrack( + editId: String, + track: String, + ): Track + + fun listTracks(editId: String): List + + fun updateTrack( + editId: String, + track: Track, + ) + + @Throws(IOException::class) + fun uploadBundle( + editId: String, + bundleFile: File, + ): Bundle + + @Throws(IOException::class) + fun uploadApk( + editId: String, + apkFile: File, + ): Apk + + fun attachObb( + editId: String, + type: String, + appVersion: Int, + obbVersion: Int, + ) + + fun uploadDeobfuscationFile( + editId: String, + file: File, + versionCode: Int, + type: String, + ): DeobfuscationFilesUploadResponse +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/PublisherModels.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/PublisherModels.kt new file mode 100644 index 0000000..d364615 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/PublisherModels.kt @@ -0,0 +1,53 @@ +package ru.kode.android.build.publish.plugin.task.play.publisher + +/** + * Models the possible release statuses for the track API. + * + * More docs are available + * [here](https://developers.google.com/android-publisher/api-ref/edits/tracks). + */ +@Suppress("EnumNaming") // Google API Model +enum class ReleaseStatus( + /** The API name of the status. */ + val publishedName: String, +) { + /** The release is live. */ + COMPLETED("completed"), + + /** The release is in draft mode. */ + DRAFT("draft"), + + /** The release was aborted. */ + HALTED("halted"), + + /** The release is still being rolled out. */ + IN_PROGRESS("inProgress"), +} + +/** + * Models the possible resolution strategies for handling artifact upload conflicts. + * + * More docs are available + * [here](https://github.com/Triple-T/gradle-play-publisher#handling-version-conflicts). + */ +@Suppress("EnumNaming") // Google API Model +enum class ResolutionStrategy( + /** The API name of the strategy. */ + val publishedName: String, +) { + /** Conflicts should be automagically resolved. */ + AUTO("auto"), + + /** + * Unlike [AUTO] which diffs your Play Store version code with the local one, [AUTO_OFFSET] is + * much simpler and just adds the local version code to the Play Store one when + * `local <= play_store`. + */ + AUTO_OFFSET("auto_offset"), + + /** Fail the build at the first sign of conflict. */ + FAIL("fail"), + + /** Keep going and pretend like nothing happened. */ + IGNORE("ignore"), +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/Responses.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/Responses.kt new file mode 100644 index 0000000..28a6bb1 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/Responses.kt @@ -0,0 +1,123 @@ +package ru.kode.android.build.publish.plugin.task.play.publisher + +import com.google.api.client.googleapis.json.GoogleJsonResponseException + +/** Response for an app details request. */ +data class GppAppDetails internal constructor( + /** The default language. */ + val defaultLocale: String?, + /** Developer contact email. */ + val contactEmail: String?, + /** Developer contact phone. */ + val contactPhone: String?, + /** Developer contact website. */ + val contactWebsite: String?, +) + +/** Response for an app listing request. */ +data class GppListing internal constructor( + /** The listing's language. */ + val locale: String, + /** The app description. */ + val fullDescription: String?, + /** The app tagline. */ + val shortDescription: String?, + /** The app title. */ + val title: String?, + /** The app promo url. */ + val video: String?, +) + +/** Response for an app graphic request. */ +data class GppImage internal constructor( + /** The image's download URL. */ + val url: String, + /** The image's SHA256 hash. */ + val sha256: String, +) + +/** Response for a track release note request. */ +data class ReleaseNote internal constructor( + /** The release note's track. */ + val track: String, + /** The release note's language. */ + val locale: String, + /** The release note. */ + val contents: String, +) + +/** Response for an edit request. */ +sealed class EditResponse { + /** Response for a successful edit request. */ + data class Success internal constructor( + /** The id of the edit in question. */ + val id: String, + ) : EditResponse() + + /** Response for an unsuccessful edit request. */ + data class Failure internal constructor( + private val e: GoogleJsonResponseException, + ) : EditResponse() { + /** @return true if the app wasn't found in the Play Console, false otherwise */ + fun isNewApp(): Boolean = e has "applicationNotFound" + + /** @return true if the provided edit is invalid for any reason, false otherwise */ + fun isInvalidEdit(): Boolean = e has "editAlreadyCommitted" || e has "editNotFound" || e has "editExpired" + + /** @return true if the user doesn't have permission to access this app, false otherwise */ + fun isUnauthorized(): Boolean = e.statusCode == 401 + + /** Cleanly rethrows the error. */ + fun rethrow(): Nothing = throw e + + /** Wraps the error in a new exception with the provided [newMessage]. */ + fun rethrow(newMessage: String): Nothing = throw IllegalStateException(newMessage, e) + } +} + +/** Response for an commit request. */ +sealed class CommitResponse { + /** Response for a successful commit request. */ + object Success : CommitResponse() + + /** Response for an unsuccessful commit request. */ + data class Failure internal constructor( + private val e: GoogleJsonResponseException, + ) : CommitResponse() { + /** @return true if the changes cannot be sent for review, false otherwise */ + fun failedToSendForReview(): Boolean = + e has "badRequest" && + e.details.message.orEmpty().contains("changesNotSentForReview") + + /** Cleanly rethrows the error. */ + fun rethrow(suppressed: Failure? = null): Nothing { + if (suppressed != null) { + e.addSuppressed(suppressed.e) + } + + throw e + } + } +} + +/** Response for an internal sharing artifact upload. */ +data class UploadInternalSharingArtifactResponse internal constructor( + /** The response's full JSON payload. */ + val json: String, + /** The download URL of the uploaded artifact. */ + val downloadUrl: String, +) + +/** Response for a product request. */ +data class GppProduct internal constructor( + /** The product ID. */ + val sku: String, + /** The response's full JSON payload. */ + val json: String, +) + +/** Response for a product update request. */ +data class UpdateProductResponse internal constructor( + /** @return true if the product doesn't exist and needs to be created, false otherwise. */ + val needsCreating: Boolean, +) diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/DefaultEditManager.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/DefaultEditManager.kt new file mode 100644 index 0000000..2572e78 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/DefaultEditManager.kt @@ -0,0 +1,130 @@ +package ru.kode.android.build.publish.plugin.task.play.track + +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import org.slf4j.LoggerFactory +import ru.kode.android.build.publish.plugin.task.play.publisher.InternalPlayPublisher +import ru.kode.android.build.publish.plugin.task.play.publisher.ReleaseStatus +import ru.kode.android.build.publish.plugin.task.play.publisher.ResolutionStrategy +import ru.kode.android.build.publish.plugin.task.play.publisher.has +import java.io.File + +internal class DefaultEditManager( + private val publisher: InternalPlayPublisher, + private val tracks: TrackManager, + private val editId: String, +) : EditManager { + override fun promoteRelease( + promoteTrackName: String, + fromTrackName: String, + releaseStatus: ReleaseStatus?, + releaseName: String?, + releaseNotes: Map?, + userFraction: Double?, + updatePriority: Int?, + retainableArtifacts: List?, + versionCode: Long?, + ) { + tracks.promote( + TrackManager.PromoteConfig( + promoteTrackName, + fromTrackName, + versionCode, + TrackManager.BaseConfig( + releaseStatus, + userFraction, + updatePriority, + releaseNotes, + retainableArtifacts, + releaseName, + ), + ), + ) + } + + override fun uploadBundle( + bundleFile: File, + strategy: ResolutionStrategy, + ): Long? { + val bundle = + try { + publisher.uploadBundle(editId, bundleFile) + } catch (e: GoogleJsonResponseException) { + handleUploadFailures(e, strategy, bundleFile) + return null + } + + return bundle.versionCode.toLong() + } + + override fun publishArtifacts( + versionCodes: List, + didPreviousBuildSkipCommit: Boolean, + trackName: String, + releaseStatus: ReleaseStatus?, + releaseName: String?, + releaseNotes: Map?, + userFraction: Double?, + updatePriority: Int?, + retainableArtifacts: List?, + ) { + if (versionCodes.isEmpty()) return + + tracks.update( + TrackManager.UpdateConfig( + trackName, + versionCodes, + didPreviousBuildSkipCommit, + TrackManager.BaseConfig( + releaseStatus, + userFraction, + updatePriority, + releaseNotes, + retainableArtifacts, + releaseName, + ), + ), + ) + } + + @Suppress("ComplexCondition") // API response code handling + private fun handleUploadFailures( + e: GoogleJsonResponseException, + strategy: ResolutionStrategy, + artifact: File, + ): Nothing? = + if ( + e has "apkNotificationMessageKeyUpgradeVersionConflict" || + e has "apkUpgradeVersionConflict" || + e has "apkNoUpgradePath" || + e has "forbidden" && + e.details.message.orEmpty().let { m -> + // Bundle message: APK specifies a version code that has already been used. + // APK message: Cannot update a published APK. + m.contains("version code", ignoreCase = true) || m.contains("Cannot update", ignoreCase = true) + } + ) { + when (strategy) { + ResolutionStrategy.AUTO, ResolutionStrategy.AUTO_OFFSET -> throw IllegalStateException( + "Concurrent uploads for app ${publisher.appId} (version code " + + "already used). Make sure to synchronously upload your APKs such " + + "that they don't conflict. If this problem persists, delete your " + + "drafts in the Play Console's artifact library.", + e, + ) + + ResolutionStrategy.FAIL -> throw IllegalStateException( + "Version code is too low or has already been used for app " + + "${publisher.appId}.", + e, + ) + + ResolutionStrategy.IGNORE -> + LoggerFactory.getLogger(EditManager::class.java).warn( + "Ignoring artifact ($artifact)", + ) + } + null + } else { + throw e + } +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/EditManager.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/EditManager.kt new file mode 100644 index 0000000..bf4476a --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/EditManager.kt @@ -0,0 +1,36 @@ +package ru.kode.android.build.publish.plugin.task.play.track + +import ru.kode.android.build.publish.plugin.task.play.publisher.ReleaseStatus +import ru.kode.android.build.publish.plugin.task.play.publisher.ResolutionStrategy +import java.io.File + +interface EditManager { + fun promoteRelease( + promoteTrackName: String, + fromTrackName: String, + releaseStatus: ReleaseStatus?, + releaseName: String?, + releaseNotes: Map?, + userFraction: Double?, + updatePriority: Int?, + retainableArtifacts: List?, + versionCode: Long?, + ) + + fun uploadBundle( + bundleFile: File, + strategy: ResolutionStrategy, + ): Long? + + fun publishArtifacts( + versionCodes: List, + didPreviousBuildSkipCommit: Boolean, + trackName: String, + releaseStatus: ReleaseStatus?, + releaseName: String?, + releaseNotes: Map?, + userFraction: Double?, + updatePriority: Int?, + retainableArtifacts: List?, + ) +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/TrackManager.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/TrackManager.kt new file mode 100644 index 0000000..2384aef --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/TrackManager.kt @@ -0,0 +1,236 @@ +package ru.kode.android.build.publish.plugin.task.play.track + +import com.google.api.services.androidpublisher.model.LocalizedText +import com.google.api.services.androidpublisher.model.Track +import com.google.api.services.androidpublisher.model.TrackRelease +import ru.kode.android.build.publish.plugin.task.play.publisher.InternalPlayPublisher +import ru.kode.android.build.publish.plugin.task.play.publisher.ReleaseStatus + +internal interface TrackManager { + fun update(config: UpdateConfig) + + fun promote(config: PromoteConfig) + + data class BaseConfig( + val releaseStatus: ReleaseStatus? = null, + val userFraction: Double?, + val updatePriority: Int?, + val releaseNotes: Map? = emptyMap(), + val retainableArtifacts: List? = null, + val releaseName: String?, + ) + + data class UpdateConfig( + val trackName: String, + val versionCodes: List, + val didPreviousBuildSkipCommit: Boolean, + val base: BaseConfig, + ) + + data class PromoteConfig( + val promoteTrackName: String, + val fromTrackName: String, + val versionCode: Long?, + val base: BaseConfig, + ) +} + +internal class DefaultTrackManager( + private val publisher: InternalPlayPublisher, + private val editId: String, +) : TrackManager { + override fun update(config: TrackManager.UpdateConfig) { + val track = + if (config.didPreviousBuildSkipCommit) { + createTrackForSkippedCommit(config) + } else if (config.base.releaseStatus.orDefault().isRollout()) { + createTrackForRollout(config) + } else { + createDefaultTrack(config) + } + + publisher.updateTrack(editId, track) + } + + override fun promote(config: TrackManager.PromoteConfig) { + val track = publisher.getTrack(editId, config.fromTrackName) + check(track.releases.orEmpty().flatMap { it.versionCodes.orEmpty() }.isNotEmpty()) { + "Track '${config.fromTrackName}' has no releases. Did you mean to run publish?" + } + + // Update the track + for (release in track.releases) { + release.mergeChanges(config.versionCode?.let { listOf(it) }, config.base) + } + // Only keep the unique statuses from the highest version code since duplicate statuses are + // not allowed. This is how we deal with an update from inProgress -> completed. We update + // all the tracks to completed, then get rid of the one that used to be inProgress. + track.releases = + track.releases.sortedByDescending { + it.versionCodes?.maxOrNull() + }.distinctBy { + it.status + } + + println("Promoting release from track '${track.track}'") + track.track = config.promoteTrackName + publisher.updateTrack(editId, track) + } + + @Suppress("NestedBlockDepth") // TODO refactor and simplify TrackManager + private fun createTrackForSkippedCommit(config: TrackManager.UpdateConfig): Track { + val track = publisher.getTrack(editId, config.trackName) + + if (track.releases.isNullOrEmpty()) { + track.releases = listOf(TrackRelease().mergeChanges(config.versionCodes, config.base)) + } else { + val hasReleaseToBeUpdated = + track.releases.firstOrNull { + it.status == config.base.releaseStatus.orDefault().publishedName + } != null + + if (hasReleaseToBeUpdated) { + for (release in track.releases) { + if (release.status == config.base.releaseStatus.orDefault().publishedName) { + release.mergeChanges( + release.versionCodes.orEmpty() + config.versionCodes, + config.base, + ) + } + } + } else { + val release = + TrackRelease().mergeChanges(config.versionCodes, config.base).apply { + maybeCopyChangelogFromPreviousRelease(config.trackName) + } + track.releases = track.releases + release + } + } + + return track + } + + private fun createTrackForRollout(config: TrackManager.UpdateConfig): Track { + val track = publisher.getTrack(editId, config.trackName) + + val keep = track.releases.orEmpty().filterNot { it.isRollout() } + val release = + TrackRelease().mergeChanges(config.versionCodes, config.base).apply { + maybeCopyChangelogFromPreviousRelease(config.trackName) + } + track.releases = keep + release + + return track + } + + private fun createDefaultTrack(config: TrackManager.UpdateConfig) = + Track().apply { + track = config.trackName + val release = + TrackRelease() + .mergeChanges(config.versionCodes, config.base) + .apply { maybeCopyChangelogFromPreviousRelease(config.trackName) } + releases = listOf(release) + } + + private fun TrackRelease.maybeCopyChangelogFromPreviousRelease(trackName: String) { + if (!releaseNotes.isNullOrEmpty()) return + + val previousRelease = + publisher.getTrack(editId, trackName) + .releases + .orEmpty() + .maxByOrNull { it.versionCodes.orEmpty().maxOrNull() ?: 1 } + releaseNotes = previousRelease?.releaseNotes + } + + private fun TrackRelease.mergeChanges( + versionCodes: List?, + config: TrackManager.BaseConfig, + ) = apply { + updateVersionCodes(versionCodes, config.retainableArtifacts) + updateStatus(config.releaseStatus, config.userFraction != null) + updateConsoleName(config.releaseName) + updateReleaseNotes(config.releaseNotes) + updateUserFraction(config.userFraction) + updateUpdatePriority(config.updatePriority) + } + + private fun TrackRelease.updateVersionCodes( + versionCodes: List?, + retainableArtifacts: List?, + ) { + val newVersions = versionCodes ?: this.versionCodes.orEmpty() + this.versionCodes = newVersions + retainableArtifacts.orEmpty() + } + + private fun TrackRelease.updateStatus( + releaseStatus: ReleaseStatus?, + hasUserFraction: Boolean, + ) { + if (releaseStatus != null) { + status = releaseStatus.publishedName + } else if (hasUserFraction) { + status = ReleaseStatus.IN_PROGRESS.publishedName + } else if (status == null) { + status = DEFAULT_RELEASE_STATUS.publishedName + } + } + + private fun TrackRelease.updateConsoleName(releaseName: String?) { + if (releaseName != null) name = releaseName + } + + private fun TrackRelease.updateReleaseNotes(rawReleaseNotes: Map?) { + val releaseNotes = + rawReleaseNotes.orEmpty().map { (locale, notes) -> + LocalizedText().apply { + language = locale + text = notes + } + } + val existingReleaseNotes = this.releaseNotes.orEmpty() + + this.releaseNotes = + if (existingReleaseNotes.isEmpty()) { + releaseNotes + } else { + val merged = releaseNotes.toMutableList() + + for (existing in existingReleaseNotes) { + if (merged.none { it.language == existing.language }) merged += existing + } + + merged + } + } + + private fun TrackRelease.updateUserFraction(userFraction: Double?) { + if (userFraction != null) { + this.userFraction = userFraction.takeIf { isRollout() } + } else if (isRollout() && this.userFraction == null) { + this.userFraction = DEFAULT_USER_FRACTION + } else if (!isRollout()) { + this.userFraction = null + } + } + + private fun TrackRelease.updateUpdatePriority(updatePriority: Int?) { + if (updatePriority != null) { + inAppUpdatePriority = updatePriority + } + } + + private fun ReleaseStatus.isRollout() = this == ReleaseStatus.IN_PROGRESS || this == ReleaseStatus.HALTED + + private fun TrackRelease.isRollout() = + status == ReleaseStatus.IN_PROGRESS.publishedName || + status == ReleaseStatus.HALTED.publishedName + + private fun ReleaseStatus?.orDefault() = this ?: DEFAULT_RELEASE_STATUS + + private companion object { + const val DEFAULT_USER_FRACTION = 0.1 + val DEFAULT_RELEASE_STATUS = ReleaseStatus.COMPLETED + } +} diff --git a/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/work/PlayUploadWork.kt b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/work/PlayUploadWork.kt new file mode 100644 index 0000000..f2a2bae --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/work/PlayUploadWork.kt @@ -0,0 +1,88 @@ +package ru.kode.android.build.publish.plugin.task.play.work + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Property +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import ru.kode.android.build.publish.plugin.task.play.publisher.DefaultPlayPublisher +import ru.kode.android.build.publish.plugin.task.play.publisher.EditResponse +import ru.kode.android.build.publish.plugin.task.play.publisher.ResolutionStrategy +import ru.kode.android.build.publish.plugin.task.play.publisher.createPublisher +import ru.kode.android.build.publish.plugin.task.play.track.DefaultEditManager +import ru.kode.android.build.publish.plugin.task.play.track.DefaultTrackManager +import ru.kode.android.build.publish.plugin.task.play.track.TrackManager + +interface PlayUploadParameters : WorkParameters { + val appId: Property + val apiToken: RegularFileProperty + val outputFile: RegularFileProperty + val trackId: Property + val releaseName: Property + val versionCode: Property + val updatePriority: Property +} + +abstract class PlayUploadWork : WorkAction { + private val logger = Logging.getLogger(this::class.java) + + override fun execute() { + val track = parameters.trackId.get() + val priority = parameters.updatePriority.orNull ?: 0 + val releaseName = parameters.releaseName.get() + val file = parameters.outputFile.asFile.get() + + val publisher = + DefaultPlayPublisher( + publisher = createPublisher(parameters.apiToken.asFile.get().inputStream()), + appId = parameters.appId.get(), + ) + logger.info("Step 1/3: Requesting track edit...") + val editId = + when (val result = publisher.insertEdit()) { + is EditResponse.Success -> result.id + is EditResponse.Failure -> { + if (result.isNewApp()) { + logger.error("Error response: app does not exist") + } else { + logger.error("Error response: $result") + } + null + } + } + + if (editId == null) { + logger.error("Failed to fetch edit id to upload bundle") + return + } + + val trackManager = DefaultTrackManager(publisher, editId) + val editManager = DefaultEditManager(publisher, trackManager, editId) + + logger.info("Step 2/3: Upload bundle for $editId") + + val versionCode = editManager.uploadBundle(file, ResolutionStrategy.IGNORE) + + if (versionCode == null) { + logger.error("Failed to upload bundle") + return + } + + logger.info("Step 3/3: Pushing $releaseName to $track at P=$priority V=$versionCode") + + trackManager.update( + config = + TrackManager.UpdateConfig( + trackName = track, + versionCodes = listOf(versionCode), + didPreviousBuildSkipCommit = false, + TrackManager.BaseConfig( + userFraction = parameters.versionCode.orNull ?: 0.1, + updatePriority = priority, + releaseName = parameters.releaseName.get(), + ), + ), + ) + logger.info("Step 3/3: Bundle upload successful") + } +}