Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2cb72d8
fix: restore API TLS verification
shameondev Aug 4, 2026
5b9ffff
test: use OkHttp hostname verifier accessor
shameondev Aug 4, 2026
fa673c0
test: avoid mocking JDK TLS session
shameondev Aug 4, 2026
907c3dc
fix: enforce TLS verification in NoCodes
shameondev Aug 5, 2026
2626158
feat: persist Remote Config last-known-good
shameondev Aug 4, 2026
2d45083
fix: make Remote Config LKG writes durable
shameondev Aug 5, 2026
97d6dd0
feat: add bundled Remote Config defaults
shameondev Aug 5, 2026
59c3808
feat: add durable remote config snapshot core
shameondev Aug 5, 2026
bcb1c90
feat(remote-config): validate and durably admit snapshots
shameondev Aug 6, 2026
1816a76
feat(remote-config): add resilient fetch policy
shameondev Aug 6, 2026
be66257
feat: guard remote config reads before activation
shameondev Aug 6, 2026
6f2bdc9
feat(remote-config): bind the fetch policy to the v2 gateway
shameondev Aug 7, 2026
e7c7d77
review: harden the v2 gateway transport against review findings
shameondev Aug 7, 2026
3de2672
feat(remote-config): expose the v2 snapshot API
shameondev Aug 7, 2026
11ec810
feat(remote-config): stop requiring a v2 context fingerprint
shameondev Aug 7, 2026
f08a581
feat(remote-config)!: learn the v2 project id from the session bootstrap
shameondev Aug 7, 2026
3cba24a
test: give the identity-transition ordering test load-proof waits
shameondev Aug 7, 2026
f81cedf
feat(remote-config): ack every activation that changes the served rel…
shameondev Aug 7, 2026
47151e1
review: keep the activation ack from ever becoming a storm
shameondev Aug 7, 2026
2ef11f3
test: stop pinning an interleaving the fetch coordinator never promised
shameondev Aug 7, 2026
e5c2218
feat(remote-config): report decode failures and read-guard events to …
shameondev Aug 10, 2026
fc7c64c
feat(remote-config): make the fetch floor configurable and unthrottle…
shameondev Aug 11, 2026
d646d3f
test: pin the RC v2 init wiring, the DI cache binding and the parsing…
shameondev Aug 11, 2026
d3d53e7
docs: draft the release-notes callout for enforced TLS verification
shameondev Aug 11, 2026
d296889
fix: classify malformed-JSON responses as parsing failures, not netwo…
shameondev Aug 11, 2026
fc8389d
test: pin the producer side of the v2 identity bridge
shameondev Aug 11, 2026
b4693a1
sample: Calmly paywall demo driven by one Remote Config v2 key
shameondev Aug 13, 2026
3d21683
feat(rc-v2): secure identity transitions and activation state
shameondev Aug 13, 2026
364855d
test(remote-config): align identity invalidation contracts
shameondev Aug 14, 2026
078c765
fix(sample): satisfy paywall demo quality gate
shameondev Aug 14, 2026
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
64 changes: 64 additions & 0 deletions docs/release-notes-draft-rc-v2-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Release notes draft — RC v2 train (Android SDK)

Status: DRAFT for the upcoming release from `release/rc-v2`. Merge the relevant sections into the
GitHub release description / changelog when the train ships.

## Breaking behavior change: TLS certificate verification is now enforced

Previous SDK versions disabled TLS verification on their own HTTP clients: the core API client
(`NetworkModule.provideOkHttpClient`) trusted every certificate and every hostname, and the NoCodes
module's `HttpsURLConnection` client did the same. This release removes both trust-all paths
(commits `2cb72d80`, `907c3dc7`). All SDK traffic is now verified against the device's trust store,
exactly like any other HTTPS traffic in your app.

**Who is affected**

- Apps inspecting SDK traffic through an intercepting proxy (Charles, Proxyman, mitmproxy, Fiddler)
with a locally installed root certificate.
- Test or staging setups pointing the SDK at endpoints with self-signed or otherwise untrusted
certificates (e.g. via a proxy URL).
- Corporate environments performing TLS interception without distributing the interception root to
the Android user trust store.

**What you will see**

Requests from the SDK fail the TLS handshake — `SSLHandshakeException` /
`CertificateException`-style errors surfacing through the SDK as network errors. Production apps
talking directly to Qonversion over the public internet are not affected.

