feat(sync): implement zero-knowledge E2EE sync engine, keyring, and payload store

This commit is contained in:
Murilo Scolari 2026-10-24 03:26:18 -03:00
parent 561bd98bad
commit a5b687628b
15 changed files with 2052 additions and 75 deletions

View file

@ -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,

View file

@ -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")

View file

@ -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,

View file

@ -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(

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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,
)

View file

@ -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)

View file

@ -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()
}

View file

@ -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()
}
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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,
)

View file

@ -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)
}
}

View file

@ -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()
}
}
}