Skip to content

Commit e647119

Browse files
Add ProDetailsRepository (#1704)
1 parent aa7c0a6 commit e647119

File tree

4 files changed

+190
-7
lines changed

4 files changed

+190
-7
lines changed

app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory
5353
import org.thoughtcrime.securesms.mms.MediaConstraints
5454
import org.thoughtcrime.securesms.pro.ProStatusManager
5555
import org.thoughtcrime.securesms.pro.ProDataState
56+
import org.thoughtcrime.securesms.pro.ProDetailsRepository
5657
import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData
5758
import org.thoughtcrime.securesms.reviews.InAppReviewManager
5859
import org.thoughtcrime.securesms.ui.SimpleDialogData
@@ -82,6 +83,7 @@ class SettingsViewModel @Inject constructor(
8283
private val inAppReviewManager: InAppReviewManager,
8384
private val avatarUploadManager: AvatarUploadManager,
8485
private val attachmentProcessor: AttachmentProcessor,
86+
val proDetailsRepository: ProDetailsRepository,
8587
) : ViewModel() {
8688
private val TAG = "SettingsViewModel"
8789

@@ -153,6 +155,8 @@ class SettingsViewModel @Inject constructor(
153155
_uiState.update { it.copy(avatarData = data) }
154156
}
155157
}
158+
159+
proDetailsRepository.requestRefresh()
156160
}
157161