**What to do**

Do not re-introduce trust-all TLS. For debugging, declare your proxy's root CA as a trusted debug
certificate with Android's standard [network security configuration](https://developer.android.com/privacy-and-security/security-config):

```xml
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
```

```xml
<!-- AndroidManifest.xml -->
<application android:networkSecurityConfig="@xml/network_security_config">
```

`<debug-overrides>` applies only to debuggable builds, so release builds keep full verification.

## Other changes worth calling out

- **Persistent Remote Config last-known-good cache.** Remote configs (v1 `remoteConfig` /
`remoteConfigList`) are now persisted per project/environment/user and served when a fetch fails
or the device is offline — previously such calls errored once the in-memory cache was gone after
a restart. If your code treats a remote-config error as "no config", it will now more often
receive the last successfully fetched values instead.
- **Stricter `remoteConfigList` parsing.** A malformed element in the response now fails the whole
list call with `ResponseParsingFailed` instead of being silently skipped, so a partial list can
no longer be mistaken for the full one.
- **New public API: `Qonversion.fallbackRemoteConfigValue(context, contextKey)`.** Synchronously
reads a default from the `qonversion_remote_config_defaults.json` asset bundled with the app —
available before SDK initialization and without any network.
4 changes: 3 additions & 1 deletion nocodes/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ dependencies {

api project(':sdk')

testImplementation 'junit:junit:4.13.2'

androidTestImplementation 'androidx.test:core:1.5.0'
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.test:rules:1.5.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
}

apply from: "../scripts/maven-release.gradle"
apply from: "../scripts/maven-release.gradle"
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,6 @@ import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager

private const val NETWORK_ENCODING = "utf-8"

Expand Down Expand Up @@ -66,19 +60,6 @@ internal class NetworkClientImpl(
return try {
val connection = url.openConnection() as HttpURLConnection

// Trust all certificates for staging
if (connection is HttpsURLConnection) {
val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
})
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, SecureRandom())
connection.sslSocketFactory = sslContext.socketFactory
connection.setHostnameVerifier { _, _ -> true }
}

// Set smart timeout based on fallback availability
val timeout = if (isFallbackAvailable) {
TimeoutConstants.FALLBACK_AVAILABLE_TIMEOUT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.qonversion.nocodes.internal.networkLayer.networkClient

import io.qonversion.nocodes.internal.common.serializers.Serializer
import org.junit.Assert.assertSame
import org.junit.Test
import java.net.URL
import java.net.URLConnection
import java.net.URLStreamHandler
import java.security.Principal
import java.security.cert.Certificate
import javax.net.ssl.HttpsURLConnection

internal class NetworkClientImplTest {
@Test
fun `https connections retain platform TLS verification`() {
lateinit var platformConnection: TestHttpsURLConnection
val url = URL(null, "https://api.qonversion.io", object : URLStreamHandler() {
override fun openConnection(url: URL): URLConnection {
return TestHttpsURLConnection(url).also { platformConnection = it }
}
})
val platformSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
val platformHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()

val connection = NetworkClientImpl(UnusedSerializer()).connect(url) as HttpsURLConnection

assertSame(platformConnection, connection)
assertSame(platformSocketFactory, connection.sslSocketFactory)
assertSame(platformHostnameVerifier, connection.hostnameVerifier)
}

private class UnusedSerializer : Serializer {
override fun serialize(data: Map<String, Any?>): String = error("not used")
override fun deserialize(payload: String): Any = error("not used")
}

private class TestHttpsURLConnection(url: URL) : HttpsURLConnection(url) {
override fun connect() = Unit
override fun disconnect() = Unit
override fun usingProxy(): Boolean = false
override fun getCipherSuite(): String = ""
override fun getLocalCertificates(): Array<Certificate>? = null
override fun getServerCertificates(): Array<Certificate> = emptyArray()
override fun getPeerPrincipal(): Principal? = null
override fun getLocalPrincipal(): Principal? = null
}
}
23 changes: 23 additions & 0 deletions sample/paywall_config.default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"headline": "Find your calm",
"subtitle": "Guided meditations, sleep stories and breathing exercises. Five minutes a day is enough.",
"accentColor": "#7C5CFF",
"ctaText": "Start 7-day free trial",
"showCountdown": false,
"countdownSeconds": 0,
"products": [
{
"id": "calmly_monthly",
"title": "Monthly",
"priceText": "$9.99 / month",
"badge": null
},
{
"id": "calmly_annual",
"title": "Annual",
"priceText": "$59.99 / year",
"badge": "BEST VALUE"
}
],
"highlightProductId": "calmly_annual"
}
9 changes: 6 additions & 3 deletions sample/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@
SDK. Production merchants set this to their project's uid
issued in Qonversion Connect Apps onboarding.

