From 4ed5d763f7e31122ca027fec0800e1408c763d99 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 29 Sep 2026 17:07:54 +0800 Subject: [PATCH 1/2] fix(wallet): preserve price alert update fields in release --- .../android/ui/wallet/alert/vo/AlertUpdateRequest.kt | 8 +++++++- .../test/java/one/mixin/android/ReleaseKeepRulesTest.kt | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/vo/AlertUpdateRequest.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/vo/AlertUpdateRequest.kt index 034ba8e953..e8e082eaf0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/vo/AlertUpdateRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/vo/AlertUpdateRequest.kt @@ -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, -) \ No newline at end of file +) diff --git a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt index 70f641a62e..48263902a5 100644 --- a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt +++ b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt @@ -5,6 +5,7 @@ import one.mixin.android.api.request.TransferRequest import one.mixin.android.api.response.perps.PerpsFavorite import one.mixin.android.api.response.perps.PerpsMarketCategoryRelation import one.mixin.android.crypto.Base64 +import one.mixin.android.ui.wallet.alert.vo.AlertUpdateRequest import org.junit.Assert.assertTrue import org.junit.Test import java.io.File @@ -39,7 +40,11 @@ class ReleaseKeepRulesTest { fun apiFieldsHaveStableJsonNamesOrExplicitKeepRules() { val retained = setOf(PerpsFavorite::class.java, PerpsMarketCategoryRelation::class.java) val violations = appClasses - .filter { it.name.startsWith("one.mixin.android.api.request.") || it.name.startsWith("one.mixin.android.api.response.") } + .filter { + it.name.startsWith("one.mixin.android.api.request.") || + it.name.startsWith("one.mixin.android.api.response.") || + it == AlertUpdateRequest::class.java + } .filterNot { it in retained || Enum::class.java.isAssignableFrom(it) } .flatMap { it.declaredFields.toList() } .filter { field -> @@ -61,7 +66,7 @@ class ReleaseKeepRulesTest { } companion object { - private val appClasses by lazy { + internal val appClasses by lazy { val names = setOf(TransferRequest::class.java, Base64::class.java) .map { File(requireNotNull(it.protectionDomain?.codeSource?.location).toURI()) } .distinct() From c8631cde68012af2b30c63d02f5c67beb4064e78 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 29 Sep 2026 17:23:48 +0800 Subject: [PATCH 2/2] test(api): verify JSON contracts against minified release --- .../one/mixin/android/ApiJsonContractTest.kt | 174 ++++++++++++++++++ .../mixin/android/ReleaseApiJsonProbe.java | 152 +++++++++++++++ .../one/mixin/android/ReleaseKeepRulesTest.kt | 10 +- tools/test-release-api-json.sh | 44 +++++ 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/one/mixin/android/ApiJsonContractTest.kt create mode 100644 app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java create mode 100755 tools/test-release-api-json.sh diff --git a/app/src/test/java/one/mixin/android/ApiJsonContractTest.kt b/app/src/test/java/one/mixin/android/ApiJsonContractTest.kt new file mode 100644 index 0000000000..f71346aa69 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ApiJsonContractTest.kt @@ -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>() + 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(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)? ;\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, Type>, path: Set, models: MutableSet>): 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 = generateSequence(type) { it.superclass } + .flatMap { it.declaredFields.asSequence() } + .filter { !it.isSynthetic && !Modifier.isStatic(it.modifiers) && !Modifier.isTransient(it.modifiers) } + .toList() +} diff --git a/app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java b/app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java new file mode 100644 index 0000000000..a12e4b650d --- /dev/null +++ b/app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java @@ -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 classes = new HashMap<>(); + private static final Map 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 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); + } +} diff --git a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt index 48263902a5..b09c8b0338 100644 --- a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt +++ b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt @@ -11,6 +11,8 @@ import org.junit.Test import java.io.File import java.io.Serializable import java.lang.reflect.Modifier +import java.net.JarURLConnection +import java.net.URL import java.util.jar.JarFile class ReleaseKeepRulesTest { @@ -68,7 +70,13 @@ class ReleaseKeepRulesTest { companion object { internal val appClasses by lazy { val names = setOf(TransferRequest::class.java, Base64::class.java) - .map { File(requireNotNull(it.protectionDomain?.codeSource?.location).toURI()) } + .map { type -> + val path = type.name.replace('.', '/') + ".class" + val resource = requireNotNull(type.getResource("/$path")) + val source = if (resource.protocol == "jar") (resource.openConnection() as JarURLConnection).jarFileURL + else URL(resource.toExternalForm().removeSuffix(path)) + File(source.toURI()) + } .distinct() .flatMap { source -> if (source.isDirectory) { diff --git a/tools/test-release-api-json.sh b/tools/test-release-api-json.sh new file mode 100755 index 0000000000..4f80592f94 --- /dev/null +++ b/tools/test-release-api-json.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run every Retrofit JSON contract against the shipped DEX without installing the app. +# Usage: tools/test-release-api-json.sh DEVICE_SERIAL [googlePlay|otherChannel] +cd "$(dirname "$0")/.." +serial=${1:?Pass an adb device serial} +flavor=${2:-googlePlay} +case "$flavor" in + googlePlay) variant=GooglePlay ;; + otherChannel) variant=OtherChannel ;; + *) echo "Unsupported flavor: $flavor" >&2; exit 1 ;; +esac +sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$(sed -n 's/^sdk.dir=//p' local.properties)}} +build_tools="$sdk/build-tools/${BUILD_TOOLS_VERSION:-36.0.0}" +android_jar="$sdk/platforms/android-36/android.jar" +adb=("$sdk/platform-tools/adb" -s "$serial") +"${adb[@]}" get-state + +./gradlew ":app:test${variant}DebugUnitTest" --rerun --tests one.mixin.android.ApiJsonContractTest --tests one.mixin.android.ReleaseKeepRulesTest +./gradlew ":app:assemble${variant}Release" \ + -x ":app:uploadCrashlyticsMappingFile${variant}Release" \ + -x ":app:bugsnagUpload${variant}ReleaseProguardMapping" \ + -x ":app:bugsnagCreate${variant}ReleaseBuild" + +scratch=$(mktemp -d "$PWD/app/build/release-api-json.XXXXXX") +remote="/data/local/tmp/${scratch##*/}" +trap '"${adb[@]}" shell rm -rf "$remote"; rm -rf "$scratch"' EXIT +mkdir -p "$scratch/classes" +javac --release 8 -cp "$android_jar" -d "$scratch/classes" app/src/test/java/one/mixin/android/ReleaseApiJsonProbe.java +"$build_tools/d8" --release --min-api 26 --lib "$android_jar" --output "$scratch/probe.jar" "$scratch"/classes/one/mixin/android/*.class +awk ' + /^[^ #]/ { keep = /^one[.]mixin[.]android[.]/ } + keep && (/^[^ #]/ || (/ -> / && !/[(]/)) { print } +' "app/build/outputs/mapping/${flavor}Release/mapping.txt" > "$scratch/mapping.txt" +apks=(app/build/outputs/apk/"$flavor"/release/*.apk) +if [ "${#apks[@]}" -ne 1 ]; then echo 'Expected exactly one Release APK' >&2; exit 1; fi +"${adb[@]}" shell mkdir -p "$remote" +"${adb[@]}" push "${apks[0]}" "$remote/app.apk" +"${adb[@]}" push "$scratch/probe.jar" "$scratch/mapping.txt" app/build/reports/api-json-contracts.json "$remote/" +"${adb[@]}" shell chmod 444 "$remote/app.apk" "$remote/probe.jar" +"${adb[@]}" shell "CLASSPATH=$remote/probe.jar" app_process /system/bin one.mixin.android.ReleaseApiJsonProbe \ + "$remote/app.apk" "$remote" "$remote/mapping.txt" "$remote/api-json-contracts.json" \ + | tee app/build/reports/release-api-json.log