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..91b9db2 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) @@ -31,7 +33,7 @@ gradlePlugin { id = "ru.kode.android.build-publish" displayName = "Configure project with Firebase App Distribution and changelogs" implementationClass = "ru.kode.android.build.publish.plugin.BuildPublishPlugin" - version = project.version + version = "1.3.2" description = "Android plugin to publish bundles and apks to Firebase App Distribution with changelogs" tags.set(listOf("firebase", "publish", "changelog", "build")) } @@ -43,7 +45,7 @@ publishing { create("maven") { groupId = project.group.toString() artifactId = "ru.kode.android.build-publish".removePrefix("$groupId.") - version = project.version.toString() + version = "1.3.2" from(components["java"]) } 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..0f5b730 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 @@ -6,6 +6,7 @@ import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.impl.VariantOutputImpl import com.android.build.gradle.AppExtension import com.android.build.gradle.AppPlugin +import com.android.build.gradle.internal.tasks.PackageBundleTask import com.android.build.gradle.tasks.PackageAndroidArtifact import com.android.builder.model.Version.ANDROID_GRADLE_PLUGIN_VERSION import com.google.firebase.appdistribution.gradle.AppDistributionExtension @@ -18,6 +19,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logging import org.gradle.api.provider.Provider import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.StopExecutionException @@ -33,11 +35,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 @@ -47,6 +51,7 @@ import ru.kode.android.build.publish.plugin.util.capitalizedName import java.io.File import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.logging.Logger internal const val SEND_SLACK_CHANGELOG_TASK_PREFIX = "sendSlackChangelog" internal const val SEND_TELEGRAM_CHANGELOG_TASK_PREFIX = "sendTelegramChangelog" @@ -59,6 +64,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" @@ -87,6 +93,10 @@ abstract class BuildPublishPlugin : Plugin { androidExtension.onVariants( callback = { variant -> + Logging.getLogger(this::class.java).info("FOUND VARIANT: $variant WITH OUTPUT NAME ${variant.outputs + .find { it is VariantOutputImpl && it.fullName == variant.name } + as? VariantOutputImpl}") + val output = variant.outputs .find { it is VariantOutputImpl && it.fullName == variant.name } @@ -239,6 +249,21 @@ abstract class BuildPublishPlugin : Plugin { ) 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, + buildVariantOutputFileProvider = outputFileProvider, + tagBuildProvider = tagBuildProvider, + outputConfig = outputConfig, + ) + tasks.registerPlayTask(params) + } val jiraConfig = with(buildPublishExtension.jira) { findByName(buildVariant.name) ?: findByName(DEFAULT_CONTAINER_NAME) @@ -461,6 +486,25 @@ 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.buildVariantOutputFileProvider) + it.apiTokenFile.set(config.apiTokenFile) + it.appId.set(config.appId) + it.trackId.set(config.trackId) + it.updatePriority.set(config.updatePriority) + } + } } private fun Project.configurePlugins( @@ -552,6 +596,15 @@ private data class AppCenterDistributionTaskParams( val outputConfig: OutputConfig, ) + +private data class PlayTaskParams( + val config: PlayConfig, + val buildVariant: BuildVariant, + val buildVariantOutputFileProvider: Provider, + val tagBuildProvider: Provider, + val outputConfig: OutputConfig, +) + private fun mapToVersionCode(tagBuildFile: RegularFile): Int { val file = tagBuildFile.asFile return if (file.exists()) { @@ -573,8 +626,15 @@ private fun mapToOutputFileName( val versionName = tagBuild.buildVariant val versionCode = tagBuild.buildNumber "$baseFileName-$versionName-vc$versionCode-$formattedDate.apk" + } else if (file.exists() && outputFileName.endsWith(".aab")) { + val tagBuild = fromJson(file) + val versionName = tagBuild.buildVariant + val versionCode = tagBuild.buildNumber + "$baseFileName-$versionName-vc$versionCode-$formattedDate.aab" } else if (!file.exists() && outputFileName.endsWith(".apk")) { "$baseFileName-$formattedDate.apk" + } else if (!file.exists() && outputFileName.endsWith(".aab")) { + "$baseFileName-$formattedDate.aab" } else { createDefaultOutputFileName(baseFileName, outputFileName) } @@ -604,9 +664,16 @@ private fun Project.mapToOutputFile( buildVariant: BuildVariant, fileName: String, ): Provider { - return project.tasks.withType(PackageAndroidArtifact::class.java) - .firstOrNull { it.variantName == buildVariant.name } - ?.outputDirectory - ?.map { directory -> directory.file(fileName) } - ?: throw GradleException("no output for variant ${buildVariant.name}") + return if (fileName.endsWith("aab")) { + project.tasks.withType(PackageBundleTask::class.java) + .firstOrNull { it.variantName == buildVariant.name } + ?.bundleFile + ?: throw GradleException("no output for variant ${buildVariant.name}") + } else { + project.tasks.withType(PackageAndroidArtifact::class.java) + .firstOrNull { it.variantName == buildVariant.name } + ?.outputDirectory + ?.map { directory -> directory.file(fileName) } + ?: throw GradleException("no output for variant ${buildVariant.name}") + } } 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..5c53e64 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,8 +7,10 @@ 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.play.PlayDistributionTask import javax.inject.Inject const val EXTENSION_NAME = "buildPublish" @@ -31,4 +33,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..c18da4c --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/extension/config/PlayConfig.kt @@ -0,0 +1,37 @@ +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.provider.SetProperty +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..ba83483 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayDistributionTask.kt @@ -0,0 +1,96 @@ +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.provider.SetProperty +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.extension.config.MAX_REQUEST_COUNT +import ru.kode.android.build.publish.plugin.extension.config.MAX_REQUEST_DELAY_MS +import ru.kode.android.build.publish.plugin.task.appcenter.work.AppCenterUploadWork +import ru.kode.android.build.publish.plugin.task.play.work.PlayUploadWork +import ru.kode.android.build.publish.plugin.util.capitalized +import javax.inject.Inject + +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..5e654bd --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/PlayPublisher.kt @@ -0,0 +1,88 @@ +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..cf62305 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/AndroidPublisher.kt @@ -0,0 +1,102 @@ +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.security.KeyStore +import java.util.concurrent.TimeUnit + +internal fun createPublisher(credentials: InputStream): AndroidPublisher { + val transport = buildTransport() + val credential = try { + GoogleCredentials.fromStream(credentials) { transport } + } catch (e: Exception) { + throw Exception( + "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..6b9ceae --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/DefaultPlayPublisher.kt @@ -0,0 +1,240 @@ +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.AppDetails +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.Image +import com.google.api.services.androidpublisher.model.InAppProduct +import com.google.api.services.androidpublisher.model.Listing +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 getAppDetails(editId: String): AppDetails { + return publisher.edits().details().get(appId, editId).execute() + } + + override fun getListings(editId: String): List { + return publisher.edits().listings().list(appId, editId).execute()?.listings.orEmpty() + } + + override fun getImages(editId: String, locale: String, type: String): List { + val response = publisher.edits().images().list(appId, editId, locale, type).execute() + return response?.images.orEmpty() + } + + override fun updateDetails(editId: String, details: AppDetails) { + publisher.edits().details().update(appId, editId, details).execute() + } + + override fun updateListing(editId: String, locale: String, listing: Listing) { + publisher.edits().listings().update(appId, editId, locale, listing).execute() + } + + override fun deleteImages(editId: String, locale: String, type: String) { + publisher.edits().images().deleteall(appId, editId, locale, type).execute() + } + + override fun uploadImage(editId: String, locale: String, type: String, image: File) { + val content = FileContent(MIME_TYPE_IMAGE, image) + publisher.edits().images().upload(appId, editId, locale, type, content).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..2690433 --- /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.AppDetails +import com.google.api.services.androidpublisher.model.Bundle +import com.google.api.services.androidpublisher.model.DeobfuscationFilesUploadResponse +import com.google.api.services.androidpublisher.model.Image +import com.google.api.services.androidpublisher.model.Listing +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 getAppDetails(editId: String): AppDetails + + fun getListings(editId: String): List+ + fun getImages(editId: String, locale: String, type: String): List + + fun updateDetails(editId: String, details: AppDetails) + + fun updateListing(editId: String, locale: String, listing: Listing) + + fun deleteImages(editId: String, locale: String, type: String) + + fun uploadImage(editId: String, locale: String, type: String, image: File) + + 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..4b34a59 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/PublisherModels.kt @@ -0,0 +1,51 @@ +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). + */ +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). + */ +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..34d4cdb --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/publisher/Responses.kt @@ -0,0 +1,124 @@ +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..29cafe2 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/DefaultEditManager.kt @@ -0,0 +1,221 @@ +package ru.kode.android.build.publish.plugin.task.play.track + +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.services.androidpublisher.model.AppDetails +import com.google.api.services.androidpublisher.model.Listing +import org.slf4j.LoggerFactory +import ru.kode.android.build.publish.plugin.task.play.publisher.GppAppDetails +import ru.kode.android.build.publish.plugin.task.play.publisher.GppImage +import ru.kode.android.build.publish.plugin.task.play.publisher.GppListing +import ru.kode.android.build.publish.plugin.task.play.publisher.InternalPlayPublisher +import ru.kode.android.build.publish.plugin.task.play.publisher.ReleaseNote +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 getAppDetails(): GppAppDetails { + val details = publisher.getAppDetails(editId) + return GppAppDetails( + details.defaultLanguage, + details.contactEmail, + details.contactPhone, + details.contactWebsite + ) + } + + override fun getListings(): List { + return publisher.getListings(editId).map { + GppListing( + it.language, + it.fullDescription, + it.shortDescription, + it.title, + it.video + ) + } + } + + override fun getImages(locale: String, type: String): List { + return publisher.getImages(editId, locale, type).map { + GppImage(it.url + HIGH_RES_IMAGE_REQUEST, it.sha256) + } + } + + override fun findMaxAppVersionCode(): Long { + return tracks.findHighestTrack()?.releases.orEmpty() + .flatMap { it.versionCodes.orEmpty() } + .maxOrNull() ?: 1 + } + + override fun findLeastStableTrackName(): String? { + return tracks.findHighestTrack()?.track + } + + override fun getReleaseNotes(): List { + return tracks.getReleaseNotes().map { (track, notes) -> + notes.map { ReleaseNote(track, it.language, it.text) } + }.flatten() + } + + override fun publishAppDetails( + defaultLocale: String?, + contactEmail: String?, + contactPhone: String?, + contactWebsite: String?, + ) { + publisher.updateDetails(editId, AppDetails().apply { + this.defaultLanguage = defaultLocale + this.contactEmail = contactEmail + this.contactPhone = contactPhone + this.contactWebsite = contactWebsite + }) + } + + override fun publishListing( + locale: String, + title: String?, + shortDescription: String?, + fullDescription: String?, + video: String?, + ) { + publisher.updateListing(editId, locale, Listing().apply { + this.title = title + this.shortDescription = shortDescription + this.fullDescription = fullDescription + this.video = video + }) + } + + override fun publishImages(locale: String, type: String, images: List) { + publisher.deleteImages(editId, locale, type) + for (image in images) { + println("Uploading $locale listing graphic for type '$type': ${image.name}") + // These can't be uploaded in parallel because order matters + publisher.uploadImage(editId, locale, type, image) + } + } + + 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 + ) + )) + } + + private fun uploadMappingFile(versionCode: Int, mappingFile: File?) { + if (mappingFile != null && mappingFile.length() > 0) { + publisher.uploadDeobfuscationFile(editId, mappingFile, versionCode, "proguard") + } + } + + private fun Int.attachObb(type: String, versionCode: Int) { + println("Attaching $type OBB ($this) to APK $versionCode") + publisher.attachObb(editId, type, versionCode, this) + } + + 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 + } +} + +private const val HIGH_RES_IMAGE_REQUEST = "=h16383" // Max res: 2^14 - 1 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..5ba181d --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/EditManager.kt @@ -0,0 +1,76 @@ +package ru.kode.android.build.publish.plugin.task.play.track + +import ru.kode.android.build.publish.plugin.task.play.PlayPublisher +import ru.kode.android.build.publish.plugin.task.play.publisher.GppAppDetails +import ru.kode.android.build.publish.plugin.task.play.publisher.GppImage +import ru.kode.android.build.publish.plugin.task.play.publisher.GppListing +import ru.kode.android.build.publish.plugin.task.play.publisher.ReleaseNote +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 +import java.util.ServiceLoader + +interface EditManager { + fun getAppDetails(): GppAppDetails + fun getListings(): List + fun getImages(locale: String, type: String): List + fun findMaxAppVersionCode(): Long + fun findLeastStableTrackName(): String? + fun getReleaseNotes(): List + fun publishAppDetails( + defaultLocale: String?, + contactEmail: String?, + contactPhone: String?, + contactWebsite: String?, + ) + + fun publishListing( + locale: String, + title: String?, + shortDescription: String?, + fullDescription: String?, + video: String?, + ) + + fun publishImages(locale: String, type: String, images: List) + 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?, + ) + + interface Factory { + fun create(publisher: PlayPublisher, editId: String): EditManager + } + + companion object { + operator fun invoke( + publisher: PlayPublisher, + editId: String, + ): EditManager = ServiceLoader.load(Factory::class.java).last() + .create(publisher, editId) + } +} 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..fa614da --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/track/TrackManager.kt @@ -0,0 +1,242 @@ +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 findHighestTrack(): Track? + + fun getReleaseNotes(): Map> + + 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 findHighestTrack(): Track? { + return publisher.listTracks(editId).maxByOrNull { + it.releases.orEmpty().flatMap { it.versionCodes.orEmpty() }.maxOrNull() ?: 0 + } + } + + override fun getReleaseNotes(): Map> { + val releaseNotes = mutableMapOf>() + + val tracks = publisher.listTracks(editId) + for (track in tracks) { + val notes = track.releases?.maxByOrNull { + it.versionCodes?.maxOrNull() ?: Long.MIN_VALUE + }?.releaseNotes.orEmpty() + + releaseNotes[track.track] = notes + } + + return releaseNotes + } + + 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) + } + + 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..30dfd84 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ru/kode/android/build/publish/plugin/task/play/work/PlayUploadWork.kt @@ -0,0 +1,81 @@ +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.api.provider.SetProperty +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 -> null + } + + if (editId == null) { + logger.error("Failed to fetch edit id to upload bundle. Check your credentials") + 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. Check your credentials") + 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") + } + +}