Scoped to the Qonversion sample project uid `kZfGkwHa`
(matches DEFAULT_PROJECT_KEY in App.java). Must stay in sync
with whatever project this sample initializes the SDK with.
Scoped to the Qonversion sample project uid `kZfGkwHa`.
Must stay in sync with whatever project this sample
initializes the SDK with — while App.java defaults to the
Remote Config v2 local playground project this filter does
not match, so redemption links stay inert until the project
key is switched back via the Home configuration dialog.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schemaVersion":1,"projectId":12109,"environmentUid":"d3v00000-0000-4000-8000-000000000001","releaseUid":"7b90a56d-70a1-4a8d-a54e-9f90bdc2ef78","releaseNumber":45,"manifestContentHash":"059cb11e690a109f572249f4af48da18f616e5bf2d53965d5c9d3fb72f17e29e","defaultsDigest":"20b0b4b19076f4476fab744fa3c2ad4e91e34aaa46666962029c9e9d5d03cf80","defaults":[{"key":"breathing_exercises","variationUid":"7c692452993197e9aa1c6e7966636e8a","valueBase64":"eyJlbmFibGVkIjpmYWxzZSwic2Vzc2lvbnMiOjN9"},{"key":"paywall_config","variationUid":"paywall-summer-sale-1","valueBase64":"eyJoZWFkbGluZSI6IkF1dHVtbiBjYWxtIOKAlCA0MCUgb2ZmIiwic3VidGl0bGUiOiJBbm51YWwgcGxhbiBkaXNjb3VudGVkIGZvciB0aGUgbmV4dCBmaXZlIG1pbnV0ZXMuIEJyZWF0aGUgaW4sIHNhdmUsIGJyZWF0aGUgb3V0LiIsImFjY2VudENvbG9yIjoiI0U4NUQ3NSIsImN0YVRleHQiOiJDbGFpbSA0MCUgb2ZmIG5vdyIsInNob3dDb3VudGRvd24iOnRydWUsImNvdW50ZG93blNlY29uZHMiOjMwMCwicHJvZHVjdHMiOlt7ImlkIjoiY2FsbWx5X21vbnRobHkiLCJ0aXRsZSI6Ik1vbnRobHkiLCJwcmljZVRleHQiOiIkOS45OSAvIG1vbnRoIiwiYmFkZ2UiOm51bGx9LHsiaWQiOiJjYWxtbHlfYW5udWFsIiwidGl0bGUiOiJBbm51YWwiLCJwcmljZVRleHQiOiIkMzUuOTkgLyB5ZWFyIiwiYmFkZ2UiOiItNDAlIn1dLCJoaWdobGlnaHRQcm9kdWN0SWQiOiJjYWxtbHlfYW5udWFsIn0="}]}
41 changes: 36 additions & 5 deletions sample/src/main/java/io/qonversion/sample/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,65 @@
import com.qonversion.android.sdk.QonversionConfig;
import com.qonversion.android.sdk.dto.QEnvironment;
import com.qonversion.android.sdk.dto.QLaunchMode;
import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config;

import io.qonversion.nocodes.NoCodes;
import io.qonversion.nocodes.NoCodesConfig;

