390 lines
15 KiB
Plaintext
390 lines
15 KiB
Plaintext
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
|
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
|
|
import com.google.api.client.http.AbstractInputStreamContent
|
|
import com.google.api.client.http.FileContent
|
|
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.JsonFactory
|
|
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.AndroidPublisherRequestInitializer
|
|
import com.google.api.services.androidpublisher.AndroidPublisherScopes
|
|
import com.google.api.services.androidpublisher.model.AppEdit
|
|
import com.google.api.services.androidpublisher.model.Bundle
|
|
import com.google.api.services.androidpublisher.model.Track
|
|
import com.google.api.services.androidpublisher.model.TrackRelease
|
|
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.IOException
|
|
import java.security.GeneralSecurityException
|
|
import java.security.KeyStore
|
|
|
|
@CacheableTask
|
|
abstract class PublishToGooglePlay @Inject constructor(
|
|
private val gpServiceAccountKey: String,
|
|
private val gpPublisherApiKey: String,
|
|
private val track: String,
|
|
private val status: String
|
|
) : DefaultTask() {
|
|
|
|
// Note that we need to have all the necessary custom task properties part of the task (i.e. no external
|
|
// dependencies allowed) to avoid:
|
|
// PublishToGooglePlay is a non-static inner class.
|
|
|
|
init {
|
|
description = "Publish universal Zcash wallet apk to Google Play release channel." // $NON-NLS-1$
|
|
group = "publishing" // $NON-NLS-1$
|
|
}
|
|
|
|
private fun log(message: String) {
|
|
println("${PublishToGooglePlay::class.java.name}: $message")
|
|
}
|
|
|
|
// Global instance of the JSON factory
|
|
private val jsonFactory: JsonFactory by lazy {
|
|
GsonFactory.getDefaultInstance()
|
|
}
|
|
|
|
// Global instance of the HTTP transport
|
|
@get:Throws(GeneralSecurityException::class, IOException::class)
|
|
private val trustedTransport: HttpTransport by lazy {
|
|
buildTransport()
|
|
}
|
|
|
|
/**
|
|
* Prepares a new trusted [HttpTransport] object to authorize [AndroidPublisher] on Google Play Publish API.
|
|
*/
|
|
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("https", "http")
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build service account credential using secret service json key file.
|
|
*
|
|
* @return OAuth credential for the given service key file path
|
|
* @throws IOException in case an incorrect key file path is provided or the credential cannot be created from
|
|
* the stream
|
|
*/
|
|
@Throws(IOException::class)
|
|
private fun getCredentialFromServiceKeyFile(serviceKey: String): GoogleCredentials {
|
|
log("Authorizing using non-empty service key: ${serviceKey.isNotEmpty()}")
|
|
|
|
return GoogleCredentials.fromStream(serviceKey.byteInputStream())
|
|
.also {
|
|
it.createScoped(listOf(AndroidPublisherScopes.ANDROIDPUBLISHER))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepares API communication service and returns [AndroidPublisher] upon which API requests can be performed. This
|
|
* operation performs all the necessary setup steps for running the requests.
|
|
*
|
|
* @param applicationName The package name of the application, e.g.: com.example.app
|
|
* @param serviceAccountKey The service account key for the API communication authorization
|
|
* @param publisherApiKey The Google Play Publisher API key for the API communication authorization
|
|
* @return The {@Link AndroidPublisher} service
|
|
*/
|
|
private fun initService(
|
|
applicationName: String,
|
|
serviceAccountKey: String,
|
|
publisherApiKey: String
|
|
): AndroidPublisher {
|
|
log("Initializing Google Play communication for: $applicationName")
|
|
|
|
// Running authorization
|
|
val credential = getCredentialFromServiceKeyFile(serviceAccountKey)
|
|
val httpInitializer = AndroidPublisherAdapter(credential)
|
|
|
|
// Set up and return API client
|
|
return AndroidPublisher.Builder(
|
|
trustedTransport,
|
|
jsonFactory,
|
|
httpInitializer
|
|
)
|
|
.setApplicationName(applicationName)
|
|
.setAndroidPublisherRequestInitializer(AndroidPublisherRequestInitializer(publisherApiKey))
|
|
.build()
|
|
}
|
|
|
|
@Throws(IllegalStateException::class, IOException::class, GeneralSecurityException::class)
|
|
@Suppress("LongMethod")
|
|
private fun runPublish(
|
|
track: String,
|
|
status: String,
|
|
serviceAccountKey: String,
|
|
publisherApiKey: String
|
|
) {
|
|
val packageName = project.property("ZCASH_RELEASE_PACKAGE_NAME").toString()
|
|
|
|
// Walk through the build directory and find the prepared release aab file
|
|
val apkFile = File("app/build/outputs/bundle/").walk()
|
|
.filter { it.name.endsWith("release.aab") }
|
|
.firstOrNull() ?: error("Universal release apk not found")
|
|
|
|
log("Publish - APK found: ${apkFile.name}")
|
|
|
|
val apkFileContent: AbstractInputStreamContent = FileContent(
|
|
"application/octet-stream", // APK file type
|
|
apkFile
|
|
)
|
|
|
|
// Create the Google Play API service for communication
|
|
val service: AndroidPublisher = initService(
|
|
packageName,
|
|
serviceAccountKey,
|
|
publisherApiKey
|
|
)
|
|
|
|
val edits: AndroidPublisher.Edits = service.edits()
|
|
|
|
// Create a new edit to make changes to the existing listing
|
|
val editRequest: AndroidPublisher.Edits.Insert = edits
|
|
.insert(
|
|
packageName,
|
|
null // Intentionally no content provided
|
|
)
|
|
|
|
log("Publish - Edits request: $editRequest")
|
|
|
|
val edit: AppEdit = editRequest.execute()
|
|
|
|
log("Publish - Edits excute: $edit")
|
|
|
|
val editId: String = edit.id
|
|
|
|
log("Publish - Edit with id: $editId")
|
|
|
|
val uploadRequest: AndroidPublisher.Edits.Bundles.Upload = edits
|
|
.bundles()
|
|
.upload(
|
|
packageName,
|
|
editId,
|
|
apkFileContent
|
|
)
|
|
val bundle: Bundle = uploadRequest.execute()
|
|
|
|
|
|
// Version code
|
|
val bundleVersionCodes: MutableList<Long> = ArrayList()
|
|
bundleVersionCodes.add(bundle.versionCode.toLong())
|
|
|
|
// Version name
|
|
val gradleVersionName = project.property("ZCASH_VERSION_NAME").toString()
|
|
val versionName = "$gradleVersionName (${bundle.versionCode.toLong()}): Automated Internal Testing Release"
|
|
|
|
log("Publish - Version: $versionName has been uploaded")
|
|
|
|
// Assign bundle to the selected track
|
|
val updateTrackRequest: AndroidPublisher.Edits.Tracks.Update = edits
|
|
.tracks()
|
|
.update(
|
|
packageName,
|
|
editId,
|
|
track,
|
|
Track().setReleases(
|
|
listOf(TrackRelease()
|
|
.setName(versionName)
|
|
.setVersionCodes(bundleVersionCodes)
|
|
.setStatus(status)
|
|
)
|
|
)
|
|
)
|
|
|
|
val updatedTrack: Track = updateTrackRequest.execute()
|
|
log("Track ${updatedTrack.track} has been updated")
|
|
|
|
// Commit changes for edit
|
|
val commitRequest: AndroidPublisher.Edits.Commit = edits.commit(
|
|
packageName,
|
|
editId
|
|
)
|
|
val appEdit: AppEdit = commitRequest.execute()
|
|
log("App edit with id ${appEdit.id} has been committed")
|
|
}
|
|
|
|
@TaskAction
|
|
fun runTask() {
|
|
log("Publish starting for track: $track and status: $status")
|
|
runPublish(
|
|
track,
|
|
status,
|
|
gpServiceAccountKey,
|
|
gpPublisherApiKey
|
|
)
|
|
log("Publishing done")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The release track identifier. This class also serves as a type-safe custom task input validation.
|
|
*/
|
|
enum class PublishTrack {
|
|
INTERNAL, // Internal testing track
|
|
ALPHA, // Closed testing track
|
|
BETA, // Open testing track. Note that use of this track is not supported by this task.
|
|
PRODUCTION; // Production track. Note that use of this track is not supported by this task.
|
|
|
|
companion object {
|
|
@Throws(IllegalArgumentException::class)
|
|
fun new(identifier: String): PublishTrack {
|
|
// Throws IllegalArgumentException if the specified name does not match any of the defined enum constants
|
|
return values().find { it.name.lowercase() == identifier }
|
|
?: throw IllegalArgumentException("Unsupported enum value: $identifier")
|
|
}
|
|
}
|
|
|
|
@Throws(IllegalStateException::class)
|
|
fun toGooglePlayIdentifier(): String {
|
|
return when (this) {
|
|
INTERNAL -> "internal" // $NON-NLS-1$
|
|
ALPHA -> "alpha" // $NON-NLS-1$
|
|
BETA, PRODUCTION -> error("For security reasons, this script does not support the $this option. Promote " +
|
|
"the app manually from a lower testing channel instead.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The status of a release. This class also serves as a type-safe custom task input validation.
|
|
*/
|
|
enum class PublishStatus {
|
|
STATUS_UNSPECIFIED, // Unspecified status.
|
|
DRAFT, // The release's APKs are not being served to users.
|
|
IN_PROGRESS, // The release's APKs are being served to a fraction of users, determined by 'userFraction'.
|
|
HALTED, // The release's APKs will no longer be served to users. Users who already have these APKs are unaffected.
|
|
COMPLETED; // The release will have no further changes. Its APKs are being served to all users, unless they are
|
|
// eligible to APKs of a more recent release.
|
|
|
|
companion object {
|
|
@Throws(IllegalArgumentException::class)
|
|
fun new(identifier: String): PublishStatus {
|
|
// Throws IllegalArgumentException if the specified name does not match any of the defined enum constants
|
|
return values().find { it.name.lowercase() == identifier }
|
|
?: throw IllegalArgumentException("Unsupported enum value: $identifier")
|
|
}
|
|
}
|
|
|
|
@Throws(IllegalStateException::class)
|
|
fun toGooglePlayIdentifier(): String {
|
|
return when (this) {
|
|
DRAFT -> "draft" // $NON-NLS-1$
|
|
COMPLETED -> "completed" // $NON-NLS-1$
|
|
STATUS_UNSPECIFIED, IN_PROGRESS, HALTED -> error("Not supported status: $this")
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks {
|
|
// Validate Google Play Service Account KEY input
|
|
val googlePlayServiceAccountKey = project.property("ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY").toString()
|
|
if (googlePlayServiceAccountKey.isEmpty()) {
|
|
// The deployment will not run: service account key is empty
|
|
return@tasks
|
|
}
|
|
// Validate Google Play Publisher API KEY input
|
|
val googlePlayPublisherApiKey = project.property("ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY").toString()
|
|
if (googlePlayServiceAccountKey.isEmpty()) {
|
|
// The deployment will not run: publisher api key is empty
|
|
return@tasks
|
|
}
|
|
|
|
// Validate deploy track
|
|
val deployTrackString = project.property("ZCASH_GOOGLE_PLAY_DEPLOY_TRACK").toString()
|
|
val deployTrack = deployTrackString.let {
|
|
if (it.isEmpty()) {
|
|
// The deployment will not run: track empty
|
|
return@tasks
|
|
}
|
|
PublishTrack.new(it)
|
|
}
|
|
|
|
// Validate deploy status
|
|
val deployStatusString = project.property("ZCASH_GOOGLE_PLAY_DEPLOY_STATUS").toString()
|
|
val deployStatus = deployStatusString.let {
|
|
if (it.isEmpty()) {
|
|
// The deployment will not run: status empty
|
|
return@tasks
|
|
}
|
|
PublishStatus.new(it)
|
|
}
|
|
|
|
// The new task [publishToGooglePlay] runs [assembleDebug] and [bundleZcashmainnetRelease] as its
|
|
// dependencies.
|
|
|
|
// Note that we need to convert these Enums to Strings as enums are not assignable via Kotlin DSL to Gradle
|
|
// custom task, although it would be better to work with more type-safe Enums furthermore.
|
|
register<PublishToGooglePlay>(
|
|
"publishToGooglePlay", // $NON-NLS-1$
|
|
googlePlayServiceAccountKey,
|
|
googlePlayPublisherApiKey,
|
|
deployTrack.toGooglePlayIdentifier(),
|
|
deployStatus.toGooglePlayIdentifier()
|
|
)
|
|
.dependsOn(":app:assembleDebug")
|
|
.dependsOn(":app:bundleZcashmainnetRelease")
|
|
|
|
println("Automated deployment task registered - all the necessary attributes set")
|
|
}
|