Skip to content

Commit c3ab821

Browse files
committed
accounts: stop a sign-in deleting anything, and scope the last unscoped save queries
1 parent 4dd7455 commit c3ab821

25 files changed

Lines changed: 196 additions & 94 deletions

app/src/main/kotlin/com/nendo/argosy/data/emulator/PlaySessionTracker.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,11 @@ class PlaySessionTracker @Inject constructor(
755755
else -> null
756756
}
757757
linkCacheToServer(session.gameId, activeChannel, result, uploadedCacheId)
758-
pendingSyncQueueDao.deleteActiveByGameAndType(session.gameId, com.nendo.argosy.data.local.entity.SyncType.SAVE_FILE)
758+
pendingSyncQueueDao.deleteActiveByGameAndType(
759+
session.gameId,
760+
com.nendo.argosy.data.local.entity.SyncType.SAVE_FILE,
761+
activeSaveRepository.activeOwnerId()
762+
)
759763
}
760764

761765
handleSaveSyncResult(session, game, result)

app/src/main/kotlin/com/nendo/argosy/data/local/dao/PendingSyncQueueDao.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ interface PendingSyncQueueDao {
2424
@Query("SELECT * FROM pending_sync_queue WHERE gameId = :gameId")
2525
suspend fun getByGameId(gameId: Long): List<PendingSyncQueueEntity>
2626

27+
@Query("SELECT * FROM pending_sync_queue WHERE gameId = :gameId AND ownerUserId IS :ownerUserId")
28+
suspend fun getByGameIdForOwner(gameId: Long, ownerUserId: Long?): List<PendingSyncQueueEntity>
29+
2730
@Insert(onConflict = OnConflictStrategy.REPLACE)
2831
suspend fun insert(entity: PendingSyncQueueEntity): Long
2932

@@ -33,11 +36,11 @@ interface PendingSyncQueueDao {
3336
@Query("DELETE FROM pending_sync_queue WHERE id = :id")
3437
suspend fun deleteById(id: Long)
3538

36-
@Query("DELETE FROM pending_sync_queue WHERE gameId = :gameId AND syncType = :syncType")
37-
suspend fun deleteByGameAndType(gameId: Long, syncType: SyncType)
39+
@Query("DELETE FROM pending_sync_queue WHERE gameId = :gameId AND syncType = :syncType AND ownerUserId IS :ownerUserId")
40+
suspend fun deleteByGameAndType(gameId: Long, syncType: SyncType, ownerUserId: Long?)
3841

39-
@Query("DELETE FROM pending_sync_queue WHERE gameId = :gameId AND syncType = :syncType AND status IN ('PENDING', 'IN_PROGRESS')")
40-
suspend fun deleteActiveByGameAndType(gameId: Long, syncType: SyncType)
42+
@Query("DELETE FROM pending_sync_queue WHERE gameId = :gameId AND syncType = :syncType AND ownerUserId IS :ownerUserId AND status IN ('PENDING', 'IN_PROGRESS')")
43+
suspend fun deleteActiveByGameAndType(gameId: Long, syncType: SyncType, ownerUserId: Long?)
4144

4245
@Query("""
4346
UPDATE pending_sync_queue

app/src/main/kotlin/com/nendo/argosy/data/local/dao/SaveCacheDao.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ interface SaveCacheDao {
157157
* ids name saves on a rom that no longer answers, and a pending upload against it can
158158
* only fail.
159159
*/
160-
@Query("UPDATE save_cache SET rommSaveId = NULL, needsRemoteSync = 0, remoteSyncError = NULL WHERE gameId = :gameId")
161-
suspend fun clearRemoteLinkage(gameId: Long)
160+
@Query("UPDATE save_cache SET rommSaveId = NULL, needsRemoteSync = 0, remoteSyncError = NULL WHERE gameId = :gameId AND ownerUserId IS :ownerUserId")
161+
suspend fun clearRemoteLinkage(gameId: Long, ownerUserId: Long?)
162162

163163
@Query("DELETE FROM save_cache WHERE gameId IN (SELECT id FROM games WHERE source IN (:sourceNames))")
164164
suspend fun deleteByGameSources(sourceNames: List<String>)
@@ -466,10 +466,11 @@ interface SaveCacheDao {
466466
SELECT EXISTS(
467467
SELECT 1 FROM save_cache
468468
WHERE gameId = :gameId AND isActive = 1 AND activeSaveApplied = 1
469+
AND (ownerUserId IS NULL OR ownerUserId IS :ownerUserId)
469470
)
470471
"""
471472
)
472-
suspend fun hasActiveSaveApplied(gameId: Long): Boolean
473+
suspend fun hasActiveSaveApplied(gameId: Long, ownerUserId: Long?): Boolean
473474

474475
@Query("SELECT COUNT(*) FROM save_cache WHERE ownerUserId IS NULL")
475476
suspend fun countUnowned(): Int

app/src/main/kotlin/com/nendo/argosy/data/local/dao/SaveSyncDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ interface SaveSyncDao {
143143
""")
144144
suspend fun clearCorruptZip(gameId: Long, emulatorId: String, channelName: String?, ownerUserId: Long?)
145145

146+
@Query("DELETE FROM save_sync WHERE gameId = :gameId AND ownerUserId IS :ownerUserId")
147+
suspend fun deleteByGameForOwner(gameId: Long, ownerUserId: Long?)
148+
146149
@Query("DELETE FROM save_sync WHERE gameId = :gameId")
147150
suspend fun deleteByGame(gameId: Long)
148151

app/src/main/kotlin/com/nendo/argosy/data/preferences/AccountScopedPreferenceKeys.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import androidx.datastore.preferences.core.Preferences
1010
* single-store behaviour it had before accounts existed, so a key added elsewhere and forgotten
1111
* here degrades to "shared between accounts" rather than to "silently empty for everyone".
1212
*
13-
* Four groups are deliberately absent and must stay absent. `secure_saves` picks one save mode
13+
* Five groups are deliberately absent and must stay absent. `sync_filter_delete_orphans` decides
14+
* whether a sync may delete rows from `games`, which is one shared row per rom and carries a
15+
* CASCADE onto every account's overlay; a per-account copy meant a newly added account read the
16+
* `true` default and re-enabled cleanup the first account had turned off. `secure_saves` picks one save mode
1417
* for one shared save directory. `builtin_custom_save_path` and `builtin_custom_state_path`
1518
* define the resolved save path itself, so a per-account value would make teardown and placement
1619
* target different directories. The `active_session_*` keys are how an interrupted session is
@@ -41,8 +44,7 @@ object AccountScopedPreferenceKeys {
4144
"sync_filter_exclude_beta",
4245
"sync_filter_exclude_proto",
4346
"sync_filter_exclude_demo",
44-
"sync_filter_exclude_hack",
45-
"sync_filter_delete_orphans"
47+
"sync_filter_exclude_hack"
4648
)
4749

4850
private val DOWNLOAD_CATEGORIES = setOf(

app/src/main/kotlin/com/nendo/argosy/data/remote/romm/RomMConnectionManager.kt

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import android.provider.Settings
77
import com.nendo.argosy.BuildConfig
88
import com.nendo.argosy.data.preferences.UserPreferencesRepository
99
import com.nendo.argosy.data.repository.BiosRepository
10+
import com.nendo.argosy.data.sync.AccountRemovalResult
11+
import com.nendo.argosy.data.sync.UnflushedQueuePolicy
1012
import android.net.ConnectivityManager
1113
import android.net.Network
1214
import com.nendo.argosy.util.Logger
@@ -81,10 +83,11 @@ class RomMConnectionManager @Inject constructor(
8183
@ApplicationContext private val context: Context,
8284
private val userPreferencesRepository: UserPreferencesRepository,
8385
private val saveSyncRepository: dagger.Lazy<com.nendo.argosy.data.repository.SaveSyncRepository>,
84-
private val databaseAdminRepository: dagger.Lazy<com.nendo.argosy.data.repository.DatabaseAdminRepository>,
85-
private val saveCacheRepository: dagger.Lazy<com.nendo.argosy.data.repository.SaveCacheRepository>,
8686
private val biosRepository: BiosRepository,
8787
private val rommAccountRepository: dagger.Lazy<com.nendo.argosy.data.repository.RomMAccountRepository>,
88+
private val accountRemovalService: dagger.Lazy<com.nendo.argosy.data.sync.AccountRemovalService>,
89+
private val syncCoordinator: dagger.Lazy<com.nendo.argosy.data.sync.SyncCoordinator>,
90+
private val retroAchievementsRepository: dagger.Lazy<com.nendo.argosy.data.repository.RetroAchievementsRepository>,
8891
private val apiFactory: RomMApiFactory
8992
) {
9093
private var api: RomMApi? = null
@@ -200,38 +203,39 @@ class RomMConnectionManager @Inject constructor(
200203
})
201204
}
202205

203-
private fun normalizeServerKey(url: String): String =
204-
url.trim().lowercase().removePrefix("https://").removePrefix("http://").trimEnd('/')
205-
206206
private suspend fun fetchCurrentUser(target: RomMApi): RomMUser? = try {
207207
val response = target.getCurrentUser()
208208
if (response.isSuccessful) response.body() else null
209209
} catch (_: Exception) {
210210
null
211211
}
212212

213-
private suspend fun persistRommCredentials(newBaseUrl: String, token: String, user: RomMUser?) {
214-
val stored = userPreferencesRepository.preferences.first()
215-
val storedKey = stored.rommBaseUrl?.let { normalizeServerKey(it) }
216-
val newKey = normalizeServerKey(newBaseUrl)
217-
val storedUserId = stored.rommUserId
213+
private fun normalizeServerKey(url: String): String =
214+
url.trim().lowercase().removePrefix("https://").removePrefix("http://").trimEnd('/')
218215

219-
val serverChanged = !storedKey.isNullOrBlank() && storedKey != newKey
220-
val userChanged = storedUserId != null && user != null && storedUserId != user.id
216+
/**
217+
* Refuses a sign-in that would put a second server's library alongside the first.
218+
*
219+
* Rom ids are only unique within one RomM instance, so two servers on one device collide on
220+
* every id the library, saves and sync queues are keyed by. Accounts are the supported way to
221+
* hold more than one identity, and they share the server the device is already registered to.
222+
* Nothing is deleted here; the existing library stays exactly as it is and the sign-in simply
223+
* does not happen.
224+
*/
225+
private suspend fun requireSameServer(newBaseUrl: String) {
226+
val accounts = rommAccountRepository.get().accounts()
227+
val known = accounts.map { normalizeServerKey(it.baseUrl) }.filter { it.isNotBlank() }.toSet()
228+
if (known.isEmpty()) return
229+
val newKey = normalizeServerKey(newBaseUrl)
230+
if (newKey in known) return
231+
Logger.info(TAG, "persistRommCredentials: refused sign-in to $newKey, device is registered to ${known.joinToString()}")
232+
throw IllegalStateException(
233+
"This device is already signed in to a different RomM server. Remove the existing accounts before connecting to another server."
234+
)
235+
}
221236

222-
if (serverChanged || userChanged) {
223-
val pendingUploads = saveCacheRepository.get().getPendingSyncCounts().pendingUploads
224-
if (pendingUploads > 0) {
225-
Logger.info(TAG, "persistRommCredentials: identity switch blocked, $pendingUploads saves pending upload")
226-
throw IllegalStateException(
227-
"Sync saves first - $pendingUploads pending upload. Switching accounts would delete them."
228-
)
229-
}
230-
val reason = if (serverChanged) "server changed ($storedKey -> $newKey)"
231-
else "user changed ($storedUserId -> ${user?.id})"
232-
Logger.info(TAG, "persistRommCredentials: $reason, purging RomM library")
233-
databaseAdminRepository.get().purgeRomMLibrary()
234-
}
237+
private suspend fun persistRommCredentials(newBaseUrl: String, token: String, user: RomMUser?) {
238+
requireSameServer(newBaseUrl)
235239
userPreferencesRepository.setRomMCredentials(newBaseUrl, token, user?.username, user?.id)
236240
if (user != null) {
237241
val stored = userPreferencesRepository.preferences.first()
@@ -548,15 +552,42 @@ class RomMConnectionManager @Inject constructor(
548552
}
549553

550554
/**
551-
* Forgets the stored RomM identity and tears down the live session. Library rows and
552-
* downloaded content are left alone; signing back in as the same user reuses them, and
553-
* signing in as a different user purges via [persistRommCredentials].
555+
* Signs the active account out: its own rows, cached saves, preferences and credentials go,
556+
* and nothing another account or the shared library owns is touched.
557+
*
558+
* This is removal aimed at the account that happens to be live, so it runs through the same
559+
* service rather than a second, weaker path - that is what brings the switch-in-progress
560+
* guard and the unflushed-work policy with it.
561+
*
562+
* The queue is drained first, while the token still authenticates. Everything the account
563+
* cached and never sent can only be sent as that account, so an upload deferred past sign-out
564+
* is an upload that never happens; refusing here is what keeps the local copy that is still
565+
* the only copy. [discardUnflushed] gives up on whatever the drain could not deliver.
554566
*/
555-
suspend fun signOut() {
567+
suspend fun signOut(discardUnflushed: Boolean = false): AccountRemovalResult {
568+
val active = rommAccountRepository.get().activeAccount()
569+
?: run {
570+
disconnect()
571+
userPreferencesRepository.clearRomMCredentials()
572+
return AccountRemovalResult.UnknownAccount
573+
}
574+
val drained = syncCoordinator.get().processQueue()
575+
Logger.info(TAG, "signOut: drained queued work before removal, result=$drained")
576+
val policy = if (discardUnflushed) {
577+
UnflushedQueuePolicy.DISCARD
578+
} else {
579+
UnflushedQueuePolicy.REFUSE
580+
}
581+
val result = accountRemovalService.get().remove(active.id, policy)
582+
if (result is AccountRemovalResult.Refused || result is AccountRemovalResult.SwitchInProgress) {
583+
Logger.info(TAG, "signOut: not signed out, $result")
584+
return result
585+
}
556586
disconnect()
557-
rommAccountRepository.get().activeAccount()?.let { rommAccountRepository.get().forget(it.id) }
558587
userPreferencesRepository.clearRomMCredentials()
559-
Logger.info(TAG, "signOut: cleared stored RomM identity")
588+
retroAchievementsRepository.get().syncRetroArchCredentials()
589+
Logger.info(TAG, "signOut: removed user ${active.rommUserId} and cleared the stored identity")
590+
return result
560591
}
561592

562593
/**

app/src/main/kotlin/com/nendo/argosy/data/remote/romm/RomMLibrarySyncService.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ class RomMLibrarySyncService @Inject constructor(
424424
}
425425

426426
if (hasLocalContent(game)) {
427-
preserveOrphanedGame(game)
427+
preserveOrphanedGame(game, ownerUserId)
428428
continue
429429
}
430430
gameDao.delete(game.id)
@@ -1257,7 +1257,7 @@ class RomMLibrarySyncService @Inject constructor(
12571257
gameDao.insert(game.copy(rommId = successor.rommId, syncDirty = false))
12581258
successor.rommId?.let { newRommId ->
12591259
saveSyncDao.realignToRommId(game.id, scope.ownerUserId, newRommId)
1260-
saveCacheDao.clearRemoteLinkage(game.id)
1260+
saveCacheDao.clearRemoteLinkage(game.id, scope.ownerUserId)
12611261
}
12621262
realigned++
12631263
Logger.info(
@@ -1281,12 +1281,12 @@ class RomMLibrarySyncService @Inject constructor(
12811281
* answers, and leaving them is what makes a device retry an upload against a dead id
12821282
* for as long as the game exists. The cached saves themselves are never touched.
12831283
*/
1284-
private suspend fun preserveOrphanedGame(game: GameEntity) {
1284+
private suspend fun preserveOrphanedGame(game: GameEntity, ownerUserId: Long?) {
12851285
val syntheticId = game.rommId?.takeIf { it < 0 } ?: -game.id
12861286
gameDao.insert(game.copy(rommId = syntheticId, syncDirty = false))
12871287
if (syntheticId != game.rommId) {
1288-
saveSyncDao.deleteByGame(game.id)
1289-
saveCacheDao.clearRemoteLinkage(game.id)
1288+
saveSyncDao.deleteByGameForOwner(game.id, ownerUserId)
1289+
saveCacheDao.clearRemoteLinkage(game.id, ownerUserId)
12901290
Logger.info(
12911291
TAG,
12921292
"preserveOrphanedGame: ${game.title} has local content, rommId ${game.rommId} -> $syntheticId, dropped remote sync state"

app/src/main/kotlin/com/nendo/argosy/data/remote/romm/RomMRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ class RomMRepository @Inject constructor(
6565

6666
fun disconnect() = connectionManager.disconnect()
6767

68-
suspend fun signOut() = connectionManager.signOut()
68+
suspend fun signOut(discardUnflushed: Boolean = false) =
69+
connectionManager.signOut(discardUnflushed)
6970

7071
suspend fun checkConnection() = connectionManager.checkConnection()
7172

app/src/main/kotlin/com/nendo/argosy/data/repository/ActiveSaveRepository.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class ActiveSaveRepository @Inject constructor(
3434
getActiveRow(gameId)?.cachedAt?.toEpochMilli()
3535

3636
suspend fun isActiveSaveApplied(gameId: Long): Boolean =
37-
saveCacheDao.hasActiveSaveApplied(gameId)
37+
saveCacheDao.hasActiveSaveApplied(gameId, activeOwnerId())
3838

3939
suspend fun getPendingDeviceSyncSaveId(gameId: Long): Long? =
4040
getActiveRow(gameId)?.pendingDeviceSyncSaveId

app/src/main/kotlin/com/nendo/argosy/data/repository/DatabaseAdminRepository.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,6 @@ class DatabaseAdminRepository @Inject constructor(
7575
true
7676
}
7777

78-
/** Wipes RomM-sourced library content AND its downloaded files so a server switch starts clean. */
79-
suspend fun purgeRomMLibrary() = withContext(Dispatchers.IO) {
80-
val sources = listOf(GameSource.ROMM_REMOTE, GameSource.ROMM_SYNCED)
81-
deleteDownloadedFiles(sources)
82-
purgeDatabase(sources, includeLocalCollections = false, clearImages = true)
83-
}
84-
8578
/** Resets the entire library database and per-game caches; downloaded ROM files stay on disk. */
8679
suspend fun purgeAllLibrary() = withContext(Dispatchers.IO) {
8780
deleteCacheDirs(GameSource.entries)

0 commit comments

Comments
 (0)