Skip to content

Commit

Permalink
Workaround bug in android 4 for JSON objects with List<String> (#942)
Browse files Browse the repository at this point in the history
Fixes
https://linear.app/revenuecat/issue/SDK-3052/400-from-post-receipts-because-product-did-not-have-quotes
https://revenuecats.atlassian.net/browse/CSDK-681

Works around the following bug in android 4:
https://stackoverflow.com/q/37317669
http://fupeg.blogspot.com/2011/07/android-json-bug.html


Essentially, this bug means that when you get a list of product_ids,
instead of sending a list of strings, it sends a list with a string in
it.

| Before | After |
| :-: | :-: |
| "product_ids": "[item1, item2]" | "product_ids": ["item1, "item2"] |
  • Loading branch information
aboedo authored May 19, 2023
1 parent c924d62 commit c21e0c7
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 27 deletions.
31 changes: 4 additions & 27 deletions common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import com.revenuecat.purchases.common.networking.ETagManager
import com.revenuecat.purchases.common.networking.Endpoint
import com.revenuecat.purchases.common.networking.HTTPRequest
import com.revenuecat.purchases.common.networking.HTTPResult
import com.revenuecat.purchases.common.networking.MapConverter
import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes
import com.revenuecat.purchases.common.verification.SignatureVerificationException
import com.revenuecat.purchases.common.verification.SignatureVerificationMode
import com.revenuecat.purchases.common.verification.SigningManager
import com.revenuecat.purchases.strings.NetworkStrings
import com.revenuecat.purchases.utils.filterNotNullValues
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.IOException
Expand All @@ -41,7 +41,8 @@ class HTTPClient(
private val eTagManager: ETagManager,
private val diagnosticsTrackerIfEnabled: DiagnosticsTracker?,
val signingManager: SigningManager,
private val dateProvider: DateProvider = DefaultDateProvider()
private val dateProvider: DateProvider = DefaultDateProvider(),
private val mapConverter: MapConverter = MapConverter()
) {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal companion object {
Expand Down Expand Up @@ -135,7 +136,7 @@ class HTTPClient(
requestHeaders: Map<String, String>,
refreshETag: Boolean
): HTTPResult? {
val jsonBody = body?.convert()
val jsonBody = body?.let { mapConverter.convertToJSON(it) }
val path = endpoint.getPath()
val urlPathWithVersion = "/v1$path"
val connection: HttpURLConnection
Expand Down Expand Up @@ -246,30 +247,6 @@ class HTTPClient(
.filterNotNullValues()
}

private fun Map<String, Any?>.convert(): JSONObject {
val mapWithoutInnerMaps = mapValues { (_, value) ->
value.tryCast<Map<String, Any?>>(ifSuccess = { convert() })
}
return JSONObject(mapWithoutInnerMaps)
}

// To avoid Java type erasure, we use a Kotlin inline function with a reified parameter
// so that we can check the type on runtime.
//
// Doing something like:
// if (value is Map<*, *>) (value as Map<String, Any?>).convert()
//
// Would give an unchecked cast warning due to Java type erasure
private inline fun <reified T> Any?.tryCast(
ifSuccess: T.() -> Any?
): Any? {
return if (this is T) {
this.ifSuccess()
} else {
this
}
}

private fun getConnection(request: HTTPRequest): HttpURLConnection {
return (request.fullURL.openConnection() as HttpURLConnection).apply {
request.headers.forEach { (key, value) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.revenuecat.purchases.common.networking

import org.json.JSONArray
import org.json.JSONObject

/**
* A class to convert a Map<String, Any?> into a JSONObject.
* This was created to workaround a bug in Android 4 , where a List<String> would be incorrectly converted into
* a single string instead of a JSONArray of strings. (i.e.: "[\"value1\", \"value2\"]" instead of "[value1, value2]")
* This class handles nested maps, lists, and other JSON-compatible types.
*/
class MapConverter {

/**
* Converts the given [inputMap] into a JSONObject.
*
* @param inputMap The input map to convert.
* @return A JSONObject representing the input map.
*/
internal fun convertToJSON(inputMap: Map<String, Any?>): JSONObject {
val mapWithoutInnerMaps = inputMap.mapValues { (_, value) ->
when (value) {
is List<*> -> {
if (value.all { it is String }) {
JSONObject(mapOf("temp_key" to JSONArray(value))).getJSONArray("temp_key")
} else {
value
}
}
else -> value.tryCast<Map<String, Any?>>(ifSuccess = { convertToJSON(this) })
}
}
return createJSONObject(mapWithoutInnerMaps)
}

internal fun createJSONObject(inputMap: Map<String, Any?>): JSONObject {
return JSONObject(inputMap)
}

/** To avoid Java type erasure, we use a Kotlin inline function with a reified parameter
* so that we can check the type on runtime.
*
* Doing something like:
* if (value is Map<*, *>) (value as Map<String, Any?>).convert()
*
* Would give an unchecked cast warning due to Java type erasure
*/
private inline fun <reified T> Any?.tryCast(
ifSuccess: T.() -> Any?
): Any? {
return if (this is T) {
this.ifSuccess()
} else {
this
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.revenuecat.purchases.common.networking

import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.spyk
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@Config(manifest = Config.NONE)
class MapConverterTest {

private lateinit var mapConverter: MapConverter

@Before
fun setUp() {
mapConverter = MapConverter()
}

@Test
fun `test convert to JSON`() {
val inputMap = mapOf(
"key1" to "value1",
"key2" to listOf("value2", "value3"),
"key3" to mapOf("nestedKey" to "nestedValue")
)

val expectedJson = JSONObject()
.put("key1", "value1")
.put("key2", JSONArray(listOf("value2", "value3")))
.put("key3", JSONObject().put("nestedKey", "nestedValue"))

val result = mapConverter.convertToJSON(inputMap)
assertEquals(expectedJson.toString(), result.toString())
}

@Test
fun `test convert to JSON with nested array of strings`() {
val inputMap = mapOf(
"key1" to "value1",
"key2" to listOf("value2", "value3"),
"key3" to mapOf("nestedKey" to "nestedValue"),
"key4" to mapOf("nestedArray" to listOf("value4", "value5")),
)

val expectedJson = JSONObject()
.put("key1", "value1")
.put("key2", JSONArray(listOf("value2", "value3")))
.put("key3", JSONObject().put("nestedKey", "nestedValue"))
.put("key4", JSONObject().put("nestedArray", JSONArray(listOf("value4", "value5"))))

val result = mapConverter.convertToJSON(inputMap)
assertEquals(expectedJson.toString(), result.toString())
}

/**
* This tests workaround for a bug in Android 4 , where a List<String> would be incorrectly converted into
* a single string instead of a JSONArray of strings.
* (i.e.: "[\"value1\", \"value2\"]" instead of "[value1, value2]")
*/
@Test
fun `test map conversion fixes wrong treatment of arrays of strings in JSON library`() {
val mapConverterPartialMock = spyk<MapConverter>()

val inputMap = mapOf(
"product_ids" to listOf("product_1", "product_2")
)

val mapContainingInputMap = mapOf(
"subscriber_info" to inputMap
)

val incorrectJsonArrayString = "[product_1,product_2]"
val correctedJSONArray = JSONArray(listOf("product_1", "product_2"))

every {
mapConverterPartialMock.createJSONObject(match { it == inputMap })
} returns JSONObject(mapOf("product_ids" to incorrectJsonArrayString))

val resultJson = mapConverterPartialMock.convertToJSON(mapContainingInputMap)
val resultArrayString = resultJson.optJSONObject("subscriber_info")?.optJSONArray("product_ids")

assertEquals(correctedJSONArray, resultArrayString)
}
}

0 comments on commit c21e0c7

Please sign in to comment.