diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 446e38d5..af80d126 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,8 @@ # Expected secrets # GOOGLE_PLAY_CLOUD_PROJECT - Google Cloud project associated with Google Play # GOOGLE_PLAY_SERVICE_ACCOUNT - Email address of service account +# GOOGLE_PLAY_SERVICE_ACCOUNT_KEY - Google Play Service Account key to authorize on Google Play +# GOOGLE_PLAY_PUBLISHER_API_KEY - Google Play Publisher API key to authorize the publisher on Google Play API # GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER - Workload identity provider to generate temporary service account key # UPLOAD_KEYSTORE_BASE_64 - The upload signing key for the app # UPLOAD_KEYSTORE_PASSWORD - The password for UPLOAD_KEYSTORE_BASE_64 @@ -54,9 +56,17 @@ jobs: - id: check_secrets env: GOOGLE_PLAY_CLOUD_PROJECT: ${{ secrets.GOOGLE_PLAY_CLOUD_PROJECT }} - GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} - GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER }} - if: "${{ env.GOOGLE_PLAY_CLOUD_PROJECT != '' && env.GOOGLE_PLAY_SERVICE_ACCOUNT != '' && env.GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER != '' }}" + # TODO [#1033]: Use token-based authorization on Google Play for automated deployment + # TODO [#1033]: https://github.com/zcash/secant-android-wallet/issues/1033 + # Note that these properties are not currently used due to #1033 + # GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + # GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GOOGLE_PLAY_WORKLOAD_IDENTITY_PROVIDER }} + GOOGLE_PLAY_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_KEY }} + GOOGLE_PLAY_PUBLISHER_API_KEY: ${{ secrets.GOOGLE_PLAY_PUBLISHER_API_KEY }} + if: "${{ env.GOOGLE_PLAY_CLOUD_PROJECT != '' && + env.GOOGLE_PLAY_SERVICE_ACCOUNT_KEY != '' && + env.GOOGLE_PLAY_PUBLISHER_API_KEY != '' + }}" run: echo "defined=true" >> $GITHUB_OUTPUT build_and_deploy: @@ -71,6 +81,9 @@ jobs: - name: Checkout timeout-minutes: 1 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + with: + ref: main + fetch-depth: 0 # To fetch all commits - name: Set up Java uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 timeout-minutes: 1 @@ -94,6 +107,10 @@ jobs: echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json - name: Authenticate to Google Cloud for Google Play + # TODO [#1033]: Use token-based authorization on Google Play for automated deployment + # TODO [#1033]: https://github.com/zcash/secant-android-wallet/issues/1033 + # Note that this step is not currently used due to #1033 + if: false id: auth_google_play uses: google-github-actions/auth@35b0e87d162680511bf346c299f71c9c5c379033 with: @@ -117,14 +134,21 @@ jobs: timeout-minutes: 25 env: ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }} - ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH: ${{ steps.auth_google_play.outputs.credentials_file_path }} + # TODO [#1033]: Use token-based authorization on Google Play for automated deployment + # TODO [#1033]: https://github.com/zcash/secant-android-wallet/issues/1033 + # Note that these properties are not currently used due to #1033 + # ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + # ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH: ${{ steps.auth_google_play.outputs.credentials_file_path }} + ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_KEY }} + ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY: ${{ secrets.GOOGLE_PLAY_PUBLISHER_API_KEY }} + ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_DEPLOY_TRACK: internal + ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_DEPLOY_STATUS: completed ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PATH: ${{ format('{0}/release.jks', env.home) }} ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }} ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEY_ALIAS_PASSWORD }} - ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_DEPLOY_MODE: deploy run: | - ./gradlew :app:assembleDebug :app:publishBundle :app:packageZcashmainnetReleaseUniversalApk + ./gradlew :app:publishToGooglePlay - name: Collect Artifacts timeout-minutes: 1 env: @@ -133,11 +157,11 @@ jobs: MAPPINGS_ZIP_PATH: ${{ format('{0}/artifacts/mappings.zip', env.home) }} run: | mkdir ${ARTIFACTS_DIR_PATH} - zip -r ${BINARIES_ZIP_PATH} . -i app/build/outputs/apk/\*/\*.apk app/build/outputs/universal_apk/\*/\*.apk app/build/outputs/bundle/\*/\*.aab + zip -r ${BINARIES_ZIP_PATH} . -i app/build/outputs/apk/\*/\*.apk app/build/outputs/bundle/\*/\*.aab zip -r ${MAPPINGS_ZIP_PATH} . -i app/build/outputs/mapping/\*/mapping.txt - name: Upload Artifacts uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 - timeout-minutes: 1 + timeout-minutes: 5 with: name: Binaries path: ~/artifacts diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0c627c01..b23f8b9d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -105,7 +105,7 @@ jobs: uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a timeout-minutes: 5 - name: Detekt - timeout-minutes: 4 + timeout-minutes: 10 run: | ./gradlew detektAll - name: Collect Artifacts @@ -228,7 +228,7 @@ jobs: uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a timeout-minutes: 5 - name: Test - timeout-minutes: 4 + timeout-minutes: 10 run: | # Note that we explicitly check just the Kotlin modules, to avoid compiling the Android modules here ./gradlew :configuration-api-lib:check :crash-lib:check :preference-api-lib:check :spackle-lib:check diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77e3385c..087f204a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import co.electriccoin.zcash.Git import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.ResValue import java.util.Locale @@ -6,10 +7,10 @@ plugins { id("com.android.application") kotlin("android") id("secant.android-build-conventions") - id("com.github.triplet.play") id("com.osacky.fladle") id("wtf.emulator.gradle") id("secant.emulator-wtf-conventions") + id("secant.publish-conventions") } val hasFirebaseApiKeys = run { @@ -158,12 +159,6 @@ android { resValue("string", "support_email_address", supportEmailAddress) } - playConfigs { - register(testNetFlavorName) { - enabled.set(false) - } - } - testCoverage { jacocoVersion = project.property("JACOCO_VERSION").toString() } @@ -202,8 +197,6 @@ dependencies { } } -val googlePlayServiceKeyFilePath = project.property("ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH").toString() - androidComponents { onVariants { variant -> for (output in variant.outputs) { @@ -223,7 +216,9 @@ androidComponents { ResValue(value = hasFirebaseApiKeys.toString()) ) - if (googlePlayServiceKeyFilePath.isNotEmpty()) { + if (project.property("ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY").toString().isNotEmpty() && + project.property("ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY").toString().isNotEmpty() + ) { // Update the versionName to reflect bumps in versionCode val versionCodeOffset = 0 // Change this to zero the final digit of the versionName @@ -243,6 +238,9 @@ androidComponents { } output.versionName.set(processedVersionCode) + + val gitInfo = Git.newInfo(Git.MAIN, parent!!.projectDir) + output.versionCode.set(gitInfo.commitCount) } } @@ -276,27 +274,6 @@ androidComponents { } } -if (googlePlayServiceKeyFilePath.isNotEmpty()) { - configure { - serviceAccountCredentials.set(File(googlePlayServiceKeyFilePath)) - - // For safety, only allow deployment to internal testing track - track.set("internal") - - // Automatically manage version incrementing - resolutionStrategy.set(com.github.triplet.gradle.androidpublisher.ResolutionStrategy.AUTO) - - val deployMode = project.property("ZCASH_GOOGLE_PLAY_DEPLOY_MODE").toString() - if ("build" == deployMode) { - releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.DRAFT) - // Prevent upload; only generates a build with the correct version number - commit.set(false) - } else if ("deploy" == deployMode) { - releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.COMPLETED) - } - } -} - fladle { // Firebase Test Lab has min and max values that might differ from our project's // These are determined by `gcloud firebase test android models list` diff --git a/build-conventions-secant/build.gradle.kts b/build-conventions-secant/build.gradle.kts index 3ec0a4c7..f7ae55f6 100644 --- a/build-conventions-secant/build.gradle.kts +++ b/build-conventions-secant/build.gradle.kts @@ -41,6 +41,10 @@ dependencies { val rootProperties = getRootProperties() implementation("com.android.tools.build:gradle:${rootProperties.getProperty("ANDROID_GRADLE_PLUGIN_VERSION")}") + implementation("com.google.apis:google-api-services-androidpublisher:${rootProperties.getProperty + ("PLAY_PUBLISHER_API_VERSION")}") + implementation("com.google.auth:google-auth-library-oauth2-http:${rootProperties.getProperty + ("GOOGLE_AUTH_LIB_JAVA_VERSION")}") implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:${rootProperties.getProperty("DETEKT_VERSION")}") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${rootProperties.getProperty("KOTLIN_VERSION")}") implementation("wtf.emulator:gradle-plugin:${rootProperties.getProperty("EMULATOR_WTF_GRADLE_PLUGIN_VERSION")}") diff --git a/build-conventions-secant/gradle.lockfile b/build-conventions-secant/gradle.lockfile index 2d55d2d6..3b00a34d 100644 --- a/build-conventions-secant/gradle.lockfile +++ b/build-conventions-secant/gradle.lockfile @@ -45,19 +45,27 @@ com.android.tools:sdklib:31.1.1=runtimeClasspath com.android:signflinger:8.1.1=runtimeClasspath com.android:zipflinger:8.1.1=compileClasspath,runtimeClasspath com.google.android:annotations:4.1.1.4=runtimeClasspath +com.google.api-client:google-api-client:2.2.0=compileClasspath,runtimeClasspath com.google.api.grpc:proto-google-common-protos:2.0.1=runtimeClasspath -com.google.auto.value:auto-value-annotations:1.6.2=runtimeClasspath -com.google.code.findbugs:jsr305:3.0.2=runtimeClasspath -com.google.code.gson:gson:2.8.9=runtimeClasspath +com.google.apis:google-api-services-androidpublisher:v3-rev20231030-2.0.0=compileClasspath,runtimeClasspath +com.google.auth:google-auth-library-credentials:1.18.0=compileClasspath,runtimeClasspath +com.google.auth:google-auth-library-oauth2-http:1.18.0=compileClasspath,runtimeClasspath +com.google.auto.value:auto-value-annotations:1.10.1=compileClasspath,runtimeClasspath +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,runtimeClasspath +com.google.code.gson:gson:2.10=compileClasspath,runtimeClasspath com.google.crypto.tink:tink:1.7.0=runtimeClasspath com.google.dagger:dagger:2.28.3=runtimeClasspath -com.google.errorprone:error_prone_annotations:2.11.0=runtimeClasspath +com.google.errorprone:error_prone_annotations:2.16=compileClasspath,runtimeClasspath com.google.flatbuffers:flatbuffers-java:1.12.0=runtimeClasspath -com.google.guava:failureaccess:1.0.1=runtimeClasspath -com.google.guava:guava:31.1-jre=runtimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=runtimeClasspath -com.google.j2objc:j2objc-annotations:1.3=runtimeClasspath +com.google.guava:failureaccess:1.0.1=compileClasspath,runtimeClasspath +com.google.guava:guava:31.1-jre=compileClasspath,runtimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=compileClasspath,runtimeClasspath +com.google.http-client:google-http-client-apache-v2:1.42.3=compileClasspath,runtimeClasspath +com.google.http-client:google-http-client-gson:1.42.3=compileClasspath,runtimeClasspath +com.google.http-client:google-http-client:1.42.3=compileClasspath,runtimeClasspath +com.google.j2objc:j2objc-annotations:1.3=compileClasspath,runtimeClasspath com.google.jimfs:jimfs:1.1=runtimeClasspath +com.google.oauth-client:google-oauth-client:1.34.1=compileClasspath,runtimeClasspath com.google.protobuf:protobuf-java-util:3.19.3=runtimeClasspath com.google.protobuf:protobuf-java:3.19.3=runtimeClasspath com.google.testing.platform:core-proto:0.0.8-alpha08=runtimeClasspath @@ -68,11 +76,12 @@ com.sun.activation:javax.activation:1.2.0=runtimeClasspath com.sun.istack:istack-commons-runtime:3.0.8=runtimeClasspath com.sun.xml.fastinfoset:FastInfoset:1.2.16=runtimeClasspath com.vdurmont:semver4j:3.1.0=runtimeClasspath -commons-codec:commons-codec:1.11=runtimeClasspath +commons-codec:commons-codec:1.15=compileClasspath,runtimeClasspath commons-io:commons-io:2.12.0=runtimeClasspath -commons-logging:commons-logging:1.2=runtimeClasspath +commons-logging:commons-logging:1.2=compileClasspath,runtimeClasspath io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.0=compileClasspath,runtimeClasspath io.grpc:grpc-api:1.45.1=runtimeClasspath +io.grpc:grpc-context:1.27.2=compileClasspath io.grpc:grpc-context:1.45.1=runtimeClasspath io.grpc:grpc-core:1.45.1=runtimeClasspath io.grpc:grpc-netty:1.45.1=runtimeClasspath @@ -90,6 +99,8 @@ io.netty:netty-handler:4.1.72.Final=runtimeClasspath io.netty:netty-resolver:4.1.72.Final=runtimeClasspath io.netty:netty-tcnative-classes:2.0.46.Final=runtimeClasspath io.netty:netty-transport:4.1.72.Final=runtimeClasspath +io.opencensus:opencensus-api:0.31.1=compileClasspath,runtimeClasspath +io.opencensus:opencensus-contrib-http-util:0.31.1=compileClasspath,runtimeClasspath io.perfmark:perfmark-api:0.23.0=runtimeClasspath jakarta.activation:jakarta.activation-api:1.2.1=runtimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=runtimeClasspath @@ -100,13 +111,13 @@ net.java.dev.jna:jna:5.6.0=runtimeClasspath net.sf.jopt-simple:jopt-simple:4.9=runtimeClasspath net.sf.kxml:kxml2:2.3.0=runtimeClasspath org.apache.commons:commons-compress:1.21=runtimeClasspath -org.apache.httpcomponents:httpclient:4.5.13=runtimeClasspath -org.apache.httpcomponents:httpcore:4.4.15=runtimeClasspath +org.apache.httpcomponents:httpclient:4.5.14=compileClasspath,runtimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=compileClasspath,runtimeClasspath org.apache.httpcomponents:httpmime:4.5.6=runtimeClasspath org.bitbucket.b_c:jose4j:0.7.0=runtimeClasspath org.bouncycastle:bcpkix-jdk15on:1.67=runtimeClasspath org.bouncycastle:bcprov-jdk15on:1.67=runtimeClasspath -org.checkerframework:checker-qual:3.12.0=runtimeClasspath +org.checkerframework:checker-qual:3.12.0=compileClasspath,runtimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.19=runtimeClasspath org.glassfish.jaxb:jaxb-runtime:2.3.2=runtimeClasspath org.glassfish.jaxb:txw2:2.3.2=runtimeClasspath diff --git a/build-conventions-secant/src/main/kotlin/secant.publish-conventions.gradle.kts b/build-conventions-secant/src/main/kotlin/secant.publish-conventions.gradle.kts new file mode 100644 index 00000000..21940e42 --- /dev/null +++ b/build-conventions-secant/src/main/kotlin/secant.publish-conventions.gradle.kts @@ -0,0 +1,389 @@ +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 = 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", // $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") +} diff --git a/build-info-lib/build.gradle.kts b/build-info-lib/build.gradle.kts index e534106f..850efee7 100644 --- a/build-info-lib/build.gradle.kts +++ b/build-info-lib/build.gradle.kts @@ -1,3 +1,4 @@ +import co.electriccoin.zcash.Git import java.text.SimpleDateFormat import java.util.* @@ -13,7 +14,10 @@ plugins { val generateBuildConfigTask = tasks.create("buildConfig") { val generatedDir = layout.buildDirectory.dir("generated").get().asFile - val gitInfo = co.electriccoin.zcash.Git.newInfo(parent!!.projectDir) + val gitInfo = co.electriccoin.zcash.Git.newInfo( + Git.HEAD, + parent!!.projectDir + ) //val buildTimestamp = newIso8601Timestamp() inputs.property("gitSha", gitInfo.sha) diff --git a/build.gradle.kts b/build.gradle.kts index 872faa2a..36cb464c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -113,8 +113,12 @@ tasks { "IS_SIGN_RELEASE_BUILD_WITH_DEBUG_KEY" to "false", + "ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT" to "", + "ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY" to "", + "ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY" to "", "ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH" to "", - "ZCASH_GOOGLE_PLAY_DEPLOY_MODE" to "build", + "ZCASH_GOOGLE_PLAY_DEPLOY_TRACK" to "internal", + "ZCASH_GOOGLE_PLAY_DEPLOY_STATUS" to "draft", "SDK_INCLUDED_BUILD_PATH" to "", "BIP_39_INCLUDED_BUILD_PATH" to "" diff --git a/buildSrc/src/main/kotlin/co/electriccoin/zcash/Git.kt b/buildSrc/src/main/kotlin/co/electriccoin/zcash/Git.kt index 68099a80..e953eb2b 100644 --- a/buildSrc/src/main/kotlin/co/electriccoin/zcash/Git.kt +++ b/buildSrc/src/main/kotlin/co/electriccoin/zcash/Git.kt @@ -6,13 +6,17 @@ import java.io.File object Git { // Get the info for the current branch - private const val HEAD = "HEAD" + const val HEAD = "HEAD" // $NON-NLS-1$ + const val MAIN = "main" // $NON-NLS-1$ - fun newInfo(workingDirectory: File): GitInfo { + fun newInfo( + branch: String, + workingDirectory: File + ): GitInfo { val git = Git.open(workingDirectory) val repository = git.repository - val head: ObjectId = repository.resolve(HEAD) + val head: ObjectId = repository.resolve(branch) val count = git.log().call().count() return GitInfo(ObjectId.toString(head), count) diff --git a/buildscript-gradle.lockfile b/buildscript-gradle.lockfile index 991297a2..2ae38aa1 100644 --- a/buildscript-gradle.lockfile +++ b/buildscript-gradle.lockfile @@ -49,13 +49,17 @@ com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin:0.47.0 com.github.ben-manes:gradle-versions-plugin:0.47.0=classpath com.google.android.gms:strict-version-matcher-plugin:1.2.4=classpath com.google.android:annotations:4.1.1.4=classpath +com.google.api-client:google-api-client:2.2.0=classpath com.google.api.grpc:proto-google-common-protos:2.0.1=classpath -com.google.auto.value:auto-value-annotations:1.6.2=classpath +com.google.apis:google-api-services-androidpublisher:v3-rev20231030-2.0.0=classpath +com.google.auth:google-auth-library-credentials:1.18.0=classpath +com.google.auth:google-auth-library-oauth2-http:1.18.0=classpath +com.google.auto.value:auto-value-annotations:1.10.1=classpath com.google.code.findbugs:jsr305:3.0.2=classpath -com.google.code.gson:gson:2.8.9=classpath +com.google.code.gson:gson:2.10=classpath com.google.crypto.tink:tink:1.7.0=classpath com.google.dagger:dagger:2.28.3=classpath -com.google.errorprone:error_prone_annotations:2.11.0=classpath +com.google.errorprone:error_prone_annotations:2.16=classpath com.google.firebase:firebase-crashlytics-buildtools:2.9.4=classpath com.google.firebase:firebase-crashlytics-gradle:2.9.4=classpath com.google.flatbuffers:flatbuffers-java:1.12.0=classpath @@ -63,8 +67,12 @@ com.google.gms:google-services:4.3.15=classpath com.google.guava:failureaccess:1.0.1=classpath com.google.guava:guava:31.1-jre=classpath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath +com.google.http-client:google-http-client-apache-v2:1.42.3=classpath +com.google.http-client:google-http-client-gson:1.42.3=classpath +com.google.http-client:google-http-client:1.42.3=classpath com.google.j2objc:j2objc-annotations:1.3=classpath com.google.jimfs:jimfs:1.1=classpath +com.google.oauth-client:google-oauth-client:1.34.1=classpath com.google.protobuf:protobuf-java-util:3.19.3=classpath com.google.protobuf:protobuf-java:3.19.3=classpath com.google.testing.platform:core-proto:0.0.8-alpha08=classpath @@ -84,7 +92,7 @@ com.sun.istack:istack-commons-runtime:3.0.8=classpath com.sun.xml.fastinfoset:FastInfoset:1.2.16=classpath com.thoughtworks.xstream:xstream:1.4.20=classpath com.vdurmont:semver4j:3.1.0=classpath -commons-codec:commons-codec:1.11=classpath +commons-codec:commons-codec:1.15=classpath commons-io:commons-io:2.12.0=classpath commons-logging:commons-logging:1.2=classpath io.github.x-stream:mxparser:1.2.2=classpath @@ -109,6 +117,8 @@ io.netty:netty-handler:4.1.72.Final=classpath io.netty:netty-resolver:4.1.72.Final=classpath io.netty:netty-tcnative-classes:2.0.46.Final=classpath io.netty:netty-transport:4.1.72.Final=classpath +io.opencensus:opencensus-api:0.31.1=classpath +io.opencensus:opencensus-contrib-http-util:0.31.1=classpath io.perfmark:perfmark-api:0.23.0=classpath jakarta.activation:jakarta.activation-api:1.2.1=classpath jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=classpath @@ -119,8 +129,8 @@ net.java.dev.jna:jna:5.6.0=classpath net.sf.jopt-simple:jopt-simple:4.9=classpath net.sf.kxml:kxml2:2.3.0=classpath org.apache.commons:commons-compress:1.21=classpath -org.apache.httpcomponents:httpclient:4.5.13=classpath -org.apache.httpcomponents:httpcore:4.4.15=classpath +org.apache.httpcomponents:httpclient:4.5.14=classpath +org.apache.httpcomponents:httpcore:4.4.16=classpath org.apache.httpcomponents:httpmime:4.5.6=classpath org.bitbucket.b_c:jose4j:0.7.0=classpath org.bouncycastle:bcpkix-jdk15on:1.67=classpath diff --git a/docs/Deployment.md b/docs/Deployment.md index e997b8a5..d1e6c2ce 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -12,20 +12,21 @@ Note that although these are called "release" keys, they may actually be the "up After signing is configured, it is possible to then configure deployment to Google Play. ## Automated Deployment -Automated deployment to Google Play configured with the [Gradle Play Publisher plugin](https://github.com/Triple-T/gradle-play-publisher). -To perform a deployment: -1. Configure a Google Cloud service API key with the correct permissions +Automated deployment to Google Play is configured with custom +[Google Play publishing Gradle task](../build-conventions-secant/src/main/kotlin/secant.publish-conventions.gradle.kts). +To perform a deployment with this task: +1. Configure a Google Cloud service account and API key with the correct permissions +1. Configure a Google Play Publishing API key in Google Cloud console 1. Configure Gradle properties - 1. `ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH` - Set to the path of the service key in JSON format - 1. `ZCASH_GOOGLE_PLAY_DEPLOY_MODE` - Set to `deploy` + 1. `ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY` - Set the Google Play Service Account enabled in the Google Cloud console + 1. `ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY` - Set the Google Play Publish API enabled in the Google Cloud console + 1. `ZCASH_GOOGLE_PLAY_DEPLOY_TRACK` - Set to `internal` or `alpha` + 1. `ZCASH_GOOGLE_PLAY_DEPLOY_STATUS` - Set to `draft` or `completed` 1. Run the Gradle task `./gradlew :app:publishBundle` -To generate a build with a correct version that can be deployed manually later: -1. Configure a Google Cloud service API key with the correct permissions -1. Configure Gradle properties - 1. `ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH` - Set to the path of the service key in JSON format - 1. `ZCASH_GOOGLE_PLAY_DEPLOY_MODE` - Set to `build` (this is the default value) -1. Run the Gradle tasks `./gradlew :app:processReleaseVersionCodes :app:bundleRelease` +For more information about proper automated deployment setup, see +[Google Play publishing Gradle task](../build-conventions-secant/src/main/kotlin/secant.publish-conventions.gradle.kts) +documentation and related [gradle.properties](../gradle.properties) attributes. Note that the above instructions are for repeat deployments. If you do not yet have an app listing, you'll need to create that manually. diff --git a/gradle.properties b/gradle.properties index 68a73b0c..2206d9b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,7 +48,7 @@ ZCASH_FIREBASE_TEST_LAB_PROJECT= IS_MINIFY_ENABLED=true # If ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH is set and the deployment task is triggered, then -# VERSION_CODE is effectively ignored VERSION_NAME is suffixed with the version code. +# VERSION_CODE is effectively ignored. VERSION_NAME is suffixed with the version code. # If not using automated Google Play deployment, then these serve as the actual version numbers. ZCASH_VERSION_CODE=1 ZCASH_VERSION_NAME=0.2.0 @@ -86,12 +86,28 @@ ZCASH_RELEASE_KEY_ALIAS_PASSWORD= # the default debug key configuration. IS_SIGN_RELEASE_BUILD_WITH_DEBUG_KEY=false -# Optionally set the Google Play Service Key path to enable deployment +# Set the Google Play Service Account email address to enable deployment +# Note that this property is not currently used due to #1033 +# TODO [#1033]: Use token-based authorization on Google Play for automated deployment +# TODO [#1033]: https://github.com/zcash/secant-android-wallet/issues/1033 +ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT= +# Also, set the Google Play Service Key path to enable deployment. It's a path to the private key file (only used for +# Service Account auth). +# Note that this property is not currently used due to #1033 +# TODO [#1033]: Use token-based authorization on Google Play for automated deployment +# TODO [#1033]: https://github.com/zcash/secant-android-wallet/issues/1033 ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH= -# Can be one of {build, deploy}. -# Build can be used to generate a version number for the next release, but does not ultimately create a release on Google Play. -# Deploy commits the build on Google Play, creating a new release -ZCASH_GOOGLE_PLAY_DEPLOY_MODE=build +# Set the Google Play Service Account key to authorize on Google Play +ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY= +# Set the Google Play Publisher API key to authorize the publisher on Google Play API +ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY= +# Can be one of {internal, alpha}. There are more of them {beta, production}, which are not supported for security +# reasons. Internal will deploy into the Internal and Alpha into the Closed testing tracks on Google Play. +ZCASH_GOOGLE_PLAY_DEPLOY_TRACK=internal +# Can be one of {draft, completed}. +# Draft can be used to generate a version number for the next release, but does not ultimately create a release on +# Google Play. Completed commits the build on Google Play, creating a new release. +ZCASH_GOOGLE_PLAY_DEPLOY_STATUS=draft ZCASH_EMULATOR_WTF_API_KEY= @@ -122,7 +138,6 @@ GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15 GRADLE_VERSIONS_PLUGIN_VERSION=0.47.0 JGIT_VERSION=6.4.0.202211300538-r KTLINT_VERSION=0.49.0 -PLAY_PUBLISHER_PLUGIN_VERSION=3.8.4 ACCOMPANIST_PERMISSIONS_VERSION=0.32.0 ANDROIDX_ACTIVITY_VERSION=1.8.0 @@ -152,6 +167,7 @@ ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1 ANDROIDX_WORK_MANAGER_VERSION=2.8.1 CORE_LIBRARY_DESUGARING_VERSION=2.0.3 FIREBASE_BOM_VERSION_MATCHER=32.0.0 +GOOGLE_AUTH_LIB_JAVA_VERSION=1.18.0 JACOCO_VERSION=0.8.9 KOTLIN_VERSION=1.9.10 KOTLINX_COROUTINES_VERSION=1.7.1 @@ -160,6 +176,7 @@ KOTLINX_IMMUTABLE_COLLECTIONS_VERSION=0.3.5 KOVER_VERSION=0.7.3 PLAY_APP_UPDATE_VERSION=2.0.1 PLAY_APP_UPDATE_KTX_VERSION=2.0.1 +PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZCASH_BIP39_VERSION=1.0.6 ZXING_VERSION=3.5.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index a13d05a2..6fd75d1e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,7 +59,6 @@ pluginManagement { id("com.android.library") version (androidGradlePluginVersion) apply (false) id("com.android.test") version (androidGradlePluginVersion) apply (false) id("com.github.ben-manes.versions") version (extra["GRADLE_VERSIONS_PLUGIN_VERSION"].toString()) apply (false) - id("com.github.triplet.play") version (extra["PLAY_PUBLISHER_PLUGIN_VERSION"].toString()) apply (false) id("com.osacky.fulladle") version (extra["FULLADLE_VERSION"].toString()) apply (false) id("org.jetbrains.kotlinx.kover") version (extra["KOVER_VERSION"].toString()) apply (false) id("wtf.emulator.gradle") version (extra["EMULATOR_WTF_GRADLE_PLUGIN_VERSION"].toString()) apply (false)