From a17288210dc91252ed76154bcdf9944690f67263 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:51:49 +1000 Subject: [PATCH 1/7] Expose the pro auto-renewing config key to Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession PR #121 adds the `A` / auto_renewing user-profile key, but it is core-only — this wrapper had no binding for it, so clients could not read or write it. Adds the JNI pair and the Kotlin declarations, following the existing ProAccessExpiry shape. The accessor is presence-only and the doc comment says so at the API boundary rather than only at the call site: set_pro_auto_renewing(false) ERASES the key, so `false` and "never written" are the same state through this getter. That is #121's encoding surfacing here, not something introduced by the binding, and it is where the next reader will meet it. Pins the libsession-util submodule to 8e5634b8, the head of #121, which is UNMERGED — so this commit cannot merge until #121 does. It is also not the identically-subjected pro-auto-renewing-config-pfs commit, which rebases the same change onto the PFS track. Verified in the built APK across all four ABIs rather than by a successful compile: a JNI signature mismatch is invisible at compile time and only traps at runtime. --- library/src/main/cpp/user_profile.cpp | 19 +++++++++++++++++++ .../loki/messenger/libsession_util/Config.kt | 12 ++++++++++++ .../messenger/libsession_util/UserProfile.kt | 2 ++ libsession-util | 2 +- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/library/src/main/cpp/user_profile.cpp b/library/src/main/cpp/user_profile.cpp index 593bfdc..80800c9 100644 --- a/library/src/main/cpp/user_profile.cpp +++ b/library/src/main/cpp/user_profile.cpp @@ -180,6 +180,25 @@ Java_network_loki_messenger_libsession_1util_UserProfile_removeProAccessExpiry(J ptrToProfile(env, thiz)->set_pro_access_expiry(std::nullopt); } +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getProAutoRenewing(JNIEnv *env, + jobject thiz) { + return static_cast(ptrToProfile(env, thiz)->get_pro_auto_renewing()); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setProAutoRenewing(JNIEnv *env, + jobject thiz, + jboolean auto_renewing) { + // Presence-only upstream: set_pro_auto_renewing uses set_nonzero_int, so writing false ERASES + // the key rather than storing it. Absent therefore means "terminal/unknown", and a caller + // cannot distinguish it from an explicit false through this binding — which is the whole of + // libsession PR #121's tri-state limitation, not something introduced here. + ptrToProfile(env, thiz)->set_pro_auto_renewing(auto_renewing); +} + extern "C" JNIEXPORT jlong JNICALL Java_network_loki_messenger_libsession_1util_UserProfile_getProFeaturesRaw(JNIEnv *env, diff --git a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt index 98385bd..c609a10 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -87,6 +87,15 @@ interface ReadableUserProfile: ReadableConfig { fun getProConfig(): ProConfig? fun getProAccessExpiry(): Long? + /** + * Whether the subscription is auto-renewing, from synced config (key `A`, libsession #121). + * + * **Presence-only.** `setProAutoRenewing(false)` ERASES the key, so `false` here means either + * "not auto-renewing" or "never written" — absent reads as terminal/unknown. Callers that need + * to tell those apart cannot, through this API. + */ + fun getProAutoRenewing(): Boolean + /** When a refund was requested (unix seconds), or null if none (values >1 week old read as null). */ fun getRefundRequested(): Long? @@ -123,6 +132,9 @@ interface MutableUserProfile : ReadableUserProfile, MutableConfig { fun setProAccessExpiry(epochSeconds: Long) fun removeProAccessExpiry() + /** See [getProAutoRenewing] — writing `false` erases the key rather than storing it. */ + fun setProAutoRenewing(autoRenewing: Boolean) + /** Record (epochSeconds) or clear (null) the "refund requested" flag; synced across devices. */ fun setRefundRequested(epochSeconds: Long?) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt b/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt index 92aa47a..78d4936 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt @@ -47,6 +47,8 @@ class UserProfile private constructor(pointer: Long) : ConfigBase(pointer), Muta external override fun setAnimatedAvatar(animatedAvatar: Boolean) external override fun setProAccessExpiry(epochSeconds: Long) external override fun removeProAccessExpiry() + external override fun getProAutoRenewing(): Boolean + external override fun setProAutoRenewing(autoRenewing: Boolean) private external fun getProFeaturesRaw(): Long override fun getProFeatures(): ProProfileFeatures = ProProfileFeatures(getProFeaturesRaw()) external override fun getProConfig(): ProConfig? diff --git a/libsession-util b/libsession-util index e241a48..8e5634b 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit e241a489fde5385a3677e8f3401627095c23eed6 +Subproject commit 8e5634b81acadb23203cb7fb8474c96db3a406ee From fe4715591724ce299f1e7b341f4c6c552d7c5891 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 12:33:25 +1000 Subject: [PATCH 2/7] Expose the pro grace period config key to Kotlin The access expiry the backend sends is grace-INCLUSIVE, so it is coverage end and the paid-through instant is expiry - grace. Clients need the grace period in synced config to compute that at all: it drives the renewal date, the grace indicator, and the startup gate's decision about whether a renewal is overdue. Adds the JNI pair and Kotlin declarations for config key `G`, matching the shape of the auto-renewing pair beside it. Crosses the boundary as seconds and is presented as a Duration. No presence check, unlike `A`: the backend sends 0 whenever the subscription is not auto-renewing, so unset and zero describe the same account and both give `expiry - 0`. There is no state a caller could act on differently, so a predicate would copy the shape of the auto-renewing accessor without its reason. Re-pins the libsession-util submodule from 8e5634b8 to 269f8b88, which contains it plus the grace key. Still UNMERGED, so this cannot merge until that does. All four JNI symbols verified present across all four ABIs in the built APK: a signature mismatch is invisible at compile time and only traps at runtime. --- library/src/main/cpp/user_profile.cpp | 15 +++++++++++++++ .../loki/messenger/libsession_util/Config.kt | 15 +++++++++++++++ .../loki/messenger/libsession_util/UserProfile.kt | 6 ++++++ libsession-util | 2 +- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/library/src/main/cpp/user_profile.cpp b/library/src/main/cpp/user_profile.cpp index 80800c9..5400392 100644 --- a/library/src/main/cpp/user_profile.cpp +++ b/library/src/main/cpp/user_profile.cpp @@ -199,6 +199,21 @@ Java_network_loki_messenger_libsession_1util_UserProfile_setProAutoRenewing(JNIE ptrToProfile(env, thiz)->set_pro_auto_renewing(auto_renewing); } +extern "C" +JNIEXPORT jlong JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getProGracePeriodSeconds(JNIEnv *env, + jobject thiz) { + return static_cast(ptrToProfile(env, thiz)->get_pro_grace_period().count()); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setProGracePeriodSeconds(JNIEnv *env, + jobject thiz, + jlong grace_seconds) { + ptrToProfile(env, thiz)->set_pro_grace_period(std::chrono::seconds{grace_seconds}); +} + extern "C" JNIEXPORT jlong JNICALL Java_network_loki_messenger_libsession_1util_UserProfile_getProFeaturesRaw(JNIEnv *env, diff --git a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt index c609a10..a8c063c 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -1,6 +1,7 @@ package network.loki.messenger.libsession_util import network.loki.messenger.libsession_util.pro.ProConfig +import java.time.Duration import network.loki.messenger.libsession_util.protocol.ProProfileFeatures import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BlindedContact @@ -96,6 +97,17 @@ interface ReadableUserProfile: ReadableConfig { */ fun getProAutoRenewing(): Boolean + /** + * The account's grace period, from synced config (key `G`). + * + * Only meaningful as `proAccessExpiry - proGracePeriod`, which is the paid-through instant: the + * backend folds grace INTO the expiry it sends, so the expiry is coverage end. Zero when unset, + * and zero is also what the backend sends when the subscription is not auto-renewing — the two + * describe the same account and both give `E - 0 == E`, so there is nothing for a presence check + * to disambiguate. + */ + fun getProGracePeriod(): Duration + /** When a refund was requested (unix seconds), or null if none (values >1 week old read as null). */ fun getRefundRequested(): Long? @@ -135,6 +147,9 @@ interface MutableUserProfile : ReadableUserProfile, MutableConfig { /** See [getProAutoRenewing] — writing `false` erases the key rather than storing it. */ fun setProAutoRenewing(autoRenewing: Boolean) + /** See [getProGracePeriod]. Write it from the SAME response that supplied the access expiry. */ + fun setProGracePeriod(grace: Duration) + /** Record (epochSeconds) or clear (null) the "refund requested" flag; synced across devices. */ fun setRefundRequested(epochSeconds: Long?) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt b/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt index 78d4936..3d927cb 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/UserProfile.kt @@ -1,5 +1,7 @@ package network.loki.messenger.libsession_util +import java.time.Duration + import network.loki.messenger.libsession_util.pro.ProConfig import network.loki.messenger.libsession_util.pro.ProProof import network.loki.messenger.libsession_util.protocol.ProProfileFeatures @@ -49,6 +51,10 @@ class UserProfile private constructor(pointer: Long) : ConfigBase(pointer), Muta external override fun removeProAccessExpiry() external override fun getProAutoRenewing(): Boolean external override fun setProAutoRenewing(autoRenewing: Boolean) + private external fun getProGracePeriodSeconds(): Long + override fun getProGracePeriod(): Duration = Duration.ofSeconds(getProGracePeriodSeconds()) + private external fun setProGracePeriodSeconds(seconds: Long) + override fun setProGracePeriod(grace: Duration) = setProGracePeriodSeconds(grace.seconds) private external fun getProFeaturesRaw(): Long override fun getProFeatures(): ProProfileFeatures = ProProfileFeatures(getProFeaturesRaw()) external override fun getProConfig(): ProConfig? diff --git a/libsession-util b/libsession-util index 8e5634b..269f8b8 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit 8e5634b81acadb23203cb7fb8474c96db3a406ee +Subproject commit 269f8b8872fc47668fd7cdbb571c45021d5305cf From 240690a5833200315f836245beda8ffe4ab00c86 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 12:45:29 +1000 Subject: [PATCH 3/7] Expose the proof response's advisory grace period and renewal flag The proof response carries the account's grace period and whether it auto-renews, so a client refreshing its cached access expiry from a proof can keep all three coherent. Neither field reached Kotlin: core only began parsing them in the commit this re-pins to. Both cross the JNI boundary as value + presence pairs and surface as nullable Kotlin properties, matching the hasLatestPayment shape already used for get-pro-status. That is not decoration: absent must stay distinguishable from zero/false. The client writes these into presence-only config keys where writing false or zero ERASES them, so an older backend -- which sends neither field -- would otherwise have every proof fetch wipe a value correctly learned from get_pro_status. Re-pins libsession-util 269f8b88 -> aa52b3ee for the parse. Still unmerged. Verified: the four user_profile symbols present across all four ABIs in the built APK, the new Kotlin properties present in the dex, and the JNI constructor descriptor cross-checked against the Kotlin signature by hand -- (L..;L..;JZJZZ)V both sides. A descriptor mismatch would be invisible at compile time and throw on the first parse. --- library/src/main/cpp/pro_backend.cpp | 13 ++++++- .../pro/ProBackendResponses.kt | 38 ++++++++++++++++++- libsession-util | 2 +- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/library/src/main/cpp/pro_backend.cpp b/library/src/main/cpp/pro_backend.cpp index 06214be..0e2898b 100644 --- a/library/src/main/cpp/pro_backend.cpp +++ b/library/src/main/cpp/pro_backend.cpp @@ -104,7 +104,7 @@ JavaLocalRef serialize_revocation_item(JNIEnv* env, const pb::ProRevoca jobject serialize_pro_proof_response(JNIEnv* env, const pb::GenerateProProofResponse& resp) { static BasicJavaClassInfo cls(env, "network/loki/messenger/libsession_util/pro/ProProofResponse", "(Lnetwork/loki/messenger/libsession_util/pro/ProResponseHeader;" - "Lnetwork/loki/messenger/libsession_util/pro/ProProof;J)V"); + "Lnetwork/loki/messenger/libsession_util/pro/ProProof;JZJZZ)V"); auto header = serialize_response_header(env, resp); JavaLocalRef proof(env, nullptr); if (resp) // ResponseBase::operator bool: true iff status == Ok (proof populated on success) @@ -114,8 +114,17 @@ jobject serialize_pro_proof_response(JNIEnv* env, const pb::GenerateProProofResp resp.account_expiry ? static_cast(resp.account_expiry->time_since_epoch().count()) : 0; + // The two advisory fields below cross as value + presence pairs. Absent must stay + // distinguishable from zero/false: the client writes them into presence-only config keys, where + // writing false or zero ERASES a value learned from get_pro_status. An older backend sends + // neither field, so collapsing absent would make every proof fetch destructive. + jboolean has_grace = resp.account_grace_period.has_value(); + jlong grace_s = has_grace ? static_cast(resp.account_grace_period->count()) : 0; + jboolean has_auto_renewing = resp.account_auto_renewing.has_value(); + jboolean auto_renewing = has_auto_renewing && *resp.account_auto_renewing; return env->NewObject( - cls.java_class, cls.constructor, header.get(), proof.get(), account_expiry_s); + cls.java_class, cls.constructor, header.get(), proof.get(), account_expiry_s, + has_grace, grace_s, has_auto_renewing, auto_renewing); } jobject serialize_pro_status_response(JNIEnv* env, const pb::ProStatusResponse& resp) { diff --git a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt index d2a7445..5b4855f 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt @@ -114,14 +114,48 @@ data class ProProofResponse( * the cached access expiry, never for entitlement gating. */ val accountExpiry: Instant?, + /** + * The grace period folded into [accountExpiry], so the paid-through instant is + * `accountExpiry - accountGracePeriod`. + * + * **null means the backend did not say — it does NOT mean zero.** Zero is a legitimate value (the + * backend sends it whenever the subscription is not auto-renewing), so the two must stay distinct: + * writing zero into config on an absent field would erase a grace period learned from + * `get_pro_status`. Absent happens against a backend predating the field. + */ + val accountGracePeriod: Duration?, + /** + * Whether the subscription behind [accountExpiry] renews itself. + * + * **null means the backend did not say — it does NOT mean false.** The config key is + * presence-only, so writing false ERASES it; collapsing absent to false would destroy a flag + * learned from `get_pro_status` on every proof fetch against an older backend. + */ + val accountAutoRenewing: Boolean?, ) : ProResponse { - /** Raw-epoch constructor used by the JNI layer (see the file header). */ + /** + * Raw-epoch constructor used by the JNI layer (see the file header). + * + * The two advisory fields arrive as value + presence pairs because absent and zero/false are + * different states and neither wire type can express both. Same shape as + * [GetProStatusResponse]'s `hasLatestPayment`. + */ @Keep constructor( header: ProResponseHeader, proof: ProProof?, accountExpirySeconds: Long, - ) : this(header, proof, accountExpirySeconds.secondsToInstantOrNull()) + hasAccountGracePeriod: Boolean, + accountGracePeriodSeconds: Long, + hasAccountAutoRenewing: Boolean, + accountAutoRenewing: Boolean, + ) : this( + header = header, + proof = proof, + accountExpiry = accountExpirySeconds.secondsToInstantOrNull(), + accountGracePeriod = if (hasAccountGracePeriod) Duration.ofSeconds(accountGracePeriodSeconds) else null, + accountAutoRenewing = if (hasAccountAutoRenewing) accountAutoRenewing else null, + ) } /** One payment/subscription record from get-pro-status. */ diff --git a/libsession-util b/libsession-util index 269f8b8..aa52b3e 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit 269f8b8872fc47668fd7cdbb571c45021d5305cf +Subproject commit aa52b3ee19c106a84976943776f9afca0c515b3c From 0f8b92347eeb7a66f2ff3cbd9c23e413a27188b7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 14:36:57 +1000 Subject: [PATCH 4/7] Make the proof response's grace period and renewal flag required, not optional Core amended these from optionals with presence flags to plain required values: no backend predates them -- Pro has not shipped -- so the absent case does not arise. Drops the presence half of the JNI constructor descriptor and the nullable folding on the Kotlin side. The Kotlin surface is non-nullable for both, which is the honest type. A nullable that can never be null invites `?: false` or `?: Duration.ZERO` at call sites, and neither default is inert: writing false to a presence-only config key ERASES it. Requiring the fields means a malformed response fails the parse and the client keeps what it has, rather than persisting a default. accountExpiry stays nullable with its 0-sentinel, because that field genuinely is absent on some outcomes. The asymmetry inside one class is real rather than an oversight, and each property says which it is. Re-pins libsession-util aa52b3ee -> 799f1972. Verified: the four user_profile symbols present across all four ABIs in the built APK; the presence-flag names absent from the dex; and the JNI constructor descriptor cross-checked by hand against the Kotlin signature AGAIN, because dropping two parameters changes it -- (L..;L..;JJZ)V both sides, was (L..;L..;JZJZZ)V. --- library/src/main/cpp/pro_backend.cpp | 19 +++++------ .../pro/ProBackendResponses.kt | 33 ++++++++++--------- libsession-util | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/library/src/main/cpp/pro_backend.cpp b/library/src/main/cpp/pro_backend.cpp index 0e2898b..38775ab 100644 --- a/library/src/main/cpp/pro_backend.cpp +++ b/library/src/main/cpp/pro_backend.cpp @@ -104,7 +104,7 @@ JavaLocalRef serialize_revocation_item(JNIEnv* env, const pb::ProRevoca jobject serialize_pro_proof_response(JNIEnv* env, const pb::GenerateProProofResponse& resp) { static BasicJavaClassInfo cls(env, "network/loki/messenger/libsession_util/pro/ProProofResponse", "(Lnetwork/loki/messenger/libsession_util/pro/ProResponseHeader;" - "Lnetwork/loki/messenger/libsession_util/pro/ProProof;JZJZZ)V"); + "Lnetwork/loki/messenger/libsession_util/pro/ProProof;JJZ)V"); auto header = serialize_response_header(env, resp); JavaLocalRef proof(env, nullptr); if (resp) // ResponseBase::operator bool: true iff status == Ok (proof populated on success) @@ -114,17 +114,16 @@ jobject serialize_pro_proof_response(JNIEnv* env, const pb::GenerateProProofResp resp.account_expiry ? static_cast(resp.account_expiry->time_since_epoch().count()) : 0; - // The two advisory fields below cross as value + presence pairs. Absent must stay - // distinguishable from zero/false: the client writes them into presence-only config keys, where - // writing false or zero ERASES a value learned from get_pro_status. An older backend sends - // neither field, so collapsing absent would make every proof fetch destructive. - jboolean has_grace = resp.account_grace_period.has_value(); - jlong grace_s = has_grace ? static_cast(resp.account_grace_period->count()) : 0; - jboolean has_auto_renewing = resp.account_auto_renewing.has_value(); - jboolean auto_renewing = has_auto_renewing && *resp.account_auto_renewing; + // Both advisory fields are REQUIRED on a successful proof (json_require in core), so there is no + // absent case and they cross as plain values. Zero/false on the failure outcomes that carry no + // proof, which is the truthful value there. Deliberately not optional: a defaulted false is not + // inert, because writing false to the presence-only config key ERASES it — so a malformed + // response now fails the parse and the client keeps what it has, rather than persisting a default. + jlong grace_s = static_cast(resp.account_grace_period.count()); + jboolean auto_renewing = resp.account_auto_renewing; return env->NewObject( cls.java_class, cls.constructor, header.get(), proof.get(), account_expiry_s, - has_grace, grace_s, has_auto_renewing, auto_renewing); + grace_s, auto_renewing); } jobject serialize_pro_status_response(JNIEnv* env, const pb::ProStatusResponse& resp) { diff --git a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt index 5b4855f..5ea1db3 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt @@ -118,43 +118,44 @@ data class ProProofResponse( * The grace period folded into [accountExpiry], so the paid-through instant is * `accountExpiry - accountGracePeriod`. * - * **null means the backend did not say — it does NOT mean zero.** Zero is a legitimate value (the - * backend sends it whenever the subscription is not auto-renewing), so the two must stay distinct: - * writing zero into config on an absent field would erase a grace period learned from - * `get_pro_status`. Absent happens against a backend predating the field. + * **Not nullable, unlike [accountExpiry].** Core requires this field on a successful proof, so + * there is no "the backend did not say" state to represent; zero on the failure outcomes that + * carry no proof, which is the truthful value there. A nullable type here would be a lie that + * invites `?: Duration.ZERO` at call sites, and zero is not inert — writing it can clear the + * config key. */ - val accountGracePeriod: Duration?, + val accountGracePeriod: Duration, /** * Whether the subscription behind [accountExpiry] renews itself. * - * **null means the backend did not say — it does NOT mean false.** The config key is - * presence-only, so writing false ERASES it; collapsing absent to false would destroy a flag - * learned from `get_pro_status` on every proof fetch against an older backend. + * **Not nullable** — same reasoning as [accountGracePeriod]. Required on a successful proof, so + * `false` here means "not renewing" rather than "unknown". Note that writing `false` into config + * ERASES the key, which is the correct representation of not-renewing under a presence-only + * encoding — but it is why a *defaulted* false would have been dangerous and the field is + * required rather than lenient. */ - val accountAutoRenewing: Boolean?, + val accountAutoRenewing: Boolean, ) : ProResponse { /** * Raw-epoch constructor used by the JNI layer (see the file header). * - * The two advisory fields arrive as value + presence pairs because absent and zero/false are - * different states and neither wire type can express both. Same shape as - * [GetProStatusResponse]'s `hasLatestPayment`. + * [accountExpirySeconds] keeps the 0-means-absent sentinel because that field genuinely is absent + * on some outcomes; the other two are always populated on success, so they cross as plain values. + * That asymmetry is real rather than an oversight — see each property. */ @Keep constructor( header: ProResponseHeader, proof: ProProof?, accountExpirySeconds: Long, - hasAccountGracePeriod: Boolean, accountGracePeriodSeconds: Long, - hasAccountAutoRenewing: Boolean, accountAutoRenewing: Boolean, ) : this( header = header, proof = proof, accountExpiry = accountExpirySeconds.secondsToInstantOrNull(), - accountGracePeriod = if (hasAccountGracePeriod) Duration.ofSeconds(accountGracePeriodSeconds) else null, - accountAutoRenewing = if (hasAccountAutoRenewing) accountAutoRenewing else null, + accountGracePeriod = Duration.ofSeconds(accountGracePeriodSeconds), + accountAutoRenewing = accountAutoRenewing, ) } diff --git a/libsession-util b/libsession-util index aa52b3e..799f197 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit aa52b3ee19c106a84976943776f9afca0c515b3c +Subproject commit 799f1972114cec5910c2712739e4c6695cacb1a5 From 43c2f492939cec4ecb7734acf6489203517af5a3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 14:41:29 +1000 Subject: [PATCH 5/7] Say where the proof response's advisory fields are valid, since the type no longer can Re-pins libsession-util 799f1972 -> f197a0bd and corrects the doc comments, which had the right nullability for the wrong reason. Removing the optionals did not remove the absent case; it collapsed it into a value. The parser returns on the failure path before filling these two, so on every non-OK outcome they hold struct defaults -- grace 0, renewing false -- and the C struct carries no presence flag, so nothing distinguishes that from a backend genuinely saying "no grace, not renewing". Which matters because the client writes them into presence-only config keys where false ERASES. For subscription_expired, not_subscribed and revoked that erasure is truthful; for a protocol error or a transport failure it would wipe a flag get_pro_status had correctly learned, on the strength of a response that said nothing about the account. The previous comment claimed false was truthful for "the failure outcomes", which holds for exactly three error codes. So both properties now say: only meaningful on a successful proof, read inside a success branch or not at all. Non-nullable is still right -- core does not model absence, and a nullable that can never be null invites the collapse it was meant to prevent -- but the protection moved from the type to the call site's placement rather than disappearing. --- .../pro/ProBackendResponses.kt | 26 ++++++++++++------- libsession-util | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt index 5ea1db3..ad01963 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt @@ -118,21 +118,27 @@ data class ProProofResponse( * The grace period folded into [accountExpiry], so the paid-through instant is * `accountExpiry - accountGracePeriod`. * - * **Not nullable, unlike [accountExpiry].** Core requires this field on a successful proof, so - * there is no "the backend did not say" state to represent; zero on the failure outcomes that - * carry no proof, which is the truthful value there. A nullable type here would be a lie that - * invites `?: Duration.ZERO` at call sites, and zero is not inert — writing it can clear the - * config key. + * ⚠️ **Only meaningful on a SUCCESSFUL proof.** Core's parser returns on the failure path before + * filling this, so on every non-OK outcome it holds a struct default of zero — and nothing here + * can tell you that: there is no presence flag and the type is not nullable. **Read it inside a + * success branch or not at all.** + * + * Not nullable because core no longer models the absent case, so a nullable type would be a lie + * that invites `?: Duration.ZERO` — and zero is not inert, since writing it can clear the config + * key. The protection moved from the type to the call site's placement; it did not disappear. */ val accountGracePeriod: Duration, /** * Whether the subscription behind [accountExpiry] renews itself. * - * **Not nullable** — same reasoning as [accountGracePeriod]. Required on a successful proof, so - * `false` here means "not renewing" rather than "unknown". Note that writing `false` into config - * ERASES the key, which is the correct representation of not-renewing under a presence-only - * encoding — but it is why a *defaulted* false would have been dangerous and the field is - * required rather than lenient. + * ⚠️ **Only meaningful on a SUCCESSFUL proof** — same as [accountGracePeriod], and worse here, + * because the default is `false` and writing `false` into the presence-only config key **ERASES** + * it. On `subscription_expired`/`not_subscribed`/`revoked` that erasure is truthful; on a protocol + * error or transport failure it would wipe a flag `get_pro_status` had correctly learned, from a + * response that said nothing about the account. + * + * Not nullable because core no longer models absence — but absence did not go away, it collapsed + * into an indistinguishable value. **Read it inside a success branch or not at all.** */ val accountAutoRenewing: Boolean, ) : ProResponse { diff --git a/libsession-util b/libsession-util index 799f197..f197a0b 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit 799f1972114cec5910c2712739e4c6695cacb1a5 +Subproject commit f197a0bd214982b4a83f6952826819b43b804c13 From 470a6f1eb9bfcb3ba57082a48dd3bd00fcab355e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 14:54:21 +1000 Subject: [PATCH 6/7] Re-pin libsession-util f197a0bd -> b066ba27 Clearing the access expiry now clears the auto-renewing flag with it, alongside the grace period it already cleared. A behaviour change in core, not in this wrapper: no binding changes. It fixes an asymmetry where a revoked or cleared subscription left a stale renewing flag behind. The three keys were previously coherent only because every consumer happens to test the expiry before reading the flag -- true on all three clients and enforced by nothing. Maintaining the invariant on the write side is what stops the next consumer inheriting the assumption without knowing it exists. Android reads the flag and the grace period in exactly one place, and it tests the expiry first, so this changes nothing here. The check stays -- it is a necessary "never subscribed" branch in its own right -- but it is no longer what keeps the keys coherent. --- libsession-util | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libsession-util b/libsession-util index f197a0b..b066ba2 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit f197a0bd214982b4a83f6952826819b43b804c13 +Subproject commit b066ba271c54253526f2741d2ad235e85eac5898 From 4c584d64b9724da730dbb87d72f482130eb6e95d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 11 Aug 2026 10:10:18 +1000 Subject: [PATCH 7/7] Pro: document grace as served-past-expiry, and name the two grace fields `G` is how much longer the account is served PAST the expiry, so coverage ends at `E + G`. The docs said the backend folded grace into the expiry and that `E - G` recovered a paid-through instant; that fold was removed upstream and subtracting now double-counts. Also names the collision the KDoc was silent about: ProPaymentItem and GetProStatusResponse both have a `gracePeriod`, and only the account-level one answers coverage questions. The payment-level field is the raw store value and is not gated on auto-renewing, so a cancelled subscriber can carry a multi-day value in it. --- .../loki/messenger/libsession_util/Config.kt | 12 ++++++----- .../pro/ProBackendResponses.kt | 21 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt index a8c063c..45308c5 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -100,11 +100,13 @@ interface ReadableUserProfile: ReadableConfig { /** * The account's grace period, from synced config (key `G`). * - * Only meaningful as `proAccessExpiry - proGracePeriod`, which is the paid-through instant: the - * backend folds grace INTO the expiry it sends, so the expiry is coverage end. Zero when unset, - * and zero is also what the backend sends when the subscription is not auto-renewing — the two - * describe the same account and both give `E - 0 == E`, so there is nothing for a presence check - * to disambiguate. + * How much longer the account is served PAST [getProAccessExpiry], so coverage ends at + * `proAccessExpiry + proGracePeriod`. The expiry is the payment-due date and needs no adjustment + * to be displayed — do not subtract this from it. + * + * Zero when unset, and zero is also what the backend sends when the subscription is not + * auto-renewing — the two describe the same account and both give `E + 0 == E`, so there is + * nothing for a presence check to disambiguate. */ fun getProGracePeriod(): Duration diff --git a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt index ad01963..78429d8 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/pro/ProBackendResponses.kt @@ -108,15 +108,16 @@ data class ProProofResponse( override val header: ProResponseHeader, val proof: ProProof?, /** - * Advisory account (subscription) expiry — grace-inclusive true entitlement end; set on a - * successful proof and on a `subscription_expired` failure (a past value), null otherwise. + * Advisory account (subscription) expiry — the payment-due date, with coverage running + * [accountGracePeriod] past it; set on a successful proof and on a `subscription_expired` failure + * (a past value), null otherwise. * Distinct from [proof]'s own clamped expiry; use for the "Pro until X" display and to refresh * the cached access expiry, never for entitlement gating. */ val accountExpiry: Instant?, /** - * The grace period folded into [accountExpiry], so the paid-through instant is - * `accountExpiry - accountGracePeriod`. + * How much longer the account is served past [accountExpiry], so coverage ends at + * `accountExpiry + accountGracePeriod`. Do NOT subtract it from the expiry. * * ⚠️ **Only meaningful on a SUCCESSFUL proof.** Core's parser returns on the failure path before * filling this, so on every non-OK outcome it holds a struct default of zero — and nothing here @@ -179,7 +180,10 @@ data class ProPaymentItem( @Serializable(with = InstantAsEpochMillisSerializer::class) val expiry: Instant?, // access expiry for this payment; null if not activated @Serializable(with = DurationAsSecondsSerializer::class) - val gracePeriod: Duration, // grace beyond [expiry] before access is really lost + // PAYMENT-level grace: what the store declared about THIS transaction. NOT the same quantity as + // GetProStatusResponse.gracePeriod, and notably not gated on auto-renewing — a cancelled + // subscriber can keep a multi-day value here. For coverage questions use the account-level field. + val gracePeriod: Duration, @Serializable(with = InstantAsEpochMillisSerializer::class) val platformRefundExpiry: Instant?, // deadline for a platform ("quick") refund; null if n/a @Serializable(with = InstantAsEpochMillisSerializer::class) @@ -228,9 +232,12 @@ data class GetProStatusResponse( val latestPayment: ProPaymentItem?, // the single most-recent payment, or null when none val autoRenewing: Boolean, @Serializable(with = InstantAsEpochMillisSerializer::class) - val expiry: Instant?, // account access expiry (incl. grace); null if never subscribed + val expiry: Instant?, // the payment-due date; null if never subscribed + // ACCOUNT-level grace: how much longer we are served past [expiry], so coverage ends at + // `expiry + gracePeriod`. This is the field a client wants for coverage questions — see the + // warning on ProPaymentItem.gracePeriod, which shares the name and answers a different question. @Serializable(with = DurationAsSecondsSerializer::class) - val gracePeriod: Duration, // grace included in [expiry] + val gracePeriod: Duration, ) : ProResponse { /** Raw-epoch constructor used by the JNI layer (see the file header); converts to the typed fields. */ @Keep