Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
android:fullBackupContent="@xml/backup_descriptor"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.DayNight"
android:enableOnBackInvokedCallback="true"
tools:ignore="GoogleAppIndexingWarning">

<activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.simplecityapps.shuttle.appinitializers.WidgetInitializer
import com.simplecityapps.shuttle.entitlement.EntitledServerStreamPolicy
import com.simplecityapps.shuttle.sources.DefaultMediaSources
import com.simplecityapps.shuttle.sources.SafScannerFolderStore
import com.simplecityapps.shuttle.ui.screens.settings.backup.LibraryBackupFlow
import com.simplecityapps.shuttle.ui.screens.settings.backup.LibraryBackupManager
import com.simplecityapps.shuttle.ui.screens.sources.MediaSources
import com.simplecityapps.shuttle.ui.screens.sources.ScannerFolderStore
import dev.zacsweers.metro.AppScope
Expand All @@ -27,6 +29,9 @@ import dev.zacsweers.metro.IntoSet
@ContributesTo(AppScope::class)
@BindingContainer
abstract class AppBindsModule {
@Binds
abstract fun bindLibraryBackupFlow(impl: LibraryBackupManager): LibraryBackupFlow

@Binds
abstract fun bindServerStreamPolicy(impl: EntitledServerStreamPolicy): ServerStreamPolicy

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.simplecityapps.shuttle.ui.screens.settings

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -123,8 +125,26 @@ private fun SettingsDestinationEntry(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
uri?.let { viewModel.exportBackupTo(it.toString()) }
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { viewModel.importBackupFrom(it.toString()) }
}
ConsumeEvents(uiState.events, viewModel::onEventHandled) { event ->
snackbarHostState.showSnackbar(context.getString(event.message))
when (event) {
is SettingsUiEvent.BackupExportReady -> exportLauncher.launch(event.suggestedName)
is SettingsUiEvent.BackupImportPickerRequested -> importLauncher.launch(arrayOf("application/json"))
is SettingsUiEvent.BackupImported -> snackbarHostState.showSnackbar(
context.getString(
R.string.settings_backup_import_done,
event.songsMatched,
event.playlistsRestored,
event.songsUnmatched
)
)
else -> snackbarHostState.showSnackbar(context.getString(event.message))
}
}
SettingsDestinationScreen(
screen = AndroidSettingsCatalog.screen(destination),
Expand All @@ -146,6 +166,19 @@ private val SettingsUiEvent.message: Int
get() = when (this) {
SettingsUiEvent.RescanStarted -> R.string.settings_rescan_started

SettingsUiEvent.BackupExportSaved -> R.string.settings_backup_export_saved

SettingsUiEvent.BackupExportFailed -> R.string.settings_backup_export_failed

SettingsUiEvent.BackupImportFailed -> R.string.settings_backup_import_failed

// Handled with launchers/report formatting above, never as plain snackbars.
is SettingsUiEvent.BackupExportReady -> R.string.settings_backup_export_saved

is SettingsUiEvent.BackupImportPickerRequested -> R.string.settings_backup_import_failed

is SettingsUiEvent.BackupImported -> R.string.settings_backup_import_done

SettingsUiEvent.ArtworkCacheCleared -> R.string.settings_artwork_cache_cleared

SettingsUiEvent.ArtworkDownloadStarted -> R.string.settings_artwork_download_started
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.simplecityapps.shuttle.ui.screens.settings.backup

import kotlinx.serialization.Serializable

/**
* Versioned library backup: per-song stats plus playlists, nothing else.
*
* Identity is (provider, path) first, (provider, externalId) for remote items, and a tag
* fingerprint as fallback. Room ids, MediaStore ids-as-primary, credentials, SAF grant URIs and
* absolute volume paths are deliberately NOT part of the format: none of them survive a reinstall.
* Times are epoch millis (stable across kotlinx-datetime/-time versions).
*/
@Serializable
data class LibraryBackup(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val appVersion: String? = null,
/** Epoch millis the backup was written. */
val exportedAt: Long,
val songs: List<BackedUpSong>,
val playlists: List<BackedUpPlaylist>
) {
companion object {
const val CURRENT_SCHEMA_VERSION = 1
}
}

@Serializable
data class SongIdentity(
/** [com.simplecityapps.shuttle.model.MediaProviderType.name]. */
val provider: String,
val path: String,
val externalId: String? = null,
val title: String? = null,
val album: String? = null,
/** albumArtist ?: artists.joinToString, as displayed. */
val artist: String? = null,
val duration: Int = 0,
val size: Long = 0
)

@Serializable
data class BackedUpSong(
val identity: SongIdentity,
val playCount: Int = 0,
/** Epoch millis, null when never played. */
val lastPlayed: Long? = null,
val lastCompleted: Long? = null,
val playbackPosition: Int = 0,
val excluded: Boolean = false,
/** Epoch millis, null when not a favourite. */
val favouritedAt: Long? = null,
/** Epoch millis, null when unknown. */
val dateAdded: Long? = null
)

@Serializable
data class BackedUpPlaylist(
val name: String,
/** [com.simplecityapps.shuttle.model.MediaProviderType.name]. */
val provider: String,
val externalId: String? = null,
/** [com.simplecityapps.shuttle.sorting.PlaylistSongSortOrder.name]. */
val sortOrder: String = "Position",
val sortDescending: Boolean = false,
val members: List<SongIdentity>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package com.simplecityapps.shuttle.ui.screens.settings.backup

import android.content.Context
import android.net.Uri
import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase
import com.simplecityapps.mediaprovider.repository.playlists.PlaylistQuery
import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
import com.simplecityapps.shuttle.di.ApplicationContext
import com.simplecityapps.shuttle.di.IoDispatcher
import com.simplecityapps.shuttle.model.MediaProviderType
import com.simplecityapps.shuttle.model.Song
import com.simplecityapps.shuttle.query.SongQuery
import com.simplecityapps.shuttle.sorting.PlaylistSongSortOrder
import com.simplecityapps.shuttle.ui.screens.settings.backup.LibraryBackupFlow
import com.simplecityapps.shuttle.ui.screens.settings.backup.RestoreReport
import dev.zacsweers.metro.Inject
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
import kotlin.time.Clock

/**
* Library backup export/import (Settings -> Library). Backs up per-song stats (play counts,
* positions, favourites, exclusions, dates) plus playlists; restore writes each matched song back
* to the backup's snapshot.
*
* Never backed up: Room ids, credentials/tokens, SAF grant URIs, transient queue/session state,
* artwork caches and aggregates. Restoring favourites touches only the local songs table (no
* `pending_favourites` rows), so a restore never pushes stale states to remote servers.
*/
class LibraryBackupManager @Inject constructor(
@ApplicationContext private val context: Context,
private val database: MediaDatabase,
private val songRepository: SongRepository,
private val playlistRepository: PlaylistRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : LibraryBackupFlow {
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }

override suspend fun buildBackupJson(): String? = withContext(ioDispatcher) {
runCatching {
val songs = songRepository.loadSongs(SongQuery.All(includeExcluded = true))
val playlists = playlistRepository.getPlaylists(PlaylistQuery.All(null)).first()
val backedPlaylists = playlists.map { playlist ->
val members = playlistRepository.getSongsForPlaylist(playlist).first()
BackedUpPlaylist(
name = playlist.name,
provider = playlist.mediaProvider.name,
externalId = playlist.externalId,
sortOrder = playlist.sortOrder.name,
sortDescending = playlist.sortDescending,
members = members.map { it.song.toIdentity() }
)
}
json.encodeToString(
LibraryBackup.serializer(),
LibraryBackup(
exportedAt = Clock.System.now().toEpochMilliseconds(),
songs = songs.map { it.toBackedUp() },
playlists = backedPlaylists
)
)
}.getOrNull()
}

override suspend fun writeBackup(destinationUri: String, backupJson: String): Boolean = withContext(ioDispatcher) {
try {
context.contentResolver.openOutputStream(Uri.parse(destinationUri))?.use { out ->
out.write(backupJson.toByteArray(Charsets.UTF_8))
true
} ?: run {
Timber.w("Could not open output stream for library backup: $destinationUri")
false
}
} catch (e: IOException) {
Timber.e(e, "Failed to write library backup to $destinationUri")
false
} catch (e: SecurityException) {
Timber.e(e, "Permission denied writing library backup to $destinationUri")
false
}
}

override suspend fun readAndRestore(sourceUri: String): RestoreReport? = withContext(ioDispatcher) {
val backup = try {
context.contentResolver.openInputStream(Uri.parse(sourceUri))?.use { input ->
json.decodeFromString<LibraryBackup>(input.readBytes().toString(Charsets.UTF_8))
}
} catch (e: Exception) {
Timber.e(e, "Failed to read library backup from $sourceUri")
null
} ?: return@withContext null
if (backup.schemaVersion > LibraryBackup.CURRENT_SCHEMA_VERSION) {
Timber.w("Backup schema ${backup.schemaVersion} newer than supported ${LibraryBackup.CURRENT_SCHEMA_VERSION}")
return@withContext null
}
restore(backup)
}

private suspend fun restore(backup: LibraryBackup): RestoreReport {
val songDataDao = database.songDataDao()
val library = songRepository.loadSongs(SongQuery.All(includeExcluded = true))
val matches = LibraryBackupMatcher.matchAll(backup.songs.map { it.identity }, library)

var statsWritten = 0
var unmatched = 0
backup.songs.forEach { backedUp ->
val match = matches[backedUp.identity]
if (match == null) {
unmatched++
return@forEach
}
val current = match.song
val merged = LibraryBackupMatcher.mergeStats(current, backedUp)
if (!LibraryBackupMatcher.statsEqual(current, merged)) {
songDataDao.restoreStats(
id = current.id,
playCount = merged.playCount,
lastPlayed = merged.lastPlayed,
lastCompleted = merged.lastCompleted,
playbackPosition = merged.playbackPosition,
dateAdded = merged.dateAdded
)
if (merged.excluded != current.blacklisted) {
songDataDao.setExcluded(listOf(current.id), merged.excluded)
}
if (current.favouritedAt != merged.favouritedAt) {
if (merged.favouritedAt == null) {
songDataDao.unfavourite(listOf(current.id))
} else if (current.favouritedAt == null) {
songDataDao.favourite(current.id, merged.favouritedAt)
} else {
songDataDao.unfavourite(listOf(current.id))
songDataDao.favourite(current.id, merged.favouritedAt)
}
}
statsWritten++
}
}

val existing = playlistRepository.getPlaylists(PlaylistQuery.All(null)).first()
var playlistsRestored = 0
var membersSkipped = 0
val unresolved = mutableListOf<String>()
backup.playlists.forEach { backedUp ->
val provider = runCatching { MediaProviderType.valueOf(backedUp.provider) }.getOrNull()
?: MediaProviderType.Shuttle
val memberSongs = backedUp.members.mapNotNull { ref ->
matches[ref]?.song ?: run { membersSkipped++; null }
}
if (memberSongs.isEmpty()) {
unresolved.add(backedUp.name)
return@forEach
}
val target = if (backedUp.externalId != null) {
existing.firstOrNull { it.mediaProvider == provider && it.externalId == backedUp.externalId }
} else null
?: existing.firstOrNull { it.mediaProvider == provider && it.name.equals(backedUp.name, ignoreCase = true) }
if (target == null) {
playlistRepository.createPlaylist(backedUp.name, provider, memberSongs, backedUp.externalId)
} else {
playlistRepository.clearPlaylist(target)
playlistRepository.addToPlaylist(target, memberSongs)
runCatching { PlaylistSongSortOrder.valueOf(backedUp.sortOrder) }.getOrNull()?.let { order ->
playlistRepository.updatePlaylistSortOder(target, order, backedUp.sortDescending)
}
}
playlistsRestored++
}

return RestoreReport(
songsMatched = backup.songs.size - unmatched,
songsUnmatched = unmatched,
statsWritten = statsWritten,
playlistsRestored = playlistsRestored,
playlistsUnresolved = unresolved,
membersSkipped = membersSkipped
)
}

private fun Song.toIdentity(): SongIdentity = SongIdentity(
provider = mediaProvider.name,
path = path,
externalId = externalId,
title = name,
album = album,
artist = albumArtist ?: artists.joinToString(", ").ifEmpty { null },
duration = duration,
size = size
)

private fun Song.toBackedUp(): BackedUpSong = BackedUpSong(
identity = toIdentity(),
playCount = playCount,
lastPlayed = lastPlayed?.toEpochMilliseconds(),
lastCompleted = lastCompleted?.toEpochMilliseconds(),
playbackPosition = playbackPosition,
excluded = blacklisted,
favouritedAt = favouritedAt?.toEpochMilliseconds(),
dateAdded = dateAdded?.toEpochMilliseconds()
)
}
Loading