diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index f71d3febc2..330fee4e19 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -5,18 +5,33 @@ import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Transaction import kotlinx.coroutines.flow.Flow import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.BaseDao +import org.threeten.bp.OffsetDateTime @Dao interface PerpsPositionDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(position: PerpsPosition) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(positions: List) + @Transaction + suspend fun insertAll(positions: List) { + positions.forEach { upsertSuspend(it) } + } + + @Transaction + override suspend fun upsertSuspend(entity: PerpsPosition) { + val current = getPosition(entity.positionId) + // Optimistic opening/adding states can carry a client-generated timestamp. + if (current != null && current.state != PerpsPosition.STATE_OPENING && current.state != PerpsPosition.STATE_ADDING) { + val updatedAt = current.updatedAt?.let(OffsetDateTime::parse) + if (updatedAt != null && OffsetDateTime.parse(entity.updatedAt).isBefore(updatedAt)) return + } + insert(entity) + } @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index b157fa7163..641c895420 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -692,7 +692,6 @@ class PerpetualViewModel @Inject constructor( positionId: String, assetId: String, amount: String, - position: PerpsPositionItem? = null, destination: String? = null, price: String? = null, takeProfitPrice: String? = null, @@ -719,21 +718,6 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date()) - withContext(Dispatchers.IO) { - val localPosition = perpsPositionDao.getPosition(positionId)?.toPosition() - ?: position?.toPosition() - if (localPosition != null) { - perpsPositionDao.upsertSuspend( - localPosition.copy( - state = PerpsPosition.STATE_ADDING, - updatedAt = now, - ) - ) - } else { - perpsPositionDao.updateStatus(positionId, PerpsPosition.STATE_ADDING, now) - } - } onSuccess(data) } else { onError(response.errorCode, response.errorDescription) @@ -1115,11 +1099,12 @@ class PerpetualViewModel @Inject constructor( val resolvedWalletId = data.walletId.ifBlank { localBefore?.walletId ?: "" } val positionForDb = data.copy(walletId = resolvedWalletId) - withContext(Dispatchers.IO) { + val currentPosition = withContext(Dispatchers.IO) { perpsPositionDao.upsertSuspend(positionForDb) + perpsPositionDao.getPosition(positionId)?.toPosition() ?: positionForDb } - onSuccess(positionForDb) + onSuccess(currentPosition) } else { val error = "Failed to load position detail: ${response.errorDescription}" Timber.e(error) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt index 303e9670ff..98ba96c7c2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt @@ -967,7 +967,6 @@ internal fun FragmentActivity.showPerpsAddPosition( positionId = position.positionId, assetId = token.assetId, amount = amount, - position = position, price = referencePrice.takeIf { it.isNotBlank() }, leaderPositionId = leaderPositionId, onSuccess = { response -> diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt new file mode 100644 index 0000000000..1707d11564 --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt @@ -0,0 +1,93 @@ +package one.mixin.android.db.perps + +import android.content.Context +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.db.PerpsDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PerpsPositionDaoTest { + private lateinit var database: PerpsDatabase + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), PerpsDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() = database.close() + + @Test + fun olderDetailAndListSnapshotsDoNotRollBackMargin() = runBlocking { + val dao = database.perpsPositionDao() + val initial = position() + val increased = initial.copy(margin = "55", updatedAt = "2026-10-07T00:00:00.250Z") + dao.upsertSuspend(initial) + assertEquals("5", dao.getOpenPositions("wallet").single().margin) + + dao.insertAll(listOf(increased)) + dao.upsertSuspend(initial) + assertEquals("55", dao.getPosition(initial.positionId)?.margin) + dao.insertAll(listOf(initial)) + assertEquals("55", dao.getOpenPositions("wallet").single().margin) + + val reduced = increased.copy(margin = "50", updatedAt = "2026-10-07T00:00:01Z") + dao.upsertSuspend(reduced) + dao.insertAll(listOf(increased)) + assertEquals("50", dao.getPosition(initial.positionId)?.margin) + } + + @Test + fun equalVersionSnapshotsStillRefreshQuotes() = runBlocking { + val dao = database.perpsPositionDao() + val initial = position() + dao.upsertSuspend(initial) + val refreshed = initial.copy(markPrice = "101", unrealizedPnl = "1", updatedAt = "2026-10-07T08:00:00+08:00") + dao.insertAll(listOf(refreshed)) + assertEquals("101", dao.getPosition(initial.positionId)?.markPrice) + assertEquals("1", dao.getPosition(initial.positionId)?.unrealizedPnl) + } + + @Test + fun remoteSnapshotsReplaceOptimisticLocalStates() = runBlocking { + val dao = database.perpsPositionDao() + val remote = position() + for (state in listOf(PerpsPosition.STATE_OPENING, PerpsPosition.STATE_ADDING)) { + dao.insert(remote.copy(state = state, updatedAt = "2026-10-07T08:00:00Z")) + dao.upsertSuspend(remote) + assertEquals(PerpsPosition.STATE_OPEN, dao.getPosition(remote.positionId)?.state) + assertEquals(remote.updatedAt, dao.getPosition(remote.positionId)?.updatedAt) + } + } + + private fun position() = PerpsPosition( + positionId = "position", + marketId = "market", + side = "long", + quantity = "1", + entryPrice = "100", + margin = "5", + leverage = 10, + state = PerpsPosition.STATE_OPEN, + markPrice = "100", + unrealizedPnl = "0", + roe = "0", + settleAssetId = "asset", + openPayAmount = "5", + openPayAssetId = "asset", + botId = "bot", + walletId = "wallet", + createdAt = "2026-10-07T00:00:00Z", + updatedAt = "2026-10-07T00:00:00Z", + ) +}