diff --git a/app/src/main/java/one/mixin/android/api/BotPublicKey.kt b/app/src/main/java/one/mixin/android/api/BotPublicKey.kt new file mode 100644 index 0000000000..bd78f4f11b --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/BotPublicKey.kt @@ -0,0 +1,125 @@ +package one.mixin.android.api + +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import one.mixin.android.Constants.Account.PREF_CASH_BOT_PK +import one.mixin.android.Constants.Account.PREF_EARN_BOT_PK +import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK +import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK +import one.mixin.android.Constants.MIXIN_CASH_USER_ID +import one.mixin.android.Constants.MIXIN_EARN_USER_ID +import one.mixin.android.Constants.RouteConfig.REFERRAL_BOT_USER_ID +import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID +import one.mixin.android.MixinApplication +import one.mixin.android.api.response.UserSession +import one.mixin.android.db.MixinDatabase +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.putString +import one.mixin.android.session.Session +import one.mixin.android.vo.ParticipantSession +import one.mixin.android.vo.generateConversationId + +class BotPublicKey( + private val readPreference: () -> String?, + private val readLocal: suspend () -> String?, + private val saveSession: suspend (UserSession) -> Unit, + private val savePreference: (String) -> Unit, + private val botId: String = ROUTE_BOT_USER_ID, +) { + private val mutex = Mutex() + private var pending: CompletableDeferred? = null + private var pendingForce = false + + suspend fun get( + requestSession: suspend (List) -> MixinResponse>, + force: Boolean = false, + ): String { + while (true) { + currentCoroutineContext().ensureActive() + var owner = false + var canReuse = false + val result = mutex.withLock { + (pending ?: CompletableDeferred().also { + pending = it + owner = true + pendingForce = force + }).also { canReuse = !force || pendingForce } + } + if (!owner) { + val key = try { + result.await() + } catch (e: CancellationException) { + currentCoroutineContext().ensureActive() + continue + } + if (canReuse) return key + continue + } + val outcome = runCatching { + withContext(Dispatchers.IO) { + if (!force) { + readPreference()?.takeIf { it.isNotBlank() }?.let { return@withContext it } + } + val cached = if (force) null else readLocal()?.takeIf { it.isNotBlank() } + (cached ?: run { + val response = requestSession(listOf(botId)) + if (!response.isSuccess) { + throw MixinResponseException(response.errorCode, response.errorDescription) + } + val session = response.data?.firstOrNull { + it.userId == botId && !it.publicKey.isNullOrBlank() + } + if (session == null) throw IOException("Bot public key is missing") + saveSession(session) + requireNotNull(session.publicKey) + }).also(savePreference) + } + } + withContext(NonCancellable) { + mutex.withLock { + pending = null + outcome.fold(result::complete, result::completeExceptionally) + } + } + return outcome.getOrThrow() + } + } + + companion object { + private val bots = mapOf( + ROUTE_BOT_USER_ID to create(ROUTE_BOT_USER_ID, PREF_ROUTE_BOT_PK), + REFERRAL_BOT_USER_ID to create(REFERRAL_BOT_USER_ID, PREF_REFERRAL_BOT_PK), + MIXIN_CASH_USER_ID to create(MIXIN_CASH_USER_ID, PREF_CASH_BOT_PK), + MIXIN_EARN_USER_ID to create(MIXIN_EARN_USER_ID, PREF_EARN_BOT_PK), + ) + val route = requireNotNull(bots[ROUTE_BOT_USER_ID]) + + fun forBot(botId: String): BotPublicKey? = bots[botId] + + private fun create(botId: String, preferenceKey: String) = BotPublicKey( + readPreference = { MixinApplication.appContext.defaultSharedPreferences.getString(preferenceKey, null) }, + readLocal = { + val account = requireNotNull(Session.getAccount()) + MixinDatabase.getDatabase(MixinApplication.appContext, account.identityNumber) + .participantSessionDao().findBotPublicKey(generateConversationId(botId, account.userId), botId) + }, + saveSession = { session -> + val account = requireNotNull(Session.getAccount()) + MixinDatabase.getDatabase(MixinApplication.appContext, account.identityNumber) + .participantSessionDao().insertSuspend( + ParticipantSession(generateConversationId(session.userId, account.userId), session.userId, session.sessionId, publicKey = session.publicKey), + ) + }, + savePreference = { MixinApplication.appContext.defaultSharedPreferences.putString(preferenceKey, it) }, + botId = botId, + ) + } +} diff --git a/app/src/main/java/one/mixin/android/api/referral/ReferralAPI.kt b/app/src/main/java/one/mixin/android/api/referral/ReferralAPI.kt index d36163e711..7bb2d69be2 100644 --- a/app/src/main/java/one/mixin/android/api/referral/ReferralAPI.kt +++ b/app/src/main/java/one/mixin/android/api/referral/ReferralAPI.kt @@ -1,48 +1,29 @@ package one.mixin.android.api.referral -import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK import one.mixin.android.Constants.RouteConfig.REFERRAL_BOT_USER_ID -import one.mixin.android.MixinApplication import one.mixin.android.api.MixinResponse +import one.mixin.android.api.MixinResponseException +import one.mixin.android.api.ResponseError +import one.mixin.android.api.BotPublicKey import one.mixin.android.api.handleMixinResponse import one.mixin.android.api.response.UserSession -import one.mixin.android.db.MixinDatabase -import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.putString -import one.mixin.android.session.Session import one.mixin.android.util.ErrorHandler -import one.mixin.android.vo.ParticipantSession -import one.mixin.android.vo.generateConversationId import retrofit2.Response -private suspend fun persistReferralBotSession(sessionData: UserSession) { - val account = Session.getAccount() ?: return - val accountId = Session.getAccountId() ?: return - - MixinApplication.appContext.defaultSharedPreferences.putString(PREF_REFERRAL_BOT_PK, sessionData.publicKey) - MixinDatabase.getDatabase(MixinApplication.appContext, account.identityNumber) - .participantSessionDao() - .insertSuspend( - ParticipantSession( - conversationId = generateConversationId(sessionData.userId, accountId), - userId = sessionData.userId, - sessionId = sessionData.sessionId, - publicKey = sessionData.publicKey, - ), - ) -} - private suspend fun retryReferralRequestAfterRefreshingSession( requestSession: suspend (List) -> MixinResponse>, retryRequest: suspend () -> R?, ): R? { return handleMixinResponse( - invokeNetwork = { requestSession(listOf(REFERRAL_BOT_USER_ID)) }, - successBlock = { response -> - val sessionData = requireNotNull(response.data).first() - persistReferralBotSession(sessionData) - retryRequest() + invokeNetwork = { + try { + requireNotNull(BotPublicKey.forBot(REFERRAL_BOT_USER_ID)).get(requestSession, force = true) + MixinResponse() + } catch (e: MixinResponseException) { + MixinResponse(ResponseError(e.errorCode, e.errorCode, e.errorDescription)) + } }, + successBlock = { retryRequest() }, ) } diff --git a/app/src/main/java/one/mixin/android/job/RefreshPriceJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPriceJob.kt index e678c3e970..e2abbc3cf9 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshPriceJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshPriceJob.kt @@ -2,15 +2,8 @@ package one.mixin.android.job import com.birbit.android.jobqueue.Params import kotlinx.coroutines.runBlocking -import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK -import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID -import one.mixin.android.MixinApplication -import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.putString -import one.mixin.android.session.Session +import one.mixin.android.api.BotPublicKey import one.mixin.android.util.ErrorHandler -import one.mixin.android.vo.ParticipantSession -import one.mixin.android.vo.generateConversationId import timber.log.Timber class RefreshPriceJob(private val assetId: String) : BaseJob( @@ -31,12 +24,7 @@ class RefreshPriceJob(private val assetId: String) : BaseJob( historyPriceDao.insert(it) } } else if (response.errorCode == ErrorHandler.AUTHENTICATION) { - val resp = userService.fetchSessionsSuspend(listOf(ROUTE_BOT_USER_ID)) - if (resp.isSuccess) { - val sessionData = requireNotNull(resp.data)[0] - MixinApplication.appContext.defaultSharedPreferences.putString(PREF_ROUTE_BOT_PK, sessionData.publicKey) - participantSessionDao.insertSuspend(ParticipantSession(generateConversationId(sessionData.userId, Session.getAccountId()!!), sessionData.userId, sessionData.sessionId, publicKey = sessionData.publicKey)) - } + BotPublicKey.route.get(userService::fetchSessionsSuspend, force = true) } } catch (e: Exception) { Timber.e(e) diff --git a/app/src/main/java/one/mixin/android/repository/UserRepository.kt b/app/src/main/java/one/mixin/android/repository/UserRepository.kt index 0b9f412a93..075aefa646 100644 --- a/app/src/main/java/one/mixin/android/repository/UserRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/UserRepository.kt @@ -6,17 +6,8 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.map import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import one.mixin.android.Constants.Account.PREF_CASH_BOT_PK -import one.mixin.android.Constants.Account.PREF_EARN_BOT_PK -import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK -import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK -import one.mixin.android.Constants.MIXIN_CASH_USER_ID -import one.mixin.android.Constants.MIXIN_EARN_USER_ID -import one.mixin.android.Constants.RouteConfig.REFERRAL_BOT_USER_ID -import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID -import one.mixin.android.MixinApplication import one.mixin.android.api.MixinResponse -import one.mixin.android.api.MixinResponseException +import one.mixin.android.api.BotPublicKey import one.mixin.android.api.handleMixinResponse import one.mixin.android.api.request.BindInviteRequest import one.mixin.android.api.request.CircleConversationRequest @@ -32,9 +23,7 @@ import one.mixin.android.db.MixinDatabase import one.mixin.android.db.ParticipantSessionDao import one.mixin.android.db.UserDao import one.mixin.android.db.provider.DataProvider -import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.oneWeekAgo -import one.mixin.android.extension.putString import one.mixin.android.session.Session import one.mixin.android.vo.App import one.mixin.android.vo.Circle @@ -48,7 +37,6 @@ import one.mixin.android.vo.SearchBot import one.mixin.android.vo.User import one.mixin.android.vo.UserItem import one.mixin.android.vo.UserRelationship -import one.mixin.android.vo.generateConversationId import javax.inject.Inject class UserRepository @@ -366,48 +354,7 @@ class UserRepository } suspend fun getBotPublicKey(botId: String, force: Boolean) { - val prefKey = - when (botId) { - ROUTE_BOT_USER_ID -> PREF_ROUTE_BOT_PK - REFERRAL_BOT_USER_ID -> PREF_REFERRAL_BOT_PK - MIXIN_CASH_USER_ID -> PREF_CASH_BOT_PK - MIXIN_EARN_USER_ID -> PREF_EARN_BOT_PK - else -> return - } - - val key = - findBotPublicKey( - generateConversationId( - botId, - Session.getAccountId()!!, - ), - botId, - ) - if (key != null && !force) { - MixinApplication.appContext.defaultSharedPreferences.putString(prefKey, key) - } else { - val sessionResponse = fetchSessionsSuspend(listOf(botId)) - if (sessionResponse.isSuccess) { - val sessionData = requireNotNull(sessionResponse.data)[0] - saveSession( - ParticipantSession( - generateConversationId( - sessionData.userId, - Session.getAccountId()!!, - ), - sessionData.userId, - sessionData.sessionId, - publicKey = sessionData.publicKey, - ), - ) - MixinApplication.appContext.defaultSharedPreferences.putString(prefKey, sessionData.publicKey) - } else { - throw MixinResponseException( - sessionResponse.errorCode, - sessionResponse.errorDescription, - ) - } - } + BotPublicKey.forBot(botId)?.get(::fetchSessionsSuspend, force) } suspend fun bindReferral(code: String) = accountService.bindReferral(BindInviteRequest(code)) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt index c0a1cfe756..9e935db10b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import one.mixin.android.Constants -import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.MixinApplication import one.mixin.android.R @@ -52,8 +51,6 @@ import one.mixin.android.ui.web.WebActivity import one.mixin.android.util.ErrorHandler import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.viewBinding -import one.mixin.android.vo.ParticipantSession -import one.mixin.android.vo.generateConversationId import one.mixin.android.vo.safe.TokenItem import one.mixin.android.widget.Keyboard import timber.log.Timber @@ -644,44 +641,6 @@ class CalculateFragment : BaseFragment(R.layout.fragment_calculate) { setLoading(true) flow { emit(ROUTE_BOT_USER_ID) - }.map { botId -> - val key = - fiatMoneyViewModel.findBotPublicKey( - generateConversationId( - botId, - Session.getAccountId()!!, - ), - botId, - ) - if (!key.isNullOrEmpty()) { - MixinApplication.appContext.defaultSharedPreferences.putString( - PREF_ROUTE_BOT_PK, - key - ) - } else { - val sessionResponse = fiatMoneyViewModel.fetchSessionsSuspend(listOf(botId)) - if (sessionResponse.isSuccess) { - val sessionData = requireNotNull(sessionResponse.data)[0] - fiatMoneyViewModel.saveSession( - ParticipantSession( - generateConversationId( - sessionData.userId, - Session.getAccountId()!!, - ), - sessionData.userId, - sessionData.sessionId, - publicKey = sessionData.publicKey, - ), - ) - MixinApplication.appContext.defaultSharedPreferences.putString(PREF_ROUTE_BOT_PK, sessionData.publicKey) - } else { - throw MixinResponseException( - sessionResponse.errorCode, - sessionResponse.errorDescription, - ) - } - } - botId }.map { _ -> val profileResponse = requestRouteAPI( diff --git a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPI.kt b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPI.kt index df7dd03248..96f6d2ddea 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPI.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPI.kt @@ -1,18 +1,12 @@ package one.mixin.android.ui.wallet.fiatmoney -import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK -import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID -import one.mixin.android.MixinApplication +import kotlinx.coroutines.CancellationException import one.mixin.android.api.MixinResponse -import one.mixin.android.api.handleMixinResponse +import one.mixin.android.api.MixinResponseException +import one.mixin.android.api.ResponseError +import one.mixin.android.api.BotPublicKey import one.mixin.android.api.response.UserSession -import one.mixin.android.db.MixinDatabase -import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.putString -import one.mixin.android.session.Session import one.mixin.android.util.ErrorHandler -import one.mixin.android.vo.ParticipantSession -import one.mixin.android.vo.generateConversationId suspend fun requestRouteAPI( invokeNetwork: suspend () -> MixinResponse, @@ -29,42 +23,46 @@ suspend fun requestRouteAPI( endBlock: (() -> Unit)? = null, authErrorRetryCount: Int = 1, requestSession: suspend (List) -> MixinResponse>, + publicKey: BotPublicKey = BotPublicKey.route, ): R? { - val response = - try { - invokeNetwork() - } catch (t: Throwable) { - if (exceptionBlock?.invoke(t) != true) { - defaultExceptionHandle.invoke(t) + var failure: Throwable? = null + try { + var retries = authErrorRetryCount + var force = false + while (true) { + var synchronized = false + val response = try { + publicKey.get(requestSession, force) + synchronized = true + invokeNetwork() + } catch (t: CancellationException) { + throw t + } catch (t: MixinResponseException) { + MixinResponse(ResponseError(t.errorCode, t.errorCode, t.errorDescription)) + } catch (t: Throwable) { + if (exceptionBlock?.invoke(t) != true) defaultExceptionHandle(t) + return null } - endBlock?.invoke() + if (synchronized) doAfterNetworkSuccess?.invoke() + if (response.isSuccess) return successBlock?.invoke(response) + if (synchronized && response.errorCode == ErrorHandler.AUTHENTICATION && retries > 0) { + retries-- + force = true + continue + } + if (failureBlock?.invoke(response) != true) defaultErrorHandle(response) return null } - - doAfterNetworkSuccess?.invoke() - - return if (response.isSuccess) { - val r = successBlock?.invoke(response) - endBlock?.invoke() - r - } else { - if (response.errorCode == ErrorHandler.AUTHENTICATION && authErrorRetryCount > 0) { - return handleMixinResponse( - invokeNetwork = { requestSession(listOf(ROUTE_BOT_USER_ID)) }, - successBlock = { resp -> - val sessionData = requireNotNull(resp.data)[0] - val identityNumber = - requireNotNull(Session.getAccount()) { "Account is required for database access." }.identityNumber - MixinApplication.appContext.defaultSharedPreferences.putString(PREF_ROUTE_BOT_PK, sessionData.publicKey) - MixinDatabase.getDatabase(MixinApplication.appContext, identityNumber).participantSessionDao().insertSuspend(ParticipantSession(generateConversationId(sessionData.userId, Session.getAccountId()!!), sessionData.userId, sessionData.sessionId, publicKey = sessionData.publicKey)) - return@handleMixinResponse requestRouteAPI(invokeNetwork, successBlock, failureBlock, exceptionBlock, doAfterNetworkSuccess, defaultErrorHandle, defaultExceptionHandle, endBlock, authErrorRetryCount - 1, requestSession) - }, - ) - } - if (failureBlock?.invoke(response) != true) { - defaultErrorHandle(response) + } catch (t: Throwable) { + failure = t + throw t + } finally { + try { + endBlock?.invoke() + } catch (t: Throwable) { + val original = failure + if (original == null) throw t + if (original !== t) original.addSuppressed(t) } - endBlock?.invoke() - null } } diff --git a/app/src/test/java/one/mixin/android/ui/common/LoginRecoveryPolicyTest.kt b/app/src/test/java/one/mixin/android/ui/common/LoginRecoveryPolicyTest.kt index 96b9362afa..39b8d22f8d 100644 --- a/app/src/test/java/one/mixin/android/ui/common/LoginRecoveryPolicyTest.kt +++ b/app/src/test/java/one/mixin/android/ui/common/LoginRecoveryPolicyTest.kt @@ -1,19 +1,35 @@ package one.mixin.android.ui.common +import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.runBlocking +import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK +import one.mixin.android.MixinApplication import one.mixin.android.api.MixinResponse import one.mixin.android.api.ResponseError +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.ErrorHandler import one.mixin.android.vo.WalletCategory import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.After import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class LoginRecoveryPolicyTest { + @After + fun clearPreferences() { + ApplicationProvider.getApplicationContext().defaultSharedPreferences + .edit().remove(PREF_ROUTE_BOT_PK).commit() + } + @Test fun missingWalletIsDeletedAndItsErrorIsHandledDuringLogin() = runBlocking { + MixinApplication.appContext = ApplicationProvider.getApplicationContext() + MixinApplication.appContext.defaultSharedPreferences.edit().putString(PREF_ROUTE_BOT_PK, "cached-key").commit() val wallets = mutableListOf("missing", "active") var defaultErrorHandled = false val result = requestRouteAPI( diff --git a/app/src/test/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPITest.kt b/app/src/test/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPITest.kt new file mode 100644 index 0000000000..7cc673a21e --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/wallet/fiatmoney/RouteAPITest.kt @@ -0,0 +1,312 @@ +package one.mixin.android.ui.wallet.fiatmoney + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK +import one.mixin.android.Constants.MIXIN_CASH_USER_ID +import one.mixin.android.Constants.MIXIN_EARN_USER_ID +import one.mixin.android.Constants.RouteConfig.REFERRAL_BOT_USER_ID +import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID +import one.mixin.android.MixinApplication +import one.mixin.android.api.MixinResponse +import one.mixin.android.api.ResponseError +import one.mixin.android.api.BotPublicKey +import one.mixin.android.api.response.UserSession +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.util.ErrorHandler +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RouteAPITest { + @After + fun clearPreferences() { + ApplicationProvider.getApplicationContext().defaultSharedPreferences + .edit().remove(PREF_ROUTE_BOT_PK).commit() + } + + private fun session(key: String? = "key", userId: String = ROUTE_BOT_USER_ID) = UserSession(userId, "session", null, key) + + private fun response(vararg sessions: UserSession) = MixinResponse>().apply { data = sessions.toList() } + + @Test + fun synchronizesBothCachesAndDeliversFailures() = runBlocking { + MixinApplication.appContext = ApplicationProvider.getApplicationContext() + val preferences = MixinApplication.appContext.defaultSharedPreferences + for (mode in listOf("missing", "cached", "local", "failed", "empty", "blank", "wrong-user", "null")) { + preferences.edit().remove(PREF_ROUTE_BOT_PK).commit() + if (mode == "cached") preferences.edit().putString(PREF_ROUTE_BOT_PK, "key").commit() + val events = mutableListOf() + var preferenceWrites = 0 + val publicKey = BotPublicKey( + readPreference = { preferences.getString(PREF_ROUTE_BOT_PK, null) }, + readLocal = { if (mode == "local") "key" else null }, + saveSession = { assertEquals(session(), it); events.add("save") }, + savePreference = { preferenceWrites++; preferences.edit().putString(PREF_ROUTE_BOT_PK, it).commit() }, + ) + val result = requestRouteAPI( + invokeNetwork = { + assertEquals("key", preferences.getString(PREF_ROUTE_BOT_PK, null)) + events.add("request") + MixinResponse() + }, + successBlock = { true }, + failureBlock = { assertEquals(404, it.errorCode); events.add("failure"); true }, + defaultErrorHandle = { error("Failure was handled") }, + defaultExceptionHandle = { events.add("exception") }, + endBlock = { events.add("end") }, + requestSession = { ids -> + assertEquals(listOf(ROUTE_BOT_USER_ID), ids) + events.add("sync") + when (mode) { + "cached", "local" -> error("Cached public key must be reused") + "failed" -> MixinResponse(ResponseError(404, 404, "Not found")) + "empty" -> response() + "blank" -> response(session(" ")) + "null" -> response(session(null)) + "wrong-user" -> response(session(userId = "other")) + else -> response(session(userId = "other"), session()) + } + }, + publicKey = publicKey, + ) + assertEquals(if (mode in listOf("missing", "local")) 1 else 0, preferenceWrites) + when (mode) { + "missing" -> assertEquals(listOf("sync", "save", "request", "end"), events) + "cached", "local" -> assertEquals(listOf("request", "end"), events) + "failed" -> assertEquals(listOf("sync", "failure", "end"), events) + else -> assertEquals(listOf("sync", "exception", "end"), events) + } + if (mode in listOf("missing", "cached", "local")) assertEquals(true, result) else assertNull(result) + } + } + + @Test + fun concurrentCallersShareSuccessAndFailureAndCanRetry() = runBlocking { + for (fail in listOf(false, true)) { + var calls = 0 + var saves = 0 + val started = CompletableDeferred() + val release = CompletableDeferred() + val publicKey = BotPublicKey({ null }, { null }, { saves++ }, {}) + val fetch: suspend (List) -> MixinResponse> = { + calls++ + started.complete(Unit) + release.await() + if (fail) MixinResponse(ResponseError(500, 500, "failed")) else response(session()) + } + val first = async { runCatching { publicKey.get(fetch) } } + started.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { runCatching { publicKey.get(fetch) } } + release.complete(Unit) + assertEquals(fail, first.await().isFailure) + assertEquals(fail, second.await().isFailure) + assertEquals(1, calls) + assertEquals(if (fail) 0 else 1, saves) + assertEquals("key", publicKey.get({ response(session()) })) + } + } + + @Test + fun forcedRefreshWaitsForCacheReadAndSharesNetworkRefresh() = runBlocking { + val reading = CompletableDeferred() + val releaseCache = CompletableDeferred() + val fetching = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val publicKey = BotPublicKey({ null }, { + reading.complete(Unit) + releaseCache.await() + "old" + }, {}, {}) + val cached = async { publicKey.get({ error("Cache hit") }) } + reading.await() + val fetch: suspend (List) -> MixinResponse> = { + calls++ + fetching.complete(Unit) + releaseFetch.await() + response(session("new")) + } + val forced = async(start = CoroutineStart.UNDISPATCHED) { publicKey.get(fetch, force = true) } + releaseCache.complete(Unit) + fetching.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { publicKey.get(fetch, force = true) } + releaseFetch.complete(Unit) + assertEquals("old", cached.await()) + assertEquals("new", forced.await()) + assertEquals("new", second.await()) + assertEquals(1, calls) + } + + @Test + fun ownerCancellationDoesNotCancelActiveWaiters() = runBlocking { + val started = CompletableDeferred() + val publicKey = BotPublicKey({ null }, { null }, {}, {}) + val owner = launch { + publicKey.get({ started.complete(Unit); awaitCancellation() }) + } + started.await() + val waiter = async(start = CoroutineStart.UNDISPATCHED) { + publicKey.get({ response(session("new")) }) + } + val cancelledWaiter = async(start = CoroutineStart.UNDISPATCHED) { + publicKey.get({ error("Cancelled waiter must not fetch") }) + } + cancelledWaiter.cancelAndJoin() + owner.cancelAndJoin() + assertEquals("new", waiter.await()) + assertTrue(cancelledWaiter.isCancelled) + } + + @Test + fun failedWaiterCanRetryAsSoonAsItResumes() = runBlocking { + val started = CompletableDeferred() + val release = CompletableDeferred() + val publicKey = BotPublicKey({ null }, { null }, {}, {}) + val first = async { + runCatching { + publicKey.get({ + started.complete(Unit) + release.await() + MixinResponse(ResponseError(500, 500, "failed")) + }) + } + } + started.await() + val waiter = async(Dispatchers.Unconfined) { + assertTrue(runCatching { publicKey.get({ error("Must join pending fetch") }) }.isFailure) + publicKey.get({ response(session("new")) }) + } + release.complete(Unit) + assertTrue(first.await().isFailure) + assertEquals("new", waiter.await()) + } + + @Test + fun allBotsValidateTheirOwnSessionAndReusePreferences() = runBlocking { + for (botId in listOf(ROUTE_BOT_USER_ID, REFERRAL_BOT_USER_ID, MIXIN_CASH_USER_ID, MIXIN_EARN_USER_ID)) { + var key: String? = null + var saved: UserSession? = null + var writes = 0 + val publicKey = BotPublicKey({ key }, { null }, { saved = it }, { key = it; writes++ }, botId) + for (sessions in listOf(emptyArray(), arrayOf(session(userId = "other")), arrayOf(session(" ", botId)))) { + assertTrue(runCatching { publicKey.get({ response(*sessions) }) }.isFailure) + assertNull(key) + assertNull(saved) + } + assertEquals("key", publicKey.get({ ids -> + assertEquals(listOf(botId), ids) + response(session(userId = "other"), session(userId = botId)) + })) + assertEquals(session(userId = botId), saved) + assertEquals("key", publicKey.get({ error("Preference hit must not fetch") })) + assertEquals(1, writes) + } + assertSame(BotPublicKey.route, BotPublicKey.forBot(ROUTE_BOT_USER_ID)) + assertNull(BotPublicKey.forBot("unknown")) + } + + @Test + fun authenticationRetryUsesValidatedSessionAndEndsOnce() = runBlocking { + for (mode in listOf("success", "failed", "empty")) { + var key = "old" + var saved: UserSession? = null + var requests = 0 + var failures = 0 + var exceptions = 0 + var ends = 0 + val publicKey = BotPublicKey({ key }, { error("Forced refresh must skip database") }, { saved = it }, { key = it }) + requestRouteAPI( + invokeNetwork = { + requests++ + if (requests == 1) MixinResponse(ResponseError(401, ErrorHandler.AUTHENTICATION, "Unauthorized")) + else { assertEquals("key", key); MixinResponse() } + }, + failureBlock = { failures++; true }, + exceptionBlock = { exceptions++; true }, + endBlock = { ends++ }, + requestSession = { + when (mode) { + "failed" -> MixinResponse(ResponseError(500, 500, "failed")) + "empty" -> response() + else -> response(session(userId = "other"), session()) + } + }, + publicKey = publicKey, + ) + assertEquals(1, ends) + assertEquals(if (mode == "success") 2 else 1, requests) + assertEquals(if (mode == "failed") 1 else 0, failures) + assertEquals(if (mode == "empty") 1 else 0, exceptions) + assertEquals(if (mode == "success") session() else null, saved) + } + } + + @Test + fun cancellationEndsOnceAndAllowsAnotherSynchronization() = runBlocking { + val started = CompletableDeferred() + val publicKey = BotPublicKey({ null }, { null }, {}, {}) + var ends = 0 + val request = launch { + requestRouteAPI( + invokeNetwork = { error("Cancelled synchronization must not send a request") }, + exceptionBlock = { error("Cancellation must propagate") }, + endBlock = { ends++ }, + requestSession = { started.complete(Unit); awaitCancellation() }, + publicKey = publicKey, + ) + } + started.await() + request.cancelAndJoin() + assertEquals(1, ends) + assertEquals("key", publicKey.get({ response(session()) })) + } + + @Test + fun endFailureIsSuppressedOnOriginalFailure() = runBlocking { + val original = IllegalStateException("Original failure") + val cleanup = IllegalStateException("Cleanup failure") + val result = runCatching { + requestRouteAPI( + invokeNetwork = { MixinResponse() }, + successBlock = { throw original }, + endBlock = { throw cleanup }, + requestSession = { error("Cached") }, + publicKey = BotPublicKey({ "key" }, { null }, {}, {}), + ) + } + assertSame(original, result.exceptionOrNull()) + assertEquals(listOf(cleanup), original.suppressed.toList()) + } + + @Test + fun throwingCallbacksDoNotRepeatEndBlock() = runBlocking { + for (callback in listOf("error", "end")) { + var ends = 0 + val result = runCatching { + requestRouteAPI( + invokeNetwork = { MixinResponse(ResponseError(500, 500, "failed")) }, + defaultErrorHandle = { if (callback == "error") error("handler failed") }, + endBlock = { ends++; if (callback == "end") error("end failed") }, + requestSession = { error("Cached") }, + publicKey = BotPublicKey({ "key" }, { null }, {}, {}), + ) + } + assertTrue(result.isFailure) + assertEquals(1, ends) + } + } +}