diff --git a/library/src/main/cpp/pro_backend.cpp b/library/src/main/cpp/pro_backend.cpp index 06214be..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;J)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,8 +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; + // 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); + cls.java_class, cls.constructor, header.get(), proof.get(), account_expiry_s, + grace_s, auto_renewing); } jobject serialize_pro_status_response(JNIEnv* env, const pb::ProStatusResponse& resp) { diff --git a/library/src/main/cpp/user_profile.cpp b/library/src/main/cpp/user_profile.cpp index 593bfdc..5400392 100644 --- a/library/src/main/cpp/user_profile.cpp +++ b/library/src/main/cpp/user_profile.cpp @@ -180,6 +180,40 @@ 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_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 98385bd..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 @@ -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 @@ -87,6 +88,28 @@ 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 + + /** + * The account's grace period, from synced config (key `G`). + * + * 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 + /** When a refund was requested (unix seconds), or null if none (values >1 week old read as null). */ fun getRefundRequested(): Long? @@ -123,6 +146,12 @@ 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) + + /** 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 92aa47a..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 @@ -47,6 +49,12 @@ 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 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/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..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,20 +108,62 @@ 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?, + /** + * 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 + * 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. + * + * ⚠️ **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 { - /** Raw-epoch constructor used by the JNI layer (see the file header). */ + /** + * Raw-epoch constructor used by the JNI layer (see the file header). + * + * [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, - ) : this(header, proof, accountExpirySeconds.secondsToInstantOrNull()) + accountGracePeriodSeconds: Long, + accountAutoRenewing: Boolean, + ) : this( + header = header, + proof = proof, + accountExpiry = accountExpirySeconds.secondsToInstantOrNull(), + accountGracePeriod = Duration.ofSeconds(accountGracePeriodSeconds), + accountAutoRenewing = accountAutoRenewing, + ) } /** One payment/subscription record from get-pro-status. */ @@ -138,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) @@ -187,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 diff --git a/libsession-util b/libsession-util index e241a48..b066ba2 160000 --- a/libsession-util +++ b/libsession-util @@ -1 +1 @@ -Subproject commit e241a489fde5385a3677e8f3401627095c23eed6 +Subproject commit b066ba271c54253526f2741d2ad235e85eac5898