public class App extends MultiDexApplication {
private static final String DEFAULT_PROJECT_KEY = "PV77YHL7qnGvsdmpTs7gimsxUvY-Znl2";
// The RC v2 playground project. The RemoteConfigV2Fragment demo only works against the project
// on the local gateway's RC v2 allowlist, so that project is the sample's default. The
// configuration dialog on the Home screen still overrides it at runtime without a rebuild.
private static final String DEFAULT_PROJECT_KEY = UtilsKt.RC_V2_PLAYGROUND_PROJECT_KEY;

@Override
public void onCreate() {
super.onCreate();

String projectKey = getProjectKey(this, DEFAULT_PROJECT_KEY);
String apiUrl = getApiUrl(this);
// The sample's default project belongs to the local RC v2 playground. Keeping the
// legacy/identity API on production while snapshots use the local gateway creates a
// split-brain identity scope: identify() mutates a production user and the local RC
// session continues to resolve the anonymous uid. An explicitly configured URL still
// wins, and a non-playground project keeps the SDK's normal production default.
String effectiveApiUrl = apiUrl != null
? apiUrl
: (DEFAULT_PROJECT_KEY.equals(projectKey)
? UtilsKt.RC_V2_PLAYGROUND_BASE_URL + "/"
: null);
// Session mint resolves the same production client that RC v2 targets. Creating the demo
// user in Sandbox would make init succeed while every production session bootstrap is 404.
QEnvironment effectiveEnvironment = DEFAULT_PROJECT_KEY.equals(projectKey)
? QEnvironment.Production
: QEnvironment.Sandbox;

QonversionConfig.Builder qonversionConfigBuilder = new QonversionConfig.Builder(
this,
projectKey,
QLaunchMode.SubscriptionManagement
).setEnvironment(QEnvironment.Sandbox);
).setEnvironment(effectiveEnvironment);

// Remote Config v2 has no default base URL — the pipeline stays dormant until a config is
// supplied, and it is addressed independently of setProxyURL below (which only moves the
// legacy REST API). minFetchIntervalSeconds is 0 so the demo is never throttled.
qonversionConfigBuilder.setRemoteConfigV2Config(new QRemoteConfigV2Config(
UtilsKt.RC_V2_PLAYGROUND_BASE_URL,
UtilsKt.RC_V2_PLAYGROUND_ENVIRONMENT_UID,
0,
DEFAULT_PROJECT_KEY.equals(projectKey)
? new LocalRemoteConfigIdentifyAssertionProvider()
: null
));

NoCodesConfig.Builder noCodesConfigBuilder = new NoCodesConfig.Builder(
this,
projectKey
).setCustomFallbackFileName("fallbacks/nocodes_fallbacks.json");

if (apiUrl != null) {
qonversionConfigBuilder.setProxyURL(apiUrl);
noCodesConfigBuilder.setProxyURL(apiUrl);
if (effectiveApiUrl != null) {
qonversionConfigBuilder.setProxyURL(effectiveApiUrl);
noCodesConfigBuilder.setProxyURL(effectiveApiUrl);
}

Qonversion.initialize(qonversionConfigBuilder.build());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.qonversion.sample;

import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigIdentifyAssertionCallback;
import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigIdentifyAssertionProvider;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;

/** Local-playground bridge that models an app asking its authenticated backend for an assertion. */
final class LocalRemoteConfigIdentifyAssertionProvider implements QRemoteConfigIdentifyAssertionProvider {
private static final String URL_STRING = "http://10.0.2.2:7089/remote-config-assertion";

@Override
public void requestAssertion(String externalUserId, QRemoteConfigIdentifyAssertionCallback callback) {
new Thread(() -> callback.onResult(fetch(externalUserId)), "rc-v2-local-assertion").start();
}

private String fetch(String externalUserId) {
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(URL_STRING).openConnection();
connection.setConnectTimeout(2_000);
connection.setReadTimeout(2_000);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
connection.setDoOutput(true);
try (OutputStream output = connection.getOutputStream()) {
output.write(externalUserId.getBytes(StandardCharsets.UTF_8));
}
if (connection.getResponseCode() != 200) return null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
return reader.readLine();
}
} catch (Exception ignored) {
return null;
} finally {
if (connection != null) connection.disconnect();
}
}
}
8 changes: 8 additions & 0 deletions sample/src/main/java/io/qonversion/sample/OtherFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class OtherFragment : Fragment() {
findNavController().navigate(R.id.remoteConfigsFragment)
}

binding.buttonRemoteConfigV2.setOnClickListener {
findNavController().navigate(R.id.remoteConfigV2Fragment)
}

binding.buttonPaywallDemo.setOnClickListener {
findNavController().navigate(R.id.paywallDemoFragment)
}

binding.buttonNoCodes.setOnClickListener {
findNavController().navigate(R.id.noCodesFragment)
}
Expand Down
Loading
Loading