Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
32566c4
WIP save point: Pro wire-format app adaptation (does NOT compile yet)
jagerman Jul 18, 2026
22e0c9f
WIP: rewire ProApi layer to consume libsession request/response structs
jagerman Jul 19, 2026
7cfcf68
WIP: android app — read Pro backend URL/pubkey from libsession (singl…
jagerman Jul 19, 2026
e074a75
android app: set Pro request Content-Type from libsession (not hardco…
jagerman Jul 20, 2026
9927bb0
WIP: add pro provider display-name strings (Crowdin stopgap)
jagerman Jul 20, 2026
9d7e4c9
WIP: android app — ProApi to Delta #12 response contract
jagerman Jul 20, 2026
e55f448
WIP: android app — get-details display mapping off the clean glue struct
jagerman Jul 20, 2026
cba814a
WIP: android app — cache/consume the clean GetProDetailsResponse
jagerman Jul 20, 2026
f5f74f9
Pro: dynamic provider display, {pro_stores} store list, and error_cod…
jagerman Jul 21, 2026
ee8cec0
Pro: rename genIndexHash -> revocationTag; fix stale pro_revocations …
jagerman Jul 22, 2026
7e14629
Pro: restore original createTable schema; rely on reshape migration
jagerman Jul 22, 2026
45fe39c
Pro: don't clear the (synced) proof on an unknown backend status
jagerman Jul 22, 2026
8d61597
Absorb get_pro_details -> get_pro_status split (libsession Delta #15)
jagerman Jul 23, 2026
f530e38
Pro plan display: hold (count, unit) end-to-end + generic plan cards
jagerman Jul 23, 2026
2de32b0
Anchor Pro discount baseline to the shortest available plan
jagerman Jul 23, 2026
148e536
Drop stale Delta-N spec citations; reference current § sections
jagerman Jul 23, 2026
93b0808
Fix doubled slash in Pro backend request path
jagerman Jul 23, 2026
3facdfd
Fixed build issues resulting from Pro changes
mpretty-cyro Jul 26, 2026
2f0ceb7
Pro: fix purchase redemption, non-originating misclassification, and …
mpretty-cyro Jul 27, 2026
a61a287
Pro: drop client-side revocation retry_in clamp (now in libsession)
jagerman Jul 28, 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
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ In practice:
- `PLAY_STORE_DISABLED`: gates store-specific behavior
- `DEVICE`: identifies Android vs Huawei device environment
- `PUSH_KEY_SUFFIX`: provider-specific push-key suffix
- `PRO_BACKEND_DEV`: currently the injected Pro backend configuration object

The Pro backend configuration is **not** among them: its URL and signing pubkey come from
libsession (`BackendRequests.proBackendUrl()` / `proBackendPubKeyHex()`, assembled into
`ProBackendConfig` by `ProModule`), so changing the backend means releasing libsession, not editing
the build script. There is no client-side override.

Manifest placeholders are also used to vary:

Expand Down
12 changes: 6 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,12 @@ android {
buildConfigField("String", "USER_AGENT", "\"OWA\"")
buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode")

buildConfigField("org.thoughtcrime.securesms.pro.ProBackendConfig", "PRO_BACKEND_DEV", """
new org.thoughtcrime.securesms.pro.ProBackendConfig(
"https://pro.session.codes",
"479ffca8bcec7b4a0f0f7afe48b8a6d15635a8c7ff15ad16add05752c19414d4"
)
""".trimIndent())
// NOTE: there is deliberately no PRO_BACKEND_DEV buildConfigField here. The Pro backend URL
// and signing pubkey are single-sourced from libsession (`BackendRequests.proBackendUrl()` /
// `proBackendPubKeyHex()`, wired up in ProModule); a second copy in the build script is a
// launch trap, since it would keep shipping the dev URL and debug pubkey after libsession
// moved to production. If a client-side override is ever wanted, add it deliberately as an
// override *of* the libsession default, not as a parallel source of truth.

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ class ProfileUpdateHandler @Inject constructor(
val profileUpdateTime: Instant?,
) {
companion object {
// No clock parameter: deciding whether to KEEP a verified proof is time-independent
// (see the status check below). Expiry is applied at render, in
// RecipientRepository.resolveProStatus(), on SnodeClock time.
fun create(content: SessionProtos.Content,
nowMills: Long,
pro: DecodedPro?): Updates? {
val profile: SessionProtos.LokiProfile
val profilePicKey: ByteString?
Expand Down Expand Up @@ -247,13 +249,35 @@ class ProfileUpdateHandler @Inject constructor(
val proProofInfo: Conversation.ProProofInfo?
val proFeatures: BitSet<ProProfileFeature>

if (pro?.status == ProProof.STATUS_VALID &&
pro.proof != null &&
pro.proof!!.expiryMs > nowMills) {
// Keep the record for any proof libsession has cryptographically verified — VALID or
// EXPIRED — and let expiry be enforced at render instead of here.
//
// EXPIRED is "genuine but lapsed", never "unverified": ProProof::status checks the
// Pro backend signature, then the user signature, and only then expiry
// (LibSession-Util `src/session_protocol.cpp:154-174`), so a forged proof lands on
// STATUS_INVALID_PRO_BACKEND_SIGNATURE / STATUS_INVALID_USER_SIGNATURE and is
// discarded by the `else` below. Those two must keep clearing the record.
//
// Why we no longer drop an expired proof here: `proProofInfo` goes into
// convoInfoVolatile, which is SHARED config. A linked device (or iOS/Desktop, which
// both preserve expired proofs) may have received this contact's proof while it was
// still valid; clearing it here would sync the clear back and cause config
// ping-pong between platforms. Android was the only client discarding them.
//
// This cannot surface a Pro badge for an expired proof: RecipientRepository
// .resolveProStatus() drops expired and revoked proofs on SnodeClock time before any
// badge is computed, and it's the only writer of `Recipient.data.proData`. Do not
// "restore" an expiry check here on the assumption that render-time filtering is
// missing — it isn't; the comparison lives inside RecipientSettings.isExpired().
if ((pro?.status == ProProof.STATUS_VALID || pro?.status == ProProof.STATUS_EXPIRED) &&
pro.proof != null) {
proProofInfo = Conversation.ProProofInfo(
genIndexHash = pro.proof!!.genIndexHashHex.hexToByteArray(),
expiryMs = pro.proof!!.expiryMs,
revocationTag = pro.proof!!.revocationTagHex.hexToByteArray(),
expirySeconds = pro.proof!!.expirySeconds,
)
// Moves with proProofInfo deliberately: proFeatures lives in the (also synced)
// contacts config, so keeping one without the other just relocates the
// ping-pong. It's inert while the proof is expired, for the reason above.
proFeatures = pro.proProfileFeatures
} else {
proProofInfo = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel
import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature
import org.session.libsession.network.SnodeClock
import org.session.protos.SessionProtos
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
Expand All @@ -28,7 +27,6 @@ class GroupMessageHandler @Inject constructor(
private val storage: StorageProtocol,
private val groupManagerV2: GroupManagerV2,
@param:ManagerScope private val scope: CoroutineScope,
private val clock: SnodeClock,
) {
fun handleGroupUpdated(
message: GroupUpdated,
Expand All @@ -43,7 +41,7 @@ class GroupMessageHandler @Inject constructor(
}

// Update profile if needed
ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates ->
ProfileUpdateHandler.Updates.create(proto, pro)?.let { updates ->
profileUpdateHandler.handleProfileUpdate(
senderId = AccountId(message.sender!!),
updates = updates,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import org.session.libsession.messaging.messages.ProfileUpdateHandler
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.network.SnodeClock
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.toAddress
import org.session.libsession.utilities.ConfigFactoryProtocol
Expand Down Expand Up @@ -37,7 +36,6 @@ class MessageRequestResponseHandler @Inject constructor(
private val smsDatabase: SmsDatabase,
private val threadDatabase: ThreadDatabase,
private val blindMappingRepository: BlindMappingRepository,
private val clock: SnodeClock,
) {

fun handleVisibleMessage(
Expand Down Expand Up @@ -90,7 +88,7 @@ class MessageRequestResponseHandler @Inject constructor(

// Always process the profile update if any. We don't need
// to process profile for other kind of messages as they should be handled elsewhere
ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates ->
ProfileUpdateHandler.Updates.create(proto, pro)?.let { updates ->
profileUpdateHandler.get().handleProfileUpdate(
senderId = (sender.address as Address.Standard).accountId,
updates = updates,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class MessageSender @Inject constructor(

// Attach pro proof
val proProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof
if (proProof != null && proProof.expiryMs > snodeClock.currentTimeMillis()) {
if (proProof != null && proProof.expirySeconds > snodeClock.currentTimeMillis() / 1000) {
builder.proMessageBuilder.proofBuilder.copyFromLibSession(proProof)
} else {
// If we don't have any valid pro proof, clear the pro message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.network.SnodeClock
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.toAddress
import org.session.libsession.utilities.MessageExpirationManagerProtocol
Expand Down Expand Up @@ -51,7 +50,6 @@ class VisibleMessageHandler @Inject constructor(
private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory,
private val messageExpirationManager: MessageExpirationManagerProtocol,
private val typingIndicators: TypingIndicatorsProtocol,
private val clock: SnodeClock,
private val jobQueue: Provider<JobQueue>,
){
fun handleVisibleMessage(
Expand Down Expand Up @@ -215,7 +213,6 @@ class VisibleMessageHandler @Inject constructor(
if (runProfileUpdate && senderAddress is Address.WithAccountId) {
val updates = ProfileUpdateHandler.Updates.create(
content = proto,
nowMills = clock.currentTimeMillis(),
pro = pro
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ object StringSubstitutionConstants {
const val BUILD_VARIANT_KEY: StringSubKey = "build_variant"
const val APP_PRO_KEY: StringSubKey = "app_pro"
const val PRO_KEY: StringSubKey = "pro"
const val PLAN_LENGTH_KEY: StringSubKey = "plan_length"
const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length"
const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length"
const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular"
const val PLATFORM_KEY: StringSubKey = "platform"
const val PLATFORM_STORE_KEY: StringSubKey = "platform_store"
const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other"
const val PRO_STORES_KEY: StringSubKey = "pro_stores"
const val PLATFORM_ACCOUNT_KEY: StringSubKey = "platform_account"
const val MONTHLY_PRICE_KEY: StringSubKey = "monthly_price"
const val PRICE_KEY: StringSubKey = "price"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.session.libsession.utilities.serializable

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant

/**
* Serializes and deserializes [java.time.Instant] as a long representing whole seconds since epoch
* in UTC. Used for the Pro wire `_ts` fields (integer seconds).
*/
class InstantAsSecondsSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("org.session.InstantSeconds", PrimitiveKind.LONG)

override fun serialize(
encoder: Encoder,
value: Instant
) {
encoder.encodeLong(value.epochSecond)
}

override fun deserialize(decoder: Decoder): Instant {
return Instant.ofEpochSecond(decoder.decodeLong())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ class RecipientRepository @Inject constructor(

// Safety: Let's filter again for the flow logic to be 100% sure we are only setting timers for valid proofs
val validProDataList = proDataContext?.proDataList?.filter {
!it.isExpired(now) && !proDatabase.isRevoked(it.genIndexHash, snodeClock.get().currentTime())
!it.isExpired(now) && !proDatabase.isRevoked(it.revocationTag, snodeClock.get().currentTime())
}

if (changeSources != null) {
Expand Down Expand Up @@ -505,7 +505,7 @@ class RecipientRepository @Inject constructor(

// 1. Filter invalid proofs
proDataList?.removeAll {
it.isExpired(now) || proDatabase.isRevoked(it.genIndexHash, snodeClock.get().currentTime())
it.isExpired(now) || proDatabase.isRevoked(it.revocationTag, snodeClock.get().currentTime())
}

// 2. Determine base Pro Data from valid proofs or ProStatusManager
Expand Down Expand Up @@ -687,7 +687,7 @@ class RecipientRepository @Inject constructor(
ProProfileFeature.PRO_BADGE
),
expiry = Instant.now().plusSeconds(3600),
genIndexHash = "a1b2c3d4",
revocationTag = "a1b2c3d4",
)
)
} else if (pro != null) {
Expand All @@ -696,8 +696,8 @@ class RecipientRepository @Inject constructor(
showProBadge = configs.userProfile.getProFeatures().contains(
ProProfileFeature.PRO_BADGE
),
expiry = Instant.ofEpochMilli(pro.proProof.expiryMs),
genIndexHash = pro.proProof.genIndexHashHex,
expiry = Instant.ofEpochSecond(pro.proProof.expirySeconds),
revocationTag = pro.proProof.revocationTagHex,
)
)
}
Expand All @@ -724,7 +724,7 @@ class RecipientRepository @Inject constructor(
RecipientSettings.ProData(
showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE),
expiry = convo.proProofInfo!!.expiry,
genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(),
revocationTag = convo.proProofInfo!!.revocationTag.data.toHexString(),
)
)
}
Expand Down Expand Up @@ -784,7 +784,7 @@ class RecipientRepository @Inject constructor(
RecipientSettings.ProData(
showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE),
expiry = convo.proProofInfo!!.expiry,
genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(),
revocationTag = convo.proProofInfo!!.revocationTag.data.toHexString(),
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV58 = 79;
private static final int lokiV59 = 80;
private static final int lokiV60 = 81;
private static final int lokiV61 = 82;

// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV60;
private static final int DATABASE_VERSION = lokiV61;
private static final int MIN_DATABASE_VERSION = lokiV7;
public static final String DATABASE_NAME = "session.db";

Expand Down Expand Up @@ -286,6 +287,8 @@ public void onCreate(SQLiteDatabase db) {

SmsDatabase.addOutgoingColumn(db);
MmsDatabase.Companion.addOutgoingColumn(db);

ProDatabase.Companion.reshapeRevocationsForSeconds(db);
}

@Override
Expand Down Expand Up @@ -649,6 +652,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
MmsDatabase.Companion.addOutgoingColumn(db);
}

if (oldVersion < lokiV61) {
ProDatabase.Companion.reshapeRevocationsForSeconds(db);
}

db.setTransactionSuccessful();
} finally {
db.endTransaction();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ data class RecipientSettings(
data class ProData(
@Serializable(with = InstantAsMillisSerializer::class)
val expiry: Instant,
val genIndexHash: String,
val revocationTag: String,
val showProBadge: Boolean,
) {

Expand All @@ -35,7 +35,7 @@ data class RecipientSettings(
features: BitSet<ProProfileFeature>,
): this(
expiry = info.expiry,
genIndexHash = info.genIndexHash.data.toHexString(),
revocationTag = info.revocationTag.data.toHexString(),
showProBadge = features.contains(ProProfileFeature.PRO_BADGE),
)
fun isExpired(now: Instant): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.pro.ProDataState
import org.thoughtcrime.securesms.pro.ProDetailsRepository
import org.thoughtcrime.securesms.pro.ProStatusRepository
import org.thoughtcrime.securesms.pro.ProStatus
import org.thoughtcrime.securesms.pro.ProStatusManager
import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData
Expand Down Expand Up @@ -93,7 +93,7 @@ class SettingsViewModel @Inject constructor(
private val inAppReviewManager: InAppReviewManager,
private val avatarUploadManager: AvatarUploadManager,
private val attachmentProcessor: AttachmentProcessor,
private val proDetailsRepository: ProDetailsRepository,
private val proStatusRepository: ProStatusRepository,
private val donationManager: DonationManager,
private val pathManager: PathManager,
private val swarmApiExecutor: SwarmApiExecutor,
Expand Down Expand Up @@ -172,9 +172,9 @@ class SettingsViewModel @Inject constructor(
}
}

// refreshes the pro details data
// refreshes the pro status data
viewModelScope.launch {
proDetailsRepository.requestRefresh()
proStatusRepository.requestRefresh()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata
import org.thoughtcrime.securesms.pro.PaymentProviderMetadata
import org.session.libsession.utilities.NonTranslatableStringConstants
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY
Expand Down
Loading
Loading