Skip to content
Merged
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
@@ -1,8 +1,14 @@
package one.mixin.android.ui.wallet.alert.vo

import com.google.gson.annotations.SerializedName

class AlertUpdateRequest(
@SerializedName("type")
val type: String? = null,
@SerializedName("frequency")
val frequency: String? = null,
@SerializedName("value")
val value: String? = null,
@SerializedName("action")
val action: String,
)
)
174 changes: 174 additions & 0 deletions app/src/test/java/one/mixin/android/ApiJsonContractTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package one.mixin.android

import android.app.Application
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.google.gson.annotations.SerializedName
import one.mixin.android.ui.wallet.alert.vo.AlertUpdateRequest
import one.mixin.android.util.GsonHelper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import retrofit2.http.Body
import java.io.File
import java.lang.reflect.Field
import java.lang.reflect.GenericArrayType
import java.lang.reflect.Modifier
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import java.lang.reflect.TypeVariable
import java.lang.reflect.WildcardType

@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, manifest = Config.NONE, sdk = [33])
class ApiJsonContractTest {
@Test
fun exportAllApiJsonContracts() {
val contracts = JsonArray()
val models = linkedSetOf<Class<*>>()
var endpoints = 0
ReleaseKeepRulesTest.appClasses.filter { it.isInterface }.sortedBy { it.name }.forEach { service ->
service.declaredMethods.filter { method ->
method.annotations.any { it.annotationClass.java.packageName == "retrofit2.http" }
}.sortedBy { it.toGenericString() }.forEach { method ->
endpoints++
val custom = service.simpleName !in setOf("RouteService", "GiphyService", "FoursquareService", "CashService", "EarnService", "ReferralService")
val gson = if (custom) GsonHelper.customGson else Gson()
val types = method.genericParameterTypes.filterIndexed { index, _ ->
method.parameterAnnotations[index].any { it is Body }
} + unwrap(method.genericParameterTypes.lastOrNull()?.takeIf {
it is ParameterizedType && it.rawType == kotlin.coroutines.Continuation::class.java
} ?: method.genericReturnType)
types.forEachIndexed { index, type ->
val input = sample(type, custom, emptyMap(), emptySet(), models)
val value = gson.fromJson<Any>(input, type)
val expected = gson.toJsonTree(value, type)
contracts.add(JsonObject().apply {
addProperty("endpoint", "${service.simpleName}.${method.name}:$index")
addProperty("customGson", custom)
add("type", typeDescription(type))
addProperty("input", input.toString())
add("expected", expected)
})
}
}
}
assertTrue("No Retrofit endpoints found", endpoints > 0)
assertTrue("Price alert update must be discovered through Retrofit", AlertUpdateRequest::class.java in models)
val keptFields = Regex("""-keepclassmembers,allowoptimization class ([^{]+)\{\s*!transient !static(?: !synthetic)? <fields>;\s*}""")
.findAll(File("proguard-rules.pro").readText())
.map { it.groupValues[1].split(',').map(String::trim) }
.toList()
fun matches(name: String, selector: String) = if (selector.endsWith(".**")) name.startsWith(selector.removeSuffix("**")) else name == selector
val unstable = models.flatMap(::jsonFields).filter { field ->
field.getAnnotation(SerializedName::class.java) == null && keptFields.none { selectors ->
selectors.any { !it.startsWith('!') && matches(field.declaringClass.name, it) } &&
selectors.none { it.startsWith('!') && matches(field.declaringClass.name, it.drop(1)) }
}
}
assertTrue("API fields need SerializedName or explicit field keep rules: $unstable", unstable.isEmpty())
val fields = JsonArray()
models.sortedBy { it.name }.forEach { model ->
jsonFields(model).forEach { field ->
fields.add(JsonObject().apply {
addProperty("owner", field.declaringClass.name)
addProperty("name", field.name)
addProperty("type", field.genericType.typeName)
})
}
}
val report = JsonObject().apply {
addProperty("endpoints", endpoints)
addProperty("models", models.size)
add("fields", fields)
add("contracts", contracts)
}
File("build/reports/api-json-contracts.json").apply {
parentFile.mkdirs()
writeText(Gson().toJson(report))
}
println("API JSON contracts: $endpoints endpoints, ${models.size} models, ${fields.size()} fields, ${contracts.size()} payloads")
}

@Test
fun priceAlertActionsUseServerJsonNames() {
val gson = Gson()
for (action in listOf("delete", "pause", "resume")) {
assertEquals("{\"action\":\"$action\"}", gson.toJson(AlertUpdateRequest(action = action)))
}
assertEquals(
"{\"type\":\"price_reached\",\"frequency\":\"once\",\"value\":\"123.45\",\"action\":\"update\"}",
gson.toJson(AlertUpdateRequest("price_reached", "once", "123.45", "update")),
)
}

private fun unwrap(type: Type): Type = when (type) {
is WildcardType -> unwrap((type.lowerBounds + type.upperBounds).first())
is ParameterizedType -> if ((type.rawType as Class<*>).name in setOf(
"kotlin.coroutines.Continuation", "kotlinx.coroutines.Deferred", "retrofit2.Call", "retrofit2.Response",
"io.reactivex.Observable", "io.reactivex.Single", "io.reactivex.Flowable", "androidx.lifecycle.LiveData",
)
) unwrap(type.actualTypeArguments.single()) else type
else -> type
}

private fun typeDescription(type: Type): JsonObject = when (type) {
is WildcardType -> typeDescription((type.lowerBounds + type.upperBounds).first())
is ParameterizedType -> typeDescription(type.rawType).apply {
add("arguments", JsonArray().apply { type.actualTypeArguments.forEach { add(typeDescription(it)) } })
}
is GenericArrayType -> JsonObject().apply { add("component", typeDescription(type.genericComponentType)) }
is Class<*> -> if (type.isArray) JsonObject().apply { add("component", typeDescription(type.componentType)) }
else JsonObject().apply { addProperty("name", type.name) }
else -> error("Unsupported API type: $type")
}

private fun sample(type: Type, custom: Boolean, bindings: Map<TypeVariable<*>, Type>, path: Set<Type>, models: MutableSet<Class<*>>): JsonElement {
if (type in path) return JsonNull.INSTANCE
val next = path + type
if (type is WildcardType) return sample((type.lowerBounds + type.upperBounds).first(), custom, bindings, path, models)
if (type is TypeVariable<*>) return sample(bindings[type] ?: Any::class.java, custom, bindings, path, models)
if (type is GenericArrayType) return JsonArray().apply { add(sample(type.genericComponentType, custom, bindings, next, models)) }
val raw = (if (type is ParameterizedType) type.rawType else type) as Class<*>
val arguments = (type as? ParameterizedType)?.actualTypeArguments.orEmpty()
val resolved = bindings + raw.typeParameters.zip(arguments)
if (raw == ByteArray::class.java && custom) return JsonPrimitive("AQID")
if (raw.isArray) return JsonArray().apply { add(sample(raw.componentType, custom, resolved, next, models)) }
if (Collection::class.java.isAssignableFrom(raw)) return JsonArray().apply {
add(sample(arguments.firstOrNull() ?: Any::class.java, custom, resolved, next, models))
}
if (Map::class.java.isAssignableFrom(raw)) return JsonObject().apply {
add("sample", sample(arguments.getOrNull(1) ?: Any::class.java, custom, resolved, next, models))
}
if (raw == String::class.java || raw == Any::class.java || raw == Char::class.java || raw == java.lang.Character::class.java) return JsonPrimitive("x")
if (raw == Boolean::class.java || raw == java.lang.Boolean::class.java) return JsonPrimitive(true)
if (raw.isPrimitive || Number::class.java.isAssignableFrom(raw)) return JsonPrimitive(7)
if (raw == Void::class.java || raw == Unit::class.java) return JsonNull.INSTANCE
if (JsonElement::class.java.isAssignableFrom(raw)) return if (raw == JsonArray::class.java) JsonArray().apply { add("sample") }
else JsonObject().apply { addProperty("sample", "value") }
if (raw.isEnum) {
val constant = raw.enumConstants.first() as Enum<*>
if (custom && raw.name == "one.mixin.android.vo.WithdrawalMemoPossibility") return JsonPrimitive("negative")
return JsonPrimitive(raw.getField(constant.name).getAnnotation(SerializedName::class.java)?.value ?: constant.name)
}
require(raw.name.startsWith("one.mixin.android.")) { "Add an API fixture for ${raw.name}" }
models.add(raw)
return JsonObject().apply {
jsonFields(raw).forEach { field ->
add(field.getAnnotation(SerializedName::class.java)?.value ?: field.name, sample(field.genericType, custom, resolved, next, models))
}
}
}

private fun jsonFields(type: Class<*>): List<Field> = generateSequence(type) { it.superclass }
.flatMap { it.declaredFields.asSequence() }
.filter { !it.isSynthetic && !Modifier.isStatic(it.modifiers) && !Modifier.isTransient(it.modifiers) }
.toList()
}
152 changes: 152 additions & 0 deletions app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package one.mixin.android;

