Skip to content
Draft

WIP. #374

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,7 +162,7 @@ internal class InAppProducts : CoreCommand() {
store: Store,
app: App,
appOutput: File,
): List<InAppProduct> {
): List<StoreInAppProduct> {
logger.log("""🔍 Querying ${store.name()} ${app.name} products""")
logger.increaseIndent()

Expand Down Expand Up @@ -190,15 +192,21 @@ internal class InAppProducts : CoreCommand() {
store: Store,
app: App,
appOutput: File,
inAppProducts: List<InAppProduct>,
inAppProducts: List<StoreInAppProduct>,
): 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()
Expand All @@ -207,15 +215,35 @@ internal class InAppProducts : CoreCommand() {
val description = stringsContent.firstOrNull { it.startsWith(linePrefixDescription) }?.removePrefix(linePrefixDescription)?.removePrefix("\">")?.removeSuffix("</string>")?.xmlUnescaped()
val title = stringsContent.firstOrNull { it.startsWith(linePrefixTitle) }?.removePrefix(linePrefixTitle)?.removePrefix("\">")?.removeSuffix("</string>")?.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()) {
Expand Down Expand Up @@ -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<InAppProduct>,
inAppProducts: List<StoreInAppProduct>,
) {
val localeInAppProducts = inAppProducts.flatMap { inAppProduct ->
inAppProduct.listings.flatMap { (localeString, inAppProductListing) ->
Expand All @@ -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,
),
)
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -391,7 +421,7 @@ internal class InAppProducts : CoreCommand() {

private fun writeFiles(
appOutput: File,
inAppProducts: List<InAppProduct>,
inAppProducts: List<StoreInAppProduct>,
) {
val deletedFiles = appOutput.listFiles { file -> file.extension == "json" }.orEmpty().toSet()
deletedFiles.forEach { it.delete() }
Expand All @@ -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
}

Expand All @@ -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()
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,43 +24,79 @@ internal class PlayStore(

override fun name() = "Android"

override suspend fun inAppProducts(app: App): List<InAppProduct> {
val products = androidPublisher.inappproducts()
.list(app.androidPackageName)
.execute()

return products?.inappproduct
?.sortedBy { it.sku }
.orEmpty()
override suspend fun inAppProducts(app: App): List<StoreInAppProduct> {
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<OneTimeProduct>()
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(
Expand All @@ -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
)
},
)
Original file line number Diff line number Diff line change
@@ -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<String, StoreInAppProductListing>,
)

internal interface Store {
fun name(): String

suspend fun inAppProducts(app: App): List<InAppProduct>
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<StoreInAppProduct>
suspend fun create(app: App, file: File): StoreInAppProduct
suspend fun edit(app: App, file: File): StoreInAppProduct
suspend fun edit(app: App, inAppProduct: StoreInAppProduct): StoreInAppProduct
}
Loading