Skip to content

Commit

Permalink
Support for Subscriptions API (#1126)
Browse files Browse the repository at this point in the history
  • Loading branch information
kb0 authored May 18, 2024
1 parent 7b4cbb9 commit 5746197
Show file tree
Hide file tree
Showing 27 changed files with 528 additions and 21 deletions.
51 changes: 32 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ listings and other metadata.
2. [Directory structure](#directory-structure)
3. [Publishing listings](#publishing-listings)
4. [Publishing in-app products](#publishing-in-app-products)
5. [Publishing in-app subscriptions](#publishing-in-app-subscriptions)
7. [Working with product flavors](#working-with-product-flavors)
1. [Disabling publishing](#disabling-publishing)
2. [Combining artifacts into a single release](#combining-artifacts-into-a-single-release)
Expand Down Expand Up @@ -540,24 +541,24 @@ Run `./gradlew publishListing`.

Base directory: `play`

File | Description
File | Description
------------------------|--------------------------------------------------------------------------------------------------
`contact-email.txt` | Developer email
`contact-phone.txt` | Developer phone
`contact-website.txt` | Developer website
`default-language.txt` | The default language for both your Play Store listing and translation merging as described above
`contact-email.txt` | Developer email
`contact-phone.txt` | Developer phone
`contact-website.txt` | Developer website
`default-language.txt` | The default language for both your Play Store listing and translation merging as described above

#### Uploading text based listings

Base directory: `play/listings/[language]` where `language` is one of the
[Play Store supported codes](https://support.google.com/googleplay/android-developer/answer/3125566)

File | Description | Character limit
File | Description | Character limit
-------------------------|-----------------------|-----------------
`title.txt` | App title | 50
`short-description.txt` | Tagline | 80
`full-description.txt` | Full description | 4000
`video-url.txt` | Youtube product video | N/A
`title.txt` | App title | 50
`short-description.txt` | Tagline | 80
`full-description.txt` | Full description | 4000
`video-url.txt` | Youtube product video | N/A

#### Uploading graphic based listings

Expand All @@ -570,16 +571,16 @@ for the same media type. While file names are arbitrary, they will be uploaded i
and presented on the Play Store as such. Therefore, we recommend using a number as the file name
(`1.png` for example). Both PNG and JPEG images are supported.

Directory | Max # of images | Image dimension constraints (px)
Directory | Max # of images | Image dimension constraints (px)
----------------------------|-----------------|----------------------------------
`icon` | 1 | 512x512
`feature-graphic` | 1 | 1024x500
`phone-screenshots` | 8 | [320..3840]x[320..3840]
`tablet-screenshots` | 8 | [320..3840]x[320..3840]
`large-tablet-screenshots` | 8 | [320..3840]x[320..3840]
`tv-banner` | 1 | 1280x720
`tv-screenshots` | 8 | [320..3840]x[320..3840]
`wear-screenshots` | 8 | [320..3840]x[320..3840]
`icon` | 1 | 512x512
`feature-graphic` | 1 | 1024x500
`phone-screenshots` | 8 | [320..3840]x[320..3840]
`tablet-screenshots` | 8 | [320..3840]x[320..3840]
`large-tablet-screenshots` | 8 | [320..3840]x[320..3840]
`tv-banner` | 1 | 1280x720
`tv-screenshots` | 8 | [320..3840]x[320..3840]
`wear-screenshots` | 8 | [320..3840]x[320..3840]

### Publishing in-app products

Expand All @@ -588,6 +589,18 @@ Run `./gradlew publishProducts`.
Manually setting up in-app purchase files is not recommended. [Bootstrap them instead](#quickstart)
with `./gradlew bootstrapListing --products`.

### Publishing in-app subscriptions

Run `./gradlew publishSubscriptions`.

Manually setting up in-app subscriptions files is not recommended. [Bootstrap them instead](#quickstart)
with `./gradlew bootstrapListing --subscriptions`.

Each subscription file must have an associated metadata file (`subscriptions/<subscription product id>.metadata.json`)
that contains JSON of the form `{"regionsVersion": ...}`. The `regionsVersion` is described
[here](https://developers.google.com/android-publisher/api-ref/rest/v3/RegionsVersion). The Google Play Developer API does
not have a way to get the latest regions version, so unfortunately bootstrapping uses a hardcoded value that you may need to fix.

## Working with product flavors

When working with product flavors, granular configuration is key. GPP provides varying levels of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ interface PlayPublisher {
*/
fun updateInAppProduct(productFile: File): UpdateProductResponse

/**
* Get all current subscriptions.
*
* More docs are available
* [here](https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions/list).
*/
fun getInAppSubscriptions(): List<GppSubscription>

/**
* Creates a new subscription from the given [subscriptionFile].
*
* More docs are available
* [here](https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions/create).
*/
fun insertInAppSubscription(subscriptionFile: File, regionsVersion: String)

/**
* Updates an existing subscription from the given [subscriptionFile].
*
* More docs are available
* [here](https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions/patch).
*/
fun updateInAppSubscription(subscriptionFile: File, regionsVersion: String): UpdateSubscriptionResponse

/** Basic factory to create [PlayPublisher] instances. */
interface Factory {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,21 @@ data class GppProduct internal constructor(
val json: String,
)

data class GppSubscription internal constructor(
/** The product ID. */
val productId: 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,
)

/** Response for a subscription update request. */
data class UpdateSubscriptionResponse internal constructor(
/** @return true if the product doesn't exist and needs to be created, false otherwise. */
val needsCreating: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package com.github.triplet.gradle.androidpublisher.internal
import com.github.triplet.gradle.androidpublisher.CommitResponse
import com.github.triplet.gradle.androidpublisher.EditResponse
import com.github.triplet.gradle.androidpublisher.GppProduct
import com.github.triplet.gradle.androidpublisher.GppSubscription
import com.github.triplet.gradle.androidpublisher.PlayPublisher
import com.github.triplet.gradle.androidpublisher.UpdateProductResponse
import com.github.triplet.gradle.androidpublisher.UpdateSubscriptionResponse
import com.github.triplet.gradle.androidpublisher.UploadInternalSharingArtifactResponse
import com.google.api.client.googleapis.json.GoogleJsonResponseException
import com.google.api.client.googleapis.media.MediaHttpUploader
Expand All @@ -20,6 +22,7 @@ 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.Subscription
import com.google.api.services.androidpublisher.model.Track
import java.io.File
import java.io.InputStream
Expand Down Expand Up @@ -212,12 +215,67 @@ internal class DefaultPlayPublisher(
return UpdateProductResponse(false)
}

override fun getInAppSubscriptions(): List<GppSubscription> {
fun AndroidPublisher.Monetization.Subscriptions.List.withPageToken(pageToken: String?) = apply {
this.pageToken = pageToken
}

val subscriptions = mutableListOf<Subscription>()

var token: String? = null
do {
val response = publisher.monetization().subscriptions().list(appId).withPageToken(token).execute()
subscriptions += response.subscriptions.orEmpty()
token = response.nextPageToken
} while (token != null)

return subscriptions.map {
GppSubscription(it.productId, it.toPrettyString())
}
}

override fun insertInAppSubscription(subscriptionFile: File, regionsVersion: String) {
val subscription = readSubscriptionFile(subscriptionFile)
publisher.monetization().subscriptions().create(subscription.packageName, subscription)
.apply {
regionsVersionVersion = regionsVersion
productId = subscription.productId
}
.execute()
}

override fun updateInAppSubscription(subscriptionFile: File, regionsVersion: String): UpdateSubscriptionResponse {
val subscription = readSubscriptionFile(subscriptionFile)
try {
publisher.monetization().subscriptions().patch(subscription.packageName, subscription.productId, subscription)
.apply {
regionsVersionVersion = regionsVersion
updateMask = SUBSCRIPTIONS_UPDATE_MASK
}
.execute()
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 404) {
return UpdateSubscriptionResponse(true)
} else {
throw e
}
}

return UpdateSubscriptionResponse(false)
}

private fun readProductFile(product: File) = product.inputStream().use {
GsonFactory.getDefaultInstance()
.createJsonParser(it)
.parse(InAppProduct::class.java)
}

private fun readSubscriptionFile(product: File) = product.inputStream().use {
GsonFactory.getDefaultInstance()
.createJsonParser(it)
.parse(Subscription::class.java)
}

private fun <T, R : AbstractGoogleClientRequest<T>> R.trackUploadProgress(
thing: String,
file: File,
Expand Down Expand Up @@ -252,5 +310,7 @@ internal class DefaultPlayPublisher(
const val MIME_TYPE_STREAM = "application/octet-stream"
const val MIME_TYPE_APK = "application/vnd.android.package-archive"
const val MIME_TYPE_IMAGE = "image/*"

const val SUBSCRIPTIONS_UPDATE_MASK = "listings,basePlans"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ abstract class FakePlayPublisher : PlayPublisher {
override fun updateInAppProduct(productFile: File): UpdateProductResponse =
throw IllegalStateException("Test wasn't expecting this method to be called.")

override fun getInAppSubscriptions(): List<GppSubscription> =
throw IllegalStateException("Test wasn't expecting this method to be called.")

override fun insertInAppSubscription(subscriptionFile: File, regionsVersion: String): Unit =
throw IllegalStateException("Test wasn't expecting this method to be called.")

override fun updateInAppSubscription(subscriptionFile: File, regionsVersion: String): UpdateSubscriptionResponse =
throw IllegalStateException("Test wasn't expecting this method to be called.")

class Factory : PlayPublisher.Factory {
override fun create(credentials: InputStream, appId: String) = publisher
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ fun newUploadInternalSharingArtifactResponse(json: String, downloadUrl: String)
fun newGppProduct(sku: String, json: String) = GppProduct(sku, json)

fun newUpdateProductResponse(needsCreating: Boolean) = UpdateProductResponse(needsCreating)

fun newGppSubscription(productId: String, json: String) = GppSubscription(productId, json)

fun newUpdateSubscriptionResponse(needsCreating: Boolean) = UpdateSubscriptionResponse(needsCreating)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.github.triplet.gradle.play.internal.PLAY_PATH
import com.github.triplet.gradle.play.internal.PRODUCTS_PATH
import com.github.triplet.gradle.play.internal.RELEASE_NAMES_PATH
import com.github.triplet.gradle.play.internal.RELEASE_NOTES_PATH
import com.github.triplet.gradle.play.internal.SUBSCRIPTIONS_PATH
import com.github.triplet.gradle.play.internal.buildExtension
import com.github.triplet.gradle.play.internal.flavorNameOrDefault
import com.github.triplet.gradle.play.internal.generateExtensionOverrideOrdering
Expand All @@ -35,6 +36,7 @@ import com.github.triplet.gradle.play.tasks.PublishInternalSharingApk
import com.github.triplet.gradle.play.tasks.PublishInternalSharingBundle
import com.github.triplet.gradle.play.tasks.PublishListings
import com.github.triplet.gradle.play.tasks.PublishProducts
import com.github.triplet.gradle.play.tasks.PublishSubscriptions
import com.github.triplet.gradle.play.tasks.internal.BootstrapLifecycleTask
import com.github.triplet.gradle.play.tasks.internal.BootstrapOptions
import com.github.triplet.gradle.play.tasks.internal.GlobalPublishableArtifactLifecycleTask
Expand Down Expand Up @@ -172,6 +174,13 @@ internal abstract class PlayPublisherPlugin @Inject constructor(
| See https://github.com/Triple-T/gradle-play-publisher#publishing-in-app-products
""".trimMargin(),
)
val publishSubscriptionsAllTask = project.newTask<Task>(
"publishSubscriptions",
"""
|Uploads all Play Store in-app subscriptions for all variants.
| See https://github.com/Triple-T/gradle-play-publisher#publishing-in-app-subscriptions
""".trimMargin(),
)

// ----------------------------------- END: GLOBAL TASKS -----------------------------------

Expand Down Expand Up @@ -392,6 +401,21 @@ internal abstract class PlayPublisherPlugin @Inject constructor(
}
publishProductsAllTask { dependsOn(publishProductsTask) }

val publishSubscriptionsTask = project.newTask<PublishSubscriptions>(
"publish${taskVariantName}Subscriptions",
"""
|Uploads all Play Store in-app subscriptions for variant $taskVariantName.
| See https://github.com/Triple-T/gradle-play-publisher#publishing-in-app-subscriptions
""".trimMargin(),
arrayOf(extension),
) {
bindApi(api)
subscriptionsDir.setFrom(resourceDir.map {
it.dir(SUBSCRIPTIONS_PATH).asFileTree.matching { include("*.json") }
})
}
publishSubscriptionsAllTask { dependsOn(publishSubscriptionsTask) }

val isAuto = extension.resolutionStrategy.get() == ResolutionStrategy.AUTO
|| extension.resolutionStrategy.get() == ResolutionStrategy.AUTO_OFFSET
val staticVersionCodes = if (isAuto) {
Expand Down Expand Up @@ -517,6 +541,7 @@ internal abstract class PlayPublisherPlugin @Inject constructor(
})
dependsOn(publishListingTask)
dependsOn(publishProductsTask)
dependsOn(publishSubscriptionsTask)
}
publishAllTask { dependsOn(publishTask) }

Expand Down Expand Up @@ -630,6 +655,14 @@ internal abstract class PlayPublisherPlugin @Inject constructor(
""".trimMargin(),
allowExisting = true,
).configure { dependsOn(publishProductsTask) }
project.newTask<Task>(
"publish${taskQualifier}Subscriptions",
"""
|Uploads all Play Store in-app subscriptions for all $taskQualifier variants.
| See https://github.com/Triple-T/gradle-play-publisher#publishing-in-app-subscriptions
""".trimMargin(),
allowExisting = true,
).configure { dependsOn(publishSubscriptionsTask) }
}

// ----------------------------- END: SEMI-GLOBAL TASKS -----------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal const val GRAPHICS_PATH = "graphics"
internal const val RELEASE_NOTES_PATH = "release-notes"
internal const val RELEASE_NAMES_PATH = "release-names"
internal const val PRODUCTS_PATH = "products"
internal const val SUBSCRIPTIONS_PATH = "subscriptions"
internal const val OUTPUT_PATH = "gpp"
internal const val RESOURCES_OUTPUT_PATH = "generated/$OUTPUT_PATH"
internal const val INTERMEDIATES_OUTPUT_PATH = "intermediates/$OUTPUT_PATH"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.github.triplet.gradle.play.internal

data class SubscriptionMetadata(
/** The subscription regions version */
val regionsVersion: String,
)
Loading

0 comments on commit 5746197

Please sign in to comment.