import dalvik.system.DexClassLoader;
import java.io.BufferedReader;
import java.io.FileReader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;

public final class ReleaseApiJsonProbe {
private static final Map<String, String> classes = new HashMap<>();
private static final Map<String, String> fields = new HashMap<>();
private static ClassLoader loader;

public static void main(String[] args) throws Exception {
String owner = null;
try (BufferedReader mapping = new BufferedReader(new FileReader(args[2]))) {
String line;
while ((line = mapping.readLine()) != null) {
if (line.startsWith("#")) continue;
if (!line.startsWith(" ") && line.endsWith(":")) {
String[] names = line.substring(0, line.length() - 1).split(" -> ");
owner = names[0];
classes.put(owner, names[1]);
} else if (owner != null && !line.contains("(") && line.contains(" -> ")) {
String[] names = line.trim().split(" -> ");
fields.put(owner + "." + names[0].substring(names[0].lastIndexOf(' ') + 1), names[1]);
}
}
}
loader = new DexClassLoader(args[0], args[1], null, ReleaseApiJsonProbe.class.getClassLoader());
Class<?> gsonClass = Class.forName("com.google.gson.Gson", true, loader);
Object defaultGson = gsonClass.getDeclaredConstructor().newInstance();
Object customGson = field("one.mixin.android.util.GsonHelper", "customGson").get(null);
Method fromJson = gsonClass.getMethod("fromJson", String.class, Type.class);
Method toJson = gsonClass.getMethod("toJson", Object.class, Type.class);
JSONObject report = new JSONObject(new String(Files.readAllBytes(Paths.get(args[3])), "UTF-8"));
int failures = 0;
JSONArray metadata = report.getJSONArray("fields");
for (int i = 0; i < metadata.length(); i++) {
JSONObject expected = metadata.getJSONObject(i);
String label = expected.getString("owner") + "." + expected.getString("name");
try {
Field actual = field(expected.getString("owner"), expected.getString("name"));
String expectedType = mappedTypeName(expected.getString("type"));
if (!actual.getGenericType().getTypeName().equals(expectedType)) {
throw new AssertionError("generic type: " + actual.getGenericType() + ", expected " + expectedType);
}
} catch (Throwable error) {
System.out.println("FAIL field " + label + ": " + error);
failures++;
}
}
JSONArray contracts = report.getJSONArray("contracts");
for (int i = 0; i < contracts.length(); i++) {
JSONObject contract = contracts.getJSONObject(i);
try {
Type type = type(contract.getJSONObject("type"));
Object gson = contract.getBoolean("customGson") ? customGson : defaultGson;
Object decoded = fromJson.invoke(gson, contract.getString("input"), type);
Object actual = new JSONTokener((String) toJson.invoke(gson, decoded, type)).nextValue();
if (!sameJson(contract.get("expected"), actual)) {
throw new AssertionError("JSON changed: " + actual + ", expected " + contract.get("expected"));
}
} catch (Throwable error) {
System.out.println("FAIL payload " + contract.getString("endpoint") + ": " + error);
failures++;
}
}
System.out.println("Release API JSON: " + report.getInt("endpoints") + " endpoints, " + report.getInt("models")
+ " models, " + metadata.length() + " fields, " + contracts.length() + " payloads; failures=" + failures);
System.exit(failures == 0 ? 0 : 1);
}

private static Field field(String owner, String name) throws Exception {
Field field = Class.forName(classes.getOrDefault(owner, owner), true, loader)
.getDeclaredField(fields.getOrDefault(owner + "." + name, name));
field.setAccessible(true);
return field;
}

private static String mappedTypeName(String original) {
Matcher matcher = Pattern.compile("[\\w$]+(?:\\.[\\w$]+)*").matcher(original);
StringBuffer result = new StringBuffer();
while (matcher.find()) matcher.appendReplacement(result, Matcher.quoteReplacement(classes.getOrDefault(matcher.group(), matcher.group())));
matcher.appendTail(result);
return result.toString();
}

private static Type type(JSONObject description) throws Exception {
if (description.has("component")) return Array.newInstance((Class<?>) type(description.getJSONObject("component")), 0).getClass();
String name = description.getString("name");
Class<?> raw;
switch (name) {
case "boolean": raw = boolean.class; break;
case "byte": raw = byte.class; break;
case "short": raw = short.class; break;
case "int": raw = int.class; break;
case "long": raw = long.class; break;
case "float": raw = float.class; break;
case "double": raw = double.class; break;
case "char": raw = char.class; break;
case "void": raw = void.class; break;
default: raw = Class.forName(classes.getOrDefault(name, name), true, loader);
}
if (!description.has("arguments")) return raw;
JSONArray arguments = description.getJSONArray("arguments");
Type[] parameters = new Type[arguments.length()];
for (int i = 0; i < parameters.length; i++) parameters[i] = type(arguments.getJSONObject(i));
final Class<?> rawType = raw;
return new ParameterizedType() {
public Type[] getActualTypeArguments() { return parameters.clone(); }
public Type getRawType() { return rawType; }
public Type getOwnerType() { return rawType.getDeclaringClass(); }
};
}

private static boolean sameJson(Object expected, Object actual) throws Exception {
if (expected instanceof JSONObject && actual instanceof JSONObject) {
JSONObject left = (JSONObject) expected, right = (JSONObject) actual;
if (left.length() != right.length()) return false;
Iterator<String> keys = left.keys();
while (keys.hasNext()) {
String key = keys.next();
if (!right.has(key) || !sameJson(left.get(key), right.get(key))) return false;
}
return true;
}
if (expected instanceof JSONArray && actual instanceof JSONArray) {
JSONArray left = (JSONArray) expected, right = (JSONArray) actual;
if (left.length() != right.length()) return false;
for (int i = 0; i < left.length(); i++) if (!sameJson(left.get(i), right.get(i))) return false;
return true;
}
if (expected instanceof Number && actual instanceof Number) {
return new java.math.BigDecimal(expected.toString()).compareTo(new java.math.BigDecimal(actual.toString())) == 0;
}
return expected.equals(actual);
}
}
Loading
Loading