feat(sync): implement zero-knowledge E2EE sync engine, keyring, and payload store
This commit is contained in:
parent
561bd98bad
commit
a5b687628b
15 changed files with 2052 additions and 75 deletions
|
|
@ -256,6 +256,12 @@ interface DatabaseDao {
|
|||
@Query("UPDATE song SET totalPlayTime = totalPlayTime + 1 WHERE videoId = :videoId")
|
||||
suspend fun updateTotalPlayTime(videoId: String)
|
||||
|
||||
@Query("UPDATE song SET totalPlayTime = :totalPlayTime WHERE videoId = :videoId AND totalPlayTime < :totalPlayTime")
|
||||
suspend fun updateTotalPlayTimeIfGreater(
|
||||
videoId: String,
|
||||
totalPlayTime: Long,
|
||||
)
|
||||
|
||||
@Query("UPDATE song SET totalPlayTime = 0 WHERE videoId = :videoId")
|
||||
suspend fun resetTotalPlayTime(videoId: String)
|
||||
|
||||
|
|
@ -1195,6 +1201,49 @@ interface DatabaseDao {
|
|||
return eventId
|
||||
}
|
||||
|
||||
@Query("SELECT channelId FROM event_artist WHERE eventId = :eventId")
|
||||
suspend fun getEventArtistChannelIds(eventId: Long): List<String>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM playback_event WHERE timestamp = :timestamp AND videoId = :videoId)")
|
||||
suspend fun hasPlaybackEvent(
|
||||
timestamp: LocalDateTime,
|
||||
videoId: String,
|
||||
): Boolean
|
||||
|
||||
@Transaction
|
||||
suspend fun insertSyncedPlaybackWithArtists(
|
||||
timestamp: LocalDateTime,
|
||||
videoId: String,
|
||||
channelIds: List<String>,
|
||||
albumBrowseId: String?,
|
||||
durationSecond: Long,
|
||||
listenedSecond: Long,
|
||||
) {
|
||||
if (hasPlaybackEvent(timestamp, videoId)) return
|
||||
val eventId =
|
||||
insertPlaybackEvent(
|
||||
PlaybackEventEntity(
|
||||
timestamp = timestamp,
|
||||
videoId = videoId,
|
||||
albumBrowseId = albumBrowseId,
|
||||
durationSecond = durationSecond,
|
||||
listenedSecond = listenedSecond,
|
||||
),
|
||||
)
|
||||
channelIds.distinct().forEach { channelId ->
|
||||
insertEventArtist(
|
||||
EventArtistEntity(
|
||||
eventId = eventId,
|
||||
channelId = channelId,
|
||||
timestamp = timestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM search_history WHERE query = :query")
|
||||
suspend fun deleteSearchHistoryItem(query: String)
|
||||
|
||||
@Query("SELECT * FROM playback_event ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
|
||||
suspend fun getPlaybackEventsByOffset(
|
||||
offset: Int,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ internal class DataStoreManagerImpl(
|
|||
}
|
||||
|
||||
override val syncServerUrl: Flow<String> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_SERVER_URL] ?: "" }
|
||||
settingsDataStore.data.map { preferences ->
|
||||
preferences[SYNC_SERVER_URL]?.takeIf { it.isNotBlank() } ?: DEFAULT_SYNC_SERVER_URL
|
||||
}
|
||||
|
||||
override suspend fun setSyncServerUrl(url: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -67,6 +69,68 @@ internal class DataStoreManagerImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override val syncCursor: Flow<Long> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_CURSOR] ?: 0L }
|
||||
|
||||
override suspend fun setSyncCursor(cursor: Long) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings -> settings[SYNC_CURSOR] = cursor }
|
||||
}
|
||||
}
|
||||
|
||||
override val syncDataKey: Flow<String?> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_DATA_KEY] }
|
||||
|
||||
override suspend fun setSyncDataKey(key: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings ->
|
||||
if (key == null) settings.remove(SYNC_DATA_KEY) else settings[SYNC_DATA_KEY] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val syncEmail: Flow<String?> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_EMAIL] }
|
||||
|
||||
override suspend fun setSyncEmail(email: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings ->
|
||||
if (email == null) settings.remove(SYNC_EMAIL) else settings[SYNC_EMAIL] = email
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val syncDeviceName: Flow<String?> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_DEVICE_NAME] }
|
||||
|
||||
override suspend fun setSyncDeviceName(deviceName: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings ->
|
||||
if (deviceName == null) settings.remove(SYNC_DEVICE_NAME) else settings[SYNC_DEVICE_NAME] = deviceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val syncRecoveryKey: Flow<String?> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_RECOVERY_KEY] }
|
||||
|
||||
override suspend fun setSyncRecoveryKey(recoveryKey: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings ->
|
||||
if (recoveryKey == null) settings.remove(SYNC_RECOVERY_KEY) else settings[SYNC_RECOVERY_KEY] = recoveryKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val syncLastSyncedAt: Flow<Long> =
|
||||
settingsDataStore.data.map { preferences -> preferences[SYNC_LAST_SYNCED_AT] ?: 0L }
|
||||
|
||||
override suspend fun setSyncLastSyncedAt(epochMillis: Long) {
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsDataStore.edit { settings -> settings[SYNC_LAST_SYNCED_AT] = epochMillis }
|
||||
}
|
||||
}
|
||||
|
||||
override val appVersion: Flow<String> =
|
||||
settingsDataStore.data.map { preferences ->
|
||||
preferences[APP_VERSION] ?: ""
|
||||
|
|
@ -1928,9 +1992,16 @@ internal class DataStoreManagerImpl(
|
|||
val MOOD_AND_GENRES_CACHE = stringPreferencesKey("mood_and_genres_cache")
|
||||
val MOOD_ARTWORK_CACHE = stringPreferencesKey("mood_artwork_cache")
|
||||
val TASTE_PROFILE = stringPreferencesKey("ai_taste_profile")
|
||||
const val DEFAULT_SYNC_SERVER_URL = "https://sync.muriloscolari.me"
|
||||
val SYNC_ENABLED = booleanPreferencesKey("sync_enabled")
|
||||
val SYNC_SERVER_URL = stringPreferencesKey("sync_server_url")
|
||||
val SYNC_SESSION = stringPreferencesKey("sync_session")
|
||||
val SYNC_CURSOR = longPreferencesKey("sync_cursor")
|
||||
val SYNC_DATA_KEY = stringPreferencesKey("sync_data_key")
|
||||
val SYNC_EMAIL = stringPreferencesKey("sync_email")
|
||||
val SYNC_DEVICE_NAME = stringPreferencesKey("sync_device_name")
|
||||
val SYNC_RECOVERY_KEY = stringPreferencesKey("sync_recovery_key")
|
||||
val SYNC_LAST_SYNCED_AT = longPreferencesKey("sync_last_synced_at")
|
||||
val QUALITY = stringPreferencesKey("quality")
|
||||
val DOWNLOAD_QUALITY = stringPreferencesKey("download_quality")
|
||||
val VIDEO_DOWNLOAD_QUALITY = stringPreferencesKey("video_download_quality")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ internal class AnalyticsDatasource(
|
|||
listenedSecond = listenedSecond,
|
||||
)
|
||||
|
||||
suspend fun insertSyncedPlaybackEvent(
|
||||
timestamp: LocalDateTime,
|
||||
videoId: String,
|
||||
channelIds: List<String>,
|
||||
albumBrowseId: String?,
|
||||
durationSecond: Long,
|
||||
listenedSecond: Long,
|
||||
) = databaseDao.insertSyncedPlaybackWithArtists(
|
||||
timestamp = timestamp,
|
||||
videoId = videoId,
|
||||
channelIds = channelIds,
|
||||
albumBrowseId = albumBrowseId,
|
||||
durationSecond = durationSecond,
|
||||
listenedSecond = listenedSecond,
|
||||
)
|
||||
|
||||
suspend fun getEventArtistChannelIds(eventId: Long): List<String> = databaseDao.getEventArtistChannelIds(eventId)
|
||||
|
||||
suspend fun getPlaybackEventsByOffset(
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ internal class LocalDataSource(
|
|||
|
||||
suspend fun deleteSearchHistory() = databaseDao.deleteSearchHistory()
|
||||
|
||||
suspend fun deleteSearchHistoryItem(query: String) = databaseDao.deleteSearchHistoryItem(query)
|
||||
|
||||
suspend fun insertSearchHistory(searchHistory: SearchHistory) = databaseDao.insertSearchHistory(searchHistory)
|
||||
|
||||
suspend fun getAllSongs(limit: Int) = databaseDao.getAllSongs(limit)
|
||||
|
|
@ -255,6 +257,11 @@ internal class LocalDataSource(
|
|||
|
||||
suspend fun updateListenCount(videoId: String) = databaseDao.updateTotalPlayTime(videoId)
|
||||
|
||||
suspend fun updateTotalPlayTimeIfGreater(
|
||||
videoId: String,
|
||||
totalPlayTime: Long,
|
||||
) = databaseDao.updateTotalPlayTimeIfGreater(videoId, totalPlayTime)
|
||||
|
||||
suspend fun resetTotalPlayTime(videoId: String) = databaseDao.resetTotalPlayTime(videoId)
|
||||
|
||||
suspend fun updateCanvasUrl(
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ val repositoryModule =
|
|||
StreamRepositoryImpl(get(), get())
|
||||
}
|
||||
|
||||
single<SyncRepository> {
|
||||
SyncRepositoryImpl(get())
|
||||
single<SyncRepository>(createdAtStart = true) {
|
||||
SyncRepositoryImpl(get(), get(), get(), get())
|
||||
}
|
||||
|
||||
single<UpdateRepository>(createdAtStart = true) {
|
||||
|
|
|
|||
|
|
@ -1,85 +1,694 @@
|
|||
package com.maxrave.data.repository
|
||||
|
||||
import com.maxrave.data.db.datasource.AnalyticsDatasource
|
||||
import com.maxrave.data.db.datasource.LocalDataSource
|
||||
import com.maxrave.data.repository.sync.SyncPayloadStore
|
||||
import com.maxrave.domain.manager.DataStoreManager
|
||||
import com.maxrave.domain.repository.CommonRepository
|
||||
import com.maxrave.domain.repository.SyncDeviceInfo
|
||||
import com.maxrave.domain.repository.SyncRepository
|
||||
import com.maxrave.domain.repository.SyncStatus
|
||||
import com.maxrave.logger.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.simpmusic.sync.SyncBlob
|
||||
import org.simpmusic.sync.SyncClient
|
||||
import org.simpmusic.sync.SyncCredentials
|
||||
import org.simpmusic.sync.SyncSession
|
||||
import org.simpmusic.sync.SyncCrypto
|
||||
import org.simpmusic.sync.SyncEnvelopeHelper
|
||||
import org.simpmusic.sync.SyncRecoveryRequest
|
||||
import org.simpmusic.sync.SyncRequest
|
||||
import org.simpmusic.sync.SyncSession
|
||||
import org.simpmusic.sync.SyncWsNotification
|
||||
import kotlin.time.Clock
|
||||
|
||||
class SyncRepositoryImpl(
|
||||
internal class SyncRepositoryImpl(
|
||||
private val dataStore: DataStoreManager,
|
||||
localDataSource: LocalDataSource,
|
||||
analyticsDatasource: AnalyticsDatasource,
|
||||
commonRepository: CommonRepository,
|
||||
) : SyncRepository {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val payloadStore = SyncPayloadStore(dataStore, localDataSource, analyticsDatasource, commonRepository)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val syncMutex = Mutex()
|
||||
private val clientMutex = Mutex()
|
||||
private var cachedUrl: String? = null
|
||||
private var cachedClient: SyncClient? = null
|
||||
|
||||
private var realtimeJob: Job? = null
|
||||
private var periodicJob: Job? = null
|
||||
private var debounceSyncJob: Job? = null
|
||||
|
||||
private val _status = MutableStateFlow(SyncStatus())
|
||||
override val status: StateFlow<SyncStatus> = _status
|
||||
|
||||
override suspend fun configure(serverUrl: String, enabled: Boolean) {
|
||||
dataStore.setSyncServerUrl(serverUrl)
|
||||
dataStore.setSyncEnabled(enabled)
|
||||
_status.value = _status.value.copy(enabled = enabled, configured = serverUrl.isNotBlank(), error = null)
|
||||
companion object {
|
||||
private const val TAG = "SimpSyncRepo"
|
||||
private const val PUSH_BATCH_SIZE = 150
|
||||
private const val PERIODIC_SYNC_MS = 5 * 60 * 1000L // 5 minutes
|
||||
}
|
||||
|
||||
override suspend fun register(email: String, authProof: String, deviceName: String) {
|
||||
authenticate(email, authProof, deviceName, register = true)
|
||||
}
|
||||
|
||||
override suspend fun login(email: String, authProof: String, deviceName: String) {
|
||||
authenticate(email, authProof, deviceName, register = false)
|
||||
}
|
||||
|
||||
override suspend fun recover(email: String, recoveryKeyHash: String, newAuthProof: String, deviceName: String) {
|
||||
val url = dataStore.syncServerUrl.first().trimEnd('/')
|
||||
require(url.isNotBlank()) { "Sync server URL is required" }
|
||||
val session = SyncClient(url).recover(SyncRecoveryRequest(email.trim(), recoveryKeyHash, newAuthProof, deviceName.trim()))
|
||||
dataStore.setSyncSession(json.encodeToString(session))
|
||||
dataStore.setSyncEnabled(true)
|
||||
_status.value = SyncStatus(enabled = true, configured = true)
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
val session = readSession() ?: return
|
||||
val url = dataStore.syncServerUrl.first()
|
||||
if (url.isNotBlank()) SyncClient(url).logout(session.token)
|
||||
dataStore.setSyncSession(null)
|
||||
_status.value = _status.value.copy(enabled = false, configured = url.isNotBlank(), error = null)
|
||||
}
|
||||
|
||||
override suspend fun synchronize() {
|
||||
val url = dataStore.syncServerUrl.first()
|
||||
val session = readSession()
|
||||
if (url.isBlank() || session == null) {
|
||||
_status.value = _status.value.copy(error = "Sync is not configured")
|
||||
return
|
||||
init {
|
||||
scope.launch {
|
||||
restorePersistedState()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePersistedState() {
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
val enabled = dataStore.syncEnabled.first()
|
||||
val session = readSession()
|
||||
val dataKey = readDataKey()
|
||||
val email = dataStore.syncEmail.first()
|
||||
val deviceName = dataStore.syncDeviceName.first()
|
||||
val recoveryKey = dataStore.syncRecoveryKey.first()
|
||||
val lastSynced = dataStore.syncLastSyncedAt.first()
|
||||
|
||||
val authenticated = session != null && dataKey != null
|
||||
_status.value = SyncStatus(
|
||||
enabled = enabled && authenticated,
|
||||
configured = url.isNotBlank(),
|
||||
authenticated = authenticated,
|
||||
serverUrl = url,
|
||||
email = email,
|
||||
deviceName = deviceName,
|
||||
recoveryKey = recoveryKey,
|
||||
lastSyncedAt = lastSynced,
|
||||
)
|
||||
|
||||
if (authenticated && enabled) {
|
||||
startBackgroundWorkers()
|
||||
scope.launch {
|
||||
synchronize()
|
||||
refreshDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun configure(serverUrl: String, enabled: Boolean) {
|
||||
val normalizedUrl = serverUrl.trim().trimEnd('/')
|
||||
dataStore.setSyncServerUrl(normalizedUrl)
|
||||
dataStore.setSyncEnabled(enabled)
|
||||
val authenticated = readSession() != null && readDataKey() != null
|
||||
_status.value = _status.value.copy(
|
||||
serverUrl = normalizedUrl,
|
||||
enabled = enabled && authenticated,
|
||||
configured = normalizedUrl.isNotBlank(),
|
||||
error = null,
|
||||
)
|
||||
if (enabled && authenticated) {
|
||||
startBackgroundWorkers()
|
||||
scope.launch { synchronize() }
|
||||
} else {
|
||||
stopBackgroundWorkers()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(
|
||||
email: String,
|
||||
password: String,
|
||||
deviceName: String,
|
||||
): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cleanEmail = email.trim().lowercase()
|
||||
val cleanDevice = deviceName.trim().ifBlank { "SimpMusic" }
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
require(url.isNotBlank()) { "Sync server URL is required" }
|
||||
|
||||
_status.value = _status.value.copy(syncing = true, error = null)
|
||||
try {
|
||||
val master = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveMasterCredentials(cleanEmail, password)
|
||||
}
|
||||
val recoveryKey = SyncCrypto.generateRecoveryKey()
|
||||
val recovery = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveRecovery(recoveryKey)
|
||||
}
|
||||
val dek = SyncCrypto.newKey()
|
||||
|
||||
val client = getClient(url)
|
||||
val session = client.register(
|
||||
SyncCredentials(
|
||||
email = cleanEmail,
|
||||
authProof = master.authProof,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKeyHash = recovery.recoveryKeyHash,
|
||||
),
|
||||
)
|
||||
|
||||
val keyringEnvelope = SyncEnvelopeHelper.createKeyringEnvelope(
|
||||
dek = dek,
|
||||
masterWrappingKey = master.wrappingKey,
|
||||
recoveryWrappingKey = recovery.wrappingKey,
|
||||
)
|
||||
val keyringSync = client.sync(
|
||||
token = session.token,
|
||||
request = SyncRequest(
|
||||
changes = listOf(
|
||||
SyncBlob(
|
||||
entityType = SyncEnvelopeHelper.KEYRING_ENTITY_TYPE,
|
||||
entityId = SyncEnvelopeHelper.KEYRING_ENTITY_ID,
|
||||
envelope = keyringEnvelope,
|
||||
deleted = false,
|
||||
),
|
||||
),
|
||||
cursor = 0L,
|
||||
),
|
||||
)
|
||||
|
||||
payloadStore.clearLocalState()
|
||||
persistSessionAndKeys(
|
||||
session = session,
|
||||
dek = dek,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = recoveryKey,
|
||||
cursor = keyringSync.cursor,
|
||||
)
|
||||
_status.value = _status.value.copy(
|
||||
enabled = true,
|
||||
configured = true,
|
||||
authenticated = true,
|
||||
syncing = false,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = recoveryKey,
|
||||
error = null,
|
||||
)
|
||||
startBackgroundWorkers()
|
||||
scope.launch {
|
||||
synchronize()
|
||||
refreshDevices()
|
||||
}
|
||||
recoveryKey
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
val msg = cleanErrorMessage(e)
|
||||
_status.value = _status.value.copy(syncing = false, error = msg)
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
deviceName: String,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val cleanEmail = email.trim().lowercase()
|
||||
val cleanDevice = deviceName.trim().ifBlank { "SimpMusic" }
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
require(url.isNotBlank()) { "Sync server URL is required" }
|
||||
|
||||
_status.value = _status.value.copy(syncing = true, error = null)
|
||||
try {
|
||||
SyncClient(url).sync(session.token, org.simpmusic.sync.SyncRequest())
|
||||
} catch (error: Exception) {
|
||||
_status.value = _status.value.copy(error = error.message ?: "Synchronization failed")
|
||||
} finally {
|
||||
_status.value = _status.value.copy(syncing = false)
|
||||
val master = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveMasterCredentials(cleanEmail, password)
|
||||
}
|
||||
val client = getClient(url)
|
||||
val session = client.login(
|
||||
SyncCredentials(
|
||||
email = cleanEmail,
|
||||
authProof = master.authProof,
|
||||
deviceName = cleanDevice,
|
||||
),
|
||||
)
|
||||
|
||||
// Locate the keyring blob on the server to unwrap the existing DEK.
|
||||
val keyringBlob = fetchKeyringBlob(client, session.token)
|
||||
val dek: ByteArray
|
||||
var newRecoveryKey: String? = dataStore.syncRecoveryKey.first()
|
||||
|
||||
if (keyringBlob != null) {
|
||||
val unwrapped = SyncEnvelopeHelper.unwrapKeyringWithMaster(keyringBlob.envelope, master.wrappingKey)
|
||||
?: error("Could not unlock encryption keyring with the provided master password")
|
||||
dek = unwrapped.first
|
||||
} else {
|
||||
// Legacy account without keyring: initialize one now.
|
||||
val generatedRecoveryKey = SyncCrypto.generateRecoveryKey()
|
||||
val recovery = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveRecovery(generatedRecoveryKey)
|
||||
}
|
||||
dek = SyncCrypto.newKey()
|
||||
newRecoveryKey = generatedRecoveryKey
|
||||
val keyringEnvelope = SyncEnvelopeHelper.createKeyringEnvelope(
|
||||
dek = dek,
|
||||
masterWrappingKey = master.wrappingKey,
|
||||
recoveryWrappingKey = recovery.wrappingKey,
|
||||
)
|
||||
client.sync(
|
||||
token = session.token,
|
||||
request = SyncRequest(
|
||||
changes = listOf(
|
||||
SyncBlob(
|
||||
entityType = SyncEnvelopeHelper.KEYRING_ENTITY_TYPE,
|
||||
entityId = SyncEnvelopeHelper.KEYRING_ENTITY_ID,
|
||||
envelope = keyringEnvelope,
|
||||
),
|
||||
),
|
||||
cursor = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Reset local state and cursor to 0 so the initial synchronize() pulls and merges all cloud data first!
|
||||
payloadStore.clearLocalState()
|
||||
persistSessionAndKeys(
|
||||
session = session,
|
||||
dek = dek,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = newRecoveryKey,
|
||||
cursor = 0L,
|
||||
)
|
||||
_status.value = _status.value.copy(
|
||||
enabled = true,
|
||||
configured = true,
|
||||
authenticated = true,
|
||||
syncing = false,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = newRecoveryKey,
|
||||
error = null,
|
||||
)
|
||||
startBackgroundWorkers()
|
||||
scope.launch {
|
||||
synchronize()
|
||||
refreshDevices()
|
||||
}
|
||||
Unit
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
val msg = cleanErrorMessage(e)
|
||||
_status.value = _status.value.copy(syncing = false, error = msg)
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun authenticate(email: String, authProof: String, deviceName: String, register: Boolean) {
|
||||
val url = dataStore.syncServerUrl.first().trimEnd('/')
|
||||
override suspend fun recover(
|
||||
email: String,
|
||||
recoveryKey: String,
|
||||
newPassword: String,
|
||||
deviceName: String,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val cleanEmail = email.trim().lowercase()
|
||||
val cleanDevice = deviceName.trim().ifBlank { "SimpMusic" }
|
||||
val cleanRecoveryKey = recoveryKey.trim().uppercase()
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
require(url.isNotBlank()) { "Sync server URL is required" }
|
||||
val client = SyncClient(url)
|
||||
val credentials = SyncCredentials(email.trim(), authProof, deviceName.trim())
|
||||
val session = if (register) client.register(credentials) else client.login(credentials)
|
||||
dataStore.setSyncSession(json.encodeToString(session))
|
||||
dataStore.setSyncEnabled(true)
|
||||
_status.value = SyncStatus(enabled = true, configured = true)
|
||||
|
||||
_status.value = _status.value.copy(syncing = true, error = null)
|
||||
try {
|
||||
val recovery = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveRecovery(cleanRecoveryKey)
|
||||
}
|
||||
val newMaster = withContext(Dispatchers.Default) {
|
||||
SyncCrypto.deriveMasterCredentials(cleanEmail, newPassword)
|
||||
}
|
||||
val client = getClient(url)
|
||||
val session = client.recover(
|
||||
SyncRecoveryRequest(
|
||||
email = cleanEmail,
|
||||
recoveryKeyHash = recovery.recoveryKeyHash,
|
||||
newAuthProof = newMaster.authProof,
|
||||
deviceName = cleanDevice,
|
||||
),
|
||||
)
|
||||
|
||||
// Unwrap the existing DEK using the Recovery Key and re-wrap it with the new Master Password!
|
||||
val keyringBlob = fetchKeyringBlob(client, session.token)
|
||||
val dek: ByteArray
|
||||
val updatedKeyringEnvelope = if (keyringBlob != null) {
|
||||
val unwrapped = SyncEnvelopeHelper.unwrapKeyringWithRecovery(keyringBlob.envelope, recovery.wrappingKey)
|
||||
?: error("Invalid recovery key for encrypted keyring")
|
||||
dek = unwrapped.first
|
||||
SyncEnvelopeHelper.rewrapKeyringWithNewMaster(
|
||||
existingKeyring = unwrapped.second,
|
||||
dek = dek,
|
||||
newMasterWrappingKey = newMaster.wrappingKey,
|
||||
)
|
||||
} else {
|
||||
dek = SyncCrypto.newKey()
|
||||
SyncEnvelopeHelper.createKeyringEnvelope(
|
||||
dek = dek,
|
||||
masterWrappingKey = newMaster.wrappingKey,
|
||||
recoveryWrappingKey = recovery.wrappingKey,
|
||||
)
|
||||
}
|
||||
|
||||
client.sync(
|
||||
token = session.token,
|
||||
request = SyncRequest(
|
||||
changes = listOf(
|
||||
SyncBlob(
|
||||
entityType = SyncEnvelopeHelper.KEYRING_ENTITY_TYPE,
|
||||
entityId = SyncEnvelopeHelper.KEYRING_ENTITY_ID,
|
||||
envelope = updatedKeyringEnvelope,
|
||||
),
|
||||
),
|
||||
cursor = 0L,
|
||||
),
|
||||
)
|
||||
|
||||
payloadStore.clearLocalState()
|
||||
persistSessionAndKeys(
|
||||
session = session,
|
||||
dek = dek,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = cleanRecoveryKey,
|
||||
cursor = 0L,
|
||||
)
|
||||
_status.value = _status.value.copy(
|
||||
enabled = true,
|
||||
configured = true,
|
||||
authenticated = true,
|
||||
syncing = false,
|
||||
email = cleanEmail,
|
||||
deviceName = cleanDevice,
|
||||
recoveryKey = cleanRecoveryKey,
|
||||
error = null,
|
||||
)
|
||||
startBackgroundWorkers()
|
||||
scope.launch {
|
||||
synchronize()
|
||||
refreshDevices()
|
||||
}
|
||||
Unit
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
val msg = cleanErrorMessage(e)
|
||||
_status.value = _status.value.copy(syncing = false, error = msg)
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() =
|
||||
withContext(Dispatchers.IO) {
|
||||
stopBackgroundWorkers()
|
||||
val session = readSession()
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
if (session != null && url.isNotBlank()) {
|
||||
runCatching { getClient(url).logout(session.token) }
|
||||
}
|
||||
payloadStore.clearLocalState()
|
||||
dataStore.setSyncSession(null)
|
||||
dataStore.setSyncDataKey(null)
|
||||
dataStore.setSyncCursor(0L)
|
||||
dataStore.setSyncEnabled(false)
|
||||
dataStore.setSyncRecoveryKey(null)
|
||||
_status.value = _status.value.copy(
|
||||
enabled = false,
|
||||
authenticated = false,
|
||||
realtimeConnected = false,
|
||||
syncing = false,
|
||||
recoveryKey = null,
|
||||
devices = emptyList(),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun synchronize() {
|
||||
syncMutex.withLock {
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
val session = readSession()
|
||||
val dek = readDataKey()
|
||||
if (url.isBlank() || session == null || dek == null) {
|
||||
_status.value = _status.value.copy(error = "Sync is not configured or logged in")
|
||||
return
|
||||
}
|
||||
|
||||
_status.value = _status.value.copy(syncing = true, error = null)
|
||||
try {
|
||||
val client = getClient(url)
|
||||
var cursor = dataStore.syncCursor.first()
|
||||
var localState = payloadStore.loadLocalState()
|
||||
val mutableHashes = localState.entityHashes.toMutableMap()
|
||||
var newestAnalyticsIso = localState.lastAnalyticsTimestampIso
|
||||
|
||||
// 1. Paginated Pull-before-Push
|
||||
do {
|
||||
val pullResponse = client.sync(
|
||||
token = session.token,
|
||||
request = SyncRequest(changes = emptyList(), cursor = cursor),
|
||||
)
|
||||
if (pullResponse.changes.isNotEmpty()) {
|
||||
val remoteAnalyticsIso = payloadStore.applyRemoteChanges(
|
||||
dek = dek,
|
||||
changes = pullResponse.changes,
|
||||
currentHashes = mutableHashes,
|
||||
)
|
||||
if (remoteAnalyticsIso != null &&
|
||||
(newestAnalyticsIso == null || remoteAnalyticsIso > newestAnalyticsIso)
|
||||
) {
|
||||
newestAnalyticsIso = remoteAnalyticsIso
|
||||
}
|
||||
}
|
||||
cursor = pullResponse.cursor
|
||||
dataStore.setSyncCursor(cursor)
|
||||
} while (pullResponse.hasMore)
|
||||
|
||||
localState = localState.copy(
|
||||
entityHashes = mutableHashes,
|
||||
lastAnalyticsTimestampIso = newestAnalyticsIso,
|
||||
)
|
||||
|
||||
// 2. Compute Local Diff & Batched Push
|
||||
val (outgoing, updatedState) = payloadStore.buildPushChanges(dek, localState)
|
||||
if (outgoing.isNotEmpty()) {
|
||||
for (batch in outgoing.chunked(PUSH_BATCH_SIZE)) {
|
||||
val pushResponse = client.sync(
|
||||
token = session.token,
|
||||
request = SyncRequest(changes = batch, cursor = cursor),
|
||||
)
|
||||
if (pushResponse.changes.isNotEmpty()) {
|
||||
payloadStore.applyRemoteChanges(
|
||||
dek = dek,
|
||||
changes = pushResponse.changes,
|
||||
currentHashes = mutableHashes,
|
||||
)
|
||||
}
|
||||
cursor = pushResponse.cursor
|
||||
dataStore.setSyncCursor(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
payloadStore.saveLocalState(updatedState)
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
dataStore.setSyncLastSyncedAt(nowMs)
|
||||
_status.value = _status.value.copy(
|
||||
syncing = false,
|
||||
lastSyncedAt = nowMs,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Logger.e(TAG, "Synchronization error: ${e.message}")
|
||||
val msg = cleanErrorMessage(e)
|
||||
if (msg.contains("401")) {
|
||||
// Session was revoked or expired
|
||||
stopBackgroundWorkers()
|
||||
dataStore.setSyncSession(null)
|
||||
dataStore.setSyncEnabled(false)
|
||||
_status.value = _status.value.copy(
|
||||
syncing = false,
|
||||
enabled = false,
|
||||
authenticated = false,
|
||||
realtimeConnected = false,
|
||||
error = "Session expired or revoked. Please sign in again.",
|
||||
)
|
||||
} else {
|
||||
_status.value = _status.value.copy(syncing = false, error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshDevices() =
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
val session = readSession() ?: return@withContext
|
||||
if (url.isBlank()) return@withContext
|
||||
try {
|
||||
val list = getClient(url).devices(session.token).map {
|
||||
SyncDeviceInfo(
|
||||
id = it.id,
|
||||
deviceName = it.deviceName,
|
||||
createdAt = it.createdAt,
|
||||
lastSeenAt = it.lastSeenAt,
|
||||
current = it.current || it.id == session.deviceId,
|
||||
)
|
||||
}
|
||||
_status.value = _status.value.copy(devices = list)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Logger.w(TAG, "Failed to refresh devices: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeDevice(deviceId: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
val session = readSession() ?: return@withContext
|
||||
if (url.isBlank()) return@withContext
|
||||
try {
|
||||
getClient(url).revokeDevice(session.token, deviceId)
|
||||
refreshDevices()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
_status.value = _status.value.copy(error = cleanErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearError() {
|
||||
_status.value = _status.value.copy(error = null)
|
||||
}
|
||||
|
||||
private fun startBackgroundWorkers() {
|
||||
stopBackgroundWorkers()
|
||||
realtimeJob = scope.launch {
|
||||
var backoffMs = 2_000L
|
||||
while (isActive && dataStore.syncEnabled.first()) {
|
||||
val url = dataStore.syncServerUrl.first().trim().trimEnd('/')
|
||||
val session = readSession()
|
||||
if (url.isBlank() || session == null) break
|
||||
try {
|
||||
val client = getClient(url)
|
||||
client.observeNotifications(
|
||||
token = session.token,
|
||||
onConnected = {
|
||||
_status.value = _status.value.copy(realtimeConnected = true)
|
||||
backoffMs = 2_000L
|
||||
},
|
||||
onMessage = { raw ->
|
||||
val notif = runCatching {
|
||||
json.decodeFromString<SyncWsNotification>(raw)
|
||||
}.getOrNull()
|
||||
val currentCursor = dataStore.syncCursor.first()
|
||||
if (notif == null || notif.version > currentCursor) {
|
||||
scheduleDebouncedSync()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Logger.w(TAG, "WebSocket disconnected, retrying in ${backoffMs}ms: ${e.message}")
|
||||
} finally {
|
||||
_status.value = _status.value.copy(realtimeConnected = false)
|
||||
}
|
||||
delay(backoffMs)
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(60_000L)
|
||||
}
|
||||
}
|
||||
|
||||
periodicJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(PERIODIC_SYNC_MS)
|
||||
if (dataStore.syncEnabled.first() && readSession() != null) {
|
||||
synchronize()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopBackgroundWorkers() {
|
||||
realtimeJob?.cancel()
|
||||
realtimeJob = null
|
||||
periodicJob?.cancel()
|
||||
periodicJob = null
|
||||
debounceSyncJob?.cancel()
|
||||
debounceSyncJob = null
|
||||
_status.value = _status.value.copy(realtimeConnected = false)
|
||||
}
|
||||
|
||||
private fun scheduleDebouncedSync() {
|
||||
debounceSyncJob?.cancel()
|
||||
debounceSyncJob = scope.launch {
|
||||
delay(1_200L)
|
||||
synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchKeyringBlob(
|
||||
client: SyncClient,
|
||||
token: String,
|
||||
): SyncBlob? {
|
||||
var cursor = 0L
|
||||
do {
|
||||
val response = client.sync(token, SyncRequest(changes = emptyList(), cursor = cursor))
|
||||
val match = response.changes.firstOrNull {
|
||||
it.entityType == SyncEnvelopeHelper.KEYRING_ENTITY_TYPE &&
|
||||
it.entityId == SyncEnvelopeHelper.KEYRING_ENTITY_ID &&
|
||||
!it.deleted
|
||||
}
|
||||
if (match != null) return match
|
||||
cursor = response.cursor
|
||||
} while (response.hasMore)
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun persistSessionAndKeys(
|
||||
session: SyncSession,
|
||||
dek: ByteArray,
|
||||
email: String,
|
||||
deviceName: String,
|
||||
recoveryKey: String?,
|
||||
cursor: Long,
|
||||
) {
|
||||
dataStore.setSyncSession(json.encodeToString(session))
|
||||
dataStore.setSyncDataKey(SyncEnvelopeHelper.encodeKey(dek))
|
||||
dataStore.setSyncEmail(email)
|
||||
dataStore.setSyncDeviceName(deviceName)
|
||||
if (recoveryKey != null) {
|
||||
dataStore.setSyncRecoveryKey(recoveryKey)
|
||||
}
|
||||
dataStore.setSyncCursor(cursor)
|
||||
dataStore.setSyncEnabled(true)
|
||||
}
|
||||
|
||||
private suspend fun getClient(url: String): SyncClient =
|
||||
clientMutex.withLock {
|
||||
val existing = cachedClient
|
||||
if (existing != null && cachedUrl == url) {
|
||||
return@withLock existing
|
||||
}
|
||||
existing?.close()
|
||||
val created = SyncClient(url)
|
||||
cachedUrl = url
|
||||
cachedClient = created
|
||||
created
|
||||
}
|
||||
|
||||
private suspend fun readSession(): SyncSession? =
|
||||
dataStore.syncSession.first()?.let { json.decodeFromString<SyncSession>(it) }
|
||||
dataStore.syncSession.first()?.let {
|
||||
runCatching { json.decodeFromString<SyncSession>(it) }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun readDataKey(): ByteArray? =
|
||||
dataStore.syncDataKey.first()?.let { SyncEnvelopeHelper.decodeKey(it) }
|
||||
|
||||
private fun cleanErrorMessage(e: Exception): String {
|
||||
val raw = e.message ?: "Synchronization failed"
|
||||
return raw.lineSequence().firstOrNull()?.take(180) ?: raw
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,830 @@
|
|||
package com.maxrave.data.repository.sync
|
||||
|
||||
import com.maxrave.data.db.datasource.AnalyticsDatasource
|
||||
import com.maxrave.data.db.datasource.LocalDataSource
|
||||
import com.maxrave.data.io.fileDir
|
||||
import com.maxrave.data.io.fileSystem
|
||||
import com.maxrave.data.loginsync.LoginBundle
|
||||
import com.maxrave.data.loginsync.LoginSyncStore
|
||||
import com.maxrave.data.loginsync.loginSyncJson
|
||||
import com.maxrave.domain.data.entities.AlbumEntity
|
||||
import com.maxrave.domain.data.entities.ArtistEntity
|
||||
import com.maxrave.domain.data.entities.DownloadState
|
||||
import com.maxrave.domain.data.entities.LocalPlaylistEntity
|
||||
import com.maxrave.domain.data.entities.PairSongLocalPlaylist
|
||||
import com.maxrave.domain.data.entities.PlaylistEntity
|
||||
import com.maxrave.domain.data.entities.PodcastsEntity
|
||||
import com.maxrave.domain.data.entities.SearchHistory
|
||||
import com.maxrave.domain.data.entities.SongEntity
|
||||
import com.maxrave.domain.data.model.loginsync.LoginSyncService
|
||||
import com.maxrave.domain.data.model.loginsync.SettingsBundle
|
||||
import com.maxrave.domain.data.model.loginsync.SettingsCategory
|
||||
import com.maxrave.domain.extension.now
|
||||
import com.maxrave.domain.manager.DataStoreManager
|
||||
import com.maxrave.domain.repository.CommonRepository
|
||||
import com.maxrave.logger.Logger
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.Path.Companion.toPath
|
||||
import org.simpmusic.sync.SyncBlob
|
||||
import org.simpmusic.sync.SyncCrypto
|
||||
import org.simpmusic.sync.SyncEnvelopeHelper
|
||||
|
||||
@Serializable
|
||||
internal data class SyncLocalState(
|
||||
val entityHashes: Map<String, String> = emptyMap(),
|
||||
val lastAnalyticsTimestampIso: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncSongWire(
|
||||
val videoId: String,
|
||||
val albumId: String? = null,
|
||||
val albumName: String? = null,
|
||||
val artistId: List<String>? = null,
|
||||
val artistName: List<String>? = null,
|
||||
val duration: String = "",
|
||||
val durationSeconds: Int = 0,
|
||||
val isAvailable: Boolean = true,
|
||||
val isExplicit: Boolean = false,
|
||||
val likeStatus: String = "INDIFFERENT",
|
||||
val thumbnails: String? = null,
|
||||
val title: String = "",
|
||||
val videoType: String = "",
|
||||
val category: String? = null,
|
||||
val resultType: String? = null,
|
||||
val liked: Boolean = false,
|
||||
val totalPlayTime: Long = 0,
|
||||
val favoriteAtIso: String? = null,
|
||||
val inLibraryIso: String? = null,
|
||||
val canvasUrl: String? = null,
|
||||
val canvasThumbUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncPlaylistTrackPairWire(
|
||||
val songId: String,
|
||||
val position: Int,
|
||||
val inPlaylistIso: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncLocalPlaylistWire(
|
||||
val title: String,
|
||||
val thumbnail: String? = null,
|
||||
val inLibraryIso: String? = null,
|
||||
val youtubePlaylistId: String? = null,
|
||||
val syncState: Int = LocalPlaylistEntity.YouTubeSyncState.NotSynced,
|
||||
val tracks: List<String> = emptyList(),
|
||||
val pairs: List<SyncPlaylistTrackPairWire> = emptyList(),
|
||||
val songs: List<SyncSongWire> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncArtistWire(
|
||||
val channelId: String,
|
||||
val name: String,
|
||||
val thumbnails: String? = null,
|
||||
val followed: Boolean = true,
|
||||
val followedAtIso: String? = null,
|
||||
val inLibraryIso: String? = null,
|
||||
val nameLogoUrl: String? = null,
|
||||
val nameLogoColor: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncAlbumWire(
|
||||
val browseId: String,
|
||||
val artistId: List<String?>? = null,
|
||||
val artistName: List<String>? = null,
|
||||
val audioPlaylistId: String = "",
|
||||
val description: String = "",
|
||||
val duration: String? = null,
|
||||
val durationSeconds: Int = 0,
|
||||
val thumbnails: String? = null,
|
||||
val title: String = "",
|
||||
val trackCount: Int = 0,
|
||||
val tracks: List<String>? = null,
|
||||
val type: String = "",
|
||||
val year: String? = null,
|
||||
val liked: Boolean = true,
|
||||
val inLibraryIso: String? = null,
|
||||
val favoriteAtIso: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncRemotePlaylistWire(
|
||||
val id: String,
|
||||
val author: String? = "",
|
||||
val description: String = "",
|
||||
val duration: String = "",
|
||||
val durationSeconds: Int = 0,
|
||||
val privacy: String = "PRIVATE",
|
||||
val thumbnails: String = "",
|
||||
val title: String = "",
|
||||
val trackCount: Int = 0,
|
||||
val tracks: List<String>? = null,
|
||||
val year: String? = null,
|
||||
val liked: Boolean = true,
|
||||
val inLibraryIso: String? = null,
|
||||
val favoriteAtIso: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncPodcastWire(
|
||||
val podcastId: String,
|
||||
val title: String,
|
||||
val authorId: String,
|
||||
val authorName: String,
|
||||
val authorThumbnail: String? = null,
|
||||
val description: String? = null,
|
||||
val thumbnail: String? = null,
|
||||
val isFavorite: Boolean = true,
|
||||
val inLibraryIso: String? = null,
|
||||
val favoriteTimeIso: String? = null,
|
||||
val listEpisodes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncSearchHistoryWire(
|
||||
val query: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SyncAnalyticsEventWire(
|
||||
val timestampIso: String,
|
||||
val videoId: String,
|
||||
val albumBrowseId: String? = null,
|
||||
val durationSecond: Long = 0,
|
||||
val listenedSecond: Long = 0,
|
||||
val channelIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal class SyncPayloadStore(
|
||||
private val dataStore: DataStoreManager,
|
||||
private val localDataSource: LocalDataSource,
|
||||
private val analyticsDatasource: AnalyticsDatasource,
|
||||
commonRepository: CommonRepository,
|
||||
) {
|
||||
private val loginSyncStore = LoginSyncStore(dataStore, localDataSource, commonRepository)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SimpSyncStore"
|
||||
private const val STATE_FILE_NAME = "simpsync_state.json"
|
||||
|
||||
const val TYPE_SETTINGS = "settings"
|
||||
const val ID_SETTINGS = "app_settings"
|
||||
|
||||
const val TYPE_ACCOUNTS = "accounts"
|
||||
const val ID_ACCOUNTS = "service_logins"
|
||||
|
||||
const val TYPE_SONG = "song"
|
||||
const val TYPE_LOCAL_PLAYLIST = "local_playlist"
|
||||
const val TYPE_ARTIST = "artist"
|
||||
const val TYPE_ALBUM = "album"
|
||||
const val TYPE_PLAYLIST = "playlist"
|
||||
const val TYPE_PODCAST = "podcast"
|
||||
const val TYPE_SEARCH_HISTORY = "search_history"
|
||||
const val TYPE_ANALYTICS = "analytics_event"
|
||||
}
|
||||
|
||||
fun loadLocalState(): SyncLocalState {
|
||||
return try {
|
||||
val path = "${fileDir()}/$STATE_FILE_NAME".toPath()
|
||||
val fs = fileSystem()
|
||||
if (!fs.exists(path)) return SyncLocalState()
|
||||
val raw = fs.read(path) { readUtf8() }
|
||||
json.decodeFromString<SyncLocalState>(raw)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Could not load sync state file: ${e.message}")
|
||||
SyncLocalState()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalState(state: SyncLocalState) {
|
||||
try {
|
||||
val path = "${fileDir()}/$STATE_FILE_NAME".toPath()
|
||||
val raw = json.encodeToString(state)
|
||||
fileSystem().write(path) { writeUtf8(raw) }
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Could not save sync state file: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
try {
|
||||
val path = "${fileDir()}/$STATE_FILE_NAME".toPath()
|
||||
val fs = fileSystem()
|
||||
if (fs.exists(path)) fs.delete(path)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun applyRemoteChanges(
|
||||
dek: ByteArray,
|
||||
changes: List<SyncBlob>,
|
||||
currentHashes: MutableMap<String, String>,
|
||||
): String? {
|
||||
var latestAnalyticsIso: String? = null
|
||||
for (blob in changes) {
|
||||
if (blob.entityType == SyncEnvelopeHelper.KEYRING_ENTITY_TYPE) continue
|
||||
val mapKey = "${blob.entityType}:${blob.entityId}"
|
||||
if (blob.deleted) {
|
||||
deleteLocalEntity(blob.entityType, blob.entityId)
|
||||
currentHashes.remove(mapKey)
|
||||
continue
|
||||
}
|
||||
val plaintextBytes = SyncEnvelopeHelper.openFromBase64(
|
||||
key = dek,
|
||||
entityType = blob.entityType,
|
||||
entityId = blob.entityId,
|
||||
envelopeBase64 = blob.envelope,
|
||||
) ?: continue
|
||||
val payloadText = plaintextBytes.decodeToString()
|
||||
try {
|
||||
val appliedCanonical = applyLocalEntity(blob.entityType, blob.entityId, payloadText)
|
||||
val hashTarget = appliedCanonical ?: payloadText
|
||||
currentHashes[mapKey] = SyncCrypto.sha256Hex(hashTarget.encodeToByteArray())
|
||||
if (blob.entityType == TYPE_ANALYTICS) {
|
||||
val event = json.decodeFromString<SyncAnalyticsEventWire>(payloadText)
|
||||
if (latestAnalyticsIso == null || event.timestampIso > latestAnalyticsIso) {
|
||||
latestAnalyticsIso = event.timestampIso
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to apply remote entity $mapKey: ${e.message}")
|
||||
}
|
||||
}
|
||||
return latestAnalyticsIso
|
||||
}
|
||||
|
||||
suspend fun buildPushChanges(
|
||||
dek: ByteArray,
|
||||
state: SyncLocalState,
|
||||
): Pair<List<SyncBlob>, SyncLocalState> {
|
||||
val updatedHashes = state.entityHashes.toMutableMap()
|
||||
val currentSnapshot = collectCurrentSnapshot()
|
||||
val outgoing = mutableListOf<SyncBlob>()
|
||||
|
||||
// 1. New or modified entities
|
||||
for ((key, jsonPayload) in currentSnapshot) {
|
||||
val (entityType, entityId) = splitKey(key) ?: continue
|
||||
val newHash = SyncCrypto.sha256Hex(jsonPayload.encodeToByteArray())
|
||||
if (updatedHashes[key] != newHash) {
|
||||
val envelope = SyncEnvelopeHelper.sealToBase64(
|
||||
key = dek,
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
plaintext = jsonPayload.encodeToByteArray(),
|
||||
)
|
||||
outgoing.add(
|
||||
SyncBlob(
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
envelope = envelope,
|
||||
deleted = false,
|
||||
),
|
||||
)
|
||||
updatedHashes[key] = newHash
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Deleted entities (present in previous state, absent in current snapshot, excluding append-only analytics)
|
||||
val removedKeys = updatedHashes.keys.filter { key ->
|
||||
!key.startsWith("$TYPE_ANALYTICS:") && key !in currentSnapshot
|
||||
}
|
||||
for (key in removedKeys) {
|
||||
val (entityType, entityId) = splitKey(key) ?: continue
|
||||
val tombstoneEnvelope = SyncEnvelopeHelper.sealToBase64(
|
||||
key = dek,
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
plaintext = "{}".encodeToByteArray(),
|
||||
)
|
||||
outgoing.add(
|
||||
SyncBlob(
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
envelope = tombstoneEnvelope,
|
||||
deleted = true,
|
||||
),
|
||||
)
|
||||
updatedHashes.remove(key)
|
||||
}
|
||||
|
||||
// 3. Append-only analytics events since lastAnalyticsTimestampIso
|
||||
var newestAnalyticsIso = state.lastAnalyticsTimestampIso
|
||||
val recentEvents = if (newestAnalyticsIso != null) {
|
||||
val cutoff = runCatching { LocalDateTime.parse(newestAnalyticsIso) }.getOrNull()
|
||||
if (cutoff != null) {
|
||||
analyticsDatasource.getPlaybackEventsByOffsetAndTimestamp(0, 500, cutoff)
|
||||
} else {
|
||||
analyticsDatasource.getPlaybackEventsByOffset(0, 500)
|
||||
}
|
||||
} else {
|
||||
analyticsDatasource.getPlaybackEventsByOffset(0, 500)
|
||||
}
|
||||
|
||||
for (event in recentEvents.asReversed()) {
|
||||
val iso = event.timestamp.toString()
|
||||
val eventId = analyticsEntityId(iso, event.videoId)
|
||||
val mapKey = "$TYPE_ANALYTICS:$eventId"
|
||||
if (mapKey in updatedHashes) {
|
||||
if (newestAnalyticsIso == null || iso > newestAnalyticsIso) newestAnalyticsIso = iso
|
||||
continue
|
||||
}
|
||||
val channels = analyticsDatasource.getEventArtistChannelIds(event.eventId)
|
||||
val wire = SyncAnalyticsEventWire(
|
||||
timestampIso = iso,
|
||||
videoId = event.videoId,
|
||||
albumBrowseId = event.albumBrowseId,
|
||||
durationSecond = event.durationSecond,
|
||||
listenedSecond = event.listenedSecond,
|
||||
channelIds = channels,
|
||||
)
|
||||
val jsonPayload = json.encodeToString(wire)
|
||||
val hash = SyncCrypto.sha256Hex(jsonPayload.encodeToByteArray())
|
||||
val envelope = SyncEnvelopeHelper.sealToBase64(
|
||||
key = dek,
|
||||
entityType = TYPE_ANALYTICS,
|
||||
entityId = eventId,
|
||||
plaintext = jsonPayload.encodeToByteArray(),
|
||||
)
|
||||
outgoing.add(
|
||||
SyncBlob(
|
||||
entityType = TYPE_ANALYTICS,
|
||||
entityId = eventId,
|
||||
envelope = envelope,
|
||||
deleted = false,
|
||||
),
|
||||
)
|
||||
updatedHashes[mapKey] = hash
|
||||
if (newestAnalyticsIso == null || iso > newestAnalyticsIso) {
|
||||
newestAnalyticsIso = iso
|
||||
}
|
||||
}
|
||||
|
||||
val newState = SyncLocalState(
|
||||
entityHashes = updatedHashes,
|
||||
lastAnalyticsTimestampIso = newestAnalyticsIso,
|
||||
)
|
||||
return outgoing to newState
|
||||
}
|
||||
|
||||
private suspend fun collectCurrentSnapshot(): Map<String, String> {
|
||||
val snapshot = linkedMapOf<String, String>()
|
||||
|
||||
// Settings
|
||||
val settingsBundle = loginSyncStore.bundleSettings(SettingsCategory.entries.toSet())
|
||||
snapshot["$TYPE_SETTINGS:$ID_SETTINGS"] = loginSyncJson.encodeToString(settingsBundle)
|
||||
|
||||
// Accounts
|
||||
val signedIn = loginSyncStore.signedIn().toSet()
|
||||
if (signedIn.isNotEmpty()) {
|
||||
val loginBundle = loginSyncStore.bundle(signedIn)
|
||||
snapshot["$TYPE_ACCOUNTS:$ID_ACCOUNTS"] = loginSyncJson.encodeToString(loginBundle)
|
||||
}
|
||||
|
||||
// Songs (liked songs + played songs in library)
|
||||
val likedSongs = localDataSource.getLikedSongs(5000, 0)
|
||||
val allSongs = localDataSource.getAllSongs(5000)
|
||||
val relevantSongs = (likedSongs + allSongs.filter { it.liked || it.totalPlayTime > 0 })
|
||||
.associateBy { it.videoId }
|
||||
.values
|
||||
for (song in relevantSongs) {
|
||||
if (song.videoId.isBlank()) continue
|
||||
snapshot["$TYPE_SONG:${song.videoId}"] = json.encodeToString(song.toWire())
|
||||
}
|
||||
|
||||
// Local Playlists
|
||||
val localPlaylists = localDataSource.getAllLocalPlaylists(500, 0)
|
||||
for (playlist in localPlaylists) {
|
||||
val playlistKey = localPlaylistEntityId(playlist)
|
||||
val pairs = localDataSource.getAllPlaylistPairSongByPosition(playlist.id) ?: emptyList()
|
||||
val trackIds = (playlist.tracks.orEmpty() + pairs.map { it.songId }).distinct()
|
||||
val songEntities = if (trackIds.isNotEmpty()) {
|
||||
localDataSource.getSongByListVideoIdFull(trackIds)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val wire = SyncLocalPlaylistWire(
|
||||
title = playlist.title,
|
||||
thumbnail = playlist.thumbnail,
|
||||
inLibraryIso = playlist.inLibrary.toString(),
|
||||
youtubePlaylistId = playlist.youtubePlaylistId,
|
||||
syncState = playlist.syncState,
|
||||
tracks = playlist.tracks ?: pairs.sortedBy { it.position }.map { it.songId },
|
||||
pairs = pairs.map {
|
||||
SyncPlaylistTrackPairWire(
|
||||
songId = it.songId,
|
||||
position = it.position,
|
||||
inPlaylistIso = it.inPlaylist.toString(),
|
||||
)
|
||||
},
|
||||
songs = songEntities.map { it.toWire() },
|
||||
)
|
||||
snapshot["$TYPE_LOCAL_PLAYLIST:$playlistKey"] = json.encodeToString(wire)
|
||||
}
|
||||
|
||||
// Followed Artists
|
||||
val followedArtists = localDataSource.getFollowedArtists(2000, 0)
|
||||
for (artist in followedArtists) {
|
||||
if (artist.channelId.isBlank()) continue
|
||||
val wire = SyncArtistWire(
|
||||
channelId = artist.channelId,
|
||||
name = artist.name,
|
||||
thumbnails = artist.thumbnails,
|
||||
followed = artist.followed,
|
||||
followedAtIso = artist.followedAt?.toString(),
|
||||
inLibraryIso = artist.inLibrary.toString(),
|
||||
nameLogoUrl = artist.nameLogoUrl,
|
||||
nameLogoColor = artist.nameLogoColor,
|
||||
)
|
||||
snapshot["$TYPE_ARTIST:${artist.channelId}"] = json.encodeToString(wire)
|
||||
}
|
||||
|
||||
// Liked Albums
|
||||
val likedAlbums = localDataSource.getLikedAlbums(2000, 0)
|
||||
for (album in likedAlbums) {
|
||||
if (album.browseId.isBlank()) continue
|
||||
val wire = SyncAlbumWire(
|
||||
browseId = album.browseId,
|
||||
artistId = album.artistId,
|
||||
artistName = album.artistName,
|
||||
audioPlaylistId = album.audioPlaylistId,
|
||||
description = album.description,
|
||||
duration = album.duration,
|
||||
durationSeconds = album.durationSeconds,
|
||||
thumbnails = album.thumbnails,
|
||||
title = album.title,
|
||||
trackCount = album.trackCount,
|
||||
tracks = album.tracks,
|
||||
type = album.type,
|
||||
year = album.year,
|
||||
liked = album.liked,
|
||||
inLibraryIso = album.inLibrary.toString(),
|
||||
favoriteAtIso = album.favoriteAt?.toString(),
|
||||
)
|
||||
snapshot["$TYPE_ALBUM:${album.browseId}"] = json.encodeToString(wire)
|
||||
}
|
||||
|
||||
// Liked Remote Playlists
|
||||
val likedPlaylists = localDataSource.getLikedPlaylists(2000, 0)
|
||||
for (playlist in likedPlaylists) {
|
||||
if (playlist.id.isBlank()) continue
|
||||
val wire = SyncRemotePlaylistWire(
|
||||
id = playlist.id,
|
||||
author = playlist.author,
|
||||
description = playlist.description,
|
||||
duration = playlist.duration,
|
||||
durationSeconds = playlist.durationSeconds,
|
||||
privacy = playlist.privacy,
|
||||
thumbnails = playlist.thumbnails,
|
||||
title = playlist.title,
|
||||
trackCount = playlist.trackCount,
|
||||
tracks = playlist.tracks,
|
||||
year = playlist.year,
|
||||
liked = playlist.liked,
|
||||
inLibraryIso = playlist.inLibrary.toString(),
|
||||
favoriteAtIso = playlist.favoriteAt?.toString(),
|
||||
)
|
||||
snapshot["$TYPE_PLAYLIST:${playlist.id}"] = json.encodeToString(wire)
|
||||
}
|
||||
|
||||
// Favorite Podcasts
|
||||
val favPodcasts = localDataSource.getFavoritePodcasts(500, 0)
|
||||
for (podcast in favPodcasts) {
|
||||
if (podcast.podcastId.isBlank()) continue
|
||||
val wire = SyncPodcastWire(
|
||||
podcastId = podcast.podcastId,
|
||||
title = podcast.title,
|
||||
authorId = podcast.authorId,
|
||||
authorName = podcast.authorName,
|
||||
authorThumbnail = podcast.authorThumbnail,
|
||||
description = podcast.description,
|
||||
thumbnail = podcast.thumbnail,
|
||||
isFavorite = podcast.isFavorite,
|
||||
inLibraryIso = podcast.inLibrary.toString(),
|
||||
favoriteTimeIso = podcast.favoriteTime?.toString(),
|
||||
listEpisodes = podcast.listEpisodes,
|
||||
)
|
||||
snapshot["$TYPE_PODCAST:${podcast.podcastId}"] = json.encodeToString(wire)
|
||||
}
|
||||
|
||||
// Search History
|
||||
val searches = localDataSource.getSearchHistory()
|
||||
for (search in searches) {
|
||||
if (search.query.isBlank()) continue
|
||||
val id = SyncCrypto.sha256Hex(search.query.trim().lowercase().encodeToByteArray()).take(32)
|
||||
snapshot["$TYPE_SEARCH_HISTORY:$id"] = json.encodeToString(SyncSearchHistoryWire(search.query))
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private suspend fun applyLocalEntity(
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
payloadText: String,
|
||||
): String? {
|
||||
return when (entityType) {
|
||||
TYPE_SETTINGS -> {
|
||||
val bundle = loginSyncJson.decodeFromString<SettingsBundle>(payloadText)
|
||||
loginSyncStore.restoreSettings(bundle)
|
||||
val current = loginSyncStore.bundleSettings(SettingsCategory.entries.toSet())
|
||||
loginSyncJson.encodeToString(current)
|
||||
}
|
||||
TYPE_ACCOUNTS -> {
|
||||
val bundle = loginSyncJson.decodeFromString<LoginBundle>(payloadText)
|
||||
loginSyncStore.restore(bundle)
|
||||
val signedIn = loginSyncStore.signedIn().toSet()
|
||||
if (signedIn.isNotEmpty()) loginSyncJson.encodeToString(loginSyncStore.bundle(signedIn)) else payloadText
|
||||
}
|
||||
TYPE_SONG -> {
|
||||
val wire = json.decodeFromString<SyncSongWire>(payloadText)
|
||||
val existing = localDataSource.getSong(wire.videoId)
|
||||
val mergedPlayTime = maxOf(existing?.totalPlayTime ?: 0L, wire.totalPlayTime)
|
||||
val entity = wire.toEntity(existing, mergedPlayTime)
|
||||
localDataSource.insertSong(entity)
|
||||
localDataSource.updateLiked(if (wire.liked) 1 else 0, wire.videoId)
|
||||
if (mergedPlayTime > 0) {
|
||||
localDataSource.updateTotalPlayTimeIfGreater(wire.videoId, mergedPlayTime)
|
||||
}
|
||||
localDataSource.getSong(wire.videoId)?.let { json.encodeToString(it.toWire()) }
|
||||
}
|
||||
TYPE_LOCAL_PLAYLIST -> {
|
||||
val wire = json.decodeFromString<SyncLocalPlaylistWire>(payloadText)
|
||||
if (wire.songs.isNotEmpty()) {
|
||||
localDataSource.insertSongs(wire.songs.map { it.toEntity(null, it.totalPlayTime) })
|
||||
}
|
||||
val existingAll = localDataSource.getAllLocalPlaylists(500, 0)
|
||||
val existing = existingAll.firstOrNull {
|
||||
(!wire.youtubePlaylistId.isNullOrBlank() && it.youtubePlaylistId == wire.youtubePlaylistId) ||
|
||||
localPlaylistEntityId(it) == entityId ||
|
||||
it.title.equals(wire.title, ignoreCase = true)
|
||||
}
|
||||
val playlistId = if (existing != null) {
|
||||
localDataSource.updateLocalPlaylistTitle(wire.title, existing.id)
|
||||
wire.thumbnail?.let { localDataSource.updateLocalPlaylistThumbnail(it, existing.id) }
|
||||
localDataSource.updateLocalPlaylistTracks(wire.tracks, existing.id)
|
||||
if (wire.youtubePlaylistId != null) {
|
||||
localDataSource.updateLocalPlaylistYouTubePlaylistId(existing.id, wire.youtubePlaylistId)
|
||||
localDataSource.updateLocalPlaylistYouTubePlaylistSyncState(existing.id, wire.syncState)
|
||||
}
|
||||
existing.id
|
||||
} else {
|
||||
localDataSource.insertLocalPlaylist(
|
||||
LocalPlaylistEntity(
|
||||
title = wire.title,
|
||||
thumbnail = wire.thumbnail,
|
||||
inLibrary = parseDateTimeOrNow(wire.inLibraryIso),
|
||||
youtubePlaylistId = wire.youtubePlaylistId,
|
||||
syncState = wire.syncState,
|
||||
tracks = wire.tracks,
|
||||
),
|
||||
)
|
||||
val afterInsert = localDataSource.getAllLocalPlaylists(500, 0)
|
||||
afterInsert.firstOrNull { it.title == wire.title }?.id ?: return payloadText
|
||||
}
|
||||
val pairsToInsert = if (wire.pairs.isNotEmpty()) {
|
||||
wire.pairs.map {
|
||||
PairSongLocalPlaylist(
|
||||
playlistId = playlistId,
|
||||
songId = it.songId,
|
||||
position = it.position,
|
||||
inPlaylist = parseDateTimeOrNow(it.inPlaylistIso),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
wire.tracks.mapIndexed { index, songId ->
|
||||
PairSongLocalPlaylist(
|
||||
playlistId = playlistId,
|
||||
songId = songId,
|
||||
position = index,
|
||||
inPlaylist = now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pairsToInsert.isNotEmpty()) {
|
||||
localDataSource.insertPairSongLocalPlaylists(pairsToInsert)
|
||||
}
|
||||
payloadText
|
||||
}
|
||||
TYPE_ARTIST -> {
|
||||
val wire = json.decodeFromString<SyncArtistWire>(payloadText)
|
||||
localDataSource.insertArtist(
|
||||
ArtistEntity(
|
||||
channelId = wire.channelId,
|
||||
name = wire.name,
|
||||
thumbnails = wire.thumbnails,
|
||||
followed = wire.followed,
|
||||
followedAt = wire.followedAtIso?.let { parseDateTimeOrNow(it) },
|
||||
inLibrary = parseDateTimeOrNow(wire.inLibraryIso),
|
||||
nameLogoUrl = wire.nameLogoUrl,
|
||||
nameLogoColor = wire.nameLogoColor,
|
||||
),
|
||||
)
|
||||
localDataSource.updateFollowed(if (wire.followed) 1 else 0, wire.channelId)
|
||||
payloadText
|
||||
}
|
||||
TYPE_ALBUM -> {
|
||||
val wire = json.decodeFromString<SyncAlbumWire>(payloadText)
|
||||
localDataSource.insertAlbum(
|
||||
AlbumEntity(
|
||||
browseId = wire.browseId,
|
||||
artistId = wire.artistId,
|
||||
artistName = wire.artistName,
|
||||
audioPlaylistId = wire.audioPlaylistId,
|
||||
description = wire.description,
|
||||
duration = wire.duration,
|
||||
durationSeconds = wire.durationSeconds,
|
||||
thumbnails = wire.thumbnails,
|
||||
title = wire.title,
|
||||
trackCount = wire.trackCount,
|
||||
tracks = wire.tracks,
|
||||
type = wire.type,
|
||||
year = wire.year,
|
||||
liked = wire.liked,
|
||||
inLibrary = parseDateTimeOrNow(wire.inLibraryIso),
|
||||
favoriteAt = wire.favoriteAtIso?.let { parseDateTimeOrNow(it) },
|
||||
),
|
||||
)
|
||||
localDataSource.updateAlbumLiked(if (wire.liked) 1 else 0, wire.browseId)
|
||||
payloadText
|
||||
}
|
||||
TYPE_PLAYLIST -> {
|
||||
val wire = json.decodeFromString<SyncRemotePlaylistWire>(payloadText)
|
||||
localDataSource.insertPlaylist(
|
||||
PlaylistEntity(
|
||||
id = wire.id,
|
||||
author = wire.author,
|
||||
description = wire.description,
|
||||
duration = wire.duration,
|
||||
durationSeconds = wire.durationSeconds,
|
||||
privacy = wire.privacy,
|
||||
thumbnails = wire.thumbnails,
|
||||
title = wire.title,
|
||||
trackCount = wire.trackCount,
|
||||
tracks = wire.tracks,
|
||||
year = wire.year,
|
||||
liked = wire.liked,
|
||||
inLibrary = parseDateTimeOrNow(wire.inLibraryIso),
|
||||
favoriteAt = wire.favoriteAtIso?.let { parseDateTimeOrNow(it) },
|
||||
),
|
||||
)
|
||||
localDataSource.updatePlaylistLiked(if (wire.liked) 1 else 0, wire.id)
|
||||
payloadText
|
||||
}
|
||||
TYPE_PODCAST -> {
|
||||
val wire = json.decodeFromString<SyncPodcastWire>(payloadText)
|
||||
localDataSource.insertPodcast(
|
||||
PodcastsEntity(
|
||||
podcastId = wire.podcastId,
|
||||
title = wire.title,
|
||||
authorId = wire.authorId,
|
||||
authorName = wire.authorName,
|
||||
authorThumbnail = wire.authorThumbnail,
|
||||
description = wire.description,
|
||||
thumbnail = wire.thumbnail,
|
||||
isFavorite = wire.isFavorite,
|
||||
inLibrary = parseDateTimeOrNow(wire.inLibraryIso),
|
||||
favoriteTime = wire.favoriteTimeIso?.let { parseDateTimeOrNow(it) },
|
||||
listEpisodes = wire.listEpisodes,
|
||||
),
|
||||
)
|
||||
payloadText
|
||||
}
|
||||
TYPE_SEARCH_HISTORY -> {
|
||||
val wire = json.decodeFromString<SyncSearchHistoryWire>(payloadText)
|
||||
if (wire.query.isNotBlank()) {
|
||||
localDataSource.insertSearchHistory(SearchHistory(wire.query))
|
||||
}
|
||||
payloadText
|
||||
}
|
||||
TYPE_ANALYTICS -> {
|
||||
val wire = json.decodeFromString<SyncAnalyticsEventWire>(payloadText)
|
||||
val ts = parseDateTimeOrNow(wire.timestampIso)
|
||||
analyticsDatasource.insertSyncedPlaybackEvent(
|
||||
timestamp = ts,
|
||||
videoId = wire.videoId,
|
||||
channelIds = wire.channelIds,
|
||||
albumBrowseId = wire.albumBrowseId,
|
||||
durationSecond = wire.durationSecond,
|
||||
listenedSecond = wire.listenedSecond,
|
||||
)
|
||||
payloadText
|
||||
}
|
||||
else -> payloadText
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteLocalEntity(
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
) {
|
||||
try {
|
||||
when (entityType) {
|
||||
TYPE_SONG -> localDataSource.updateLiked(0, entityId)
|
||||
TYPE_LOCAL_PLAYLIST -> {
|
||||
val playlists = localDataSource.getAllLocalPlaylists(500, 0)
|
||||
playlists.firstOrNull { localPlaylistEntityId(it) == entityId }?.let {
|
||||
localDataSource.deleteLocalPlaylist(it.id)
|
||||
}
|
||||
}
|
||||
TYPE_ARTIST -> localDataSource.updateFollowed(0, entityId)
|
||||
TYPE_ALBUM -> localDataSource.updateAlbumLiked(0, entityId)
|
||||
TYPE_PLAYLIST -> localDataSource.updatePlaylistLiked(0, entityId)
|
||||
TYPE_PODCAST -> localDataSource.favoritePodcast(entityId, false)
|
||||
TYPE_SEARCH_HISTORY -> {
|
||||
val searches = localDataSource.getSearchHistory()
|
||||
searches.firstOrNull {
|
||||
SyncCrypto.sha256Hex(it.query.trim().lowercase().encodeToByteArray()).take(32) == entityId
|
||||
}?.let { localDataSource.deleteSearchHistoryItem(it.query) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to delete local entity $entityType:$entityId: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun localPlaylistEntityId(playlist: LocalPlaylistEntity): String {
|
||||
val raw = playlist.youtubePlaylistId?.takeIf { it.isNotBlank() }
|
||||
?: "local:${playlist.title.trim().lowercase()}"
|
||||
return SyncCrypto.sha256Hex(raw.encodeToByteArray()).take(32)
|
||||
}
|
||||
|
||||
private fun analyticsEntityId(timestampIso: String, videoId: String): String =
|
||||
SyncCrypto.sha256Hex("$timestampIso|$videoId".encodeToByteArray()).take(40)
|
||||
|
||||
private fun splitKey(key: String): Pair<String, String>? {
|
||||
val idx = key.indexOf(':')
|
||||
if (idx <= 0 || idx >= key.length - 1) return null
|
||||
return key.substring(0, idx) to key.substring(idx + 1)
|
||||
}
|
||||
|
||||
private fun parseDateTimeOrNow(iso: String?): LocalDateTime =
|
||||
if (iso.isNullOrBlank()) {
|
||||
now()
|
||||
} else {
|
||||
runCatching { LocalDateTime.parse(iso) }.getOrElse { now() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SongEntity.toWire() =
|
||||
SyncSongWire(
|
||||
videoId = videoId,
|
||||
albumId = albumId,
|
||||
albumName = albumName,
|
||||
artistId = artistId,
|
||||
artistName = artistName,
|
||||
duration = duration,
|
||||
durationSeconds = durationSeconds,
|
||||
isAvailable = isAvailable,
|
||||
isExplicit = isExplicit,
|
||||
likeStatus = likeStatus,
|
||||
thumbnails = thumbnails,
|
||||
title = title,
|
||||
videoType = videoType,
|
||||
category = category,
|
||||
resultType = resultType,
|
||||
liked = liked,
|
||||
totalPlayTime = totalPlayTime,
|
||||
favoriteAtIso = favoriteAt?.toString(),
|
||||
inLibraryIso = inLibrary.toString(),
|
||||
canvasUrl = canvasUrl,
|
||||
canvasThumbUrl = canvasThumbUrl,
|
||||
)
|
||||
|
||||
private fun SyncSongWire.toEntity(existing: SongEntity?, mergedPlayTime: Long) =
|
||||
SongEntity(
|
||||
videoId = videoId,
|
||||
albumId = albumId ?: existing?.albumId,
|
||||
albumName = albumName ?: existing?.albumName,
|
||||
artistId = artistId ?: existing?.artistId,
|
||||
artistName = artistName ?: existing?.artistName,
|
||||
duration = duration.ifBlank { existing?.duration.orEmpty() },
|
||||
durationSeconds = if (durationSeconds > 0) durationSeconds else (existing?.durationSeconds ?: 0),
|
||||
isAvailable = isAvailable,
|
||||
isExplicit = isExplicit,
|
||||
likeStatus = if (liked) "LIKE" else likeStatus,
|
||||
thumbnails = thumbnails ?: existing?.thumbnails,
|
||||
title = title.ifBlank { existing?.title.orEmpty() },
|
||||
videoType = videoType.ifBlank { existing?.videoType.orEmpty() },
|
||||
category = category ?: existing?.category,
|
||||
resultType = resultType ?: existing?.resultType,
|
||||
liked = liked,
|
||||
totalPlayTime = mergedPlayTime,
|
||||
downloadState = existing?.downloadState ?: DownloadState.STATE_NOT_DOWNLOADED,
|
||||
favoriteAt = favoriteAtIso?.let { runCatching { LocalDateTime.parse(it) }.getOrNull() } ?: existing?.favoriteAt ?: now(),
|
||||
downloadedAt = existing?.downloadedAt ?: now(),
|
||||
inLibrary = inLibraryIso?.let { runCatching { LocalDateTime.parse(it) }.getOrNull() } ?: existing?.inLibrary ?: now(),
|
||||
canvasUrl = canvasUrl ?: existing?.canvasUrl,
|
||||
canvasThumbUrl = canvasThumbUrl ?: existing?.canvasThumbUrl,
|
||||
)
|
||||
|
|
@ -14,6 +14,24 @@ interface DataStoreManager {
|
|||
val syncSession: Flow<String?>
|
||||
suspend fun setSyncSession(session: String?)
|
||||
|
||||
val syncCursor: Flow<Long>
|
||||
suspend fun setSyncCursor(cursor: Long)
|
||||
|
||||
val syncDataKey: Flow<String?>
|
||||
suspend fun setSyncDataKey(key: String?)
|
||||
|
||||
val syncEmail: Flow<String?>
|
||||
suspend fun setSyncEmail(email: String?)
|
||||
|
||||
val syncDeviceName: Flow<String?>
|
||||
suspend fun setSyncDeviceName(deviceName: String?)
|
||||
|
||||
val syncRecoveryKey: Flow<String?>
|
||||
suspend fun setSyncRecoveryKey(recoveryKey: String?)
|
||||
|
||||
val syncLastSyncedAt: Flow<Long>
|
||||
suspend fun setSyncLastSyncedAt(epochMillis: Long)
|
||||
|
||||
val appVersion: Flow<String>
|
||||
|
||||
suspend fun setAppVersion(version: String)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,26 @@ package com.maxrave.domain.repository
|
|||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class SyncDeviceInfo(
|
||||
val id: String,
|
||||
val deviceName: String,
|
||||
val createdAt: String,
|
||||
val lastSeenAt: String,
|
||||
val current: Boolean = false,
|
||||
)
|
||||
|
||||
data class SyncStatus(
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val authenticated: Boolean = false,
|
||||
val realtimeConnected: Boolean = false,
|
||||
val syncing: Boolean = false,
|
||||
val serverUrl: String = "https://sync.muriloscolari.me",
|
||||
val email: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val recoveryKey: String? = null,
|
||||
val lastSyncedAt: Long = 0L,
|
||||
val devices: List<SyncDeviceInfo> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
|
|
@ -14,13 +30,19 @@ interface SyncRepository {
|
|||
|
||||
suspend fun configure(serverUrl: String, enabled: Boolean)
|
||||
|
||||
suspend fun register(email: String, authProof: String, deviceName: String)
|
||||
suspend fun register(email: String, password: String, deviceName: String): String
|
||||
|
||||
suspend fun login(email: String, authProof: String, deviceName: String)
|
||||
suspend fun login(email: String, password: String, deviceName: String)
|
||||
|
||||
suspend fun recover(email: String, recoveryKeyHash: String, newAuthProof: String, deviceName: String)
|
||||
suspend fun recover(email: String, recoveryKey: String, newPassword: String, deviceName: String)
|
||||
|
||||
suspend fun logout()
|
||||
|
||||
suspend fun synchronize()
|
||||
|
||||
suspend fun refreshDevices()
|
||||
|
||||
suspend fun revokeDevice(deviceId: String)
|
||||
|
||||
fun clearError()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
package org.simpmusic.sync
|
||||
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object SyncCrypto {
|
||||
const val VERSION: Byte = 1
|
||||
actual object SyncCrypto {
|
||||
actual const val VERSION: Byte = 1
|
||||
private const val KEY_BYTES = 32
|
||||
private const val NONCE_BYTES = 12
|
||||
private const val TAG_BITS = 128
|
||||
private const val PBKDF2_ITERATIONS = 120_000
|
||||
private const val BASE32_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun newKey(): ByteArray = ByteArray(KEY_BYTES).also(random::nextBytes)
|
||||
actual fun newKey(): ByteArray = ByteArray(KEY_BYTES).also(random::nextBytes)
|
||||
|
||||
fun seal(key: ByteArray, associatedData: ByteArray, plaintext: ByteArray): ByteArray {
|
||||
actual fun seal(key: ByteArray, associatedData: ByteArray, plaintext: ByteArray): ByteArray {
|
||||
require(key.size == KEY_BYTES) { "data key must be 256 bits" }
|
||||
val nonce = ByteArray(NONCE_BYTES).also(random::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
|
@ -24,8 +30,8 @@ object SyncCrypto {
|
|||
return byteArrayOf(VERSION) + nonce + cipher.doFinal(plaintext)
|
||||
}
|
||||
|
||||
fun open(key: ByteArray, associatedData: ByteArray, envelope: ByteArray): ByteArray? {
|
||||
if (key.size != KEY_BYTES || envelope.size < 1 + NONCE_BYTES + TAG_BITS / 8) return null
|
||||
actual fun open(key: ByteArray, associatedData: ByteArray, envelope: ByteArray): ByteArray? {
|
||||
if (key.size != KEY_BYTES || envelope.size < 1 + NONCE_BYTES + TAG_BITS / 8 || envelope[0] != VERSION) return null
|
||||
return try {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, envelope, 1, NONCE_BYTES))
|
||||
|
|
@ -35,4 +41,52 @@ object SyncCrypto {
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun deriveMasterCredentials(email: String, password: String): DerivedCredentials {
|
||||
val normalizedEmail = email.trim().lowercase()
|
||||
require(normalizedEmail.isNotEmpty()) { "email is required" }
|
||||
require(password.length >= 8) { "password must be at least 8 characters" }
|
||||
val salt = "simpsync-master-kdf-v1:$normalizedEmail".toByteArray(Charsets.UTF_8)
|
||||
val derived = pbkdf2(password.toCharArray(), salt, KEY_BYTES * 2)
|
||||
val authBytes = derived.copyOfRange(0, KEY_BYTES)
|
||||
val wrapBytes = derived.copyOfRange(KEY_BYTES, KEY_BYTES * 2)
|
||||
val authProof = Base64.getUrlEncoder().withoutPadding().encodeToString(authBytes)
|
||||
return DerivedCredentials(authProof = authProof, wrappingKey = wrapBytes)
|
||||
}
|
||||
|
||||
actual fun generateRecoveryKey(): String {
|
||||
val indices = ByteArray(32).also(random::nextBytes)
|
||||
val sb = StringBuilder(39)
|
||||
for (i in 0 until 32) {
|
||||
if (i > 0 && i % 4 == 0) sb.append('-')
|
||||
val idx = (indices[i].toInt() and 0xFF) % BASE32_ALPHABET.length
|
||||
sb.append(BASE32_ALPHABET[idx])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
actual fun deriveRecovery(recoveryKey: String): DerivedRecovery {
|
||||
val normalized = recoveryKey.uppercase().replace("-", "").replace(" ", "").trim()
|
||||
require(normalized.length >= 16) { "invalid recovery key" }
|
||||
val salt = "simpsync-recovery-kdf-v1".toByteArray(Charsets.UTF_8)
|
||||
val derived = pbkdf2(normalized.toCharArray(), salt, KEY_BYTES * 2)
|
||||
val hashBytes = derived.copyOfRange(0, KEY_BYTES)
|
||||
val wrapBytes = derived.copyOfRange(KEY_BYTES, KEY_BYTES * 2)
|
||||
val recoveryHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes)
|
||||
return DerivedRecovery(recoveryKeyHash = recoveryHash, wrappingKey = wrapBytes)
|
||||
}
|
||||
|
||||
actual fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun pbkdf2(chars: CharArray, salt: ByteArray, byteLength: Int): ByteArray {
|
||||
val spec = PBEKeySpec(chars, salt, PBKDF2_ITERATIONS, byteLength * 8)
|
||||
return try {
|
||||
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,27 +4,33 @@ import com.maxrave.ktorext.getEngine
|
|||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class SyncClient(
|
||||
private val baseUrl: String,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val http = HttpClient(getEngine()) {
|
||||
install(ContentNegotiation) { json(json) }
|
||||
install(WebSockets)
|
||||
|
|
@ -47,7 +53,7 @@ class SyncClient(
|
|||
val response = http.post("$baseUrl/v1/auth/logout") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
if (response.status != HttpStatusCode.NoContent) {
|
||||
if (response.status != HttpStatusCode.NoContent && response.status != HttpStatusCode.Unauthorized) {
|
||||
error("logout failed with HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +68,15 @@ class SyncClient(
|
|||
return response.body()
|
||||
}
|
||||
|
||||
suspend fun revokeDevice(token: String, deviceId: String) {
|
||||
val response = http.delete("$baseUrl/v1/devices/$deviceId") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
if (response.status != HttpStatusCode.NoContent) {
|
||||
error("device revocation failed with HTTP ${response.status.value}: ${response.bodyAsText()}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectNotifications(token: String): WebSocketSession {
|
||||
val websocketUrl = baseUrl
|
||||
.replaceFirst("https://", "wss://")
|
||||
|
|
@ -78,7 +93,26 @@ class SyncClient(
|
|||
else -> ""
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
suspend fun observeNotifications(
|
||||
token: String,
|
||||
onConnected: () -> Unit = {},
|
||||
onMessage: suspend (String) -> Unit,
|
||||
) {
|
||||
val session = connectNotifications(token)
|
||||
onConnected()
|
||||
try {
|
||||
while (true) {
|
||||
val text = nextNotification(session) ?: break
|
||||
if (text.isNotBlank()) {
|
||||
onMessage(text)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { session.close() }
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
http.close()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
package org.simpmusic.sync
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
expect object SyncCrypto {
|
||||
val VERSION: Byte
|
||||
|
||||
fun newKey(): ByteArray
|
||||
|
||||
fun seal(key: ByteArray, associatedData: ByteArray, plaintext: ByteArray): ByteArray
|
||||
|
||||
fun open(key: ByteArray, associatedData: ByteArray, envelope: ByteArray): ByteArray?
|
||||
|
||||
fun deriveMasterCredentials(email: String, password: String): DerivedCredentials
|
||||
|
||||
fun generateRecoveryKey(): String
|
||||
|
||||
fun deriveRecovery(recoveryKey: String): DerivedRecovery
|
||||
|
||||
fun sha256Hex(data: ByteArray): String
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
object SyncEnvelopeHelper {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
const val KEYRING_ENTITY_TYPE = "crypto"
|
||||
const val KEYRING_ENTITY_ID = "keyring"
|
||||
private const val MASTER_WRAP_AAD = "crypto/keyring/master"
|
||||
private const val RECOVERY_WRAP_AAD = "crypto/keyring/recovery"
|
||||
|
||||
fun aad(entityType: String, entityId: String): ByteArray =
|
||||
"$entityType/$entityId".encodeToByteArray()
|
||||
|
||||
fun sealToBase64(
|
||||
key: ByteArray,
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
plaintext: ByteArray,
|
||||
): String {
|
||||
val sealed = SyncCrypto.seal(key, aad(entityType, entityId), plaintext)
|
||||
return Base64.encode(sealed)
|
||||
}
|
||||
|
||||
fun openFromBase64(
|
||||
key: ByteArray,
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
envelopeBase64: String,
|
||||
): ByteArray? {
|
||||
if (envelopeBase64.isBlank()) return null
|
||||
val bytes = try {
|
||||
Base64.decode(envelopeBase64)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return null
|
||||
}
|
||||
return SyncCrypto.open(key, aad(entityType, entityId), bytes)
|
||||
}
|
||||
|
||||
fun encodeKey(key: ByteArray): String = Base64.encode(key)
|
||||
|
||||
fun decodeKey(encoded: String): ByteArray? =
|
||||
try {
|
||||
Base64.decode(encoded).takeIf { it.size == 32 }
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun createKeyringEnvelope(
|
||||
dek: ByteArray,
|
||||
masterWrappingKey: ByteArray,
|
||||
recoveryWrappingKey: ByteArray,
|
||||
): String {
|
||||
val masterWrapped = Base64.encode(
|
||||
SyncCrypto.seal(masterWrappingKey, MASTER_WRAP_AAD.encodeToByteArray(), dek),
|
||||
)
|
||||
val recoveryWrapped = Base64.encode(
|
||||
SyncCrypto.seal(recoveryWrappingKey, RECOVERY_WRAP_AAD.encodeToByteArray(), dek),
|
||||
)
|
||||
val keyring = SyncKeyring(
|
||||
version = 1,
|
||||
masterWrappedDek = masterWrapped,
|
||||
recoveryWrappedDek = recoveryWrapped,
|
||||
)
|
||||
return Base64.encode(json.encodeToString(keyring).encodeToByteArray())
|
||||
}
|
||||
|
||||
fun unwrapKeyringWithMaster(
|
||||
keyringEnvelopeBase64: String,
|
||||
masterWrappingKey: ByteArray,
|
||||
): Pair<ByteArray, SyncKeyring>? {
|
||||
val keyring = decodeKeyring(keyringEnvelopeBase64) ?: return null
|
||||
val wrappedBytes = try {
|
||||
Base64.decode(keyring.masterWrappedDek)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return null
|
||||
}
|
||||
val dek = SyncCrypto.open(masterWrappingKey, MASTER_WRAP_AAD.encodeToByteArray(), wrappedBytes)
|
||||
?: return null
|
||||
return dek to keyring
|
||||
}
|
||||
|
||||
fun unwrapKeyringWithRecovery(
|
||||
keyringEnvelopeBase64: String,
|
||||
recoveryWrappingKey: ByteArray,
|
||||
): Pair<ByteArray, SyncKeyring>? {
|
||||
val keyring = decodeKeyring(keyringEnvelopeBase64) ?: return null
|
||||
val wrappedBytes = try {
|
||||
Base64.decode(keyring.recoveryWrappedDek)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return null
|
||||
}
|
||||
val dek = SyncCrypto.open(recoveryWrappingKey, RECOVERY_WRAP_AAD.encodeToByteArray(), wrappedBytes)
|
||||
?: return null
|
||||
return dek to keyring
|
||||
}
|
||||
|
||||
fun rewrapKeyringWithNewMaster(
|
||||
existingKeyring: SyncKeyring,
|
||||
dek: ByteArray,
|
||||
newMasterWrappingKey: ByteArray,
|
||||
): String {
|
||||
val masterWrapped = Base64.encode(
|
||||
SyncCrypto.seal(newMasterWrappingKey, MASTER_WRAP_AAD.encodeToByteArray(), dek),
|
||||
)
|
||||
val updated = existingKeyring.copy(masterWrappedDek = masterWrapped)
|
||||
return Base64.encode(json.encodeToString(updated).encodeToByteArray())
|
||||
}
|
||||
|
||||
private fun decodeKeyring(keyringEnvelopeBase64: String): SyncKeyring? =
|
||||
try {
|
||||
val rawJson = Base64.decode(keyringEnvelopeBase64).decodeToString()
|
||||
json.decodeFromString<SyncKeyring>(rawJson)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ data class SyncDevice(
|
|||
val deviceName: String,
|
||||
val createdAt: String,
|
||||
val lastSeenAt: String,
|
||||
val current: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -53,4 +54,29 @@ data class SyncRequest(
|
|||
data class SyncResponse(
|
||||
val changes: List<SyncBlob> = emptyList(),
|
||||
val cursor: Long = 0,
|
||||
val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncKeyring(
|
||||
val version: Int = 1,
|
||||
val masterWrappedDek: String,
|
||||
val recoveryWrappedDek: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncWsNotification(
|
||||
val entityType: String = "",
|
||||
val entityId: String = "",
|
||||
val version: Long = 0,
|
||||
)
|
||||
|
||||
data class DerivedCredentials(
|
||||
val authProof: String,
|
||||
val wrappingKey: ByteArray,
|
||||
)
|
||||
|
||||
data class DerivedRecovery(
|
||||
val recoveryKeyHash: String,
|
||||
val wrappingKey: ByteArray,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package org.simpmusic.sync
|
|||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SyncCryptoTest {
|
||||
@Test
|
||||
|
|
@ -15,4 +17,44 @@ class SyncCryptoTest {
|
|||
assertNull(SyncCrypto.open(key, "wrong".encodeToByteArray(), envelope))
|
||||
assertNotNull(SyncCrypto.open(key, aad, envelope))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyringMasterAndRecoveryRotation() {
|
||||
val master1 = SyncCrypto.deriveMasterCredentials("User@Example.com ", "correct-horse-battery-staple")
|
||||
val recoveryKey = SyncCrypto.generateRecoveryKey()
|
||||
val recovery = SyncCrypto.deriveRecovery(recoveryKey)
|
||||
|
||||
assertTrue(master1.authProof.length >= 32)
|
||||
assertTrue(recovery.recoveryKeyHash.length >= 32)
|
||||
|
||||
val dek = SyncCrypto.newKey()
|
||||
val keyringEnvelope = SyncEnvelopeHelper.createKeyringEnvelope(
|
||||
dek = dek,
|
||||
masterWrappingKey = master1.wrappingKey,
|
||||
recoveryWrappingKey = recovery.wrappingKey,
|
||||
)
|
||||
|
||||
// Unwrap with master password on another device
|
||||
val unwrappedByMaster = SyncEnvelopeHelper.unwrapKeyringWithMaster(keyringEnvelope, master1.wrappingKey)
|
||||
assertNotNull(unwrappedByMaster)
|
||||
assertContentEquals(dek, unwrappedByMaster.first)
|
||||
|
||||
// Recover with Recovery Key and rotate to a new master password without losing DEK!
|
||||
val unwrappedByRecovery = SyncEnvelopeHelper.unwrapKeyringWithRecovery(keyringEnvelope, recovery.wrappingKey)
|
||||
assertNotNull(unwrappedByRecovery)
|
||||
assertContentEquals(dek, unwrappedByRecovery.first)
|
||||
|
||||
val master2 = SyncCrypto.deriveMasterCredentials("user@example.com", "new-password-after-recovery")
|
||||
val rotatedEnvelope = SyncEnvelopeHelper.rewrapKeyringWithNewMaster(
|
||||
existingKeyring = unwrappedByRecovery.second,
|
||||
dek = unwrappedByRecovery.first,
|
||||
newMasterWrappingKey = master2.wrappingKey,
|
||||
)
|
||||
|
||||
assertNull(SyncEnvelopeHelper.unwrapKeyringWithMaster(rotatedEnvelope, master1.wrappingKey))
|
||||
val unwrappedWithNewMaster = SyncEnvelopeHelper.unwrapKeyringWithMaster(rotatedEnvelope, master2.wrappingKey)
|
||||
assertNotNull(unwrappedWithNewMaster)
|
||||
assertContentEquals(dek, unwrappedWithNewMaster.first)
|
||||
assertEquals(64, SyncCrypto.sha256Hex(dek).length)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
package org.simpmusic.sync
|
||||
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object SyncCrypto {
|
||||
const val VERSION: Byte = 1
|
||||
actual object SyncCrypto {
|
||||
actual const val VERSION: Byte = 1
|
||||
private const val KEY_BYTES = 32
|
||||
private const val NONCE_BYTES = 12
|
||||
private const val TAG_BITS = 128
|
||||
private const val PBKDF2_ITERATIONS = 120_000
|
||||
private const val BASE32_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun newKey(): ByteArray = ByteArray(KEY_BYTES).also(random::nextBytes)
|
||||
actual fun newKey(): ByteArray = ByteArray(KEY_BYTES).also(random::nextBytes)
|
||||
|
||||
fun seal(key: ByteArray, associatedData: ByteArray, plaintext: ByteArray): ByteArray {
|
||||
actual fun seal(key: ByteArray, associatedData: ByteArray, plaintext: ByteArray): ByteArray {
|
||||
require(key.size == KEY_BYTES) { "data key must be 256 bits" }
|
||||
val nonce = ByteArray(NONCE_BYTES).also(random::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
|
@ -24,7 +30,7 @@ object SyncCrypto {
|
|||
return byteArrayOf(VERSION) + nonce + cipher.doFinal(plaintext)
|
||||
}
|
||||
|
||||
fun open(key: ByteArray, associatedData: ByteArray, envelope: ByteArray): ByteArray? {
|
||||
actual fun open(key: ByteArray, associatedData: ByteArray, envelope: ByteArray): ByteArray? {
|
||||
if (key.size != KEY_BYTES || envelope.size < 1 + NONCE_BYTES + TAG_BITS / 8 || envelope[0] != VERSION) return null
|
||||
return try {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
|
@ -35,4 +41,52 @@ object SyncCrypto {
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun deriveMasterCredentials(email: String, password: String): DerivedCredentials {
|
||||
val normalizedEmail = email.trim().lowercase()
|
||||
require(normalizedEmail.isNotEmpty()) { "email is required" }
|
||||
require(password.length >= 8) { "password must be at least 8 characters" }
|
||||
val salt = "simpsync-master-kdf-v1:$normalizedEmail".toByteArray(Charsets.UTF_8)
|
||||
val derived = pbkdf2(password.toCharArray(), salt, KEY_BYTES * 2)
|
||||
val authBytes = derived.copyOfRange(0, KEY_BYTES)
|
||||
val wrapBytes = derived.copyOfRange(KEY_BYTES, KEY_BYTES * 2)
|
||||
val authProof = Base64.getUrlEncoder().withoutPadding().encodeToString(authBytes)
|
||||
return DerivedCredentials(authProof = authProof, wrappingKey = wrapBytes)
|
||||
}
|
||||
|
||||
actual fun generateRecoveryKey(): String {
|
||||
val indices = ByteArray(32).also(random::nextBytes)
|
||||
val sb = StringBuilder(39)
|
||||
for (i in 0 until 32) {
|
||||
if (i > 0 && i % 4 == 0) sb.append('-')
|
||||
val idx = (indices[i].toInt() and 0xFF) % BASE32_ALPHABET.length
|
||||
sb.append(BASE32_ALPHABET[idx])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
actual fun deriveRecovery(recoveryKey: String): DerivedRecovery {
|
||||
val normalized = recoveryKey.uppercase().replace("-", "").replace(" ", "").trim()
|
||||
require(normalized.length >= 16) { "invalid recovery key" }
|
||||
val salt = "simpsync-recovery-kdf-v1".toByteArray(Charsets.UTF_8)
|
||||
val derived = pbkdf2(normalized.toCharArray(), salt, KEY_BYTES * 2)
|
||||
val hashBytes = derived.copyOfRange(0, KEY_BYTES)
|
||||
val wrapBytes = derived.copyOfRange(KEY_BYTES, KEY_BYTES * 2)
|
||||
val recoveryHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes)
|
||||
return DerivedRecovery(recoveryKeyHash = recoveryHash, wrappingKey = wrapBytes)
|
||||
}
|
||||
|
||||
actual fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun pbkdf2(chars: CharArray, salt: ByteArray, byteLength: Int): ByteArray {
|
||||
val spec = PBEKeySpec(chars, salt, PBKDF2_ITERATIONS, byteLength * 8)
|
||||
return try {
|
||||
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue