diff --git a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/App.kt b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/App.kt index 074c31d..425e5e3 100644 --- a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/App.kt +++ b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/App.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable @Serializable internal data class App( @SerialName("name") val name: String, @SerialName("android_package_name") val androidPackageName: String, + @SerialName("read_only") val readOnly: Boolean = false, @SerialName("indentation") val indentation: String = " ", @SerialName("android_resource_directory") val androidResourceDirectory: String? = null, @SerialName("android_resource_strings_file_name") val androidResourceStringsFileName: String = "strings.xml", diff --git a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/InAppProducts.kt b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/InAppProducts.kt index ed1ec5f..971d2f6 100644 --- a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/InAppProducts.kt +++ b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/InAppProducts.kt @@ -9,6 +9,8 @@ import com.vanniktech.locale.google.play.store.googlePlayStoreLocale import de.belabs.appstatistics.CoreCommand import de.belabs.appstatistics.inappproducts.store.PlayStore import de.belabs.appstatistics.inappproducts.store.Store +import de.belabs.appstatistics.inappproducts.store.StoreInAppProduct +import de.belabs.appstatistics.inappproducts.store.StoreInAppProductListing import de.belabs.appstatistics.jsonPretty import kotlinx.serialization.builtins.ListSerializer import java.io.File @@ -160,7 +162,7 @@ internal class InAppProducts : CoreCommand() { store: Store, app: App, appOutput: File, - ): List { + ): List { logger.log("""🔍 Querying ${store.name()} ${app.name} products""") logger.increaseIndent() @@ -190,15 +192,21 @@ internal class InAppProducts : CoreCommand() { store: Store, app: App, appOutput: File, - inAppProducts: List, + inAppProducts: List, ): Boolean { + if (app.readOnly) { + return false + } + val androidResourceDirectory = app.androidResourceDirectory?.let(::File) if (androidResourceDirectory != null && androidResourceDirectory.exists()) { val valuesDirectories = androidResourceDirectory.valuesDirectories() val modifiedInAppProducts = inAppProducts.mapNotNull { inAppProduct -> - val hasChanged = valuesDirectories.map { valuesDirectory -> + val listings = inAppProduct.listings.toMutableMap() + + valuesDirectories.map { valuesDirectory -> val locale = valuesDirectory.googlePlayStoreLocale().toString() val stringsFile = valuesDirectory.resolve(app.androidResourceStringsFileName) val stringsContent = stringsFile.readLines() @@ -207,15 +215,35 @@ internal class InAppProducts : CoreCommand() { val description = stringsContent.firstOrNull { it.startsWith(linePrefixDescription) }?.removePrefix(linePrefixDescription)?.removePrefix("\">")?.removeSuffix("")?.xmlUnescaped() val title = stringsContent.firstOrNull { it.startsWith(linePrefixTitle) }?.removePrefix(linePrefixTitle)?.removePrefix("\">")?.removeSuffix("")?.xmlUnescaped() val current = inAppProduct.listings[locale] - val willChange = current?.title() != title || current?.description() != description - val match = current ?: InAppProductListing() - match.title = title - match.description = description - inAppProduct.listings = inAppProduct.listings + (locale to match) - willChange - }.any { it } - - inAppProduct.takeIf { hasChanged } + val willChange = current?.title != title || current?.description != description + + if (willChange) { + logger.log("""Change in $locale for ${inAppProduct.sku}""") + + if (current?.title != title) { + logger.log("""Title changed from ${current?.title} to $title""") + } + + if (current?.description != description) { + logger.log("""Title changed from ${current?.description} to $description""") + } + + listings += ( + locale to StoreInAppProductListing( + title = title.orEmpty(), + description = description.orEmpty(), + ) + ) + } else { + null + } + } + + if (listings != inAppProduct.listings) { + inAppProduct.copy(listings = listings) + } else { + null + } } if (modifiedInAppProducts.isNotEmpty()) { @@ -244,16 +272,16 @@ internal class InAppProducts : CoreCommand() { return false } - private fun resourcePrefix(app: App, inAppProduct: InAppProduct) = "${app.name.snakecase()}_inapp_${inAppProduct.sku}_" + private fun resourcePrefix(app: App, inAppProduct: StoreInAppProduct) = "${app.name.snakecase()}_inapp_${inAppProduct.sku}_" - private fun resourceTitle(app: App, inAppProduct: InAppProduct) = "${resourcePrefix(app, inAppProduct)}title" + private fun resourceTitle(app: App, inAppProduct: StoreInAppProduct) = "${resourcePrefix(app, inAppProduct)}title" - private fun resourceDescription(app: App, inAppProduct: InAppProduct) = "${resourcePrefix(app, inAppProduct)}description" + private fun resourceDescription(app: App, inAppProduct: StoreInAppProduct) = "${resourcePrefix(app, inAppProduct)}description" private fun writeStringsFile( app: App, appOutput: File, - inAppProducts: List, + inAppProducts: List, ) { val localeInAppProducts = inAppProducts.flatMap { inAppProduct -> inAppProduct.listings.flatMap { (localeString, inAppProductListing) -> @@ -263,13 +291,13 @@ internal class InAppProducts : CoreCommand() { sku = inAppProduct.sku, locale = locale, name = resourceDescription(app, inAppProduct), - value = inAppProductListing.description(), + value = inAppProductListing.description, ), LocalisedInAppProduct( sku = inAppProduct.sku, locale = locale, name = resourceTitle(app, inAppProduct), - value = inAppProductListing.title(), + value = inAppProductListing.title, ), ) } @@ -339,6 +367,8 @@ internal class InAppProducts : CoreCommand() { if (androidResourceDirectory != null) { if (!androidResourceDirectory.exists()) { logger.log("""âŒī¸ Android resource directory does not exist: $androidResourceDirectory""") + } else if (app.readOnly) { + logger.log("""âŠī¸ Skipping writing strings.xml since it is read only""") } else { val valuesDirectories = androidResourceDirectory.valuesDirectories() val localeInAppProductsLocaleMap = localeInAppProducts.groupBy { it.locale } @@ -391,7 +421,7 @@ internal class InAppProducts : CoreCommand() { private fun writeFiles( appOutput: File, - inAppProducts: List, + inAppProducts: List, ) { val deletedFiles = appOutput.listFiles { file -> file.extension == "json" }.orEmpty().toSet() deletedFiles.forEach { it.delete() } @@ -407,10 +437,10 @@ internal class InAppProducts : CoreCommand() { } } - private fun File.write(inAppProduct: InAppProduct): File { + private fun File.write(inAppProduct: StoreInAppProduct): File { val file = resolve("${inAppProduct.sku}.json") logger.log("""âœī¸ Writing ${inAppProduct.sku} to $file""") - file.writeText(inAppProduct.toPrettyString()) + file.writeText(inAppProduct.prettyString) return file } @@ -430,6 +460,3 @@ private fun String.snakecase() = replace(" ", "_") private fun String.xmlEscaped() = replace("'", "’") private fun String.xmlUnescaped() = replace("\\'", "'") - -private fun InAppProductListing.title() = title.trim() -private fun InAppProductListing.description() = description.trim() diff --git a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/PlayStore.kt b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/PlayStore.kt index 5e55448..f5fcfc6 100644 --- a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/PlayStore.kt +++ b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/PlayStore.kt @@ -1,10 +1,12 @@ package de.belabs.appstatistics.inappproducts.store import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.googleapis.json.GoogleJsonResponseException import com.google.api.client.json.gson.GsonFactory import com.google.api.services.androidpublisher.AndroidPublisher import com.google.api.services.androidpublisher.AndroidPublisherScopes import com.google.api.services.androidpublisher.model.InAppProduct +import com.google.api.services.androidpublisher.model.OneTimeProduct import com.google.auth.http.HttpCredentialsAdapter import com.google.auth.oauth2.GoogleCredentials import de.belabs.appstatistics.inappproducts.App @@ -22,43 +24,79 @@ internal class PlayStore( override fun name() = "Android" - override suspend fun inAppProducts(app: App): List { - val products = androidPublisher.inappproducts() - .list(app.androidPackageName) - .execute() - - return products?.inappproduct - ?.sortedBy { it.sku } - .orEmpty() + override suspend fun inAppProducts(app: App): List { + try { + val products = androidPublisher.inappproducts() + .list(app.androidPackageName) + .execute() + + return products?.inappproduct + ?.sortedBy { it.sku } + .orEmpty() + .map { it.converted() } + } catch (exception: GoogleJsonResponseException) { + if (exception.statusCode == 403 && exception.statusMessage == "Forbidden") { + val all = mutableListOf() + var pageToken: String? = null + + do { + val response = androidPublisher.monetization() + .onetimeproducts() + .list(app.androidPackageName) + .setPageSize(1000) + .setPageToken(pageToken) + .execute() + + all += response.oneTimeProducts.orEmpty() + pageToken = response.nextPageToken + } while (!pageToken.isNullOrEmpty()) + + return all.map { it.converted() } + } else { + throw exception + } + } } override suspend fun create( app: App, file: File, - ): InAppProduct { + ): StoreInAppProduct { val inAppProduct = inAppProduct(file, app) return androidPublisher.inappproducts() .insert(inAppProduct.packageName, inAppProduct) .execute() + .converted() } - override suspend fun edit(app: App, file: File): InAppProduct { + override suspend fun edit(app: App, file: File): StoreInAppProduct { val inAppProduct = inAppProduct(file, app) return androidPublisher.inappproducts() .update(inAppProduct.packageName, inAppProduct.sku, inAppProduct) .execute() + .converted() } - override suspend fun edit(app: App, inAppProduct: InAppProduct): InAppProduct { - require(inAppProduct.packageName == app.androidPackageName) { - "Package names differ. Expected \"${app.androidPackageName}\" Actual: \"${inAppProduct.packageName}\"" + override suspend fun edit(app: App, inAppProduct: StoreInAppProduct): StoreInAppProduct { + val current = androidPublisher.inappproducts().get(app.androidPackageName, inAppProduct.sku) + .execute() + + require(current.packageName == app.androidPackageName) { + "Package names differ. Expected \"${app.androidPackageName}\" Actual: \"${current.packageName}\"" + } + + current.listings.forEach { (key, value) -> + val match = inAppProduct.listings.getValue(key) + value.title = match.title + value.description = match.description } return androidPublisher.inappproducts() - .update(inAppProduct.packageName, inAppProduct.sku, inAppProduct) + .update(current.packageName, current.sku, current) .execute() + .converted() } private fun inAppProduct( @@ -79,3 +117,25 @@ internal class PlayStore( return inAppProduct } } + +private fun InAppProduct.converted() = StoreInAppProduct( + sku = sku, + prettyString = toPrettyString(), + listings = listings.mapValues { + StoreInAppProductListing( + title = it.value.title.trim(), + description = it.value.description.trim(), + ) + }, +) + +private fun OneTimeProduct.converted() = StoreInAppProduct( + sku = productId, + prettyString = toPrettyString(), + listings = listings.associate { + it.languageCode to StoreInAppProductListing( + it.title, + it.description + ) + }, +) diff --git a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/Store.kt b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/Store.kt index 6c7c85c..908a374 100644 --- a/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/Store.kt +++ b/inapp-products/src/main/kotlin/de/belabs/appstatistics/inappproducts/store/Store.kt @@ -1,14 +1,24 @@ package de.belabs.appstatistics.inappproducts.store -import com.google.api.services.androidpublisher.model.InAppProduct import de.belabs.appstatistics.inappproducts.App import java.io.File +data class StoreInAppProductListing( + val title: String, + val description: String, +) + +data class StoreInAppProduct( + val sku: String, + val prettyString: String, + val listings: Map, +) + internal interface Store { fun name(): String - suspend fun inAppProducts(app: App): List - suspend fun create(app: App, file: File): InAppProduct - suspend fun edit(app: App, file: File): InAppProduct - suspend fun edit(app: App, inAppProduct: InAppProduct): InAppProduct + suspend fun inAppProducts(app: App): List + suspend fun create(app: App, file: File): StoreInAppProduct + suspend fun edit(app: App, file: File): StoreInAppProduct + suspend fun edit(app: App, inAppProduct: StoreInAppProduct): StoreInAppProduct }