Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PerpsPosition> {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(position: PerpsPosition)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(positions: List<PerpsPosition>)
@Transaction
suspend fun insertAll(positions: List<PerpsPosition>) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>(), 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",
)
}
Loading