Skip to content
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ configurations.configureEach {
exclude(module = "commons-logging")
}

val canonicalVersionCode = 451
val canonicalVersionName = "1.33.4"
val canonicalVersionCode = 453
val canonicalVersionName = "1.34.0"

val postFixSize = 10
val abiPostFix = mapOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import network.loki.messenger.libsession_util.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.protocol.DecodedPro
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.Util
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
Expand Down Expand Up @@ -156,7 +155,11 @@ class VisibleMessageHandler @Inject constructor(

// Verify the incoming message length and truncate it if needed, before saving it to the db
val maxChars = proStatusManager.getIncomingMessageMaxLength(message)
val messageText = message.text?.let { Util.truncateCodepoints(it, maxChars) } // truncate to max char limit for this message
// truncate to max char limit for this message (codepoint-aware, native)
val messageText = message.text?.let { text ->
if (text.codePointCount(0, text.length) <= maxChars) text
else text.substring(0, text.offsetByCodePoints(0, maxChars))
}
message.text = messageText
message.hasMention = (sequenceOf(ctx.currentUserPublicKey) + ctx.getCurrentUserBlindedIDsByThread(threadAddress).asSequence())
.any { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.Util
import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY
import org.thoughtcrime.securesms.database.RecipientRepository
import org.thoughtcrime.securesms.pro.ProStatus
Expand All @@ -34,7 +33,8 @@ abstract class InputbarViewModel(
fun onTextChanged(text: CharSequence) {
// check the character limit
val maxChars = proStatusManager.getCharacterLimit(currentUser.isPro)
val charsLeft = maxChars - Util.countCodepoints(text.toString())
val message = text.toString()
val charsLeft = maxChars - message.codePointCount(0, message.length)

// update the char limit state based on characters left
val charLimitState = if(charsLeft <= CHARACTER_LIMIT_THRESHOLD){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,53 +144,6 @@ class ProSettingsViewModel @AssistedInject constructor(
).show()
}

is SubscriptionManager.PurchaseEvent.Failed.ServerError -> {
// this is a special case of failure. We should display a custom dialog and allow the user to retry
_dialogState.update {
val action = context.getString(
when(_proSettingsUIState.value.proDataState.type) {
is ProStatus.Active -> R.string.proUpdatingAction
is ProStatus.Expired -> R.string.proRenewingAction
else -> R.string.proUpgradingAction
}
)

it.copy(
showSimpleDialog = SimpleDialogData(
title = context.getString(R.string.paymentError),
message = Phrase.from(context, R.string.paymentProError)
.put(ACTION_TYPE_KEY, action)
.put(PRO_KEY, NonTranslatableStringConstants.PRO)
.format(),
positiveText = context.getString(R.string.retry),
negativeText = context.getString(R.string.helpSupport),
positiveStyleDanger = false,
showXIcon = true,
onPositive = {
// show the loader again
val data = choosePlanState.value
if(data is State.Success) {
_choosePlanState.update {
State.Success(
data.value.copy(purchaseInProgress = true)
)
}
}

// retry the post purchase code
subscriptionCoordinator.getCurrentManager().onPurchaseSuccessful(
orderId = purchaseEvent.orderId,
paymentId = purchaseEvent.paymentId
)
},
onNegative = {
onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT))
}
)
)
}
}

is SubscriptionManager.PurchaseEvent.Cancelled -> {
// nothing to do in this case
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,14 @@ class FetchProStatusWorker @AssistedInject constructor(
if (details.userStatus == ProUserStatus.EXPIRED ||
details.userStatus == ProUserStatus.NEVER
) {
configs.userProfile.removeProConfig()
// Downgrade guard: never wipe a currently-valid proof (a stale status read vs a
// fresh proof another device just landed). At a genuine lapse the proof has also
// expired (proof.expiry <= account expiry by backend clamp), so this still passes.
val nowSeconds = snodeClock.currentTime().epochSecond
val proof = configs.userProfile.getProConfig()?.proProof
if (proof == null || proof.expirySeconds <= nowSeconds) {
configs.userProfile.removeProConfig()
}
}
}
proDatabase.updateProStatus(proStatus = details, updatedAt = snodeClock.currentTime())
Expand All @@ -124,33 +131,39 @@ class FetchProStatusWorker @AssistedInject constructor(


private suspend fun scheduleProofGenerationIfNeeded(details: GetProStatusResponse) {
val now = snodeClock.currentTimeMillis()

if (details.userStatus != ProUserStatus.ACTIVE) {
Log.d(TAG, "Pro is not active, cancelling any existing proof generation work")
ProProofGenerationWorker.cancel(context)
} else {
val currentProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof

if (currentProof == null || currentProof.expirySeconds * 1000L <= now) {
Log.d(
TAG,
"Pro is active but no valid proof found, scheduling proof generation now"
)
// Not (yet) Pro — but if a purchase is in flight (possibly synced from another device that
// bought and set pro_prepaid), keep driving the redemption poll so any device can pull the
// entitlement through. Otherwise there's nothing to generate.
val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null
if (purchasePending) {
Log.d(TAG, "Not active but a purchase is in flight; scheduling proof redemption")
ProProofGenerationWorker.schedule(context)
} else if (currentProof.expirySeconds * 1000L - now <= Duration.ofMinutes(60).toMillis() &&
details.expiry!!.toEpochMilli() - now > Duration.ofMinutes(60).toMillis() &&
details.autoRenewing == true
) {
val delay = Duration.ofMinutes((Math.random() * 50 + 10).toLong())
Log.d(TAG, "Pro proof is expiring soon, scheduling proof generation in $delay")
ProProofGenerationWorker.schedule(context, delay)
} else {
Log.d(
TAG,
"Pro proof is still valid for a long period, no need to schedule proof generation"
)
Log.d(TAG, "Pro is not active, cancelling any existing proof generation work")
ProProofGenerationWorker.cancel(context)
}
return
}

// libsession owns the renewal schedule now — no more client-side autoRenewing/expiry logic (which
// was inconsistent and skipped non-auto-renewing but still-valid entitlements). getProRenewalTarget
// returns null (valid proof, no renewal needed), a target <= now (renew now), or a future target
// (~1h before proof expiry, nudged off the rotation-period boundary so all devices converge).
val nowSeconds = snodeClock.currentTime().epochSecond
val target = configFactory.withUserConfigs { it.userProfile.getProRenewalTarget(nowSeconds) }
if (target == null) {
Log.d(TAG, "Pro proof is still valid; no renewal needed")
return
}

val delay = Duration.ofSeconds((target - nowSeconds).coerceAtLeast(0L))
if (delay.isZero) {
Log.d(TAG, "Pro proof needs (re)generation now, scheduling immediately")
ProProofGenerationWorker.schedule(context)
} else {
Log.d(TAG, "Pro proof renewal due in $delay, scheduling")
ProProofGenerationWorker.schedule(context, delay)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ object ProUserStatus {
* Map a libsession-parsed get-pro-status response to the app's [ProStatus] domain model. Needs a [Context]
* to resolve the (client-owned) provider display strings.
*/
fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus {
fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProgress: Boolean): ProStatus {
return when (userStatus) {
ProUserStatus.ACTIVE -> {
val paymentItem = latestPayment ?: return ProStatus.NeverSubscribed
Expand All @@ -36,7 +36,6 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus {
val renewingAt = Instant.ofEpochMilli(renewingAtMs)
val providerData = providerMetadata(paymentItem.paymentProvider, context)
val duration = paymentItem.toProPlanPeriod()
val refundInProgress = refundRequested != null

// Correctness guard (plan grammar, §1): a lifetime plan is NOT a renewing/expiring subscription and
// has no renewal/expiry date to render. A genuine lifetime carries no account `expiry`, so it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import network.loki.messenger.libsession_util.ED25519
import network.loki.messenger.libsession_util.pro.ProConfig
import org.session.libsession.network.SnodeClock
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.withMutableUserConfigs
import org.session.libsession.utilities.withUserConfigs
import org.session.libsignal.exceptions.NonRetryableException
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException
Expand Down Expand Up @@ -49,22 +51,29 @@ class ProProofGenerationWorker @AssistedInject constructor(
private val proStatusRepository: ProStatusRepository,
private val loginStateRepository: LoginStateRepository,
private val configFactory: ConfigFactoryProtocol,
private val snodeClock: SnodeClock,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) {
"User must be logged to generate proof"
}

val details = checkNotNull(proStatusRepository.loadState.value.lastUpdated) {
"Pro status must be available to generate proof"
}

check(details.first.userStatus == ProUserStatus.ACTIVE) {
"Pro status must be active to generate proof"
// Run when we're already Pro (proof renewal) OR when a purchase is in flight (redemption): in the
// latter case get_pro_status may not be ACTIVE yet, and calling generate_pro_proof is what pulls
// the entitlement through once the backend has ingested the payment. If neither holds, nothing to do.
val isActive = proStatusRepository.loadState.value.lastUpdated?.first?.userStatus == ProUserStatus.ACTIVE
val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null
if (!isActive && !purchasePending) {
Log.d(WORK_NAME, "Not Pro and no purchase in flight; nothing to generate")
return Result.success()
}

return try {
val rotatingPrivateKey = ED25519.generate(null).secretKey.data
// Rotating key is the deterministic seed derived from the Pro master key for the current
// time (libsession owns the rotation schedule), so every device converges on the same key
// instead of each generating a random one. Expand the 32-byte seed to a full keypair.
val rotatingSeed = ED25519.proRotatingSeed(proMasterKey, snodeClock.currentTime().epochSecond)
val rotatingPrivateKey = ED25519.generate(rotatingSeed).secretKey.data

val response = apiExecutor.execute(
ServerApiRequest(
Expand All @@ -78,10 +87,19 @@ class ProProofGenerationWorker @AssistedInject constructor(
// §5.2 invariant: an `ok` proof response always carries the proof.
val proof = requireNotNull(response.proof) { "generate-proof returned ok without a proof" }

configFactory.withMutableUserConfigs {
it.userProfile.setProConfig(ProConfig(
proProof = proof,
rotatingPrivateKey = rotatingPrivateKey))
configFactory.withMutableUserConfigs { configs ->
// Upgrade guard: only replace the proof if it extends coverage (monotonic merge;
// same-period races round to the same expiry -> byte-identical -> no-op). Avoids
// churning a proof another device just landed.
val current = configs.userProfile.getProConfig()?.proProof
if (current == null || proof.expirySeconds > current.expirySeconds) {
configs.userProfile.setProConfig(ProConfig(
proProof = proof,
rotatingPrivateKey = rotatingPrivateKey))
}
// Refresh the cached access-expiry from the advisory account_expiry that rides the
// proof response, so the renewal path keeps E fresh without a separate get_pro_status.
response.accountExpiry?.let { configs.userProfile.setProAccessExpiry(it.epochSecond) }
}


Expand All @@ -91,9 +109,13 @@ class ProProofGenerationWorker @AssistedInject constructor(
if (e is CancellationException) throw e

Log.e(WORK_NAME, "Error generating Pro proof", e)
if (e is NonRetryableException ||
// HTTP 403 indicates that the user is not
e.findCause<UnhandledStatusCodeException>()?.code == 403) {
// 403 / NonRetryable normally means "not entitled" -> stop. But while a purchase is in flight
// that same "not Pro yet" response just means the backend hasn't ingested the payment yet, so
// keep polling (WorkManager's exponential backoff is our capped poll; the pro_prepaid 1-week
// gate checked at the top of doWork() eventually terminates it).
val notEntitled = e is NonRetryableException ||
e.findCause<UnhandledStatusCodeException>()?.code == 403
if (notEntitled && !purchasePending) {
Result.failure()
} else {
Result.retry()
Expand Down
Loading