158162
private fun getVersionNumber(): CharSequence {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.thoughtcrime.securesms.pro
2+
3+
import kotlinx.coroutines.flow.Flow
4+
import kotlinx.coroutines.flow.filter
5+
import kotlinx.coroutines.flow.flatMapLatest
6+
import kotlinx.coroutines.flow.onStart
7+
import org.session.libsession.utilities.TextSecurePreferences
8+
import org.thoughtcrime.securesms.util.castAwayType
9+
10+
/**
11+
* Creates a flow that only emits when the debug flag forcePostPro is enabled.
12+
*/
13+
fun <T> TextSecurePreferences.flowPostProLaunch(flowFactory: () -> Flow<T>): Flow<T> {
14+
@Suppress("OPT_IN_USAGE")
15+
return TextSecurePreferences.events
16+
.filter { it == TextSecurePreferences.SET_FORCE_POST_PRO }
17+
.castAwayType()
18+
.onStart { emit(Unit) }
19+
.filter { forcePostPro() }
20+
.flatMapLatest { flowFactory() }
21+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package org.thoughtcrime.securesms.pro
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.channels.BufferOverflow
5+
import kotlinx.coroutines.channels.Channel
6+
import kotlinx.coroutines.channels.SendChannel
7+
import kotlinx.coroutines.flow.SharingStarted
8+
import kotlinx.coroutines.flow.StateFlow
9+
import kotlinx.coroutines.flow.distinctUntilChanged
10+
import kotlinx.coroutines.flow.first
11+
import kotlinx.coroutines.flow.flatMapLatest
12+
import kotlinx.coroutines.flow.flow
13+
import kotlinx.coroutines.flow.mapNotNull
14+
import kotlinx.coroutines.flow.stateIn
15+
import kotlinx.coroutines.selects.select
16+
import kotlinx.coroutines.selects.onTimeout
17+
import org.session.libsession.utilities.TextSecurePreferences
18+
import org.session.libsignal.utilities.Log
19+
import org.thoughtcrime.securesms.auth.LoginStateRepository
20+
import org.thoughtcrime.securesms.dependencies.ManagerScope
21+
import org.thoughtcrime.securesms.pro.api.GetProDetailsRequest
22+
import org.thoughtcrime.securesms.pro.api.ProApiExecutor
23+
import org.thoughtcrime.securesms.pro.api.ProDetails
24+
import org.thoughtcrime.securesms.pro.api.successOrThrow
25+
import org.thoughtcrime.securesms.pro.db.ProDatabase
26+
import org.thoughtcrime.securesms.util.NetworkConnectivity
27+
import java.time.Duration
28+
import java.time.Instant
29+
import javax.inject.Inject
30+
import javax.inject.Singleton
31+
import kotlin.coroutines.cancellation.CancellationException
32+
33+
@Singleton
34+
class ProDetailsRepository @Inject constructor(
35+
private val db: ProDatabase,
36+
private val apiExecutor: ProApiExecutor,
37+
private val getProDetailsRequestFactory: GetProDetailsRequest.Factory,
38+
private val loginStateRepository: LoginStateRepository,
39+
private val prefs: TextSecurePreferences,
40+
networkConnectivity: NetworkConnectivity,
41+
@ManagerScope scope: CoroutineScope,
42+
) {
43+
sealed interface LoadState {
44+
val lastUpdated: Pair<ProDetails, Instant>?
45+
46+
data object Init : LoadState {
47+
override val lastUpdated: Pair<ProDetails, Instant>?
48+
get() = null
49+
}
50+
51+
data class Loading(
52+
override val lastUpdated: Pair<ProDetails, Instant>?,
53+
val waitingForNetwork: Boolean
54+
) : LoadState
55+
56+
data class Loaded(override val lastUpdated: Pair<ProDetails, Instant>) : LoadState
57+
data class Error(override val lastUpdated: Pair<ProDetails, Instant>?) : LoadState
58+
}
59+
60+
private val refreshRequests: SendChannel<Unit>
61+
62+
val loadState: StateFlow<LoadState>
63+
64+
init {
65+
val channel = Channel<Unit>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
66+
67+
refreshRequests = channel
68+
@Suppress("OPT_IN_USAGE")
69+
loadState = prefs.flowPostProLaunch {
70+
loginStateRepository.loggedInState
71+
.mapNotNull { it?.seeded?.proMasterPrivateKey }
72+
}.distinctUntilChanged()
73+
.flatMapLatest { proMasterKey ->
74+
flow {
75+
var last = db.getProDetailsAndLastUpdated()
76+
var numRetried = 0
77+
78+
while (true) {
79+
// Drain all pending requests as we are about to execute a request
80+
while (channel.tryReceive().isSuccess) { }
81+
82+
var retryingAt: Instant? = null
83+
84+
if (last != null && last.second.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) >= Instant.now()) {
85+
Log.d(TAG, "Pro details is fresh enough, skipping fetch")
86+
// Last update was recent enough, skip fetching
87+
emit(LoadState.Loaded(last))
88+
} else {
89+
if (!networkConnectivity.networkAvailable.value) {
90+
// No network...mark the state and wait for it to come back
91+
emit(LoadState.Loading(last, waitingForNetwork = true))
92+
networkConnectivity.networkAvailable.first { it }
93+
}
94+
95+
emit(LoadState.Loading(last, waitingForNetwork = false))
96+
97+
// Fetch new details
98+
try {
99+
Log.d(TAG, "Start fetching Pro details from backend")
100+
last = apiExecutor.executeRequest(
101+
request = getProDetailsRequestFactory.create(proMasterKey)
102+
).successOrThrow() to Instant.now()
103+
104+
db.updateProDetails(last.first, last.second)
105+
106+
Log.d(TAG, "Successfully fetched Pro details from backend")
107+
emit(LoadState.Loaded(last))
108+
numRetried = 0
109+
} catch (e: Exception) {
110+
if (e is CancellationException) throw e
111+
112+
emit(LoadState.Error(last))
113+
114+
// Exponential backoff for retries, capped at 2 minutes
115+
val delaySeconds = minOf(10L * (1L shl numRetried), 120L)
116+
Log.e(TAG, "Error fetching Pro details from backend, retrying in ${delaySeconds}s", e)
117+
118+
retryingAt = Instant.now().plusSeconds(delaySeconds)
119+
numRetried++
120+
}
121+
}
122+
123+
124+
// Wait until either a refresh is requested, or it's time to retry
125+
select {
126+
refreshRequests.onReceiveCatching {
127+
Log.d(TAG, "Manual refresh requested")
128+
}
129+
130+
if (retryingAt != null) {
131+
val delayMillis =
132+
Duration.between(Instant.now(), retryingAt).toMillis()
133+
onTimeout(delayMillis) {
134+
Log.d(TAG, "Retrying Pro details fetch after delay")
135+
}
136+
}
137+
}
138+
}
139+
}
140+
}.stateIn(scope, SharingStarted.Eagerly, LoadState.Init)
141+
}
142+
143+
fun requestRefresh() {
144+
refreshRequests.trySend(Unit)
145+
}
146+
147+
companion object {
148+
private const val TAG = "ProDetailsRepository"
149+
private const val MIN_UPDATE_INTERVAL_SECONDS = 120L
150+
}
151+
}

app/src/main/java/org/thoughtcrime/securesms/pro/ProStatePoller.kt

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ import kotlinx.coroutines.flow.SharingStarted
88
import kotlinx.coroutines.flow.StateFlow
99
import kotlinx.coroutines.flow.distinctUntilChanged
1010
import kotlinx.coroutines.flow.emptyFlow
11+
import kotlinx.coroutines.flow.filter
1112
import kotlinx.coroutines.flow.first
1213
import kotlinx.coroutines.flow.flatMapLatest
1314
import kotlinx.coroutines.flow.flow
1415
import kotlinx.coroutines.flow.map
16+
import kotlinx.coroutines.flow.mapNotNull
17+
import kotlinx.coroutines.flow.onStart
1518
import kotlinx.coroutines.flow.stateIn
1619
import kotlinx.coroutines.selects.onTimeout
1720
import kotlinx.coroutines.selects.select
1821
import org.session.libsession.snode.SnodeClock
22+
import org.session.libsession.utilities.TextSecurePreferences
1923
import org.session.libsignal.utilities.Log
2024
import org.thoughtcrime.securesms.auth.LoginStateRepository
2125
import org.thoughtcrime.securesms.dependencies.ManagerScope
@@ -28,6 +32,7 @@ import org.thoughtcrime.securesms.pro.api.ProDetails
2832
import org.thoughtcrime.securesms.pro.api.successOrThrow
2933
import org.thoughtcrime.securesms.pro.db.ProDatabase
3034
import org.thoughtcrime.securesms.util.NetworkConnectivity
35+
import org.thoughtcrime.securesms.util.castAwayType
3136
import java.time.Duration
3237
import java.time.Instant
3338
import javax.inject.Inject
@@ -45,6 +50,8 @@ class ProStatePoller @Inject constructor(
4550
private val proDatabase: ProDatabase,
4651
private val snodeClock: SnodeClock,
4752
private val apiExecutor: ProApiExecutor,
53+
private val proDetailsRepository: ProDetailsRepository,
54+
prefs: TextSecurePreferences,
4855
@ManagerScope scope: CoroutineScope,
4956
): OnAppStartupComponent {
5057
private val manualPollRequest = Channel<PollToken>()
@@ -56,9 +63,11 @@ class ProStatePoller @Inject constructor(
5663
}
5764

5865
@OptIn(ExperimentalCoroutinesApi::class)
59-
val pollState: StateFlow<PollState> = loginStateRepository
60-
.loggedInState
61-
.map { it?.seeded?.proMasterPrivateKey }
66+
val pollState: StateFlow<PollState> = prefs.flowPostProLaunch {
67+
loginStateRepository
68+
.loggedInState
69+
.map { it?.seeded?.proMasterPrivateKey }
70+
}
6271
.distinctUntilChanged()
6372
.flatMapLatest { proMasterPrivateKey ->
6473
if (proMasterPrivateKey == null) {
@@ -148,11 +157,9 @@ class ProStatePoller @Inject constructor(
148157

149158
if (currentProof == null || currentProof.expiryMs <= snodeClock.currentTimeMills()) {
150159
// Current proof is missing or expired, grab the pro details to decide what to do next
151-
val details = apiExecutor.executeRequest(
152-
request = getProDetailsRequestFactory.create(masterPrivateKey = proMasterPrivateKey)
153-
).successOrThrow()
160+
proDetailsRepository.requestRefresh()
154161

155-
proDatabase.updateProDetails(details, Instant.now())
162+
val details = proDetailsRepository.loadState.mapNotNull { it.lastUpdated }.first().first
156163

157164
val newProof = if (details.status == ProDetails.DETAILS_STATUS_ACTIVE) {
158165
Log.d(TAG, "User is active Pro but has no valid proof, generating new proof")

0 commit comments

Comments
 (0)