From e527561efb8afb95c727fb82cb39571596e36723 Mon Sep 17 00:00:00 2001 From: epikaigle444 Date: Fri, 16 Oct 2026 22:17:16 +0200 Subject: [PATCH] feat(android): add Google Drive cross-device history sync - Add optional cross-device listening history synchronization via Google Drive appDataFolder - Implement Room migrations 55->56->57->58 for durable origin aliases and account scoping - Add background synchronization via WorkManager (DriveHistorySyncWorker) - Add Google Drive history sync UI section and viewmodel in Settings - Preserve origin provenance for LAN scrobble ingestion to prevent publication loops - Add comprehensive unit tests for protocol, migrations, cursors, and account boundaries --- app/build.gradle.kts | 13 +- .../56.json | 2305 ++++++++++++++++ .../57.json | 2320 ++++++++++++++++ .../58.json | 2328 +++++++++++++++++ .../java/me/avinas/tempo/TempoApplication.kt | 21 + .../tempo/data/drive/DriveAccountSession.kt | 32 + .../tempo/data/drive/DriveAppDataClient.kt | 511 ++++ .../data/drive/DriveHistoryEventMapper.kt | 116 + .../tempo/data/drive/DriveHistoryProtocol.kt | 430 +++ .../data/drive/DriveHistorySyncManager.kt | 1060 ++++++++ .../drive/DriveHistorySyncSettingsManager.kt | 105 + .../me/avinas/tempo/data/drive/DriveModels.kt | 4 +- .../tempo/data/drive/GoogleAuthManager.kt | 752 ++---- .../data/drive/GoogleDriveTokenStorage.kt | 36 +- .../data/importexport/ImportExportManager.kt | 210 +- .../importexport/RestoredPlaybackIdentity.kt | 25 + .../data/importexport/TempoExportData.kt | 5 +- .../data/importexport/TempoExportJsonCodec.kt | 23 +- .../me/avinas/tempo/data/local/AppDatabase.kt | 107 +- .../tempo/data/local/DatabaseOptimization.kt | 13 +- .../tempo/data/local/SourceAuthority.kt | 12 + .../tempo/data/local/dao/ListeningEventDao.kt | 488 +++- .../data/local/dao/NearestPlaybackMatch.kt | 76 + .../data/local/entities/ListeningEvent.kt | 9 +- .../local/entities/ListeningEventOrigin.kt | 38 + .../tempo/desktop/DesktopSatelliteServer.kt | 15 +- .../DesktopScrobbleIngestionService.kt | 96 +- .../java/me/avinas/tempo/di/DriveModule.kt | 6 +- .../tempo/ui/components/SettingsComponents.kt | 4 +- .../tempo/ui/settings/BackupRestoreScreen.kt | 83 +- .../ui/settings/BackupRestoreViewModel.kt | 51 +- .../ui/settings/DriveHistorySyncSection.kt | 296 +++ .../ui/settings/DriveHistorySyncViewModel.kt | 158 ++ .../tempo/ui/settings/SettingsViewModel.kt | 6 +- .../tempo/worker/DriveHistorySyncWorker.kt | 128 + app/src/main/res/values-de/strings.xml | 73 +- app/src/main/res/values-fr/strings.xml | 73 +- app/src/main/res/values-hu/strings.xml | 71 + app/src/main/res/values-pt/strings.xml | 73 +- app/src/main/res/values-ru/strings.xml | 73 +- app/src/main/res/values/strings.xml | 47 +- .../data/drive/DriveAccountSessionTest.kt | 54 + .../data/drive/DriveAccountSwitchingTest.kt | 109 + .../drive/DriveAccountUploadCursorTest.kt | 92 + .../data/drive/DriveHistoryProtocolTest.kt | 471 ++++ .../DriveRestoreCursorInvalidationTest.kt | 36 + .../RestoredPlaybackIdentityTest.kt | 51 + .../TempoExportJsonCodecRoundTripTest.kt | 39 +- .../data/local/DriveSourceAuthorityTest.kt | 45 + .../data/local/LanOriginPromotionTest.kt | 146 ++ .../tempo/data/local/Migration55To56Test.kt | 129 + .../tempo/data/local/Migration56To57Test.kt | 69 + .../tempo/data/local/Migration57To58Test.kt | 72 + .../local/dao/NearestPlaybackMatchTest.kt | 138 + .../DriveHistoryUiOperationCoordinatorTest.kt | 34 + docs/DRIVE_HISTORY_SYNC_V1.md | 299 +++ docs/DRIVE_MULTI_CLIENT_ACCEPTANCE.md | 162 ++ 57 files changed, 13551 insertions(+), 687 deletions(-) create mode 100644 app/schemas/me.avinas.tempo.data.local.AppDatabase/56.json create mode 100644 app/schemas/me.avinas.tempo.data.local.AppDatabase/57.json create mode 100644 app/schemas/me.avinas.tempo.data.local.AppDatabase/58.json create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveAccountSession.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveAppDataClient.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryEventMapper.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryProtocol.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncManager.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncSettingsManager.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentity.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatch.kt create mode 100644 app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEventOrigin.kt create mode 100644 app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncSection.kt create mode 100644 app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncViewModel.kt create mode 100644 app/src/main/java/me/avinas/tempo/worker/DriveHistorySyncWorker.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSessionTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSwitchingTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/drive/DriveAccountUploadCursorTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/drive/DriveHistoryProtocolTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/drive/DriveRestoreCursorInvalidationTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentityTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/DriveSourceAuthorityTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/LanOriginPromotionTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/Migration55To56Test.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/Migration56To57Test.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/Migration57To58Test.kt create mode 100644 app/src/test/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatchTest.kt create mode 100644 app/src/test/java/me/avinas/tempo/ui/settings/DriveHistoryUiOperationCoordinatorTest.kt create mode 100644 docs/DRIVE_HISTORY_SYNC_V1.md create mode 100644 docs/DRIVE_MULTI_CLIENT_ACCEPTANCE.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c96eccdf9..46d561cb0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,13 @@ val localProperties = val appVersionCode = 4811 val appVersionName = "4.8.11" +// Local development can keep using local.properties. CI/release builders can +// inject the public Google OAuth client ID without creating a repository file. +val googleWebClientId = localProperties.getProperty("GOOGLE_WEB_CLIENT_ID") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: System.getenv("TEMPO_GOOGLE_WEB_CLIENT_ID")?.trim().orEmpty() + android { namespace = "me.avinas.tempo" compileSdk = 36 @@ -64,7 +71,7 @@ android { ) buildConfigField("Long", "MUSICBRAINZ_RATE_LIMIT_MS", "1000L") buildConfigField("String", "LASTFM_API_KEY", "\"${localProperties.getProperty("LASTFM_API_KEY", "")}\"") - buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"${localProperties.getProperty("GOOGLE_WEB_CLIENT_ID", "")}\"") + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"") // Anonymous app-health analytics. The key is deliberately NOT committed: a blank // key makes AnalyticsGate report the build as unconfigured, so anyone building @@ -265,6 +272,10 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.coroutines.test) testImplementation(libs.sqlite.jdbc) + // Android's local-unit-test stub does not provide a functional org.json. + // Use the reference JVM implementation so Drive protocol encode/decode tests + // execute the same JSON semantics instead of returning stub nulls. + testImplementation("org.json:json:20260814") androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/schemas/me.avinas.tempo.data.local.AppDatabase/56.json b/app/schemas/me.avinas.tempo.data.local.AppDatabase/56.json new file mode 100644 index 000000000..8341fe2f5 --- /dev/null +++ b/app/schemas/me.avinas.tempo.data.local.AppDatabase/56.json @@ -0,0 +1,2305 @@ +{ + "formatVersion": 1, + "database": { + "version": 56, + "identityHash": "cb864482ff80488be540d6ef19b190c7", + "entities": [ + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `duration` INTEGER, `album_art_url` TEXT, `spotify_id` TEXT, `youtube_id` TEXT, `musicbrainz_id` TEXT, `primary_artist_id` INTEGER, `content_type` TEXT NOT NULL DEFAULT 'MUSIC', FOREIGN KEY(`primary_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "youtubeId", + "columnName": "youtube_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryArtistId", + "columnName": "primary_artist_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'MUSIC'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracks_spotify_id", + "unique": true, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_tracks_youtube_id", + "unique": true, + "columnNames": [ + "youtube_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_youtube_id` ON `${TABLE_NAME}` (`youtube_id`)" + }, + { + "name": "index_tracks_musicbrainz_id", + "unique": false, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_tracks_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_artist` ON `${TABLE_NAME}` (`artist`)" + }, + { + "name": "index_tracks_album", + "unique": false, + "columnNames": [ + "album" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_album` ON `${TABLE_NAME}` (`album`)" + }, + { + "name": "index_tracks_primary_artist_id", + "unique": false, + "columnNames": [ + "primary_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_primary_artist_id` ON `${TABLE_NAME}` (`primary_artist_id`)" + }, + { + "name": "index_tracks_title_artist", + "unique": false, + "columnNames": [ + "title", + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_title_artist` ON `${TABLE_NAME}` (`title`, `artist`)" + }, + { + "name": "index_tracks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_content_type` ON `${TABLE_NAME}` (`content_type`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "primary_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `playDuration` INTEGER NOT NULL, `completionPercentage` INTEGER NOT NULL, `source` TEXT NOT NULL, `was_skipped` INTEGER NOT NULL DEFAULT 0, `is_replay` INTEGER NOT NULL DEFAULT 0, `estimated_duration_ms` INTEGER DEFAULT NULL, `pause_count` INTEGER NOT NULL DEFAULT 0, `session_id` TEXT DEFAULT NULL, `end_timestamp` INTEGER DEFAULT NULL, `total_pause_duration_ms` INTEGER NOT NULL DEFAULT 0, `seek_count` INTEGER NOT NULL DEFAULT 0, `position_updates_count` INTEGER NOT NULL DEFAULT 0, `was_interrupted` INTEGER NOT NULL DEFAULT 0, `volume_level` INTEGER DEFAULT NULL, `content_fingerprint` TEXT DEFAULT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "track_id", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playDuration", + "columnName": "playDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionPercentage", + "columnName": "completionPercentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wasSkipped", + "columnName": "was_skipped", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isReplay", + "columnName": "is_replay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "estimatedDurationMs", + "columnName": "estimated_duration_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "pauseCount", + "columnName": "pause_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "endTimestamp", + "columnName": "end_timestamp", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "totalPauseDurationMs", + "columnName": "total_pause_duration_ms", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "seekCount", + "columnName": "seek_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionUpdatesCount", + "columnName": "position_updates_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wasInterrupted", + "columnName": "was_interrupted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "volumeLevel", + "columnName": "volume_level", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "contentFingerprint", + "columnName": "content_fingerprint", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_listening_events_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_listening_events_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_session_id` ON `${TABLE_NAME}` (`session_id`)" + }, + { + "name": "index_listening_events_was_skipped", + "unique": false, + "columnNames": [ + "was_skipped" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_was_skipped` ON `${TABLE_NAME}` (`was_skipped`)" + }, + { + "name": "index_listening_events_is_replay", + "unique": false, + "columnNames": [ + "is_replay" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_is_replay` ON `${TABLE_NAME}` (`is_replay`)" + }, + { + "name": "index_listening_events_source", + "unique": false, + "columnNames": [ + "source" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source` ON `${TABLE_NAME}` (`source`)" + }, + { + "name": "index_listening_events_source_timestamp", + "unique": false, + "columnNames": [ + "source", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source_timestamp` ON `${TABLE_NAME}` (`source`, `timestamp`)" + }, + { + "name": "index_listening_events_timestamp_track_id", + "unique": false, + "columnNames": [ + "timestamp", + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_timestamp_track_id` ON `${TABLE_NAME}` (`timestamp`, `track_id`)" + }, + { + "name": "index_listening_events_track_id_timestamp", + "unique": false, + "columnNames": [ + "track_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id_timestamp` ON `${TABLE_NAME}` (`track_id`, `timestamp`)" + }, + { + "name": "index_listening_events_stats", + "unique": false, + "columnNames": [ + "timestamp", + "track_id", + "playDuration", + "completionPercentage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_stats` ON `${TABLE_NAME}` (`timestamp`, `track_id`, `playDuration`, `completionPercentage`)" + }, + { + "name": "index_listening_events_content_fingerprint", + "unique": false, + "columnNames": [ + "content_fingerprint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_content_fingerprint` ON `${TABLE_NAME}` (`content_fingerprint`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_event_origins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`originEventId` TEXT NOT NULL, `listeningEventId` INTEGER NOT NULL, `sourceDeviceId` TEXT NOT NULL, PRIMARY KEY(`originEventId`), FOREIGN KEY(`listeningEventId`) REFERENCES `listening_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "originEventId", + "columnName": "originEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listeningEventId", + "columnName": "listeningEventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceDeviceId", + "columnName": "sourceDeviceId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "originEventId" + ] + }, + "indices": [ + { + "name": "index_listening_event_origins_listeningEventId", + "unique": false, + "columnNames": [ + "listeningEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId` ON `${TABLE_NAME}` (`listeningEventId`)" + }, + { + "name": "index_listening_event_origins_listeningEventId_sourceDeviceId", + "unique": true, + "columnNames": [ + "listeningEventId", + "sourceDeviceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId_sourceDeviceId` ON `${TABLE_NAME}` (`listeningEventId`, `sourceDeviceId`)" + } + ], + "foreignKeys": [ + { + "table": "listening_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "listeningEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `image_url` TEXT, `genres` TEXT NOT NULL, `musicbrainz_id` TEXT, `spotify_id` TEXT, `country` TEXT, `artist_type` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artists_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_artists_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `release_year` INTEGER, `artwork_url` TEXT, `musicbrainz_id` TEXT, `release_type` TEXT, FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkUrl", + "columnName": "artwork_url", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_albums_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_albums_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_albums_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "enriched_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `musicbrainz_recording_id` TEXT, `musicbrainz_artist_id` TEXT, `musicbrainz_release_id` TEXT, `musicbrainz_release_group_id` TEXT, `album_title` TEXT, `release_date` TEXT, `release_year` INTEGER, `release_type` TEXT, `album_art_url` TEXT, `album_art_url_small` TEXT, `album_art_url_large` TEXT, `artist_name` TEXT, `artist_country` TEXT, `artist_type` TEXT, `track_duration_ms` INTEGER, `isrc` TEXT, `tags` TEXT NOT NULL, `genres` TEXT NOT NULL, `genre_source` TEXT NOT NULL DEFAULT 'NONE', `record_label` TEXT, `audio_features_json` TEXT, `spotify_id` TEXT, `spotify_artist_id` TEXT, `spotify_artist_ids` TEXT, `spotify_verified_artist` TEXT, `spotify_artist_image_url` TEXT, `spotify_preview_url` TEXT, `reccobeats_id` TEXT, `audio_features_source` TEXT NOT NULL, `spotify_enrichment_status` TEXT NOT NULL, `spotify_enrichment_error` TEXT, `spotify_last_attempt` INTEGER, `enrichment_status` TEXT NOT NULL, `enrichment_error` TEXT, `retry_count` INTEGER NOT NULL, `last_enrichment_attempt` INTEGER, `itunes_artist_image_url` TEXT, `apple_music_url` TEXT, `release_date_full` TEXT, `deezer_artist_image_url` TEXT, `lastfm_artist_image_url` TEXT, `spotify_track_url` TEXT, `preview_url` TEXT, `album_art_source` TEXT NOT NULL DEFAULT 'NONE', `cache_timestamp` INTEGER NOT NULL, `cache_version` INTEGER NOT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "musicbrainzRecordingId", + "columnName": "musicbrainz_recording_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzArtistId", + "columnName": "musicbrainz_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseId", + "columnName": "musicbrainz_release_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseGroupId", + "columnName": "musicbrainz_release_group_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumTitle", + "columnName": "album_title", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlSmall", + "columnName": "album_art_url_small", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlLarge", + "columnName": "album_art_url_large", + "affinity": "TEXT" + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT" + }, + { + "fieldPath": "artistCountry", + "columnName": "artist_country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + }, + { + "fieldPath": "trackDurationMs", + "columnName": "track_duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "isrc", + "columnName": "isrc", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genreSource", + "columnName": "genre_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "recordLabel", + "columnName": "record_label", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesJson", + "columnName": "audio_features_json", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistId", + "columnName": "spotify_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistIds", + "columnName": "spotify_artist_ids", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyVerifiedArtist", + "columnName": "spotify_verified_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistImageUrl", + "columnName": "spotify_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyPreviewUrl", + "columnName": "spotify_preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "reccoBeatsId", + "columnName": "reccobeats_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesSource", + "columnName": "audio_features_source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentStatus", + "columnName": "spotify_enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentError", + "columnName": "spotify_enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyLastAttempt", + "columnName": "spotify_last_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "enrichmentStatus", + "columnName": "enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrichmentError", + "columnName": "enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEnrichmentAttempt", + "columnName": "last_enrichment_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "iTunesArtistImageUrl", + "columnName": "itunes_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "appleMusicUrl", + "columnName": "apple_music_url", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDateFull", + "columnName": "release_date_full", + "affinity": "TEXT" + }, + { + "fieldPath": "deezerArtistImageUrl", + "columnName": "deezer_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFmArtistImageUrl", + "columnName": "lastfm_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyTrackUrl", + "columnName": "spotify_track_url", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtSource", + "columnName": "album_art_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "cacheTimestamp", + "columnName": "cache_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheVersion", + "columnName": "cache_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_enriched_metadata_track_id", + "unique": true, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_enriched_metadata_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_enriched_metadata_musicbrainz_recording_id", + "unique": false, + "columnNames": [ + "musicbrainz_recording_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_musicbrainz_recording_id` ON `${TABLE_NAME}` (`musicbrainz_recording_id`)" + }, + { + "name": "index_enriched_metadata_enrichment_status", + "unique": false, + "columnNames": [ + "enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_enrichment_status` ON `${TABLE_NAME}` (`enrichment_status`)" + }, + { + "name": "index_enriched_metadata_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_enriched_metadata_spotify_artist_id", + "unique": false, + "columnNames": [ + "spotify_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_artist_id` ON `${TABLE_NAME}` (`spotify_artist_id`)" + }, + { + "name": "index_enriched_metadata_spotify_enrichment_status", + "unique": false, + "columnNames": [ + "spotify_enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_enrichment_status` ON `${TABLE_NAME}` (`spotify_enrichment_status`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `theme` TEXT NOT NULL, `notifications` INTEGER NOT NULL, `spotifyLinked` INTEGER NOT NULL, `extendedAudioAnalysis` INTEGER NOT NULL, `mergeAlternateVersions` INTEGER NOT NULL, `filterPodcasts` INTEGER NOT NULL, `filterAudiobooks` INTEGER NOT NULL, `hasSeenHistoryCoachMark` INTEGER NOT NULL, `hasSeenSpotlightTutorial` INTEGER NOT NULL, `hasSeenStatsSortTutorial` INTEGER NOT NULL, `hasSeenStatsItemClickTutorial` INTEGER NOT NULL, `lastWeeklyReminderShown` TEXT, `lastMonthlyReminderShown` TEXT, `lastYearlyReminderShown` TEXT, `lastAllTimeReminderShown` TEXT, `spotifyApiOnlyMode` INTEGER NOT NULL, `spotifyImportCursor` TEXT, `lastSpotifyImportTimestamp` INTEGER, `lastfmUsername` TEXT, `lastfmConnected` INTEGER NOT NULL, `lastfmSyncFrequency` TEXT NOT NULL, `smartChallengeNotifHour` INTEGER, `smartChallengeNotifCalcTime` INTEGER, `isGamificationEnabled` INTEGER NOT NULL, `pauseTrackingOnLowBattery` INTEGER NOT NULL, `lastSpotlightStoryViewed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notifications", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyLinked", + "columnName": "spotifyLinked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extendedAudioAnalysis", + "columnName": "extendedAudioAnalysis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mergeAlternateVersions", + "columnName": "mergeAlternateVersions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterPodcasts", + "columnName": "filterPodcasts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterAudiobooks", + "columnName": "filterAudiobooks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenHistoryCoachMark", + "columnName": "hasSeenHistoryCoachMark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenSpotlightTutorial", + "columnName": "hasSeenSpotlightTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsSortTutorial", + "columnName": "hasSeenStatsSortTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsItemClickTutorial", + "columnName": "hasSeenStatsItemClickTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWeeklyReminderShown", + "columnName": "lastWeeklyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMonthlyReminderShown", + "columnName": "lastMonthlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastYearlyReminderShown", + "columnName": "lastYearlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastAllTimeReminderShown", + "columnName": "lastAllTimeReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyApiOnlyMode", + "columnName": "spotifyApiOnlyMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyImportCursor", + "columnName": "spotifyImportCursor", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSpotifyImportTimestamp", + "columnName": "lastSpotifyImportTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfmUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "lastfmConnected", + "columnName": "lastfmConnected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmSyncFrequency", + "columnName": "lastfmSyncFrequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smartChallengeNotifHour", + "columnName": "smartChallengeNotifHour", + "affinity": "INTEGER" + }, + { + "fieldPath": "smartChallengeNotifCalcTime", + "columnName": "smartChallengeNotifCalcTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isGamificationEnabled", + "columnName": "isGamificationEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauseTrackingOnLowBattery", + "columnName": "pauseTrackingOnLowBattery", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSpotlightStoryViewed", + "columnName": "lastSpotlightStoryViewed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "track_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `role` TEXT NOT NULL, `credit_order` INTEGER NOT NULL, PRIMARY KEY(`track_id`, `artist_id`), FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditOrder", + "columnName": "credit_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_id", + "artist_id" + ] + }, + "indices": [ + { + "name": "index_track_artists_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_track_artists_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_track_artists_track_id_role", + "unique": false, + "columnNames": [ + "track_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id_role` ON `${TABLE_NAME}` (`track_id`, `role`)" + }, + { + "name": "index_track_artists_artist_id_role", + "unique": false, + "columnNames": [ + "artist_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id_role` ON `${TABLE_NAME}` (`artist_id`, `role`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "track_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_track_aliases_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_track_aliases_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_track_aliases_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_aliases_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "manual_content_marks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `pattern_type` TEXT NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `pattern_value` TEXT NOT NULL, `content_type` TEXT NOT NULL, `marked_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patternType", + "columnName": "pattern_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patternValue", + "columnName": "pattern_value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedAt", + "columnName": "marked_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_manual_content_marks_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_manual_content_marks_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_manual_content_marks_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + }, + { + "name": "index_manual_content_marks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_content_type` ON `${TABLE_NAME}` (`content_type`)" + }, + { + "name": "index_manual_content_marks_pattern_type_original_artist", + "unique": false, + "columnNames": [ + "pattern_type", + "original_artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_pattern_type_original_artist` ON `${TABLE_NAME}` (`pattern_type`, `original_artist`)" + } + ] + }, + { + "tableName": "artist_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_artist_id` INTEGER NOT NULL, `original_name` TEXT NOT NULL, `original_name_normalized` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetArtistId", + "columnName": "target_artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "original_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalNameNormalized", + "columnName": "original_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artist_aliases_original_name_normalized", + "unique": true, + "columnNames": [ + "original_name_normalized" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artist_aliases_original_name_normalized` ON `${TABLE_NAME}` (`original_name_normalized`)" + }, + { + "name": "index_artist_aliases_target_artist_id", + "unique": false, + "columnNames": [ + "target_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_aliases_target_artist_id` ON `${TABLE_NAME}` (`target_artist_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "app_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isUserAdded` INTEGER NOT NULL, `isBlocked` INTEGER NOT NULL, `category` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserAdded", + "columnName": "isUserAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "lastfm_import_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lastfm_username` TEXT NOT NULL, `import_tier` TEXT NOT NULL, `active_track_threshold` INTEGER NOT NULL, `recent_months_included` INTEGER NOT NULL, `total_scrobbles_found` INTEGER NOT NULL, `earliest_scrobble` INTEGER, `latest_scrobble` INTEGER, `status` TEXT NOT NULL, `current_page` INTEGER NOT NULL, `total_pages` INTEGER NOT NULL, `scrobbles_processed` INTEGER NOT NULL, `events_imported` INTEGER NOT NULL, `tracks_created` INTEGER NOT NULL, `artists_created` INTEGER NOT NULL, `scrobbles_archived` INTEGER NOT NULL, `duplicates_skipped` INTEGER NOT NULL, `last_sync_cursor` INTEGER, `last_sync_timestamp` INTEGER, `import_started_at` INTEGER NOT NULL, `import_completed_at` INTEGER, `error_message` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfm_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importTier", + "columnName": "import_tier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeTrackThreshold", + "columnName": "active_track_threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recentMonthsIncluded", + "columnName": "recent_months_included", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalScrobblesFound", + "columnName": "total_scrobbles_found", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "earliestScrobble", + "columnName": "earliest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestScrobble", + "columnName": "latest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentPage", + "columnName": "current_page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "total_pages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesProcessed", + "columnName": "scrobbles_processed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventsImported", + "columnName": "events_imported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tracksCreated", + "columnName": "tracks_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistsCreated", + "columnName": "artists_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesArchived", + "columnName": "scrobbles_archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duplicatesSkipped", + "columnName": "duplicates_skipped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncCursor", + "columnName": "last_sync_cursor", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "last_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "importStartedAt", + "columnName": "import_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importCompletedAt", + "columnName": "import_completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "scrobbles_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_hash` TEXT NOT NULL, `track_title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_name_normalized` TEXT NOT NULL, `album_name` TEXT, `musicbrainz_id` TEXT, `timestamps_blob` BLOB NOT NULL, `play_count` INTEGER NOT NULL, `first_scrobble` INTEGER NOT NULL, `last_scrobble` INTEGER NOT NULL, `album_art_url` TEXT, `was_loved` INTEGER NOT NULL, `import_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackHash", + "columnName": "track_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "track_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNameNormalized", + "columnName": "artist_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampsBlob", + "columnName": "timestamps_blob", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstScrobble", + "columnName": "first_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrobble", + "columnName": "last_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "wasLoved", + "columnName": "was_loved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scrobbles_archive_track_hash", + "unique": true, + "columnNames": [ + "track_hash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scrobbles_archive_track_hash` ON `${TABLE_NAME}` (`track_hash`)" + }, + { + "name": "index_scrobbles_archive_artist_name_normalized", + "unique": false, + "columnNames": [ + "artist_name_normalized" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_artist_name_normalized` ON `${TABLE_NAME}` (`artist_name_normalized`)" + }, + { + "name": "index_scrobbles_archive_first_scrobble", + "unique": false, + "columnNames": [ + "first_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_first_scrobble` ON `${TABLE_NAME}` (`first_scrobble`)" + }, + { + "name": "index_scrobbles_archive_last_scrobble", + "unique": false, + "columnNames": [ + "last_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_last_scrobble` ON `${TABLE_NAME}` (`last_scrobble`)" + }, + { + "name": "index_scrobbles_archive_play_count", + "unique": false, + "columnNames": [ + "play_count" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_play_count` ON `${TABLE_NAME}` (`play_count`)" + } + ] + }, + { + "tableName": "user_level", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `total_xp` INTEGER NOT NULL, `current_level` INTEGER NOT NULL, `xp_for_current_level` INTEGER NOT NULL, `xp_for_next_level` INTEGER NOT NULL, `last_xp_awarded_at` INTEGER NOT NULL, `current_streak` INTEGER NOT NULL, `longest_streak` INTEGER NOT NULL, `last_streak_date` TEXT NOT NULL, `banked_challenge_xp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalXp", + "columnName": "total_xp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentLevel", + "columnName": "current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForCurrentLevel", + "columnName": "xp_for_current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForNextLevel", + "columnName": "xp_for_next_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastXpAwardedAt", + "columnName": "last_xp_awarded_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentStreak", + "columnName": "current_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "longestStreak", + "columnName": "longest_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStreakDate", + "columnName": "last_streak_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bankedChallengeXp", + "columnName": "banked_challenge_xp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "badges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `badge_id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT NOT NULL, `category` TEXT NOT NULL, `earned_at` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `max_progress` INTEGER NOT NULL, `is_earned` INTEGER NOT NULL, `stars` INTEGER NOT NULL, `is_acknowledged` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "badgeId", + "columnName": "badge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "earnedAt", + "columnName": "earned_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxProgress", + "columnName": "max_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEarned", + "columnName": "is_earned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stars", + "columnName": "stars", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAcknowledged", + "columnName": "is_acknowledged", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_badges_badge_id", + "unique": true, + "columnNames": [ + "badge_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_badges_badge_id` ON `${TABLE_NAME}` (`badge_id`)" + }, + { + "name": "index_badges_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_category` ON `${TABLE_NAME}` (`category`)" + }, + { + "name": "index_badges_is_earned", + "unique": false, + "columnNames": [ + "is_earned" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_is_earned` ON `${TABLE_NAME}` (`is_earned`)" + } + ] + }, + { + "tableName": "user_known_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `created_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_user_known_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_known_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "daily_challenges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `challenge_id` TEXT NOT NULL, `date` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `xp_reward` INTEGER NOT NULL, `target_value` INTEGER NOT NULL, `current_progress` INTEGER NOT NULL, `is_completed` INTEGER NOT NULL, `completed_at` INTEGER NOT NULL, `category` TEXT NOT NULL, `difficulty` TEXT NOT NULL, `target_metadata` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "challengeId", + "columnName": "challenge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "xpReward", + "columnName": "xp_reward", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetValue", + "columnName": "target_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentProgress", + "columnName": "current_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "is_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "difficulty", + "columnName": "difficulty", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetMetadata", + "columnName": "target_metadata", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_daily_challenges_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date` ON `${TABLE_NAME}` (`date`)" + }, + { + "name": "index_daily_challenges_date_is_completed", + "unique": false, + "columnNames": [ + "date", + "is_completed" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date_is_completed` ON `${TABLE_NAME}` (`date`, `is_completed`)" + }, + { + "name": "index_daily_challenges_challenge_id_date", + "unique": true, + "columnNames": [ + "challenge_id", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_challenges_challenge_id_date` ON `${TABLE_NAME}` (`challenge_id`, `date`)" + } + ] + }, + { + "tableName": "desktop_pairing_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `auth_token` TEXT NOT NULL, `device_name` TEXT NOT NULL DEFAULT '', `paired_at_ms` INTEGER NOT NULL, `last_seen_ms` INTEGER DEFAULT NULL, `is_active` INTEGER NOT NULL DEFAULT 1, `desktop_ip` TEXT DEFAULT NULL, `desktop_port` INTEGER DEFAULT NULL, `phone_public_key` TEXT DEFAULT NULL, `desktop_public_key` TEXT DEFAULT NULL, `token_version` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authToken", + "columnName": "auth_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pairedAtMs", + "columnName": "paired_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenMs", + "columnName": "last_seen_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "desktopIp", + "columnName": "desktop_ip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPort", + "columnName": "desktop_port", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "phonePublicKey", + "columnName": "phone_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPublicKey", + "columnName": "desktop_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "tokenVersion", + "columnName": "token_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_desktop_pairing_sessions_auth_token", + "unique": false, + "columnNames": [ + "auth_token" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_auth_token` ON `${TABLE_NAME}` (`auth_token`)" + }, + { + "name": "index_desktop_pairing_sessions_is_active", + "unique": false, + "columnNames": [ + "is_active" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_is_active` ON `${TABLE_NAME}` (`is_active`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb864482ff80488be540d6ef19b190c7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.avinas.tempo.data.local.AppDatabase/57.json b/app/schemas/me.avinas.tempo.data.local.AppDatabase/57.json new file mode 100644 index 000000000..177fc5534 --- /dev/null +++ b/app/schemas/me.avinas.tempo.data.local.AppDatabase/57.json @@ -0,0 +1,2320 @@ +{ + "formatVersion": 1, + "database": { + "version": 57, + "identityHash": "c267e959e49a1e7406a8ea8a11f06cac", + "entities": [ + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `duration` INTEGER, `album_art_url` TEXT, `spotify_id` TEXT, `youtube_id` TEXT, `musicbrainz_id` TEXT, `primary_artist_id` INTEGER, `content_type` TEXT NOT NULL DEFAULT 'MUSIC', FOREIGN KEY(`primary_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "youtubeId", + "columnName": "youtube_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryArtistId", + "columnName": "primary_artist_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'MUSIC'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracks_spotify_id", + "unique": true, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_tracks_youtube_id", + "unique": true, + "columnNames": [ + "youtube_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_youtube_id` ON `${TABLE_NAME}` (`youtube_id`)" + }, + { + "name": "index_tracks_musicbrainz_id", + "unique": false, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_tracks_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_artist` ON `${TABLE_NAME}` (`artist`)" + }, + { + "name": "index_tracks_album", + "unique": false, + "columnNames": [ + "album" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_album` ON `${TABLE_NAME}` (`album`)" + }, + { + "name": "index_tracks_primary_artist_id", + "unique": false, + "columnNames": [ + "primary_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_primary_artist_id` ON `${TABLE_NAME}` (`primary_artist_id`)" + }, + { + "name": "index_tracks_title_artist", + "unique": false, + "columnNames": [ + "title", + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_title_artist` ON `${TABLE_NAME}` (`title`, `artist`)" + }, + { + "name": "index_tracks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_content_type` ON `${TABLE_NAME}` (`content_type`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "primary_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `playDuration` INTEGER NOT NULL, `completionPercentage` INTEGER NOT NULL, `source` TEXT NOT NULL, `was_skipped` INTEGER NOT NULL DEFAULT 0, `is_replay` INTEGER NOT NULL DEFAULT 0, `estimated_duration_ms` INTEGER DEFAULT NULL, `pause_count` INTEGER NOT NULL DEFAULT 0, `session_id` TEXT DEFAULT NULL, `end_timestamp` INTEGER DEFAULT NULL, `total_pause_duration_ms` INTEGER NOT NULL DEFAULT 0, `seek_count` INTEGER NOT NULL DEFAULT 0, `position_updates_count` INTEGER NOT NULL DEFAULT 0, `was_interrupted` INTEGER NOT NULL DEFAULT 0, `volume_level` INTEGER DEFAULT NULL, `content_fingerprint` TEXT DEFAULT NULL, `drive_account_subject` TEXT DEFAULT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "track_id", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playDuration", + "columnName": "playDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionPercentage", + "columnName": "completionPercentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wasSkipped", + "columnName": "was_skipped", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isReplay", + "columnName": "is_replay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "estimatedDurationMs", + "columnName": "estimated_duration_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "pauseCount", + "columnName": "pause_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "endTimestamp", + "columnName": "end_timestamp", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "totalPauseDurationMs", + "columnName": "total_pause_duration_ms", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "seekCount", + "columnName": "seek_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionUpdatesCount", + "columnName": "position_updates_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wasInterrupted", + "columnName": "was_interrupted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "volumeLevel", + "columnName": "volume_level", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "contentFingerprint", + "columnName": "content_fingerprint", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "driveAccountSubject", + "columnName": "drive_account_subject", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_listening_events_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_listening_events_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_session_id` ON `${TABLE_NAME}` (`session_id`)" + }, + { + "name": "index_listening_events_was_skipped", + "unique": false, + "columnNames": [ + "was_skipped" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_was_skipped` ON `${TABLE_NAME}` (`was_skipped`)" + }, + { + "name": "index_listening_events_is_replay", + "unique": false, + "columnNames": [ + "is_replay" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_is_replay` ON `${TABLE_NAME}` (`is_replay`)" + }, + { + "name": "index_listening_events_source", + "unique": false, + "columnNames": [ + "source" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source` ON `${TABLE_NAME}` (`source`)" + }, + { + "name": "index_listening_events_source_timestamp", + "unique": false, + "columnNames": [ + "source", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source_timestamp` ON `${TABLE_NAME}` (`source`, `timestamp`)" + }, + { + "name": "index_listening_events_timestamp_track_id", + "unique": false, + "columnNames": [ + "timestamp", + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_timestamp_track_id` ON `${TABLE_NAME}` (`timestamp`, `track_id`)" + }, + { + "name": "index_listening_events_track_id_timestamp", + "unique": false, + "columnNames": [ + "track_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id_timestamp` ON `${TABLE_NAME}` (`track_id`, `timestamp`)" + }, + { + "name": "index_listening_events_stats", + "unique": false, + "columnNames": [ + "timestamp", + "track_id", + "playDuration", + "completionPercentage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_stats` ON `${TABLE_NAME}` (`timestamp`, `track_id`, `playDuration`, `completionPercentage`)" + }, + { + "name": "index_listening_events_content_fingerprint", + "unique": false, + "columnNames": [ + "content_fingerprint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_content_fingerprint` ON `${TABLE_NAME}` (`content_fingerprint`)" + }, + { + "name": "index_listening_events_drive_account_subject", + "unique": false, + "columnNames": [ + "drive_account_subject" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_drive_account_subject` ON `${TABLE_NAME}` (`drive_account_subject`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_event_origins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`originEventId` TEXT NOT NULL, `listeningEventId` INTEGER NOT NULL, `sourceDeviceId` TEXT NOT NULL, PRIMARY KEY(`originEventId`), FOREIGN KEY(`listeningEventId`) REFERENCES `listening_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "originEventId", + "columnName": "originEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listeningEventId", + "columnName": "listeningEventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceDeviceId", + "columnName": "sourceDeviceId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "originEventId" + ] + }, + "indices": [ + { + "name": "index_listening_event_origins_listeningEventId", + "unique": false, + "columnNames": [ + "listeningEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId` ON `${TABLE_NAME}` (`listeningEventId`)" + }, + { + "name": "index_listening_event_origins_listeningEventId_sourceDeviceId", + "unique": true, + "columnNames": [ + "listeningEventId", + "sourceDeviceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId_sourceDeviceId` ON `${TABLE_NAME}` (`listeningEventId`, `sourceDeviceId`)" + } + ], + "foreignKeys": [ + { + "table": "listening_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "listeningEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `image_url` TEXT, `genres` TEXT NOT NULL, `musicbrainz_id` TEXT, `spotify_id` TEXT, `country` TEXT, `artist_type` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artists_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_artists_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `release_year` INTEGER, `artwork_url` TEXT, `musicbrainz_id` TEXT, `release_type` TEXT, FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkUrl", + "columnName": "artwork_url", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_albums_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_albums_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_albums_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "enriched_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `musicbrainz_recording_id` TEXT, `musicbrainz_artist_id` TEXT, `musicbrainz_release_id` TEXT, `musicbrainz_release_group_id` TEXT, `album_title` TEXT, `release_date` TEXT, `release_year` INTEGER, `release_type` TEXT, `album_art_url` TEXT, `album_art_url_small` TEXT, `album_art_url_large` TEXT, `artist_name` TEXT, `artist_country` TEXT, `artist_type` TEXT, `track_duration_ms` INTEGER, `isrc` TEXT, `tags` TEXT NOT NULL, `genres` TEXT NOT NULL, `genre_source` TEXT NOT NULL DEFAULT 'NONE', `record_label` TEXT, `audio_features_json` TEXT, `spotify_id` TEXT, `spotify_artist_id` TEXT, `spotify_artist_ids` TEXT, `spotify_verified_artist` TEXT, `spotify_artist_image_url` TEXT, `spotify_preview_url` TEXT, `reccobeats_id` TEXT, `audio_features_source` TEXT NOT NULL, `spotify_enrichment_status` TEXT NOT NULL, `spotify_enrichment_error` TEXT, `spotify_last_attempt` INTEGER, `enrichment_status` TEXT NOT NULL, `enrichment_error` TEXT, `retry_count` INTEGER NOT NULL, `last_enrichment_attempt` INTEGER, `itunes_artist_image_url` TEXT, `apple_music_url` TEXT, `release_date_full` TEXT, `deezer_artist_image_url` TEXT, `lastfm_artist_image_url` TEXT, `spotify_track_url` TEXT, `preview_url` TEXT, `album_art_source` TEXT NOT NULL DEFAULT 'NONE', `cache_timestamp` INTEGER NOT NULL, `cache_version` INTEGER NOT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "musicbrainzRecordingId", + "columnName": "musicbrainz_recording_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzArtistId", + "columnName": "musicbrainz_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseId", + "columnName": "musicbrainz_release_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseGroupId", + "columnName": "musicbrainz_release_group_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumTitle", + "columnName": "album_title", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlSmall", + "columnName": "album_art_url_small", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlLarge", + "columnName": "album_art_url_large", + "affinity": "TEXT" + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT" + }, + { + "fieldPath": "artistCountry", + "columnName": "artist_country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + }, + { + "fieldPath": "trackDurationMs", + "columnName": "track_duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "isrc", + "columnName": "isrc", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genreSource", + "columnName": "genre_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "recordLabel", + "columnName": "record_label", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesJson", + "columnName": "audio_features_json", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistId", + "columnName": "spotify_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistIds", + "columnName": "spotify_artist_ids", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyVerifiedArtist", + "columnName": "spotify_verified_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistImageUrl", + "columnName": "spotify_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyPreviewUrl", + "columnName": "spotify_preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "reccoBeatsId", + "columnName": "reccobeats_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesSource", + "columnName": "audio_features_source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentStatus", + "columnName": "spotify_enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentError", + "columnName": "spotify_enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyLastAttempt", + "columnName": "spotify_last_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "enrichmentStatus", + "columnName": "enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrichmentError", + "columnName": "enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEnrichmentAttempt", + "columnName": "last_enrichment_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "iTunesArtistImageUrl", + "columnName": "itunes_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "appleMusicUrl", + "columnName": "apple_music_url", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDateFull", + "columnName": "release_date_full", + "affinity": "TEXT" + }, + { + "fieldPath": "deezerArtistImageUrl", + "columnName": "deezer_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFmArtistImageUrl", + "columnName": "lastfm_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyTrackUrl", + "columnName": "spotify_track_url", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtSource", + "columnName": "album_art_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "cacheTimestamp", + "columnName": "cache_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheVersion", + "columnName": "cache_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_enriched_metadata_track_id", + "unique": true, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_enriched_metadata_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_enriched_metadata_musicbrainz_recording_id", + "unique": false, + "columnNames": [ + "musicbrainz_recording_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_musicbrainz_recording_id` ON `${TABLE_NAME}` (`musicbrainz_recording_id`)" + }, + { + "name": "index_enriched_metadata_enrichment_status", + "unique": false, + "columnNames": [ + "enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_enrichment_status` ON `${TABLE_NAME}` (`enrichment_status`)" + }, + { + "name": "index_enriched_metadata_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_enriched_metadata_spotify_artist_id", + "unique": false, + "columnNames": [ + "spotify_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_artist_id` ON `${TABLE_NAME}` (`spotify_artist_id`)" + }, + { + "name": "index_enriched_metadata_spotify_enrichment_status", + "unique": false, + "columnNames": [ + "spotify_enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_enrichment_status` ON `${TABLE_NAME}` (`spotify_enrichment_status`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `theme` TEXT NOT NULL, `notifications` INTEGER NOT NULL, `spotifyLinked` INTEGER NOT NULL, `extendedAudioAnalysis` INTEGER NOT NULL, `mergeAlternateVersions` INTEGER NOT NULL, `filterPodcasts` INTEGER NOT NULL, `filterAudiobooks` INTEGER NOT NULL, `hasSeenHistoryCoachMark` INTEGER NOT NULL, `hasSeenSpotlightTutorial` INTEGER NOT NULL, `hasSeenStatsSortTutorial` INTEGER NOT NULL, `hasSeenStatsItemClickTutorial` INTEGER NOT NULL, `lastWeeklyReminderShown` TEXT, `lastMonthlyReminderShown` TEXT, `lastYearlyReminderShown` TEXT, `lastAllTimeReminderShown` TEXT, `spotifyApiOnlyMode` INTEGER NOT NULL, `spotifyImportCursor` TEXT, `lastSpotifyImportTimestamp` INTEGER, `lastfmUsername` TEXT, `lastfmConnected` INTEGER NOT NULL, `lastfmSyncFrequency` TEXT NOT NULL, `smartChallengeNotifHour` INTEGER, `smartChallengeNotifCalcTime` INTEGER, `isGamificationEnabled` INTEGER NOT NULL, `pauseTrackingOnLowBattery` INTEGER NOT NULL, `lastSpotlightStoryViewed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notifications", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyLinked", + "columnName": "spotifyLinked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extendedAudioAnalysis", + "columnName": "extendedAudioAnalysis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mergeAlternateVersions", + "columnName": "mergeAlternateVersions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterPodcasts", + "columnName": "filterPodcasts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterAudiobooks", + "columnName": "filterAudiobooks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenHistoryCoachMark", + "columnName": "hasSeenHistoryCoachMark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenSpotlightTutorial", + "columnName": "hasSeenSpotlightTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsSortTutorial", + "columnName": "hasSeenStatsSortTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsItemClickTutorial", + "columnName": "hasSeenStatsItemClickTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWeeklyReminderShown", + "columnName": "lastWeeklyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMonthlyReminderShown", + "columnName": "lastMonthlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastYearlyReminderShown", + "columnName": "lastYearlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastAllTimeReminderShown", + "columnName": "lastAllTimeReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyApiOnlyMode", + "columnName": "spotifyApiOnlyMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyImportCursor", + "columnName": "spotifyImportCursor", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSpotifyImportTimestamp", + "columnName": "lastSpotifyImportTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfmUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "lastfmConnected", + "columnName": "lastfmConnected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmSyncFrequency", + "columnName": "lastfmSyncFrequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smartChallengeNotifHour", + "columnName": "smartChallengeNotifHour", + "affinity": "INTEGER" + }, + { + "fieldPath": "smartChallengeNotifCalcTime", + "columnName": "smartChallengeNotifCalcTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isGamificationEnabled", + "columnName": "isGamificationEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauseTrackingOnLowBattery", + "columnName": "pauseTrackingOnLowBattery", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSpotlightStoryViewed", + "columnName": "lastSpotlightStoryViewed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "track_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `role` TEXT NOT NULL, `credit_order` INTEGER NOT NULL, PRIMARY KEY(`track_id`, `artist_id`), FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditOrder", + "columnName": "credit_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_id", + "artist_id" + ] + }, + "indices": [ + { + "name": "index_track_artists_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_track_artists_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_track_artists_track_id_role", + "unique": false, + "columnNames": [ + "track_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id_role` ON `${TABLE_NAME}` (`track_id`, `role`)" + }, + { + "name": "index_track_artists_artist_id_role", + "unique": false, + "columnNames": [ + "artist_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id_role` ON `${TABLE_NAME}` (`artist_id`, `role`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "track_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_track_aliases_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_track_aliases_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_track_aliases_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_aliases_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "manual_content_marks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `pattern_type` TEXT NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `pattern_value` TEXT NOT NULL, `content_type` TEXT NOT NULL, `marked_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patternType", + "columnName": "pattern_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patternValue", + "columnName": "pattern_value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedAt", + "columnName": "marked_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_manual_content_marks_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_manual_content_marks_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_manual_content_marks_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + }, + { + "name": "index_manual_content_marks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_content_type` ON `${TABLE_NAME}` (`content_type`)" + }, + { + "name": "index_manual_content_marks_pattern_type_original_artist", + "unique": false, + "columnNames": [ + "pattern_type", + "original_artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_pattern_type_original_artist` ON `${TABLE_NAME}` (`pattern_type`, `original_artist`)" + } + ] + }, + { + "tableName": "artist_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_artist_id` INTEGER NOT NULL, `original_name` TEXT NOT NULL, `original_name_normalized` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetArtistId", + "columnName": "target_artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "original_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalNameNormalized", + "columnName": "original_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artist_aliases_original_name_normalized", + "unique": true, + "columnNames": [ + "original_name_normalized" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artist_aliases_original_name_normalized` ON `${TABLE_NAME}` (`original_name_normalized`)" + }, + { + "name": "index_artist_aliases_target_artist_id", + "unique": false, + "columnNames": [ + "target_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_aliases_target_artist_id` ON `${TABLE_NAME}` (`target_artist_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "app_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isUserAdded` INTEGER NOT NULL, `isBlocked` INTEGER NOT NULL, `category` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserAdded", + "columnName": "isUserAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "lastfm_import_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lastfm_username` TEXT NOT NULL, `import_tier` TEXT NOT NULL, `active_track_threshold` INTEGER NOT NULL, `recent_months_included` INTEGER NOT NULL, `total_scrobbles_found` INTEGER NOT NULL, `earliest_scrobble` INTEGER, `latest_scrobble` INTEGER, `status` TEXT NOT NULL, `current_page` INTEGER NOT NULL, `total_pages` INTEGER NOT NULL, `scrobbles_processed` INTEGER NOT NULL, `events_imported` INTEGER NOT NULL, `tracks_created` INTEGER NOT NULL, `artists_created` INTEGER NOT NULL, `scrobbles_archived` INTEGER NOT NULL, `duplicates_skipped` INTEGER NOT NULL, `last_sync_cursor` INTEGER, `last_sync_timestamp` INTEGER, `import_started_at` INTEGER NOT NULL, `import_completed_at` INTEGER, `error_message` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfm_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importTier", + "columnName": "import_tier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeTrackThreshold", + "columnName": "active_track_threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recentMonthsIncluded", + "columnName": "recent_months_included", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalScrobblesFound", + "columnName": "total_scrobbles_found", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "earliestScrobble", + "columnName": "earliest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestScrobble", + "columnName": "latest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentPage", + "columnName": "current_page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "total_pages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesProcessed", + "columnName": "scrobbles_processed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventsImported", + "columnName": "events_imported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tracksCreated", + "columnName": "tracks_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistsCreated", + "columnName": "artists_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesArchived", + "columnName": "scrobbles_archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duplicatesSkipped", + "columnName": "duplicates_skipped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncCursor", + "columnName": "last_sync_cursor", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "last_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "importStartedAt", + "columnName": "import_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importCompletedAt", + "columnName": "import_completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "scrobbles_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_hash` TEXT NOT NULL, `track_title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_name_normalized` TEXT NOT NULL, `album_name` TEXT, `musicbrainz_id` TEXT, `timestamps_blob` BLOB NOT NULL, `play_count` INTEGER NOT NULL, `first_scrobble` INTEGER NOT NULL, `last_scrobble` INTEGER NOT NULL, `album_art_url` TEXT, `was_loved` INTEGER NOT NULL, `import_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackHash", + "columnName": "track_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "track_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNameNormalized", + "columnName": "artist_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampsBlob", + "columnName": "timestamps_blob", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstScrobble", + "columnName": "first_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrobble", + "columnName": "last_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "wasLoved", + "columnName": "was_loved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scrobbles_archive_track_hash", + "unique": true, + "columnNames": [ + "track_hash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scrobbles_archive_track_hash` ON `${TABLE_NAME}` (`track_hash`)" + }, + { + "name": "index_scrobbles_archive_artist_name_normalized", + "unique": false, + "columnNames": [ + "artist_name_normalized" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_artist_name_normalized` ON `${TABLE_NAME}` (`artist_name_normalized`)" + }, + { + "name": "index_scrobbles_archive_first_scrobble", + "unique": false, + "columnNames": [ + "first_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_first_scrobble` ON `${TABLE_NAME}` (`first_scrobble`)" + }, + { + "name": "index_scrobbles_archive_last_scrobble", + "unique": false, + "columnNames": [ + "last_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_last_scrobble` ON `${TABLE_NAME}` (`last_scrobble`)" + }, + { + "name": "index_scrobbles_archive_play_count", + "unique": false, + "columnNames": [ + "play_count" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_play_count` ON `${TABLE_NAME}` (`play_count`)" + } + ] + }, + { + "tableName": "user_level", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `total_xp` INTEGER NOT NULL, `current_level` INTEGER NOT NULL, `xp_for_current_level` INTEGER NOT NULL, `xp_for_next_level` INTEGER NOT NULL, `last_xp_awarded_at` INTEGER NOT NULL, `current_streak` INTEGER NOT NULL, `longest_streak` INTEGER NOT NULL, `last_streak_date` TEXT NOT NULL, `banked_challenge_xp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalXp", + "columnName": "total_xp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentLevel", + "columnName": "current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForCurrentLevel", + "columnName": "xp_for_current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForNextLevel", + "columnName": "xp_for_next_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastXpAwardedAt", + "columnName": "last_xp_awarded_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentStreak", + "columnName": "current_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "longestStreak", + "columnName": "longest_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStreakDate", + "columnName": "last_streak_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bankedChallengeXp", + "columnName": "banked_challenge_xp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "badges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `badge_id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT NOT NULL, `category` TEXT NOT NULL, `earned_at` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `max_progress` INTEGER NOT NULL, `is_earned` INTEGER NOT NULL, `stars` INTEGER NOT NULL, `is_acknowledged` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "badgeId", + "columnName": "badge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "earnedAt", + "columnName": "earned_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxProgress", + "columnName": "max_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEarned", + "columnName": "is_earned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stars", + "columnName": "stars", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAcknowledged", + "columnName": "is_acknowledged", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_badges_badge_id", + "unique": true, + "columnNames": [ + "badge_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_badges_badge_id` ON `${TABLE_NAME}` (`badge_id`)" + }, + { + "name": "index_badges_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_category` ON `${TABLE_NAME}` (`category`)" + }, + { + "name": "index_badges_is_earned", + "unique": false, + "columnNames": [ + "is_earned" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_is_earned` ON `${TABLE_NAME}` (`is_earned`)" + } + ] + }, + { + "tableName": "user_known_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `created_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_user_known_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_known_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "daily_challenges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `challenge_id` TEXT NOT NULL, `date` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `xp_reward` INTEGER NOT NULL, `target_value` INTEGER NOT NULL, `current_progress` INTEGER NOT NULL, `is_completed` INTEGER NOT NULL, `completed_at` INTEGER NOT NULL, `category` TEXT NOT NULL, `difficulty` TEXT NOT NULL, `target_metadata` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "challengeId", + "columnName": "challenge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "xpReward", + "columnName": "xp_reward", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetValue", + "columnName": "target_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentProgress", + "columnName": "current_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "is_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "difficulty", + "columnName": "difficulty", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetMetadata", + "columnName": "target_metadata", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_daily_challenges_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date` ON `${TABLE_NAME}` (`date`)" + }, + { + "name": "index_daily_challenges_date_is_completed", + "unique": false, + "columnNames": [ + "date", + "is_completed" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date_is_completed` ON `${TABLE_NAME}` (`date`, `is_completed`)" + }, + { + "name": "index_daily_challenges_challenge_id_date", + "unique": true, + "columnNames": [ + "challenge_id", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_challenges_challenge_id_date` ON `${TABLE_NAME}` (`challenge_id`, `date`)" + } + ] + }, + { + "tableName": "desktop_pairing_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `auth_token` TEXT NOT NULL, `device_name` TEXT NOT NULL DEFAULT '', `paired_at_ms` INTEGER NOT NULL, `last_seen_ms` INTEGER DEFAULT NULL, `is_active` INTEGER NOT NULL DEFAULT 1, `desktop_ip` TEXT DEFAULT NULL, `desktop_port` INTEGER DEFAULT NULL, `phone_public_key` TEXT DEFAULT NULL, `desktop_public_key` TEXT DEFAULT NULL, `token_version` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authToken", + "columnName": "auth_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pairedAtMs", + "columnName": "paired_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenMs", + "columnName": "last_seen_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "desktopIp", + "columnName": "desktop_ip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPort", + "columnName": "desktop_port", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "phonePublicKey", + "columnName": "phone_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPublicKey", + "columnName": "desktop_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "tokenVersion", + "columnName": "token_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_desktop_pairing_sessions_auth_token", + "unique": false, + "columnNames": [ + "auth_token" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_auth_token` ON `${TABLE_NAME}` (`auth_token`)" + }, + { + "name": "index_desktop_pairing_sessions_is_active", + "unique": false, + "columnNames": [ + "is_active" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_is_active` ON `${TABLE_NAME}` (`is_active`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c267e959e49a1e7406a8ea8a11f06cac')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.avinas.tempo.data.local.AppDatabase/58.json b/app/schemas/me.avinas.tempo.data.local.AppDatabase/58.json new file mode 100644 index 000000000..f141fe149 --- /dev/null +++ b/app/schemas/me.avinas.tempo.data.local.AppDatabase/58.json @@ -0,0 +1,2328 @@ +{ + "formatVersion": 1, + "database": { + "version": 58, + "identityHash": "07157e7e31d42fd8e0f08e31ea1fb07c", + "entities": [ + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `duration` INTEGER, `album_art_url` TEXT, `spotify_id` TEXT, `youtube_id` TEXT, `musicbrainz_id` TEXT, `primary_artist_id` INTEGER, `content_type` TEXT NOT NULL DEFAULT 'MUSIC', FOREIGN KEY(`primary_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "youtubeId", + "columnName": "youtube_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryArtistId", + "columnName": "primary_artist_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'MUSIC'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracks_spotify_id", + "unique": true, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_tracks_youtube_id", + "unique": true, + "columnNames": [ + "youtube_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_youtube_id` ON `${TABLE_NAME}` (`youtube_id`)" + }, + { + "name": "index_tracks_musicbrainz_id", + "unique": false, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_tracks_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_artist` ON `${TABLE_NAME}` (`artist`)" + }, + { + "name": "index_tracks_album", + "unique": false, + "columnNames": [ + "album" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_album` ON `${TABLE_NAME}` (`album`)" + }, + { + "name": "index_tracks_primary_artist_id", + "unique": false, + "columnNames": [ + "primary_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_primary_artist_id` ON `${TABLE_NAME}` (`primary_artist_id`)" + }, + { + "name": "index_tracks_title_artist", + "unique": false, + "columnNames": [ + "title", + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_title_artist` ON `${TABLE_NAME}` (`title`, `artist`)" + }, + { + "name": "index_tracks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_content_type` ON `${TABLE_NAME}` (`content_type`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "primary_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `playDuration` INTEGER NOT NULL, `completionPercentage` INTEGER NOT NULL, `source` TEXT NOT NULL, `was_skipped` INTEGER NOT NULL DEFAULT 0, `is_replay` INTEGER NOT NULL DEFAULT 0, `estimated_duration_ms` INTEGER DEFAULT NULL, `pause_count` INTEGER NOT NULL DEFAULT 0, `session_id` TEXT DEFAULT NULL, `end_timestamp` INTEGER DEFAULT NULL, `total_pause_duration_ms` INTEGER NOT NULL DEFAULT 0, `seek_count` INTEGER NOT NULL DEFAULT 0, `position_updates_count` INTEGER NOT NULL DEFAULT 0, `was_interrupted` INTEGER NOT NULL DEFAULT 0, `volume_level` INTEGER DEFAULT NULL, `content_fingerprint` TEXT DEFAULT NULL, `drive_account_subject` TEXT DEFAULT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "track_id", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playDuration", + "columnName": "playDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionPercentage", + "columnName": "completionPercentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wasSkipped", + "columnName": "was_skipped", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isReplay", + "columnName": "is_replay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "estimatedDurationMs", + "columnName": "estimated_duration_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "pauseCount", + "columnName": "pause_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "endTimestamp", + "columnName": "end_timestamp", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "totalPauseDurationMs", + "columnName": "total_pause_duration_ms", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "seekCount", + "columnName": "seek_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionUpdatesCount", + "columnName": "position_updates_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wasInterrupted", + "columnName": "was_interrupted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "volumeLevel", + "columnName": "volume_level", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "contentFingerprint", + "columnName": "content_fingerprint", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "driveAccountSubject", + "columnName": "drive_account_subject", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_listening_events_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_listening_events_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_session_id` ON `${TABLE_NAME}` (`session_id`)" + }, + { + "name": "index_listening_events_was_skipped", + "unique": false, + "columnNames": [ + "was_skipped" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_was_skipped` ON `${TABLE_NAME}` (`was_skipped`)" + }, + { + "name": "index_listening_events_is_replay", + "unique": false, + "columnNames": [ + "is_replay" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_is_replay` ON `${TABLE_NAME}` (`is_replay`)" + }, + { + "name": "index_listening_events_source", + "unique": false, + "columnNames": [ + "source" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source` ON `${TABLE_NAME}` (`source`)" + }, + { + "name": "index_listening_events_source_timestamp", + "unique": false, + "columnNames": [ + "source", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_source_timestamp` ON `${TABLE_NAME}` (`source`, `timestamp`)" + }, + { + "name": "index_listening_events_timestamp_track_id", + "unique": false, + "columnNames": [ + "timestamp", + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_timestamp_track_id` ON `${TABLE_NAME}` (`timestamp`, `track_id`)" + }, + { + "name": "index_listening_events_track_id_timestamp", + "unique": false, + "columnNames": [ + "track_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_track_id_timestamp` ON `${TABLE_NAME}` (`track_id`, `timestamp`)" + }, + { + "name": "index_listening_events_stats", + "unique": false, + "columnNames": [ + "timestamp", + "track_id", + "playDuration", + "completionPercentage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_stats` ON `${TABLE_NAME}` (`timestamp`, `track_id`, `playDuration`, `completionPercentage`)" + }, + { + "name": "index_listening_events_content_fingerprint", + "unique": false, + "columnNames": [ + "content_fingerprint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_content_fingerprint` ON `${TABLE_NAME}` (`content_fingerprint`)" + }, + { + "name": "index_listening_events_drive_account_subject", + "unique": false, + "columnNames": [ + "drive_account_subject" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_events_drive_account_subject` ON `${TABLE_NAME}` (`drive_account_subject`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "listening_event_origins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`originEventId` TEXT NOT NULL, `listeningEventId` INTEGER NOT NULL, `sourceDeviceId` TEXT NOT NULL, `accountSubject` TEXT NOT NULL DEFAULT 'legacy-unverified', PRIMARY KEY(`accountSubject`, `originEventId`), FOREIGN KEY(`listeningEventId`) REFERENCES `listening_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "originEventId", + "columnName": "originEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listeningEventId", + "columnName": "listeningEventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceDeviceId", + "columnName": "sourceDeviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountSubject", + "columnName": "accountSubject", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'legacy-unverified'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountSubject", + "originEventId" + ] + }, + "indices": [ + { + "name": "index_listening_event_origins_listeningEventId", + "unique": false, + "columnNames": [ + "listeningEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId` ON `${TABLE_NAME}` (`listeningEventId`)" + }, + { + "name": "index_listening_event_origins_listeningEventId_sourceDeviceId", + "unique": true, + "columnNames": [ + "listeningEventId", + "sourceDeviceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_listening_event_origins_listeningEventId_sourceDeviceId` ON `${TABLE_NAME}` (`listeningEventId`, `sourceDeviceId`)" + } + ], + "foreignKeys": [ + { + "table": "listening_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "listeningEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `image_url` TEXT, `genres` TEXT NOT NULL, `musicbrainz_id` TEXT, `spotify_id` TEXT, `country` TEXT, `artist_type` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artists_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + }, + { + "name": "index_artists_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `release_year` INTEGER, `artwork_url` TEXT, `musicbrainz_id` TEXT, `release_type` TEXT, FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkUrl", + "columnName": "artwork_url", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_albums_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_albums_musicbrainz_id", + "unique": true, + "columnNames": [ + "musicbrainz_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_albums_musicbrainz_id` ON `${TABLE_NAME}` (`musicbrainz_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "enriched_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `musicbrainz_recording_id` TEXT, `musicbrainz_artist_id` TEXT, `musicbrainz_release_id` TEXT, `musicbrainz_release_group_id` TEXT, `album_title` TEXT, `release_date` TEXT, `release_year` INTEGER, `release_type` TEXT, `album_art_url` TEXT, `album_art_url_small` TEXT, `album_art_url_large` TEXT, `artist_name` TEXT, `artist_country` TEXT, `artist_type` TEXT, `track_duration_ms` INTEGER, `isrc` TEXT, `tags` TEXT NOT NULL, `genres` TEXT NOT NULL, `genre_source` TEXT NOT NULL DEFAULT 'NONE', `record_label` TEXT, `audio_features_json` TEXT, `spotify_id` TEXT, `spotify_artist_id` TEXT, `spotify_artist_ids` TEXT, `spotify_verified_artist` TEXT, `spotify_artist_image_url` TEXT, `spotify_preview_url` TEXT, `reccobeats_id` TEXT, `audio_features_source` TEXT NOT NULL, `spotify_enrichment_status` TEXT NOT NULL, `spotify_enrichment_error` TEXT, `spotify_last_attempt` INTEGER, `enrichment_status` TEXT NOT NULL, `enrichment_error` TEXT, `retry_count` INTEGER NOT NULL, `last_enrichment_attempt` INTEGER, `itunes_artist_image_url` TEXT, `apple_music_url` TEXT, `release_date_full` TEXT, `deezer_artist_image_url` TEXT, `lastfm_artist_image_url` TEXT, `spotify_track_url` TEXT, `preview_url` TEXT, `album_art_source` TEXT NOT NULL DEFAULT 'NONE', `cache_timestamp` INTEGER NOT NULL, `cache_version` INTEGER NOT NULL, FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "musicbrainzRecordingId", + "columnName": "musicbrainz_recording_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzArtistId", + "columnName": "musicbrainz_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseId", + "columnName": "musicbrainz_release_id", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzReleaseGroupId", + "columnName": "musicbrainz_release_group_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumTitle", + "columnName": "album_title", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseYear", + "columnName": "release_year", + "affinity": "INTEGER" + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlSmall", + "columnName": "album_art_url_small", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtUrlLarge", + "columnName": "album_art_url_large", + "affinity": "TEXT" + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT" + }, + { + "fieldPath": "artistCountry", + "columnName": "artist_country", + "affinity": "TEXT" + }, + { + "fieldPath": "artistType", + "columnName": "artist_type", + "affinity": "TEXT" + }, + { + "fieldPath": "trackDurationMs", + "columnName": "track_duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "isrc", + "columnName": "isrc", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genreSource", + "columnName": "genre_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "recordLabel", + "columnName": "record_label", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesJson", + "columnName": "audio_features_json", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyId", + "columnName": "spotify_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistId", + "columnName": "spotify_artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistIds", + "columnName": "spotify_artist_ids", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyVerifiedArtist", + "columnName": "spotify_verified_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyArtistImageUrl", + "columnName": "spotify_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyPreviewUrl", + "columnName": "spotify_preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "reccoBeatsId", + "columnName": "reccobeats_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFeaturesSource", + "columnName": "audio_features_source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentStatus", + "columnName": "spotify_enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spotifyEnrichmentError", + "columnName": "spotify_enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyLastAttempt", + "columnName": "spotify_last_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "enrichmentStatus", + "columnName": "enrichment_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrichmentError", + "columnName": "enrichment_error", + "affinity": "TEXT" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEnrichmentAttempt", + "columnName": "last_enrichment_attempt", + "affinity": "INTEGER" + }, + { + "fieldPath": "iTunesArtistImageUrl", + "columnName": "itunes_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "appleMusicUrl", + "columnName": "apple_music_url", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDateFull", + "columnName": "release_date_full", + "affinity": "TEXT" + }, + { + "fieldPath": "deezerArtistImageUrl", + "columnName": "deezer_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFmArtistImageUrl", + "columnName": "lastfm_artist_image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyTrackUrl", + "columnName": "spotify_track_url", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "preview_url", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtSource", + "columnName": "album_art_source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "cacheTimestamp", + "columnName": "cache_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheVersion", + "columnName": "cache_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_enriched_metadata_track_id", + "unique": true, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_enriched_metadata_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_enriched_metadata_musicbrainz_recording_id", + "unique": false, + "columnNames": [ + "musicbrainz_recording_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_musicbrainz_recording_id` ON `${TABLE_NAME}` (`musicbrainz_recording_id`)" + }, + { + "name": "index_enriched_metadata_enrichment_status", + "unique": false, + "columnNames": [ + "enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_enrichment_status` ON `${TABLE_NAME}` (`enrichment_status`)" + }, + { + "name": "index_enriched_metadata_spotify_id", + "unique": false, + "columnNames": [ + "spotify_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_id` ON `${TABLE_NAME}` (`spotify_id`)" + }, + { + "name": "index_enriched_metadata_spotify_artist_id", + "unique": false, + "columnNames": [ + "spotify_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_artist_id` ON `${TABLE_NAME}` (`spotify_artist_id`)" + }, + { + "name": "index_enriched_metadata_spotify_enrichment_status", + "unique": false, + "columnNames": [ + "spotify_enrichment_status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_enriched_metadata_spotify_enrichment_status` ON `${TABLE_NAME}` (`spotify_enrichment_status`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `theme` TEXT NOT NULL, `notifications` INTEGER NOT NULL, `spotifyLinked` INTEGER NOT NULL, `extendedAudioAnalysis` INTEGER NOT NULL, `mergeAlternateVersions` INTEGER NOT NULL, `filterPodcasts` INTEGER NOT NULL, `filterAudiobooks` INTEGER NOT NULL, `hasSeenHistoryCoachMark` INTEGER NOT NULL, `hasSeenSpotlightTutorial` INTEGER NOT NULL, `hasSeenStatsSortTutorial` INTEGER NOT NULL, `hasSeenStatsItemClickTutorial` INTEGER NOT NULL, `lastWeeklyReminderShown` TEXT, `lastMonthlyReminderShown` TEXT, `lastYearlyReminderShown` TEXT, `lastAllTimeReminderShown` TEXT, `spotifyApiOnlyMode` INTEGER NOT NULL, `spotifyImportCursor` TEXT, `lastSpotifyImportTimestamp` INTEGER, `lastfmUsername` TEXT, `lastfmConnected` INTEGER NOT NULL, `lastfmSyncFrequency` TEXT NOT NULL, `smartChallengeNotifHour` INTEGER, `smartChallengeNotifCalcTime` INTEGER, `isGamificationEnabled` INTEGER NOT NULL, `pauseTrackingOnLowBattery` INTEGER NOT NULL, `lastSpotlightStoryViewed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notifications", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyLinked", + "columnName": "spotifyLinked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extendedAudioAnalysis", + "columnName": "extendedAudioAnalysis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mergeAlternateVersions", + "columnName": "mergeAlternateVersions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterPodcasts", + "columnName": "filterPodcasts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filterAudiobooks", + "columnName": "filterAudiobooks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenHistoryCoachMark", + "columnName": "hasSeenHistoryCoachMark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenSpotlightTutorial", + "columnName": "hasSeenSpotlightTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsSortTutorial", + "columnName": "hasSeenStatsSortTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSeenStatsItemClickTutorial", + "columnName": "hasSeenStatsItemClickTutorial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWeeklyReminderShown", + "columnName": "lastWeeklyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMonthlyReminderShown", + "columnName": "lastMonthlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastYearlyReminderShown", + "columnName": "lastYearlyReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "lastAllTimeReminderShown", + "columnName": "lastAllTimeReminderShown", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyApiOnlyMode", + "columnName": "spotifyApiOnlyMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spotifyImportCursor", + "columnName": "spotifyImportCursor", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSpotifyImportTimestamp", + "columnName": "lastSpotifyImportTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfmUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "lastfmConnected", + "columnName": "lastfmConnected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmSyncFrequency", + "columnName": "lastfmSyncFrequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smartChallengeNotifHour", + "columnName": "smartChallengeNotifHour", + "affinity": "INTEGER" + }, + { + "fieldPath": "smartChallengeNotifCalcTime", + "columnName": "smartChallengeNotifCalcTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isGamificationEnabled", + "columnName": "isGamificationEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauseTrackingOnLowBattery", + "columnName": "pauseTrackingOnLowBattery", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSpotlightStoryViewed", + "columnName": "lastSpotlightStoryViewed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "track_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `role` TEXT NOT NULL, `credit_order` INTEGER NOT NULL, PRIMARY KEY(`track_id`, `artist_id`), FOREIGN KEY(`track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditOrder", + "columnName": "credit_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_id", + "artist_id" + ] + }, + "indices": [ + { + "name": "index_track_artists_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id` ON `${TABLE_NAME}` (`track_id`)" + }, + { + "name": "index_track_artists_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_track_artists_track_id_role", + "unique": false, + "columnNames": [ + "track_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_track_id_role` ON `${TABLE_NAME}` (`track_id`, `role`)" + }, + { + "name": "index_track_artists_artist_id_role", + "unique": false, + "columnNames": [ + "artist_id", + "role" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artists_artist_id_role` ON `${TABLE_NAME}` (`artist_id`, `role`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "track_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "track_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_track_id`) REFERENCES `tracks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_track_aliases_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_track_aliases_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_track_aliases_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_aliases_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + } + ], + "foreignKeys": [ + { + "table": "tracks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_track_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "manual_content_marks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_track_id` INTEGER NOT NULL, `pattern_type` TEXT NOT NULL, `original_title` TEXT NOT NULL, `original_artist` TEXT NOT NULL, `pattern_value` TEXT NOT NULL, `content_type` TEXT NOT NULL, `marked_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetTrackId", + "columnName": "target_track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patternType", + "columnName": "pattern_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patternValue", + "columnName": "pattern_value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedAt", + "columnName": "marked_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_manual_content_marks_original_title_original_artist", + "unique": true, + "columnNames": [ + "original_title", + "original_artist" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_manual_content_marks_original_title_original_artist` ON `${TABLE_NAME}` (`original_title`, `original_artist`)" + }, + { + "name": "index_manual_content_marks_target_track_id", + "unique": false, + "columnNames": [ + "target_track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_target_track_id` ON `${TABLE_NAME}` (`target_track_id`)" + }, + { + "name": "index_manual_content_marks_content_type", + "unique": false, + "columnNames": [ + "content_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_content_type` ON `${TABLE_NAME}` (`content_type`)" + }, + { + "name": "index_manual_content_marks_pattern_type_original_artist", + "unique": false, + "columnNames": [ + "pattern_type", + "original_artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_manual_content_marks_pattern_type_original_artist` ON `${TABLE_NAME}` (`pattern_type`, `original_artist`)" + } + ] + }, + { + "tableName": "artist_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_artist_id` INTEGER NOT NULL, `original_name` TEXT NOT NULL, `original_name_normalized` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`target_artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetArtistId", + "columnName": "target_artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "original_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalNameNormalized", + "columnName": "original_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artist_aliases_original_name_normalized", + "unique": true, + "columnNames": [ + "original_name_normalized" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artist_aliases_original_name_normalized` ON `${TABLE_NAME}` (`original_name_normalized`)" + }, + { + "name": "index_artist_aliases_target_artist_id", + "unique": false, + "columnNames": [ + "target_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_aliases_target_artist_id` ON `${TABLE_NAME}` (`target_artist_id`)" + } + ], + "foreignKeys": [ + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "target_artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "app_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isUserAdded` INTEGER NOT NULL, `isBlocked` INTEGER NOT NULL, `category` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserAdded", + "columnName": "isUserAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "lastfm_import_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lastfm_username` TEXT NOT NULL, `import_tier` TEXT NOT NULL, `active_track_threshold` INTEGER NOT NULL, `recent_months_included` INTEGER NOT NULL, `total_scrobbles_found` INTEGER NOT NULL, `earliest_scrobble` INTEGER, `latest_scrobble` INTEGER, `status` TEXT NOT NULL, `current_page` INTEGER NOT NULL, `total_pages` INTEGER NOT NULL, `scrobbles_processed` INTEGER NOT NULL, `events_imported` INTEGER NOT NULL, `tracks_created` INTEGER NOT NULL, `artists_created` INTEGER NOT NULL, `scrobbles_archived` INTEGER NOT NULL, `duplicates_skipped` INTEGER NOT NULL, `last_sync_cursor` INTEGER, `last_sync_timestamp` INTEGER, `import_started_at` INTEGER NOT NULL, `import_completed_at` INTEGER, `error_message` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastfmUsername", + "columnName": "lastfm_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importTier", + "columnName": "import_tier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeTrackThreshold", + "columnName": "active_track_threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recentMonthsIncluded", + "columnName": "recent_months_included", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalScrobblesFound", + "columnName": "total_scrobbles_found", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "earliestScrobble", + "columnName": "earliest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestScrobble", + "columnName": "latest_scrobble", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentPage", + "columnName": "current_page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "total_pages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesProcessed", + "columnName": "scrobbles_processed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventsImported", + "columnName": "events_imported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tracksCreated", + "columnName": "tracks_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistsCreated", + "columnName": "artists_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrobblesArchived", + "columnName": "scrobbles_archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duplicatesSkipped", + "columnName": "duplicates_skipped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncCursor", + "columnName": "last_sync_cursor", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "last_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "importStartedAt", + "columnName": "import_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importCompletedAt", + "columnName": "import_completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "scrobbles_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_hash` TEXT NOT NULL, `track_title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_name_normalized` TEXT NOT NULL, `album_name` TEXT, `musicbrainz_id` TEXT, `timestamps_blob` BLOB NOT NULL, `play_count` INTEGER NOT NULL, `first_scrobble` INTEGER NOT NULL, `last_scrobble` INTEGER NOT NULL, `album_art_url` TEXT, `was_loved` INTEGER NOT NULL, `import_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackHash", + "columnName": "track_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackTitle", + "columnName": "track_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNameNormalized", + "columnName": "artist_name_normalized", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT" + }, + { + "fieldPath": "musicbrainzId", + "columnName": "musicbrainz_id", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampsBlob", + "columnName": "timestamps_blob", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstScrobble", + "columnName": "first_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrobble", + "columnName": "last_scrobble", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtUrl", + "columnName": "album_art_url", + "affinity": "TEXT" + }, + { + "fieldPath": "wasLoved", + "columnName": "was_loved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scrobbles_archive_track_hash", + "unique": true, + "columnNames": [ + "track_hash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scrobbles_archive_track_hash` ON `${TABLE_NAME}` (`track_hash`)" + }, + { + "name": "index_scrobbles_archive_artist_name_normalized", + "unique": false, + "columnNames": [ + "artist_name_normalized" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_artist_name_normalized` ON `${TABLE_NAME}` (`artist_name_normalized`)" + }, + { + "name": "index_scrobbles_archive_first_scrobble", + "unique": false, + "columnNames": [ + "first_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_first_scrobble` ON `${TABLE_NAME}` (`first_scrobble`)" + }, + { + "name": "index_scrobbles_archive_last_scrobble", + "unique": false, + "columnNames": [ + "last_scrobble" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_last_scrobble` ON `${TABLE_NAME}` (`last_scrobble`)" + }, + { + "name": "index_scrobbles_archive_play_count", + "unique": false, + "columnNames": [ + "play_count" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scrobbles_archive_play_count` ON `${TABLE_NAME}` (`play_count`)" + } + ] + }, + { + "tableName": "user_level", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `total_xp` INTEGER NOT NULL, `current_level` INTEGER NOT NULL, `xp_for_current_level` INTEGER NOT NULL, `xp_for_next_level` INTEGER NOT NULL, `last_xp_awarded_at` INTEGER NOT NULL, `current_streak` INTEGER NOT NULL, `longest_streak` INTEGER NOT NULL, `last_streak_date` TEXT NOT NULL, `banked_challenge_xp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalXp", + "columnName": "total_xp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentLevel", + "columnName": "current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForCurrentLevel", + "columnName": "xp_for_current_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpForNextLevel", + "columnName": "xp_for_next_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastXpAwardedAt", + "columnName": "last_xp_awarded_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentStreak", + "columnName": "current_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "longestStreak", + "columnName": "longest_streak", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStreakDate", + "columnName": "last_streak_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bankedChallengeXp", + "columnName": "banked_challenge_xp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "badges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `badge_id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT NOT NULL, `category` TEXT NOT NULL, `earned_at` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `max_progress` INTEGER NOT NULL, `is_earned` INTEGER NOT NULL, `stars` INTEGER NOT NULL, `is_acknowledged` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "badgeId", + "columnName": "badge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "earnedAt", + "columnName": "earned_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxProgress", + "columnName": "max_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEarned", + "columnName": "is_earned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stars", + "columnName": "stars", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAcknowledged", + "columnName": "is_acknowledged", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_badges_badge_id", + "unique": true, + "columnNames": [ + "badge_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_badges_badge_id` ON `${TABLE_NAME}` (`badge_id`)" + }, + { + "name": "index_badges_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_category` ON `${TABLE_NAME}` (`category`)" + }, + { + "name": "index_badges_is_earned", + "unique": false, + "columnNames": [ + "is_earned" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_badges_is_earned` ON `${TABLE_NAME}` (`is_earned`)" + } + ] + }, + { + "tableName": "user_known_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `created_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_user_known_artists_normalized_name", + "unique": true, + "columnNames": [ + "normalized_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_known_artists_normalized_name` ON `${TABLE_NAME}` (`normalized_name`)" + } + ] + }, + { + "tableName": "daily_challenges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `challenge_id` TEXT NOT NULL, `date` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `xp_reward` INTEGER NOT NULL, `target_value` INTEGER NOT NULL, `current_progress` INTEGER NOT NULL, `is_completed` INTEGER NOT NULL, `completed_at` INTEGER NOT NULL, `category` TEXT NOT NULL, `difficulty` TEXT NOT NULL, `target_metadata` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "challengeId", + "columnName": "challenge_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "xpReward", + "columnName": "xp_reward", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetValue", + "columnName": "target_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentProgress", + "columnName": "current_progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "is_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "difficulty", + "columnName": "difficulty", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetMetadata", + "columnName": "target_metadata", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_daily_challenges_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date` ON `${TABLE_NAME}` (`date`)" + }, + { + "name": "index_daily_challenges_date_is_completed", + "unique": false, + "columnNames": [ + "date", + "is_completed" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_daily_challenges_date_is_completed` ON `${TABLE_NAME}` (`date`, `is_completed`)" + }, + { + "name": "index_daily_challenges_challenge_id_date", + "unique": true, + "columnNames": [ + "challenge_id", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_challenges_challenge_id_date` ON `${TABLE_NAME}` (`challenge_id`, `date`)" + } + ] + }, + { + "tableName": "desktop_pairing_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `auth_token` TEXT NOT NULL, `device_name` TEXT NOT NULL DEFAULT '', `paired_at_ms` INTEGER NOT NULL, `last_seen_ms` INTEGER DEFAULT NULL, `is_active` INTEGER NOT NULL DEFAULT 1, `desktop_ip` TEXT DEFAULT NULL, `desktop_port` INTEGER DEFAULT NULL, `phone_public_key` TEXT DEFAULT NULL, `desktop_public_key` TEXT DEFAULT NULL, `token_version` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authToken", + "columnName": "auth_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pairedAtMs", + "columnName": "paired_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenMs", + "columnName": "last_seen_ms", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "desktopIp", + "columnName": "desktop_ip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPort", + "columnName": "desktop_port", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "phonePublicKey", + "columnName": "phone_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "desktopPublicKey", + "columnName": "desktop_public_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "tokenVersion", + "columnName": "token_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_desktop_pairing_sessions_auth_token", + "unique": false, + "columnNames": [ + "auth_token" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_auth_token` ON `${TABLE_NAME}` (`auth_token`)" + }, + { + "name": "index_desktop_pairing_sessions_is_active", + "unique": false, + "columnNames": [ + "is_active" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_desktop_pairing_sessions_is_active` ON `${TABLE_NAME}` (`is_active`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '07157e7e31d42fd8e0f08e31ea1fb07c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/avinas/tempo/TempoApplication.kt b/app/src/main/java/me/avinas/tempo/TempoApplication.kt index 1a121f551..6c3f231f7 100644 --- a/app/src/main/java/me/avinas/tempo/TempoApplication.kt +++ b/app/src/main/java/me/avinas/tempo/TempoApplication.kt @@ -23,6 +23,7 @@ import me.avinas.tempo.data.analytics.CrashSignatureRecorder import me.avinas.tempo.data.analytics.DbMigration import me.avinas.tempo.data.drive.BackupInterval import me.avinas.tempo.data.drive.BackupSettingsManager +import me.avinas.tempo.data.drive.DriveHistorySyncSettingsManager import me.avinas.tempo.data.drive.LocalBackupStorage import me.avinas.tempo.data.local.AppDatabase import me.avinas.tempo.data.local.dao.UserKnownArtistDao @@ -35,6 +36,7 @@ import me.avinas.tempo.utils.FrameworkRaceGuard import me.avinas.tempo.worker.AnalyticsFlushWorker import me.avinas.tempo.worker.ChallengeWorker import me.avinas.tempo.worker.DriveBackupWorker +import me.avinas.tempo.worker.DriveHistorySyncWorker import me.avinas.tempo.worker.EnrichmentWorker import me.avinas.tempo.worker.ListeningActivityWorker import me.avinas.tempo.worker.LocalBackupWorker @@ -77,6 +79,9 @@ class TempoApplication : @Inject lateinit var backupSettingsManager: BackupSettingsManager + @Inject + lateinit var driveHistorySyncSettingsManager: DriveHistorySyncSettingsManager + @Inject lateinit var crashSignatureRecorder: CrashSignatureRecorder @@ -314,6 +319,22 @@ class TempoApplication : scheduleSpotifyPollingIfEnabled() + // Restore the independent, opt-in history schedule after a restart. + applicationScope.launch { + try { + val settings = driveHistorySyncSettingsManager.settings.first() + if (settings.enabled) { + DriveHistorySyncWorker.schedule(this@TempoApplication) + } else { + DriveHistorySyncWorker.cancel(this@TempoApplication) + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("TempoApplication", "Failed to restore Drive history sync schedule", e) + } + } + Handler(Looper.getMainLooper()).postDelayed({ SpotlightUnlockWorker.scheduleWeekly(this) ChallengeWorker.scheduleDaily(this) diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveAccountSession.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveAccountSession.kt new file mode 100644 index 000000000..931c1b833 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveAccountSession.kt @@ -0,0 +1,32 @@ +package me.avinas.tempo.data.drive + +import kotlinx.coroutines.withContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +/** Keep every request and retry in a history operation on its original account. */ +internal class DriveAccountSession(private val currentEmail: () -> String?) { + private class Binding(val email: String) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + } + + suspend fun withAccount(block: suspend (String) -> T): T { + val email = normalizedEmail() ?: throw DriveException.Auth( + "Google account identity is unavailable. Reconnect Google Drive." + ) + return withContext(Binding(email)) { block(email) } + } + + suspend fun requireUnchangedAccount() { + val binding = coroutineContext[Binding] ?: return + if (normalizedEmail() != binding.email) { + throw DriveException.Auth( + "Google account changed during history sync. Connect Google again before retrying." + ) + } + } + + private fun normalizedEmail(): String? = currentEmail()?.trim()?.lowercase() + ?.takeIf { it.isNotEmpty() } +} diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveAppDataClient.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveAppDataClient.kt new file mode 100644 index 000000000..9eb190397 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveAppDataClient.kt @@ -0,0 +1,511 @@ +package me.avinas.tempo.data.drive + +import android.util.Log +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.HttpRequestInitializer +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.model.File as DriveFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import me.avinas.tempo.BuildConfig +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Minimal Google Drive appDataFolder client dedicated to cross-device history. + * It intentionally does not share the visible "Tempo Backups" folder used by + * [GoogleDriveService]. + */ +@Singleton +class DriveAppDataClient @Inject constructor( + private val authManager: GoogleAuthManager +) { + companion object { + private const val TAG = "DriveAppDataClient" + private const val APP_DATA_FOLDER = "appDataFolder" + private const val MIME_GZIP = "application/gzip" + private const val MIME_JSON = "application/json" + private const val DISABLE_MARKER_NAME = "tempo_history_control_v1.json" + private const val MAX_RETRIES = 3 + private const val RETRY_BASE_DELAY_MS = 1_500L + private const val UNCERTAIN_UPLOAD_MESSAGE = + "Google Drive upload outcome is uncertain. Sync again to verify the batch before retrying." + + /** Only read-only calls and deletes are safe to retry after an unknown outcome. + * A marker PATCH updates the server's deletion-generation timestamp. + */ + internal fun canRetryAfterAmbiguousFailure(method: String): Boolean = + method.uppercase() in setOf("GET", "HEAD", "DELETE") + + internal fun markerVersion(versions: List): Long { + if (versions.isEmpty()) return 0L + if (versions.any { it == null || it <= 0L }) { + throw DriveException.Server("Google Drive did not return a valid deletion marker version") + } + return versions.maxOf { requireNotNull(it) } + } + } + + @Volatile private var cachedClient: AuthorizedDriveClient? = null + private val accountSession = DriveAccountSession { authManager.currentAccount.value?.subject } + + suspend fun withAccountBoundSession(block: suspend (String) -> T): T = + accountSession.withAccount(block) + + private suspend fun service(): AuthorizedDriveClient = withContext(Dispatchers.IO) { + accountSession.requireUnchangedAccount() + val token = authManager.getAccessToken() + ?: throw DriveException.Auth("Google Drive authorization is unavailable") + // Token refresh can suspend while interactive sign-in selects another account. + accountSession.requireUnchangedAccount() + + cachedClient?.takeIf { it.accessToken == token }?.let { return@withContext it } + synchronized(this@DriveAppDataClient) { + cachedClient?.takeIf { it.accessToken == token } ?: AuthorizedDriveClient( + accessToken = token, + service = Drive.Builder( + NetHttpTransport(), + GsonFactory.getDefaultInstance(), + HttpRequestInitializer { request -> + request.headers.authorization = "Bearer $token" + request.connectTimeout = 30_000 + request.readTimeout = 30_000 + // The Google HTTP client must not silently replay POST + // or deletion-marker mutations below our own retry + // policy. Safe GET/DELETE retries are performed by + // executeWithRetry, where account scope is rechecked. + request.numberOfRetries = 0 + } + ) + .setApplicationName("Tempo/${BuildConfig.VERSION_NAME}") + .build() + ).also { cachedClient = it } + } + } + + private suspend fun executeWithRetry( + authAttempts: Int = 0, + transientAttempts: Int = 0, + // A Drive create POST may have succeeded even when its reply was lost. + // Do not retry such ambiguous failures inside the same upload operation: + // the next sync must first look up its deterministic batch filename. + retryOnAmbiguousFailure: Boolean = true, + block: suspend (Drive) -> T + ): T { + val client = service() + return try { + val result = block(client.service) + accountSession.requireUnchangedAccount() + result + } catch (e: GoogleJsonResponseException) { + when (e.statusCode) { + 401 -> { + clearCacheForAccessToken(client.accessToken) + if (authAttempts < 1 && + authManager.refreshAccessTokenAfterFailure(client.accessToken) + ) { + executeWithRetry(authAttempts + 1, transientAttempts, retryOnAmbiguousFailure, block) + } else { + throw DriveException.Auth("Google Drive session expired", e) + } + } + 403 -> { + val reasons = e.details?.errors.orEmpty().mapNotNull { it.reason }.toSet() + when { + reasons.any { it == "authError" || it == "insufficientPermissions" } -> { + clearCacheForAccessToken(client.accessToken) + val invalidated = authManager.invalidateAuthorizationForAccessToken( + client.accessToken + ) + if (!invalidated && authAttempts < 1) { + executeWithRetry(authAttempts + 1, transientAttempts, retryOnAmbiguousFailure, block) + } else { + throw DriveException.Auth( + "Google Drive app-data permission is missing. Reconnect Google Drive.", + e + ) + } + } + reasons.any { it.contains("rateLimit", ignoreCase = true) } -> + if (retryOnAmbiguousFailure) { + retryTransient(authAttempts, transientAttempts, e, block) + } else { + throw DriveException.Server(UNCERTAIN_UPLOAD_MESSAGE, e) + } + else -> throw DriveException.Server("Google Drive rejected the request: ${e.message}", e) + } + } + 429, 500, 502, 503, 504 -> + if (retryOnAmbiguousFailure) { + retryTransient(authAttempts, transientAttempts, e, block) + } else { + throw DriveException.Server(UNCERTAIN_UPLOAD_MESSAGE, e) + } + else -> throw DriveException.Server("Drive error ${e.statusCode}: ${e.message}", e) + } + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + if (retryOnAmbiguousFailure) { + retryTransient(authAttempts, transientAttempts, e, block) + } else { + throw DriveException.Network(UNCERTAIN_UPLOAD_MESSAGE, e) + } + } + } + + private suspend fun retryTransient( + authAttempts: Int, + transientAttempts: Int, + error: Exception, + block: suspend (Drive) -> T + ): T { + if (transientAttempts >= MAX_RETRIES) { + if (error is IOException) throw DriveException.Network("Drive network request failed", error) + throw DriveException.Server("Drive is temporarily unavailable", error) + } + val delayMs = RETRY_BASE_DELAY_MS * (1L shl transientAttempts) + Log.w(TAG, "Transient Drive failure; retrying in ${delayMs}ms", error) + delay(delayMs) + clearCache() + return executeWithRetry(authAttempts, transientAttempts + 1, block = block) + } + + /** + * Create an immutable history batch unless the same deterministic filename is + * already present. The existence check makes the operation idempotent across + * the dangerous window where Drive accepted an upload but the process died + * before Tempo could advance its local cursor. + */ + suspend fun uploadHistoryBatch( + fileName: String, + compressedBytes: ByteArray, + appProperties: Map + ): DriveAppDataFile = withContext(Dispatchers.IO) { + require(compressedBytes.size <= DriveHistoryProtocol.MAX_COMPRESSED_BYTES) { + "Tempo Drive history batch exceeds the compressed size limit" + } + val expectedMd5 = md5Hex(compressedBytes) + val expectedSha256 = sha256Hex(compressedBytes) + executeWithRetry(retryOnAmbiguousFailure = canRetryAfterAmbiguousFailure("POST")) { api -> + findFilesByExactName(api, fileName).firstOrNull { existing -> + existing.getSize()?.toLong() == compressedBytes.size.toLong() && + existing.md5Checksum.equals(expectedMd5, ignoreCase = true) && + existing.appProperties?.get(DriveHistoryProtocol.APP_PROPERTY_SHA256) == expectedSha256 && + appProperties.all { (key, value) -> existing.appProperties?.get(key) == value } + }?.let { existing -> + Log.d(TAG, "Verified existing history batch; treating retry as success: $fileName") + return@executeWithRetry existing.toAppDataFile() + } + // Do not delete an older same-name object before a validated + // replacement exists. A previous app version may have serialized + // created_at_utc differently; removing the only cloud copy during + // an upload retry would risk irreversible history loss. Drive permits + // same-name appDataFolder objects. Readers validate each checksum + // and de-duplicate by originating event ID. + // The stable batch timestamp now makes ordinary retries byte-identical. + + val metadata = DriveFile().apply { + name = fileName + parents = listOf(APP_DATA_FOLDER) + this.appProperties = appProperties + + (DriveHistoryProtocol.APP_PROPERTY_SHA256 to expectedSha256) + } + val result = api.files() + .create(metadata, ByteArrayContent(MIME_GZIP, compressedBytes)) + .setFields("id,name,size,md5Checksum,createdTime,modifiedTime,appProperties") + .execute() + require( + result.getSize()?.toLong() == compressedBytes.size.toLong() && + result.md5Checksum.equals(expectedMd5, ignoreCase = true) && + result.appProperties?.get(DriveHistoryProtocol.APP_PROPERTY_SHA256) == expectedSha256 && + appProperties.all { (key, value) -> result.appProperties?.get(key) == value } + ) { "Google Drive returned mismatched metadata for the uploaded history batch" } + result.toAppDataFile() + } + } + + /** + * Lists history batches. Passing [createdAfterMillis] keeps steady-state sync + * cheap while a new device can pass null to discover the full history. + * Filter by filename only: malformed/missing appProperties must be visible + * to client-side validation and reported as rejected history files. + */ + suspend fun listHistoryBatches(createdAfterMillis: Long? = null): List = + withContext(Dispatchers.IO) { + executeWithRetry { api -> + val clauses = mutableListOf( + "name contains '${DriveHistoryProtocol.FILE_PREFIX}'", + "trashed = false" + ) + if (createdAfterMillis != null && createdAfterMillis > 0L) { + clauses += "createdTime > '${Instant.ofEpochMilli(createdAfterMillis)}'" + } + val query = clauses.joinToString(" and ") + val files = mutableListOf() + var pageToken: String? = null + do { + val result = api.files().list() + .setSpaces(APP_DATA_FOLDER) + .setQ(query) + .setPageSize(1_000) + .setPageToken(pageToken) + .setFields("nextPageToken,files(id,name,size,md5Checksum,createdTime,modifiedTime,appProperties)") + .execute() + result.files.orEmpty().forEach { files += it.toAppDataFile() } + pageToken = result.nextPageToken + } while (!pageToken.isNullOrBlank()) + // Avoid expensive server-side ordering for large Drive archives. + // Checkpoint consumers require oldest-first processing. + files.sortedWith(compareBy { it.createdAt }.thenBy { it.fileId }) + } + } + + suspend fun download(file: DriveAppDataFile): ByteArray = withContext(Dispatchers.IO) { + require(file.sizeBytes in 1..DriveHistoryProtocol.MAX_COMPRESSED_BYTES.toLong()) { + "Tempo Drive history batch has an invalid compressed size" + } + executeWithRetry { api -> + val output = BoundedByteArrayOutputStream(DriveHistoryProtocol.MAX_COMPRESSED_BYTES) + api.files().get(file.fileId).executeMediaAndDownloadTo(output) + output.toByteArray().also { bytes -> + require(bytes.size.toLong() == file.sizeBytes) { + "Tempo Drive history batch size does not match its metadata" + } + file.md5Checksum?.takeIf { it.isNotBlank() }?.let { expected -> + require(md5Hex(bytes).equals(expected, ignoreCase = true)) { + "Tempo Drive history batch checksum does not match its metadata" + } + } + val expectedSha256 = file.appProperties[DriveHistoryProtocol.APP_PROPERTY_SHA256] + require(expectedSha256?.matches(Regex("^[0-9a-f]{64}$")) == true && + sha256Hex(bytes) == expectedSha256 + ) { "Tempo Drive history batch SHA-256 does not match its metadata" } + } + } + } + + /** + * Server-side version of the shared deletion marker. Clients persist the + * marker version they explicitly accepted when enabling sync. If another + * client later updates this marker, stale clients know a global delete was + * requested and must stop before uploading anything new. + */ + suspend fun getHistoryDisableMarkerVersion(): Long = withContext(Dispatchers.IO) { + executeWithRetry { api -> + val markers = findFilesByExactName(api, DISABLE_MARKER_NAME) + markerVersion(markers.map { it.modifiedTime?.value }) + } + } + + /** + * Create or update the single shared deletion marker and return Google's + * server-side modifiedTime. Using Drive's timestamp avoids device-clock skew. + */ + suspend fun bumpHistoryDisableMarker(): Long = withContext(Dispatchers.IO) { + // Deliberately no generic executeWithRetry around this multi-step + // mutation. A POST or PATCH can already be committed when Google Drive + // times out or its response is lost. Retrying the whole block (even on + // a later 401) would publish another deletion generation. + // On failure, the caller must inspect the remote marker before a + // separate explicit delete attempt. + val api = service().service + accountSession.requireUnchangedAccount() + val payload = JSONObject() + .put("schema_version", 1) + .put("history_sync_disabled", true) + .toString() + .toByteArray(Charsets.UTF_8) + val media = ByteArrayContent(MIME_JSON, payload) + val previousVersion = markerVersion(findFilesByExactName(api, DISABLE_MARKER_NAME) + .map { it.modifiedTime?.value }) + var markers = findFilesByExactName(api, DISABLE_MARKER_NAME) + if (markers.isEmpty()) { + api.files() + .create( + DriveFile().apply { + name = DISABLE_MARKER_NAME + parents = listOf(APP_DATA_FOLDER) + appProperties = mapOf("tempo_kind" to "history_sync_control") + }, + media + ) + .setFields("id,name,size,md5Checksum,createdTime,modifiedTime,appProperties") + .execute() + } + // Update every same-name marker. This converges concurrent first-use + // creates and ensures all clients observe the maximum server version. + var markerVersion = 0L + for (attempt in 0 until 3) { + markers = findFilesByExactName(api, DISABLE_MARKER_NAME) + for (marker in markers) { + val updated = api.files() + .update( + marker.id, + DriveFile().apply { + appProperties = mapOf( + "tempo_kind" to "history_sync_control", + "revision" to UUID.randomUUID().toString() + ) + }, + media + ) + .setFields("id,name,size,md5Checksum,createdTime,modifiedTime,appProperties") + .execute() + markerVersion = maxOf(markerVersion, Companion.markerVersion(listOf(updated.modifiedTime?.value))) + } + if (markerVersion > previousVersion) break + if (attempt < 2) delay(5L) + } + markerVersion.takeIf { it > previousVersion } + ?: throw DriveException.Server("Google Drive did not return a valid deletion marker version") + } + + /** Best-effort single-file delete retained for non-destructive callers. */ + suspend fun delete(fileId: String): Boolean = withContext(Dispatchers.IO) { + try { + deleteStrict(fileId) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to delete appData file $fileId", e) + false + } + } + + /** + * Delete only batches from generations older than [generation]. Old clients + * and pre-upgrade files have no generation property and are treated as 0. + * This is the key race guard: after a user deliberately re-enables sync and + * starts publishing generation N, a stale device honoring the deletion marker + * may clean generation < N but can never erase the newly seeded generation N. + */ + suspend fun deleteHistoryBatchesBeforeGeneration(generation: Long, ownerDelete: Boolean = false): Int = + withContext(Dispatchers.IO) { + require(generation >= 0L) { "Drive history generation cannot be negative" } + val files = listHistoryBatches() + var deleted = 0 + for (file in files) { + if (!DriveHistoryProtocol.shouldDeleteHistoryBatch( + file.fileName, + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_GENERATION], + generation, + ownerDelete + )) continue + deleteStrict(file.fileId) + deleted++ + } + deleted + } + + private suspend fun deleteStrict(fileId: String) { + try { + executeWithRetry { api -> api.files().delete(fileId).execute() } + } catch (e: DriveException.Server) { + // Another linked client may have deleted the same immutable batch. + if (!e.message.orEmpty().contains("Drive error 404")) throw e + } + } + + @Synchronized + fun clearCache() { + cachedClient = null + } + + @Synchronized + private fun clearCacheForAccessToken(accessToken: String) { + if (cachedClient?.accessToken == accessToken) cachedClient = null + } + + private fun findFilesByExactName(api: Drive, fileName: String): List { + val safeName = fileName + .replace("\\", "\\\\") + .replace("'", "\\'") + val files = mutableListOf() + var pageToken: String? = null + do { + val page = api.files().list() + .setSpaces(APP_DATA_FOLDER) + .setQ("name = '$safeName' and trashed = false") + .setPageSize(1_000) + .setPageToken(pageToken) + .setFields("nextPageToken,files(id,name,size,md5Checksum,createdTime,modifiedTime,appProperties)") + .execute() + files += page.files.orEmpty() + pageToken = page.nextPageToken + } while (!pageToken.isNullOrBlank()) + return files + } + + private fun md5Hex(bytes: ByteArray): String = + MessageDigest.getInstance("MD5") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + + private fun DriveFile.toAppDataFile(): DriveAppDataFile = DriveAppDataFile( + fileId = id, + fileName = name, + sizeBytes = getSize()?.toLong() ?: 0L, + md5Checksum = md5Checksum, + createdAt = createdTime?.value ?: 0L, + modifiedAt = modifiedTime?.value ?: 0L, + appProperties = appProperties.orEmpty() + ) + + /** + * ByteArrayOutputStream with a hard cap so a corrupt or unexpectedly large + * remote file cannot allocate unbounded heap before protocol validation runs. + */ + private class BoundedByteArrayOutputStream( + private val maxBytes: Int + ) : ByteArrayOutputStream() { + private fun checkAdditional(additional: Int) { + require(additional >= 0 && count <= maxBytes - additional) { + "Tempo Drive history batch exceeds the compressed size limit" + } + } + + override fun write(b: Int) { + checkAdditional(1) + super.write(b) + } + + override fun write(b: ByteArray, off: Int, len: Int) { + checkAdditional(len) + super.write(b, off, len) + } + } + + /** Keep the Drive service and its immutable Authorization header together. */ + private data class AuthorizedDriveClient( + val accessToken: String, + val service: Drive + ) +} + +data class DriveAppDataFile( + val fileId: String, + val fileName: String, + val sizeBytes: Long, + val md5Checksum: String?, + val createdAt: Long, + val modifiedAt: Long, + val appProperties: Map +) diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryEventMapper.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryEventMapper.kt new file mode 100644 index 000000000..28edb1fb8 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryEventMapper.kt @@ -0,0 +1,116 @@ +package me.avinas.tempo.data.drive + +import me.avinas.tempo.data.local.AppDatabase +import me.avinas.tempo.data.local.entities.ListeningEvent +import me.avinas.tempo.data.repository.TrackResolver + +/** Maps between Tempo listening rows and the immutable Drive history wire format. */ +internal const val DRIVE_IMPORT_FINGERPRINT_PREFIX = "drive:v1:" + +internal class DriveHistoryEventMapper( + private val database: AppDatabase, + private val trackResolver: TrackResolver, + private val deviceId: String, +) { + suspend fun localEventToProtocol( + event: ListeningEvent, + persistedOriginId: String? + ): DriveHistoryEvent? { + val track = database.trackDao().getTrackById(event.track_id) ?: return null + val title = DriveHistoryProtocol.truncateText(track.title.trim()).takeIf { it.isNotBlank() } ?: return null + val artist = DriveHistoryProtocol.truncateText(track.artist.trim()).takeIf { it.isNotBlank() } ?: return null + if (event.timestamp !in 1..DriveHistoryProtocol.MAX_WIRE_INTEGER) return null + val durationMs = (event.estimatedDurationMs ?: track.duration ?: event.playDuration) + .coerceAtLeast(event.playDuration) + .coerceAtLeast(0L) + .coerceAtMost(DriveHistoryProtocol.MAX_WIRE_INTEGER) + // A LAN import carries the precise original event ID and music source. + // Publish it through this Android account only if the sender has not + // used Drive itself; the remote clients will deduplicate using event_id. + // A *cloud* import never enters this export path. + val lanRelay = event.source.startsWith("lan:") + val origin = if (lanRelay) { + DriveHistorySyncManager.lanRelayIdentity(event.source, event.contentFingerprint) ?: return null + } else null + val originalSource = origin?.first ?: event.source + val sourceApp = originalSource + .removePrefix("desktop:") + .removePrefix("browser:") + .ifBlank { "android" } + .let { DriveHistoryProtocol.truncateText(it) } + val source = DriveHistoryProtocol.truncateText(originalSource.ifBlank { "android" }) + val originalEventId = origin?.second + + return DriveHistoryEvent( + eventId = originalEventId ?: DriveHistorySyncManager.stableLocalEventId( + existingOriginId = persistedOriginId, + deviceId = deviceId, + localEventId = event.id, + timestampUtc = event.timestamp, + title = title, + artist = artist + ), + title = title, + artist = artist, + album = track.album?.trim()?.let { DriveHistoryProtocol.truncateText(it) }?.takeIf { it.isNotBlank() }, + timestampUtc = event.timestamp, + durationMs = durationMs, + listenedMs = event.playDuration.coerceIn(0L, DriveHistoryProtocol.MAX_WIRE_INTEGER), + sourceApp = sourceApp, + source = source, + skipped = event.wasSkipped, + replayCount = if (event.isReplay) 1 else 0, + completionPercentage = event.completionPercentage.coerceIn(0, 100), + pauseCount = event.pauseCount.coerceAtLeast(0), + seekCount = event.seekCount.coerceAtLeast(0), + sessionId = event.sessionId?.let { DriveHistoryProtocol.truncateText(it) }, + site = null, + contentType = DriveHistoryProtocol.truncateText(track.contentType.ifBlank { "MUSIC" }), + volumeLevel = DriveHistorySyncManager.protocolVolumeLevel(event.volumeLevel), + totalPauseDurationMs = event.totalPauseDurationMs + .coerceIn(0L, DriveHistoryProtocol.MAX_WIRE_INTEGER), + positionUpdatesCount = event.positionUpdatesCount.coerceAtLeast(0) + ) + } + + + suspend fun protocolEventToLocal( + event: DriveHistoryEvent, + sourceDeviceId: String + ): ListeningEvent? { + if (event.timestampUtc <= 0L || event.title.isBlank() || event.artist.isBlank()) return null + + val resolution = trackResolver.resolve( + TrackResolver.Query( + title = event.title.trim(), + artist = event.artist.trim(), + album = event.album?.trim()?.takeIf { it.isNotBlank() }, + duration = event.durationMs.takeIf { it > 0L } + ), + contentType = event.contentType.ifBlank { "MUSIC" } + ) + + val listenedMs = event.listenedMs.coerceAtLeast(0L) + return ListeningEvent( + track_id = resolution.trackId, + timestamp = event.timestampUtc, + playDuration = listenedMs, + completionPercentage = event.completionPercentage.coerceIn(0, 100), + source = "drive:$sourceDeviceId:${event.source.ifBlank { event.sourceApp }}", + wasSkipped = event.skipped, + isReplay = event.replayCount > 0, + estimatedDurationMs = event.durationMs.takeIf { it > 0L }, + pauseCount = event.pauseCount.coerceAtLeast(0), + sessionId = event.sessionId, + endTimestamp = if (listenedMs > 0L) event.timestampUtc + listenedMs else null, + totalPauseDurationMs = event.totalPauseDurationMs.coerceAtLeast(0L), + seekCount = event.seekCount.coerceAtLeast(0), + positionUpdatesCount = event.positionUpdatesCount.coerceAtLeast(0), + wasInterrupted = event.skipped, + volumeLevel = event.volumeLevel?.takeIf { it == 0 }, + contentFingerprint = "$DRIVE_IMPORT_FINGERPRINT_PREFIX${event.eventId}" + ) + } + + +} diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryProtocol.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryProtocol.kt new file mode 100644 index 000000000..bd9d6bbf1 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistoryProtocol.kt @@ -0,0 +1,430 @@ +package me.avinas.tempo.data.drive + +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.security.MessageDigest +import java.util.UUID +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Versioned, platform-neutral protocol used to exchange listening history through + * Google Drive's appDataFolder. Android, the browser extension and Tempo Desktop + * must keep this wire format compatible. + * + * Drive is only the transport. The local databases remain the source of truth on + * each device and every imported event goes through Tempo's normal dedup pipeline. + */ +object DriveHistoryProtocol { + const val SCHEMA_VERSION = 1 + const val FILE_PREFIX = "tempo_history_v1_" + const val APP_PROPERTY_KIND = "tempo_kind" + const val APP_PROPERTY_SCHEMA = "tempo_schema" + const val APP_PROPERTY_DEVICE_ID = "source_device_id" + const val APP_PROPERTY_PLATFORM = "source_platform" + const val APP_PROPERTY_GENERATION = "tempo_generation" + const val APP_PROPERTY_SHA256 = "tempo_sha256" + const val KIND_HISTORY_BATCH = "history_batch" + + // Keep hostile/corrupt Drive files from forcing unbounded allocations. These + // limits mirror the Desktop reader and are deliberately far above normal + // 50-event Tempo batches. + const val MAX_COMPRESSED_BYTES = 10 * 1024 * 1024 + const val MAX_DECOMPRESSED_BYTES = 10 * 1024 * 1024 + const val MAX_EVENTS_PER_BATCH = 1_000 + private const val MAX_PRIMARY_TEXT_LENGTH = 1_000 + const val MAX_WIRE_INTEGER = 9_007_199_254_740_991L + private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + private val DEVICE_ID_PATTERN = Regex("^[A-Za-z0-9._-]{1,200}$") + private val PLATFORM_PATTERN = Regex("^[A-Za-z0-9._-]{1,100}$") + + /** Kept for callers that need a one-off identifier outside retryable uploads. */ + fun newBatchId(): String = sha256(UUID.randomUUID().toString()) + + /** + * Generation is the Google-server timestamp of the deletion marker this + * client explicitly accepted. It is part of the filename as well as Drive + * metadata so a post-delete re-seed can never collide with an old immutable + * batch that happens to contain the same deterministic event IDs. + */ + fun fileName(deviceId: String, batchId: String, generation: Long = 0L): String { + require(isValidDeviceId(deviceId)) { "Invalid Tempo Drive device id" } + require(SHA256_PATTERN.matches(batchId)) { "Invalid Tempo Drive batch id" } + require(generation in 0..MAX_WIRE_INTEGER) { "Invalid Tempo Drive generation" } + return "${FILE_PREFIX}g${generation}_${deviceId}_${batchId}.json.gz" + } + + /** Missing generation metadata is legacy v1; malformed metadata is never generation zero. */ + fun parseGeneration(value: String?): Long? { + if (value == null) return 0L + if (value.isEmpty() || value.any { it !in '0'..'9' }) return null + return value.toLongOrNull()?.takeIf { it in 0..MAX_WIRE_INTEGER } + } + + /** Decide cleanup using both the immutable filename and Drive metadata. + * Damaged/missing appProperties cannot make a current-generation filename + * look like an old batch. Only the account owner's deliberate delete may + * remove a file whose generation is unrecognizable. + */ + fun shouldDeleteHistoryBatch( + fileName: String, + metadataGeneration: String?, + markerGeneration: Long, + ownerDelete: Boolean, + ): Boolean { + if (!fileName.startsWith(FILE_PREFIX)) return false + if (!fileName.endsWith(".json.gz")) return ownerDelete + + val encoded = fileName.removePrefix(FILE_PREFIX).substringBefore('_') + val nameGeneration = encoded.takeIf { it.startsWith('g') }?.drop(1) + ?.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } } + ?.toLongOrNull()?.takeIf { it in 0..MAX_WIRE_INTEGER } + if (nameGeneration != null && nameGeneration >= markerGeneration) return false + + val declaredGeneration = parseGeneration(metadataGeneration) + return declaredGeneration?.let { it < markerGeneration } ?: ownerDelete + } + + /** The wire limit counts UTF-16 units; never split a supplementary code point. */ + internal fun truncateText(value: String, maxLength: Int = MAX_PRIMARY_TEXT_LENGTH): String { + require(maxLength >= 0) + var end = minOf(value.length, maxLength) + if (end > 0 && end < value.length && + Character.isHighSurrogate(value[end - 1]) && Character.isLowSurrogate(value[end]) + ) end-- + return value.substring(0, end) + } + + private fun isWellFormedText(value: String): Boolean { + var index = 0 + while (index < value.length) { + val unit = value[index++] + if (Character.isHighSurrogate(unit)) { + if (index >= value.length || !Character.isLowSurrogate(value[index++])) return false + } else if (Character.isLowSurrogate(unit)) return false + } + return true + } + + /** + * Deterministic batch id derived only from the ordered event ids. + * + * A network upload may reach Drive even if the client is killed before it can + * persist its local cursor/flag. Retrying the same chunk must therefore target + * the same Drive filename instead of creating a second immutable copy. + */ + fun createBatchId(events: List): String { + require(events.isNotEmpty()) { "A history batch cannot be empty" } + require(events.all { SHA256_PATTERN.matches(it.eventId) }) { + "A history batch contains an invalid event id" + } + return sha256( + buildString { + append("tempo-batch-v1") + events.forEach { event -> + append('|') + append(event.eventId) + } + } + ) + } + + /** + * Stable event id for a locally-owned database row. It deliberately includes + * the random Tempo device id so integer row ids from two devices can never + * collide. Imported Drive events retain their original event_id instead of + * generating a new one, preventing sync loops. + */ + fun createEventId( + deviceId: String, + localEventId: Long, + timestampUtc: Long, + title: String, + artist: String + ): String { + require(isValidDeviceId(deviceId)) { "Invalid Tempo Drive device id" } + require(localEventId >= 0L) { "Tempo local event id cannot be negative" } + require(timestampUtc in 1..MAX_WIRE_INTEGER) { "Invalid Tempo event timestamp" } + require(title.isNotBlank() && artist.isNotBlank()) { "Tempo event identity is incomplete" } + require(isWellFormedText(title) && isWellFormedText(artist)) { "Malformed Tempo event identity text" } + val canonical = buildString { + append("tempo-history-v1|") + append(deviceId) + append('|') + append(localEventId) + append('|') + append(timestampUtc) + append('|') + append(title.trim().lowercase()) + append('|') + append(artist.trim().lowercase()) + } + return sha256(canonical) + } + + fun encodeCompressed(batch: DriveHistoryBatch): ByteArray { + validateBatch(batch) + val json = JSONObject().apply { + put("schema_version", batch.schemaVersion) + put("batch_id", batch.batchId) + put("source_device_id", batch.sourceDeviceId) + put("source_device_name", batch.sourceDeviceName) + put("source_platform", batch.sourcePlatform) + put("created_at_utc", batch.createdAtUtc) + put("events", JSONArray().apply { + batch.events.forEach { put(eventToJson(it)) } + }) + }.toString().toByteArray(Charsets.UTF_8) + require(json.size <= MAX_DECOMPRESSED_BYTES) { + "Tempo Drive history batch exceeds the decompressed size limit" + } + + return ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { gzip -> gzip.write(json) } + output.toByteArray().also { compressed -> + require(compressed.size <= MAX_COMPRESSED_BYTES) { + "Tempo Drive history batch exceeds the compressed size limit" + } + } + } + } + + fun decodeCompressed(bytes: ByteArray): DriveHistoryBatch { + require(bytes.size <= MAX_COMPRESSED_BYTES) { + "Tempo Drive history batch exceeds the compressed size limit" + } + + val jsonBytes = GZIPInputStream(ByteArrayInputStream(bytes)).use { input -> + ByteArrayOutputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + require(total <= MAX_DECOMPRESSED_BYTES) { + "Tempo Drive history batch expands beyond the safe size limit" + } + output.write(buffer, 0, read) + } + output.toByteArray() + } + } + val text = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(jsonBytes)).toString() + val root = JSONObject(text) + val schema = root.requireInt("schema_version", 0, Int.MAX_VALUE) + require(schema == SCHEMA_VERSION) { + "Unsupported Tempo history schema: $schema" + } + + val batchId = root.requireString("batch_id", 64) + val sourceDeviceId = root.requireString("source_device_id", 200) + require(SHA256_PATTERN.matches(batchId) && isValidDeviceId(sourceDeviceId)) { + "Malformed Tempo Drive history batch identity" + } + + val eventsJson = root.optJSONArray("events") ?: JSONArray() + require(eventsJson.length() <= MAX_EVENTS_PER_BATCH) { + "Tempo Drive history batch contains too many events" + } + val events = ArrayList(eventsJson.length()) + for (index in 0 until eventsJson.length()) { + events.add(eventFromJson(eventsJson.getJSONObject(index))) + } + val batch = DriveHistoryBatch( + schemaVersion = schema, + batchId = batchId, + sourceDeviceId = sourceDeviceId, + sourceDeviceName = root.requireString("source_device_name", MAX_PRIMARY_TEXT_LENGTH), + sourcePlatform = root.requireString("source_platform", 100), + createdAtUtc = root.requireLong("created_at_utc", 1L, MAX_WIRE_INTEGER), + events = events + ) + validateBatch(batch) + return batch + } + + private fun eventToJson(event: DriveHistoryEvent): JSONObject = JSONObject().apply { + put("event_id", event.eventId) + put("title", event.title) + put("artist", event.artist) + putNullable("album", event.album) + put("timestamp_utc", event.timestampUtc) + put("duration_ms", event.durationMs) + put("listened_ms", event.listenedMs) + put("source_app", event.sourceApp) + put("source", event.source) + put("skipped", event.skipped) + put("replay_count", event.replayCount) + put("completion_percentage", event.completionPercentage) + put("pause_count", event.pauseCount) + put("seek_count", event.seekCount) + putNullable("session_id", event.sessionId) + putNullable("site", event.site) + put("content_type", event.contentType) + putNullable("volume_level", event.volumeLevel) + put("total_pause_duration_ms", event.totalPauseDurationMs) + put("position_updates_count", event.positionUpdatesCount) + } + + private fun eventFromJson(json: JSONObject): DriveHistoryEvent = DriveHistoryEvent( + eventId = json.requireString("event_id", 64).also { + require(SHA256_PATTERN.matches(it)) { "Malformed Tempo Drive event id" } + }, + title = json.requireString("title", MAX_PRIMARY_TEXT_LENGTH), + artist = json.requireString("artist", MAX_PRIMARY_TEXT_LENGTH), + album = json.optNullableString("album"), + timestampUtc = json.requireLong("timestamp_utc", 1L, MAX_WIRE_INTEGER), + durationMs = json.requireLong("duration_ms", 0L, MAX_WIRE_INTEGER), + listenedMs = json.requireLong("listened_ms", 0L, MAX_WIRE_INTEGER), + sourceApp = json.requireString("source_app", MAX_PRIMARY_TEXT_LENGTH), + source = json.requireString("source", MAX_PRIMARY_TEXT_LENGTH), + skipped = json.requireBoolean("skipped"), + replayCount = json.requireInt("replay_count", 0, Int.MAX_VALUE), + completionPercentage = json.requireInt("completion_percentage", 0, 100), + pauseCount = json.requireInt("pause_count", 0, Int.MAX_VALUE), + seekCount = json.requireInt("seek_count", 0, Int.MAX_VALUE), + sessionId = json.optNullableString("session_id"), + site = json.optNullableString("site"), + contentType = json.requireString("content_type", MAX_PRIMARY_TEXT_LENGTH), + volumeLevel = if (json.has("volume_level") && json.isNull("volume_level")) { + null + } else { + json.requireInt("volume_level", 0, 100) + }, + totalPauseDurationMs = json.requireLong("total_pause_duration_ms", 0L, MAX_WIRE_INTEGER), + positionUpdatesCount = json.requireInt("position_updates_count", 0, Int.MAX_VALUE) + ) + + private fun validateBatch(batch: DriveHistoryBatch) { + require(batch.schemaVersion == SCHEMA_VERSION) { "Unsupported Tempo history schema" } + require(SHA256_PATTERN.matches(batch.batchId)) { "Malformed Tempo Drive batch id" } + require(isValidDeviceId(batch.sourceDeviceId)) { "Malformed Tempo Drive device id" } + require(batch.sourceDeviceName.isNotBlank() && batch.sourceDeviceName.length <= MAX_PRIMARY_TEXT_LENGTH) { + "Malformed Tempo Drive device name" + } + require(isWellFormedText(batch.sourceDeviceName)) { "Malformed Tempo Drive device name text" } + require(PLATFORM_PATTERN.matches(batch.sourcePlatform)) { "Malformed Tempo Drive source platform" } + require(batch.createdAtUtc in 1..MAX_WIRE_INTEGER) { "Malformed Tempo Drive batch timestamp" } + require(batch.events.isNotEmpty()) { "Tempo Drive history batch is empty" } + require(batch.events.size <= MAX_EVENTS_PER_BATCH) { "Tempo Drive history batch contains too many events" } + batch.events.forEach { event -> + require(listOfNotNull(event.title, event.artist, event.album, event.sourceApp, event.source, + event.sessionId, event.site, event.contentType).all(::isWellFormedText)) { + "Malformed Tempo event Unicode text" + } + require(SHA256_PATTERN.matches(event.eventId)) { "Malformed Tempo Drive event id" } + require(event.title.isNotBlank() && event.title.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo event title" } + require(event.artist.isNotBlank() && event.artist.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo event artist" } + require(event.timestampUtc in 1..MAX_WIRE_INTEGER) { "Malformed Tempo event timestamp" } + require(event.durationMs in 0..MAX_WIRE_INTEGER && event.listenedMs in 0..MAX_WIRE_INTEGER) { + "Malformed Tempo event duration" + } + require(event.sourceApp.isNotBlank() && event.sourceApp.length <= MAX_PRIMARY_TEXT_LENGTH && + event.source.isNotBlank() && event.source.length <= MAX_PRIMARY_TEXT_LENGTH + ) { "Malformed Tempo event source" } + require(event.album == null || event.album.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo event album" } + require(event.replayCount >= 0 && event.completionPercentage in 0..100 && + event.pauseCount >= 0 && event.seekCount >= 0 && event.positionUpdatesCount >= 0 + ) { "Malformed Tempo event counters" } + require(event.sessionId == null || event.sessionId.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo event session" } + require(event.site == null || event.site.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo event site" } + require(event.contentType.isNotBlank() && event.contentType.length <= MAX_PRIMARY_TEXT_LENGTH) { + "Malformed Tempo event content type" + } + require(event.totalPauseDurationMs in 0..MAX_WIRE_INTEGER) { "Malformed Tempo event pause duration" } + require(event.volumeLevel == null || event.volumeLevel in 0..100) { "Malformed Tempo event volume" } + } + require(createBatchId(batch.events) == batch.batchId) { + "Tempo Drive history batch id does not match its events" + } + } + + fun isValidDeviceId(value: String): Boolean = DEVICE_ID_PATTERN.matches(value) + + private fun JSONObject.putNullable(key: String, value: Any?) { + if (value == null) put(key, JSONObject.NULL) else put(key, value) + } + + private fun JSONObject.optNullableString(key: String): String? { + require(has(key)) { "Malformed Tempo field: $key" } + if (isNull(key)) return null + val value = get(key) + require(value is String && value.length <= MAX_PRIMARY_TEXT_LENGTH) { "Malformed Tempo field: $key" } + return value.takeIf { it.isNotBlank() } + } + + private fun JSONObject.requireString(key: String, maxLength: Int): String { + val value = get(key) + require(value is String && value.isNotBlank() && value.length <= maxLength) { + "Malformed Tempo field: $key" + } + return value + } + + private fun JSONObject.requireLong(key: String, min: Long, max: Long): Long { + val number = get(key) + require(number is Number) { "Malformed Tempo field: $key" } + val double = number.toDouble() + val value = number.toLong() + require(double.isFinite() && value.toDouble() == double && value in min..max) { + "Malformed Tempo field: $key" + } + return value + } + + private fun JSONObject.requireInt(key: String, min: Int, max: Int): Int = + requireLong(key, min.toLong(), max.toLong()).toInt() + + private fun JSONObject.requireBoolean(key: String): Boolean { + val value = get(key) + require(value is Boolean) { "Malformed Tempo field: $key" } + return value + } + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} + +data class DriveHistoryBatch( + val schemaVersion: Int = DriveHistoryProtocol.SCHEMA_VERSION, + val batchId: String, + val sourceDeviceId: String, + val sourceDeviceName: String, + val sourcePlatform: String, + val createdAtUtc: Long, + val events: List +) + +data class DriveHistoryEvent( + val eventId: String, + val title: String, + val artist: String, + val album: String?, + val timestampUtc: Long, + val durationMs: Long, + val listenedMs: Long, + val sourceApp: String, + val source: String, + val skipped: Boolean, + val replayCount: Int, + val completionPercentage: Int, + val pauseCount: Int, + val seekCount: Int, + val sessionId: String?, + val site: String?, + val contentType: String, + /** 0 means muted; any positive value means audible; null means unknown. */ + val volumeLevel: Int?, + val totalPauseDurationMs: Long, + val positionUpdatesCount: Int +) diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncManager.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncManager.kt new file mode 100644 index 000000000..6ba72adda --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncManager.kt @@ -0,0 +1,1060 @@ +package me.avinas.tempo.data.drive + +import android.content.Context +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import me.avinas.tempo.data.local.AppDatabase +import me.avinas.tempo.data.local.entities.ListeningEvent +import me.avinas.tempo.data.local.entities.ListeningEventOrigin +import me.avinas.tempo.data.repository.TrackResolver +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Bidirectional cross-device history sync through Google Drive appDataFolder. + * + * Local -> Drive uses an id cursor over Room rows and immutable compressed batch + * files. Drive -> Local resolves tracks through Tempo's normal TrackResolver and + * inserts listening events through the source-aware dedup pipeline. + */ +@Singleton +class DriveHistorySyncManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val database: AppDatabase, + private val trackResolver: TrackResolver, + private val appDataClient: DriveAppDataClient, + private val authManager: GoogleAuthManager, + private val settingsManager: DriveHistorySyncSettingsManager +) { + companion object { + private const val TAG = "DriveHistorySync" + private const val STATE_PREFS = "drive_history_sync_state" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_UPLOAD_CURSOR = "upload_cursor" + private const val KEY_DOWNLOAD_CREATED_CURSOR = "download_created_cursor" + private const val KEY_ACCEPTED_DISABLE_VERSION = "accepted_disable_marker_version" + private const val KEY_GOOGLE_ACCOUNT_EMAIL = "google_account_email" + private const val KEY_GOOGLE_ACCOUNT_SUBJECT = "google_account_subject" + private const val KEY_INVALID_EXPORT_IDS = "invalid_drive_export_ids" + private const val KEY_REJECTED_FILES = "drive_rejected_files" + private const val KEY_SUPPRESSED_THROUGH = "drive_cloud_suppressed_through" + private const val KEY_UPLOADED_BEFORE_DELETION = "drive_uploaded_before_deletion" + private const val KEY_AUTHORIZED_OLD_LAN_THROUGH = "drive_authorized_old_lan_through" + private const val PAGE_SIZE = 200 + private const val BATCH_SIZE = 50 + private const val DOWNLOAD_OVERLAP_MS = 24L * 60L * 60L * 1000L + + internal data class RejectedBatchRecord( + val fileId: String, + val fileName: String, + val reason: String, + val recordedAt: Long, + ) { + fun serialize(): String = "$fileId\t$fileName\t${reason.replace('\t', ' ').replace('\n', ' ')}\t$recordedAt" + companion object { + fun parse(raw: String): RejectedBatchRecord? { + val parts = raw.split('\t') + if (parts.size < 4) return null + return RejectedBatchRecord( + fileId = parts[0], + fileName = parts[1], + reason = parts[2], + recordedAt = parts[3].toLongOrNull() ?: 0L, + ) + } + } + } + + /** + * Original identity of a LAN-delivered event. Only authenticated LAN + * payloads with an intact producer ID and event fingerprint qualify. + * Keep the original ID on Drive so relaying cannot double-count a play. + */ + /** + * Once an Android-native play has an origin ID, its identity must not + * change if the user later corrects the track's title or artist. + */ + internal fun stableLocalEventId( + existingOriginId: String?, + deviceId: String, + localEventId: Long, + timestampUtc: Long, + title: String, + artist: String + ): String { + if (existingOriginId != null) { + require(existingOriginId.matches(Regex("^[0-9a-f]{64}$"))) { + "Invalid persisted Android playback origin" + } + return existingOriginId + } + return DriveHistoryProtocol.createEventId( + deviceId = deviceId, + localEventId = localEventId, + timestampUtc = timestampUtc, + title = title, + artist = artist + ) + } + + /** + * A local database restore can remap listening-row IDs for every Google + * account. Reset upload/download cursors and row-specific diagnostics, + * but NEVER forget a previously accepted cloud deletion generation: + * those server checkpoints are independent of Room row IDs. Otherwise + * switching A -> B -> A could re-accept an old deletion marker and + * suppress legitimate post-deletion history. + */ + internal fun isPerAccountRestoreStateKey(key: String): Boolean = + key.startsWith("${KEY_UPLOAD_CURSOR}:") || + key.startsWith("${KEY_DOWNLOAD_CREATED_CURSOR}:") || + key.startsWith("${KEY_REJECTED_FILES}:") || + key.startsWith("${KEY_INVALID_EXPORT_IDS}:") + + /** Only a complete Drive listing can prove an older rejected file was deleted. + * Incremental queries omit files outside the 24-hour overlap. + */ + internal fun retainedRejectedRecords( + previous: Set, + listedFileIds: Set, + completeListing: Boolean + ): Set = if (!completeListing) previous else previous.filterTo(mutableSetOf()) { + listedFileIds.contains(it.substringBefore('\t')) + } + + /** + * A failed cleanup of already-deleted cloud generations must not block + * importing current-generation batches. Never swallow cancellation. + */ + internal suspend fun cleanupStaleBatchBestEffort( + delete: suspend () -> Unit, + onError: (Exception) -> Unit, + ): Boolean = try { + delete() + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onError(e) + false + } + + + /** Local row IDs fence the deletion snapshot; a capture made AFTER + * the server-timestamped deletion is new history, even when the phone + * only learns about the deletion after recording it offline. + */ + internal fun isCloudSuppressedRow( + eventId: Long, + suppressedThrough: Long, + recordedAtUtc: Long, + deletionMarkerUtc: Long, + uploadedBeforeDeletion: Long, + ): Boolean = eventId <= suppressedThrough && + (recordedAtUtc <= deletionMarkerUtc || eventId <= uploadedBeforeDeletion) + + /** A LAN pairing does not prove Google identity. Reject unknown owners + * and old deliveries arriving after cloud deletion unless the user + * explicitly authorized the bounded local snapshot. + */ + internal fun canRelayLanToCloud( + owner: String?, + currentSubject: String, + timestampUtc: Long, + eventId: Long, + deletionMarkerUtc: Long, + authorizedOldLanThrough: Long, + ): Boolean = owner == currentSubject && + (deletionMarkerUtc <= 0L || timestampUtc > deletionMarkerUtc || + eventId <= authorizedOldLanThrough) + + /** Pure resume rule: existing per-subject progress always wins over + * global progress from the account that happened to be active last. + */ + + internal fun chooseAccountUploadCursor( + savedForSubject: Long?, + legacyEmailCursor: Long?, + isNewAccount: Boolean, + previouslyActiveCursor: Long, + newestLocalRow: Long, + ): Long = savedForSubject ?: legacyEmailCursor ?: + if (isNewAccount) newestLocalRow else previouslyActiveCursor + + internal fun lanBatchProducer(source: String): BatchProducer? { + val parts = source.split(':', limit = 3) + if (parts.size != 3 || parts[0] != "lan" || + !DriveHistoryProtocol.isValidDeviceId(parts[1]) || parts[2].isBlank() + ) return null + val platform = when { + parts[2].startsWith("desktop:") -> "desktop" + parts[2].startsWith("browser:") -> "browser" + else -> "android" + } + return BatchProducer(parts[1], platform) + } + + internal fun lanRelayIdentity(source: String, fingerprint: String?): Pair? { + val parts = source.split(':', limit = 3) + if (parts.size != 3 || parts[0] != "lan" || + !DriveHistoryProtocol.isValidDeviceId(parts[1]) || parts[2].isBlank() + ) return null + val rawFingerprint = fingerprint ?: return null + if (!rawFingerprint.startsWith(DRIVE_IMPORT_FINGERPRINT_PREFIX)) return null + val eventId = rawFingerprint.removePrefix(DRIVE_IMPORT_FINGERPRINT_PREFIX) + if (!eventId.matches(Regex("^[0-9a-f]{64}$"))) return null + return parts[2] to eventId + } + + /** + * Android stores the device-specific audio stream index, not a portable + * percentage. Preserve a known mute as 0 and report non-zero indices as + * unknown instead of misrepresenting (for example) level 8/15 as 8%. + */ + internal fun protocolVolumeLevel(androidStreamIndex: Int?): Int? = when (androidStreamIndex) { + 0 -> 0 + else -> null + } + } + + private val mutex = Mutex() + private val eventMapper by lazy { DriveHistoryEventMapper(database, trackResolver, deviceId) } + private val statePrefs by lazy { + context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + } + + val deviceId: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + val existing = statePrefs.getString(KEY_DEVICE_ID, null) + if (existing != null && DriveHistoryProtocol.isValidDeviceId(existing)) return@lazy existing + val generated = UUID.randomUUID().toString() + check(statePrefs.edit().putString(KEY_DEVICE_ID, generated).commit()) { + "Could not persist Tempo's Drive device identity" + } + generated + } + + val deviceName: String + get() = "Tempo Android" + + /** + * Explicit user opt-in. A shared deletion marker is acknowledged only here, + * never silently by a background worker. Re-enabling does not authorize + * republishing history previously removed from Drive. + */ + suspend fun enableSync(): Boolean = mutex.withLock { + withContext(Dispatchers.IO) { + if (!ensureAuthorized()) return@withContext false + appDataClient.withAccountBoundSession { accountEmail -> + reconcileGoogleAccountBoundary(accountEmail) + val currentMarker = appDataClient.getHistoryDisableMarkerVersion() + val acceptedMarker = statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L) + if (currentMarker > acceptedMarker) { + // An offline phone must not resurrect a deleted cloud + // archive just because the user reconnects. + val subject = statePrefs.getString(KEY_GOOGLE_ACCOUNT_SUBJECT, null) + ?: error("Missing verified Google subject") + val ceiling = database.listeningEventDao().getMaxEventId() + val key = suppressedThroughKey(subject) + val existing = statePrefs.getLong(key, 0L) + val uploadedKey = uploadedBeforeDeletionKey(subject) + val uploaded = statePrefs.getLong(uploadCursorKey(subject), + statePrefs.getLong(KEY_UPLOAD_CURSOR, 0L)) + val scopedDisableKey = acceptedDisableVersionKey(subject) + val scopedDownloadKey = downloadCursorKey(subject) + val currentDisable = statePrefs.getLong(scopedDisableKey, acceptedMarker) + val newDisable = maxOf(currentDisable, currentMarker) + check(statePrefs.edit() + .remove(authorizedOldLanKey(subject)) + .putLong(key, maxOf(existing, ceiling)) + .putLong(uploadedKey, + maxOf(statePrefs.getLong(uploadedKey, 0L), uploaded)) + .remove(KEY_DOWNLOAD_CREATED_CURSOR) + .remove(scopedDownloadKey) + .putLong(KEY_ACCEPTED_DISABLE_VERSION, newDisable) + .putLong(scopedDisableKey, newDisable) + .remove(rejectedFilesKey(subject)) + .commit()) { "Could not persist Google Drive deletion boundary" } + } + settingsManager.setEnabled(true) + true + } + } + } + + suspend fun disableSync() = mutex.withLock { + settingsManager.setEnabled(false) + } + + /** Explicit consent, separate from merely enabling Drive, to republish old + * local recordings previously suppressed by a cloud deletion. + */ + suspend fun shareOlderLocalHistory(): DriveHistorySyncResult = mutex.withLock { + withContext(Dispatchers.IO) { + if (!settingsManager.settings.first().enabled || !ensureAuthorized()) { + return@withContext DriveHistorySyncResult.Error("Connect Google Drive first") + } + appDataClient.withAccountBoundSession { accountSubject -> + if (reconcileGoogleAccountBoundary(accountSubject)) { + settingsManager.setEnabled(false) + return@withAccountBoundSession DriveHistorySyncResult.Error( + "Google account changed; reconnect before sharing old history" + ) + } + val remoteDisabled = handleRemoteDisableIfNeeded() + if (remoteDisabled != null) return@withAccountBoundSession remoteDisabled + val key = suppressedThroughKey(accountSubject) + val dao = database.listeningEventDao() + val throughEventId = dao.getMaxEventId() + // Persist snapshot-scoped consent and rewind BEFORE claiming SQL + // ownership. A crash cannot silently authorize future arrivals + // or strand approved rows behind the upload cursor. + check(statePrefs.edit().remove(key) + .remove(uploadedBeforeDeletionKey(accountSubject)) + .putLong(authorizedOldLanKey(accountSubject), throughEventId) + .remove(KEY_UPLOAD_CURSOR) + .remove(uploadCursorKey(accountSubject)).commit()) { + "Could not authorize the local Drive history upload" + } + dao.authorizeLanRelaysForAccount(accountSubject, throughEventId) + // Reuse the normal sync path after releasing the mutex. + DriveHistorySyncResult.Success(0, 0, 0, 0) + } + } + } + + /** Re-read all cloud batches for explicit historical recovery; never delete local plays. */ + suspend fun restoreFullHistory(): DriveHistorySyncResult = syncNow(forceFullRestore = true) + + suspend fun syncNow(forceFullRestore: Boolean = false): DriveHistorySyncResult = mutex.withLock { + withContext(Dispatchers.IO) { + val settings = settingsManager.settings.first() + if (!settings.enabled) { + return@withContext DriveHistorySyncResult.Disabled + } + + settingsManager.markRunning() + try { + if (!ensureAuthorized()) { + val message = "Google Drive authorization is required" + settingsManager.markFailure(message) + return@withContext DriveHistorySyncResult.Error(message) + } + + appDataClient.withAccountBoundSession { accountEmail -> + if (reconcileGoogleAccountBoundary(accountEmail)) { + val message = + "Google account changed. Cross-device sync was turned off; enable it again to use the new Drive account." + settingsManager.setEnabled(false) + settingsManager.markFailure(message) + return@withAccountBoundSession DriveHistorySyncResult.RemoteDisabled(message) + } + + val remoteDisable = handleRemoteDisableIfNeeded() + if (remoteDisable != null) return@withAccountBoundSession remoteDisable + + var uploadFailure: Exception? = null + val uploaded = try { + uploadLocalHistory(accountEmail) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + // Failed outgoing transport must not stop independent + // inbound Drive history from updating this device. + uploadFailure = failure + 0 + } + if (forceFullRestore) { + // Clear only the receive cursor. If the download fails, retry + // again from the beginning rather than losing an old batch. + statePrefs.edit() + .remove(KEY_DOWNLOAD_CREATED_CURSOR) + .remove(downloadCursorKey(accountEmail)) + .commit() + .also { check(it) { "Could not persist Drive full-restore request" } } + } + val download = downloadRemoteHistory(includeOwnDeviceBatches = forceFullRestore, accountSubject = accountEmail) + uploadFailure?.let { throw it } + val invalidCount = statePrefs.getStringSet("${KEY_INVALID_EXPORT_IDS}:${accountEmail}", emptySet()) + .orEmpty().size + val rejectedList = getRejectedBatches(accountEmail) + val warningMessage = when { + rejectedList.isNotEmpty() -> { + val sample = rejectedList.first() + "${rejectedList.size} Google Drive history file(s) need attention (e.g. ${sample.fileName}: ${sample.reason}). Check the affected history and run Restore full history to retry." + } + invalidCount > 0 -> "${invalidCount} local event(s) need metadata repair; preserved on device" + download.skipped > 0 -> "${download.skipped} duplicate event(s) ignored" + else -> null + } + settingsManager.markSuccess( + uploaded = uploaded, + imported = download.inserted, + message = warningMessage + ) + DriveHistorySyncResult.Success( + uploaded = uploaded, + imported = download.inserted, + duplicates = download.skipped, + replaced = download.replaced + ) + } + } catch (e: CancellationException) { + withContext(NonCancellable) { + settingsManager.markFailure("Cross-device history sync was cancelled") + } + throw e + } catch (e: Exception) { + Log.e(TAG, "History sync failed", e) + val message = e.message ?: "Cross-device history sync failed" + settingsManager.markFailure(message) + DriveHistorySyncResult.Error(message, e) + } + } + } + + private suspend fun ensureAuthorized(): Boolean { + if (!authManager.isSignedIn.value && !authManager.restoreSessionSilently()) { + return false + } + return authManager.getAccessToken() != null + } + + /** + * Keep Drive cursors scoped to one Google account. The account identity is + * intentionally stored in the sync state (not in auth token storage), so it + * survives Google sign-out long enough to detect a later account switch. + * + * Returns true only when a previously-known account changed. Cursors and the + * accepted deletion marker are reset before any operation against the new + * account. The caller decides whether that account change is an explicit + * opt-in (enableSync) or must stop a background/manual sync (syncNow). + */ + private fun uploadCursorKey(accountSubject: String): String = "${KEY_UPLOAD_CURSOR}:${accountSubject}" + private fun downloadCursorKey(accountSubject: String): String = "${KEY_DOWNLOAD_CREATED_CURSOR}:${accountSubject}" + private fun acceptedDisableVersionKey(accountSubject: String): String = "${KEY_ACCEPTED_DISABLE_VERSION}:${accountSubject}" + private fun rejectedFilesKey(accountSubject: String): String = "${KEY_REJECTED_FILES}:${accountSubject}" + private fun suppressedThroughKey(accountSubject: String): String = + "${KEY_SUPPRESSED_THROUGH}:${accountSubject}" + private fun uploadedBeforeDeletionKey(accountSubject: String): String = + "${KEY_UPLOADED_BEFORE_DELETION}:${accountSubject}" + private fun authorizedOldLanKey(accountSubject: String): String = + "${KEY_AUTHORIZED_OLD_LAN_THROUGH}:${accountSubject}" + + private fun getRejectedBatches(accountSubject: String): List { + val raw = statePrefs.getStringSet(rejectedFilesKey(accountSubject), emptySet()) ?: emptySet() + return raw.mapNotNull { RejectedBatchRecord.parse(it) } + .sortedBy { it.recordedAt } + } + + private fun markRejectedBatch( + accountSubject: String, + fileId: String, + fileName: String, + reason: String + ) { + val key = rejectedFilesKey(accountSubject) + val current = statePrefs.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$fileId\t") } + val record = RejectedBatchRecord( + fileId = fileId, + fileName = fileName, + reason = reason, + recordedAt = System.currentTimeMillis() + ) + current.add(record.serialize()) + statePrefs.edit().putStringSet(key, current).apply() + } + + private fun clearRejectedBatch(accountSubject: String, fileId: String) { + val key = rejectedFilesKey(accountSubject) + val current = statePrefs.getStringSet(key, emptySet())?.toMutableSet() ?: return + if (current.removeAll { it.startsWith("$fileId\t") }) { + statePrefs.edit().putStringSet(key, current).apply() + } + } + + private fun clearMissingRejectedBatches( + accountSubject: String, + liveFileIds: Set, + completeListing: Boolean + ) { + val key = rejectedFilesKey(accountSubject) + val current = statePrefs.getStringSet(key, emptySet())?.toMutableSet() ?: return + val filtered = retainedRejectedRecords(current, liveFileIds, completeListing) + if (filtered.size != current.size) { + statePrefs.edit().putStringSet(key, filtered).apply() + } + } + + private fun clearAllRejectedBatches(accountSubject: String) { + statePrefs.edit().remove(rejectedFilesKey(accountSubject)).apply() + } + + private suspend fun reconcileGoogleAccountBoundary(current: String): Boolean { + require(current.isNotBlank()) { "Google account subject cannot be empty" } + val dao = database.listeningEventDao() + val previous = statePrefs.getString(KEY_GOOGLE_ACCOUNT_SUBJECT, null) + ?.takeIf { it.isNotBlank() } + val previousEmail = statePrefs.getString(KEY_GOOGLE_ACCOUNT_EMAIL, null) + ?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } + val currentEmail = authManager.currentAccount.value?.email?.trim()?.lowercase() + if (previous == current) return false + + // Pre-fix GoogleIdTokenCredential.id returned email, NOT a stable + // subject. Migrate only rows matching the email verified by the + // newly selected Google identity; never reassign quarantined imports. + val legacySameAccount = previous != null && previous == currentEmail + if (currentEmail != null && currentEmail != current) { + dao.upgradeLegacyEmailOwner(currentEmail, current) + } + + val changed = (previous != null && !legacySameAccount) || + (previous == null && previousEmail != null && previousEmail != currentEmail) + val legacyOwner = when { + previous != null -> if (legacySameAccount) current else previous + previousEmail == null || previousEmail == currentEmail -> current + else -> "legacy-unverified" + } + // Claim rows before moving the active account pointer. If a failure + // occurs here, the old credentials and preferences remain unchanged. + dao.claimUnownedDriveHistory(legacyOwner) + + val globalCursor = statePrefs.getLong(KEY_UPLOAD_CURSOR, 0L) + val maxId = dao.getMaxEventId() + val scopedCursor = uploadCursorKey(current) + val oldEmailCursor = currentEmail?.let { uploadCursorKey(it) } + val resumedCursor = chooseAccountUploadCursor( + savedForSubject = statePrefs.getLong(scopedCursor, 0L) + .takeIf { statePrefs.contains(scopedCursor) }, + legacyEmailCursor = oldEmailCursor?.takeIf(statePrefs::contains) + ?.let { statePrefs.getLong(it, 0L) }, + isNewAccount = changed, + previouslyActiveCursor = globalCursor, + newestLocalRow = maxId, + ) + + val scopedDownloadKey = downloadCursorKey(current) + val oldEmailDownloadKey = currentEmail?.let { downloadCursorKey(it) } + val resumedDownloadCursor = when { + statePrefs.contains(scopedDownloadKey) -> statePrefs.getLong(scopedDownloadKey, 0L) + oldEmailDownloadKey != null && statePrefs.contains(oldEmailDownloadKey) -> + statePrefs.getLong(oldEmailDownloadKey, 0L) + else -> 0L + } + + val scopedDisableKey = acceptedDisableVersionKey(current) + val oldEmailDisableKey = currentEmail?.let { acceptedDisableVersionKey(it) } + val resumedDisableVersion = when { + statePrefs.contains(scopedDisableKey) -> statePrefs.getLong(scopedDisableKey, 0L) + oldEmailDisableKey != null && statePrefs.contains(oldEmailDisableKey) -> + statePrefs.getLong(oldEmailDisableKey, 0L) + else -> 0L + } + + val editor = statePrefs.edit() + if (previous != null) { + // Snapshot departing account progress so returning to this account + // resumes where it left off, rather than re-evaluating old deletion markers. + editor.putLong(uploadCursorKey(previous), globalCursor) + editor.putLong(downloadCursorKey(previous), statePrefs.getLong(KEY_DOWNLOAD_CREATED_CURSOR, 0L)) + editor.putLong(acceptedDisableVersionKey(previous), statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L)) + } + editor.putString(KEY_GOOGLE_ACCOUNT_SUBJECT, current) + .putString(KEY_GOOGLE_ACCOUNT_EMAIL, currentEmail) + .putLong(KEY_UPLOAD_CURSOR, resumedCursor) + .putLong(scopedCursor, resumedCursor) + .putLong(KEY_DOWNLOAD_CREATED_CURSOR, resumedDownloadCursor) + .putLong(scopedDownloadKey, resumedDownloadCursor) + .putLong(KEY_ACCEPTED_DISABLE_VERSION, resumedDisableVersion) + .putLong(scopedDisableKey, resumedDisableVersion) + + check(editor.commit()) { "Could not persist Google Drive account boundary" } + return changed + } + + /** + * If another linked device bumped the shared deletion marker after this + * device last explicitly enabled Drive sync, honor that deletion before any + * upload. Only generations older than the marker are removed: a different + * device may already have explicitly re-enabled sync and started generation N, + * and this stale device must never erase that newly-seeded generation. + */ + private suspend fun handleRemoteDisableIfNeeded(): DriveHistorySyncResult.RemoteDisabled? { + val currentMarker = appDataClient.getHistoryDisableMarkerVersion() + val acceptedMarker = statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L) + if (currentMarker <= acceptedMarker) return null + + val message = "Cross-device sync was turned off because another linked Tempo device deleted the shared Drive history." + acceptDeletionMarker(currentMarker, message) + appDataClient.deleteHistoryBatchesBeforeGeneration(currentMarker) + return DriveHistorySyncResult.RemoteDisabled(message) + } + + private suspend fun acceptDeletionMarker(marker: Long, message: String? = null) { + withContext(NonCancellable) { + // Stop before cleanup; keep local plays and their cloud suppression + // durable even if the network or process fails afterwards. + settingsManager.markStopped(message ?: "Cloud history sync was turned off after deletion") + val subject = statePrefs.getString(KEY_GOOGLE_ACCOUNT_SUBJECT, null) + ?: error("Missing verified Google subject") + val ceiling = database.listeningEventDao().getMaxEventId() + val suppressionKey = suppressedThroughKey(subject) + val previous = statePrefs.getLong(suppressionKey, 0L) + val uploadedKey = uploadedBeforeDeletionKey(subject) + val alreadyUploaded = statePrefs.getLong(uploadCursorKey(subject), + statePrefs.getLong(KEY_UPLOAD_CURSOR, 0L)) + val scopedDisableKey = acceptedDisableVersionKey(subject) + val scopedDownloadKey = downloadCursorKey(subject) + val currentDisable = statePrefs.getLong(scopedDisableKey, statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L)) + val newDisable = maxOf(currentDisable, marker) + check(statePrefs.edit() + .remove(authorizedOldLanKey(subject)) + .putLong(KEY_ACCEPTED_DISABLE_VERSION, newDisable) + .putLong(scopedDisableKey, newDisable) + .putLong(suppressionKey, maxOf(previous, ceiling)) + .putLong(uploadedKey, maxOf(statePrefs.getLong(uploadedKey, 0L), alreadyUploaded)) + .remove(KEY_DOWNLOAD_CREATED_CURSOR) + .remove(scopedDownloadKey) + .remove(rejectedFilesKey(subject)) + .commit()) { "Could not persist Drive history deletion marker" } + } + } + + /** The authenticated LAN producer is the owner of relayed events. + * Writing all relays as Android-origin batches would wrongly classify two + * distinct producers' captures as two events from one producer, breaking + * one-to-one deduplication on the receiving clients. + */ + internal data class BatchProducer(val deviceId: String, val platform: String) { + val displayName: String get() = "Tempo ${platform.replaceFirstChar { it.uppercase() }}" + } + + private fun uploadProducer(event: ListeningEvent): BatchProducer { + if (!event.source.startsWith("lan:")) { + return BatchProducer(deviceId, "android") + } + return lanBatchProducer(event.source) + ?: error("Cannot recover producer identity for relayed LAN listening event ${event.id}") + } + + /** + * Uploads locally-owned Room events in id order. Imported Drive events are + * skipped so another device's event can never bounce back into Drive as a new + * event. The cursor advances only after every eligible event in a page has + * been safely uploaded. + */ + private suspend fun uploadLocalHistory(accountSubject: String): Int { + val dao = database.listeningEventDao() + val maxId = dao.getMaxEventId() + val storedCursor = statePrefs.getLong(uploadCursorKey(accountSubject), + statePrefs.getLong(KEY_UPLOAD_CURSOR, 0L)) + val generation = statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L).coerceAtLeast(0L) + val suppressedThrough = statePrefs.getLong(suppressedThroughKey(accountSubject), 0L) + val uploadedBeforeDeletion = + statePrefs.getLong(uploadedBeforeDeletionKey(accountSubject), 0L) + val authorizedOldLanThrough = + statePrefs.getLong(authorizedOldLanKey(accountSubject), 0L) + // Background sync never claims LAN records with unverified Google + // ownership, even if the original producer event ID looks valid. + // A database restore can move Room row ids backwards while SharedPreferences + // survive. If the persisted cursor is now beyond the database snapshot, + // keeping it would make every newly-created row look already scanned until + // ids eventually caught up. Restart from zero instead; deterministic Drive + // event/batch ids make replay safe and preferable to silently losing plays. + var afterId = if (storedCursor > maxId) { + check(statePrefs.edit() + .remove(KEY_UPLOAD_CURSOR).remove(uploadCursorKey(accountSubject)) + .commit()) { "Could not restart Drive upload after database restore" } + 0L + } else { + storedCursor + } + var uploaded = 0 + val retryIds = statePrefs.getStringSet("${KEY_INVALID_EXPORT_IDS}:${accountSubject}", emptySet()).orEmpty() + .mapNotNull { it.toLongOrNull() }.toSet() + val stillInvalid = retryIds.toMutableSet() + // Every SQL query AND every serialization page must remain bounded. + // The old implementation fetched in chunks, then concatenated the + // entire repair queue before getOriginClaimsForEvents(IN (...)), which + // could overflow SQLite's 999-variable limit on older Android. + val repairPages = retryIds.toList().sorted().chunked(PAGE_SIZE) + var repairIndex = 0 + + while (afterId < maxId || repairIndex < repairPages.size) { + val retrying = repairIndex < repairPages.size + val requestedRepairIds = if (retrying) repairPages[repairIndex++] else emptyList() + val page = if (retrying) dao.getDriveRetryRows(requestedRepairIds) + else dao.getEventsPage(afterId, maxId, PAGE_SIZE) + if (retrying) { + // Removed rows cannot be repaired and should not accumulate + // as ghost IDs in the persistent retry queue. + val present = page.mapTo(HashSet()) { it.id } + stillInvalid.removeAll(requestedRepairIds.filterNot(present::contains).toSet()) + } + if (page.isEmpty() && !retrying) break + if (page.isEmpty()) continue + + // Resolve per-play identities before serialization. Persist new + // Android-native IDs BEFORE network I/O: a crash after the Drive + // upload must never allow a later metadata correction to change + // the event ID on the retry or a full-history restore. + val ownOrigins = dao.getOriginClaimsForEvents(page.map { it.id }) + .filter { it.sourceDeviceId == deviceId } + .associate { it.listeningEventId to it.originEventId } + val newOwnOrigins = mutableListOf() + val eventsByProducer = linkedMapOf>() + for (event in page) { + if (isCloudSuppressedRow( + event.id, suppressedThrough, event.timestamp, generation, + uploadedBeforeDeletion, + )) continue + if (event.driveAccountSubject != null && + event.driveAccountSubject != accountSubject) continue + if (event.source.startsWith("lan:") && !canRelayLanToCloud( + event.driveAccountSubject, accountSubject, event.timestamp, + event.id, generation, authorizedOldLanThrough, + )) continue + // Downloaded Drive history never bounces back into the cloud. + // Account-matched LAN events keep the original producer ID. + // Late arrivals recorded before deletion require consent. + if (event.contentFingerprint?.startsWith(DRIVE_IMPORT_FINGERPRINT_PREFIX) == true && + !event.source.startsWith("lan:") + ) continue + // Never move the persistent upload cursor past a local play that + // cannot be exported (for example, a temporarily missing Track). + // Surface the row ID and retry after its metadata is repaired. + val exported = eventMapper.localEventToProtocol(event, ownOrigins[event.id]) + if (exported == null) { + stillInvalid.add(event.id) + Log.w(TAG, "Retaining invalid Drive export row ${event.id} for later repair") + continue + } + stillInvalid.remove(event.id) + if (!event.source.startsWith("lan:") && !event.source.startsWith("drive:") && + ownOrigins[event.id] == null + ) { + newOwnOrigins.add(ListeningEventOrigin(exported.eventId, event.id, deviceId, accountSubject)) + } + eventsByProducer.getOrPut(uploadProducer(event)) { mutableListOf() }.add(exported) + } + if (newOwnOrigins.isNotEmpty()) dao.insertOriginAliases(newOwnOrigins) + + // Keep each producer's events in its own immutable Drive batch. + // Event IDs and batch IDs are still deterministic across retries. + for ((producer, producerEvents) in eventsByProducer) { + for (events in producerEvents.chunked(BATCH_SIZE)) { + val batchId = DriveHistoryProtocol.createBatchId(events) + val batch = DriveHistoryBatch( + batchId = batchId, + sourceDeviceId = producer.deviceId, + sourceDeviceName = producer.displayName, + sourcePlatform = producer.platform, + createdAtUtc = events.maxOf { it.timestampUtc }, + events = events + ) + val bytes = DriveHistoryProtocol.encodeCompressed(batch) + appDataClient.uploadHistoryBatch( + fileName = DriveHistoryProtocol.fileName(producer.deviceId, batchId, generation), + compressedBytes = bytes, + appProperties = mapOf( + DriveHistoryProtocol.APP_PROPERTY_KIND to DriveHistoryProtocol.KIND_HISTORY_BATCH, + DriveHistoryProtocol.APP_PROPERTY_SCHEMA to DriveHistoryProtocol.SCHEMA_VERSION.toString(), + DriveHistoryProtocol.APP_PROPERTY_DEVICE_ID to producer.deviceId, + DriveHistoryProtocol.APP_PROPERTY_PLATFORM to producer.platform, + DriveHistoryProtocol.APP_PROPERTY_GENERATION to generation.toString() + ) + ) + uploaded += events.size + } + } + + if (!retrying) afterId = page.last().id + check(statePrefs.edit() + .putLong(KEY_UPLOAD_CURSOR, afterId) + .putLong(uploadCursorKey(accountSubject), afterId) + .putStringSet("${KEY_INVALID_EXPORT_IDS}:${accountSubject}", stillInvalid.map(Long::toString).toSet()) + .commit()) { "Could not checkpoint Drive upload history and invalid rows" } + } + + return uploaded + } + + /** + * Uses a 24-hour overlap around the Drive created-time cursor. Overlap makes + * the cursor resilient to delayed/out-of-order uploads; exact event ids plus + * Tempo's temporal reconciliation make re-reading those files harmless. + */ + private suspend fun downloadRemoteHistory( + includeOwnDeviceBatches: Boolean = false, + accountSubject: String, + ): ImportSummary { + val cursor = statePrefs.getLong(KEY_DOWNLOAD_CREATED_CURSOR, 0L) + val acceptedGeneration = statePrefs.getLong(KEY_ACCEPTED_DISABLE_VERSION, 0L).coerceAtLeast(0L) + val createdAfter = if (cursor > 0L) { + (cursor - DOWNLOAD_OVERLAP_MS).coerceAtLeast(0L) + } else null + + val files = appDataClient.listHistoryBatches(createdAfter) + // An incremental result omits older files: absence alone does not mean deletion. + clearMissingRejectedBatches( + accountSubject, files.map { it.fileId }.toSet(), + completeListing = createdAfter == null || createdAfter <= 0L + ) + var maxCreated = cursor + var inserted = 0 + var skipped = 0 + var replaced = 0 + + for ((index, file) in files.sortedBy { it.createdAt }.withIndex()) { + // Full restoration may process thousands of batches. Persist the + // last completed prefix periodically so process death or a work + // timeout does not force replaying the entire historic archive. + if (index > 0 && index % 50 == 0 && maxCreated > cursor) { + check(statePrefs.edit() + .putLong(KEY_DOWNLOAD_CREATED_CURSOR, maxCreated) + .putLong(downloadCursorKey(accountSubject), maxCreated) + .commit()) { + "Could not checkpoint Drive history restoration" + } + } + val fileGeneration = DriveHistoryProtocol.parseGeneration( + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_GENERATION] + ) + if (fileGeneration == null) { + Log.w(TAG, "Skipping history file with an invalid generation: ${file.fileName}") + markRejectedBatch(accountSubject, file.fileId, file.fileName, "Invalid generation property") + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + if (fileGeneration < acceptedGeneration) { + if (!DriveHistoryProtocol.shouldDeleteHistoryBatch( + file.fileName, + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_GENERATION], + acceptedGeneration, + ownerDelete = false + )) { + markRejectedBatch(accountSubject, file.fileId, file.fileName, + "Generation metadata conflicts with newer batch filename") + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + // Pre-delete data (including a late upload) must not resurrect. + // Cleanup is best effort: transient Drive DELETE failures must + // not stop later, current-generation history from downloading. + // Cancellation is different: always propagate it immediately. + val deleted = cleanupStaleBatchBestEffort( + delete = { appDataClient.delete(file.fileId) }, + onError = { error -> + Log.w(TAG, "Could not remove obsolete Drive batch ${file.fileName}; ignoring it for import", error) + } + ) + if (deleted) { + clearRejectedBatch(accountSubject, file.fileId) + } else { + // Cloud privacy cleanup may still be incomplete. Keep a + // durable, account-scoped warning until a full Drive scan + // confirms the file is gone or deletion succeeds. + markRejectedBatch(accountSubject, file.fileId, file.fileName, + "Obsolete pre-deletion Drive file could not be removed") + } + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + + val sourceDeviceId = file.appProperties[DriveHistoryProtocol.APP_PROPERTY_DEVICE_ID] + ?.takeIf(DriveHistoryProtocol::isValidDeviceId) + val metadataValid = file.fileName.startsWith(DriveHistoryProtocol.FILE_PREFIX) && + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_KIND] == DriveHistoryProtocol.KIND_HISTORY_BATCH && + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_SCHEMA] == DriveHistoryProtocol.SCHEMA_VERSION.toString() && + file.appProperties[DriveHistoryProtocol.APP_PROPERTY_SHA256] + ?.matches(Regex("^[0-9a-f]{64}$")) == true && + sourceDeviceId != null + if (!metadataValid) { + Log.w(TAG, "Skipping history file with invalid Tempo metadata: ${file.fileName}") + markRejectedBatch(accountSubject, file.fileId, file.fileName, "Invalid Tempo metadata") + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + val remoteDeviceId = requireNotNull(sourceDeviceId) + if (!includeOwnDeviceBatches && remoteDeviceId == deviceId) { + clearRejectedBatch(accountSubject, file.fileId) + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + + // Network/API failures are retryable. Do not move the created-time + // cursor past a file we did not actually obtain, otherwise after the + // 24-hour overlap expires that batch could be lost forever. + // A permanently malformed/oversized payload must not block all later + // history forever. Treat that specific file as consumed, but never do + // the same for a transient download failure above. + val batch = try { + val bytes = appDataClient.download(file) + DriveHistoryProtocol.decodeCompressed(bytes) + } catch (e: CancellationException) { + throw e + } catch (e: DriveException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Skipping malformed history batch ${file.fileName}", e) + markRejectedBatch(accountSubject, file.fileId, file.fileName, e.message ?: "Malformed payload") + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + val sourcePlatform = file.appProperties[DriveHistoryProtocol.APP_PROPERTY_PLATFORM] + val expectedName = DriveHistoryProtocol.fileName(remoteDeviceId, batch.batchId, fileGeneration) + if (batch.sourceDeviceId != remoteDeviceId || + sourcePlatform != batch.sourcePlatform || + file.fileName != expectedName + ) { + Log.w(TAG, "Skipping history batch whose payload does not match Drive metadata: ${file.fileName}") + markRejectedBatch(accountSubject, file.fileId, file.fileName, "Payload does not match Drive metadata") + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + if (!includeOwnDeviceBatches && batch.sourceDeviceId == deviceId) { + clearRejectedBatch(accountSubject, file.fileId) + maxCreated = maxOf(maxCreated, file.createdAt) + continue + } + + val incoming = mutableListOf() + for (event in batch.events) { + eventMapper.protocolEventToLocal(event, batch.sourceDeviceId)?.let { + incoming.add(it.copy(driveAccountSubject = accountSubject)) + } + } + + // Resolver/database failures are not safe to skip. Let them abort this + // sync so the cursor remains behind the uncommitted batch and the next + // run can retry it idempotently. + val result = database.listeningEventDao().insertAllBatchedWithDedup(incoming, accountSubject) + clearRejectedBatch(accountSubject, file.fileId) + inserted += result.inserted + skipped += result.skipped + replaced += result.replaced + maxCreated = maxOf(maxCreated, file.createdAt) + } + + if (maxCreated > cursor) { + statePrefs.edit() + .putLong(KEY_DOWNLOAD_CREATED_CURSOR, maxCreated) + .putLong(downloadCursorKey(accountSubject), maxCreated) + .apply() + } + return ImportSummary(inserted, skipped, replaced) + } + + suspend fun deleteCloudHistoryAndReset(): Int = mutex.withLock { + withContext(Dispatchers.IO) { + if (!ensureAuthorized()) throw DriveException.Auth("Google Drive authorization is required") + appDataClient.withAccountBoundSession { accountEmail -> + if (reconcileGoogleAccountBoundary(accountEmail)) { + settingsManager.setEnabled(false) + throw DriveException.Auth( + "Google account changed. Enable cross-device sync for the new account before deleting its Drive history." + ) + } + + // Bump the shared server-timestamped generation BEFORE deleting old + // batches. A client that explicitly re-enables after this point writes + // generation N and is therefore protected from stale-device cleanup. + val markerVersion = appDataClient.bumpHistoryDisableMarker() + acceptDeletionMarker(markerVersion) + val deleted = appDataClient.deleteHistoryBatchesBeforeGeneration(markerVersion, ownerDelete = true) + clearAllRejectedBatches(accountEmail) + deleted + } + } + } + + /** + * Reset cloud-sync cursors without deleting local history. A separate + * per-account suppression fence prevents cloud-deleted plays from being + * republished on an ordinary reconnect. + * A database restore may change primary keys for every Google owner. + * Invalid-row retry IDs also refer to old primary keys and must be cleared. + * A cloud deletion marker, by contrast, resets only the active account. + */ + private fun resetCursorsLocked(clearEveryAccount: Boolean = false) { + val editor = statePrefs.edit() + .remove(KEY_UPLOAD_CURSOR) + .remove(KEY_DOWNLOAD_CREATED_CURSOR) + if (clearEveryAccount) { + statePrefs.all.keys.filter(::isPerAccountRestoreStateKey) + .forEach(editor::remove) + // A restore can remap Room IDs. Do not reuse old consent for rows + // that acquire those IDs from a new database snapshot. + statePrefs.all.keys.filter { + it.startsWith("${KEY_AUTHORIZED_OLD_LAN_THROUGH}:") + }.forEach(editor::remove) + editor.remove(KEY_INVALID_EXPORT_IDS) + } else { + val subject = statePrefs.getString(KEY_GOOGLE_ACCOUNT_SUBJECT, null) + if (subject != null) { + editor.remove(uploadCursorKey(subject)) + .remove(downloadCursorKey(subject)) + .remove(rejectedFilesKey(subject)) + } + } + check(editor.commit()) { "Could not invalidate Google Drive cursors after history change" } + } + + /** + * Serialize a local import/restore with Drive synchronization and invalidate + * both cursors afterwards. Without holding the same mutex as [syncNow], a + * worker could advance its upload cursor while Room is being replaced and + * permanently skip restored rows whose ids fall behind that cursor. + * + * Cursors are reset even when the import fails because importers may already + * have committed part of the database before reporting an error. + */ + suspend fun withLocalHistoryRestore(block: suspend () -> T): T = mutex.withLock { + try { + block() + } finally { + try { + // Room restore can remap primary keys and move previously + // deleted history above its prior ID ceiling. Conservatively + // fence all restored rows for suppressed Google accounts. + val ceiling = database.listeningEventDao().getMaxEventId() + val editor = statePrefs.edit() + statePrefs.all.keys.filter { it.startsWith("${KEY_SUPPRESSED_THROUGH}:") } + .forEach { key -> + editor.putLong(key, maxOf(statePrefs.getLong(key, 0L), ceiling)) + } + check(editor.commit()) { "Could not preserve deletion suppression after restore" } + } finally { + // Invalidate stale Room row-ID upload cursors even if a restore + // or a suppression-fence update fails. + resetCursorsLocked(clearEveryAccount = true) + } + } + } + + private data class ImportSummary( + val inserted: Int, + val skipped: Int, + val replaced: Int + ) +} + +sealed class DriveHistorySyncResult { + data object Disabled : DriveHistorySyncResult() + data class RemoteDisabled(val message: String) : DriveHistorySyncResult() + data class Success( + val uploaded: Int, + val imported: Int, + val duplicates: Int, + val replaced: Int + ) : DriveHistorySyncResult() + data class Error(val message: String, val exception: Exception? = null) : DriveHistorySyncResult() +} diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncSettingsManager.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncSettingsManager.kt new file mode 100644 index 000000000..23e234d24 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveHistorySyncSettingsManager.kt @@ -0,0 +1,105 @@ +package me.avinas.tempo.data.drive + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.driveHistorySyncDataStore: DataStore by preferencesDataStore( + name = "drive_history_sync_settings" +) + +@Singleton +class DriveHistorySyncSettingsManager @Inject constructor( + @param:ApplicationContext private val context: Context +) { + companion object { + private val ENABLED = booleanPreferencesKey("enabled") + private val LAST_SYNC_TIME = longPreferencesKey("last_sync_time") + private val LAST_STATUS = stringPreferencesKey("last_status") + private val LAST_MESSAGE = stringPreferencesKey("last_message") + private val LAST_UPLOADED = intPreferencesKey("last_uploaded") + private val LAST_IMPORTED = intPreferencesKey("last_imported") + } + + val settings: Flow = context.driveHistorySyncDataStore.data.map { prefs -> + DriveHistorySyncSettings( + enabled = prefs[ENABLED] ?: false, + lastSyncTime = prefs[LAST_SYNC_TIME], + lastStatus = DriveHistorySyncStatus.entries.firstOrNull { + it.name == prefs[LAST_STATUS] + } ?: DriveHistorySyncStatus.NEVER, + lastMessage = prefs[LAST_MESSAGE], + lastUploaded = prefs[LAST_UPLOADED] ?: 0, + lastImported = prefs[LAST_IMPORTED] ?: 0 + ) + } + + suspend fun setEnabled(enabled: Boolean) { + context.driveHistorySyncDataStore.edit { it[ENABLED] = enabled } + } + + suspend fun markRunning() { + context.driveHistorySyncDataStore.edit { + it[LAST_STATUS] = DriveHistorySyncStatus.RUNNING.name + it.remove(LAST_MESSAGE) + } + } + + suspend fun markSuccess(uploaded: Int, imported: Int, message: String? = null) { + context.driveHistorySyncDataStore.edit { + it[LAST_SYNC_TIME] = System.currentTimeMillis() + it[LAST_STATUS] = DriveHistorySyncStatus.SUCCESS.name + it[LAST_UPLOADED] = uploaded + it[LAST_IMPORTED] = imported + if (message.isNullOrBlank()) it.remove(LAST_MESSAGE) else it[LAST_MESSAGE] = message + } + } + + suspend fun markFailure(message: String) { + context.driveHistorySyncDataStore.edit { + it[LAST_STATUS] = DriveHistorySyncStatus.FAILED.name + it[LAST_MESSAGE] = message + } + } + + suspend fun markStopped(message: String) { + context.driveHistorySyncDataStore.edit { + it[ENABLED] = false + it[LAST_STATUS] = DriveHistorySyncStatus.NEVER.name + it[LAST_MESSAGE] = message + it[LAST_UPLOADED] = 0 + it[LAST_IMPORTED] = 0 + } + } + + suspend fun clearForSignOut() { + context.driveHistorySyncDataStore.edit { it.clear() } + } +} + +data class DriveHistorySyncSettings( + val enabled: Boolean = false, + val lastSyncTime: Long? = null, + val lastStatus: DriveHistorySyncStatus = DriveHistorySyncStatus.NEVER, + val lastMessage: String? = null, + val lastUploaded: Int = 0, + val lastImported: Int = 0 +) + +enum class DriveHistorySyncStatus { + NEVER, + RUNNING, + SUCCESS, + FAILED +} diff --git a/app/src/main/java/me/avinas/tempo/data/drive/DriveModels.kt b/app/src/main/java/me/avinas/tempo/data/drive/DriveModels.kt index 40c056c88..ee91f659a 100644 --- a/app/src/main/java/me/avinas/tempo/data/drive/DriveModels.kt +++ b/app/src/main/java/me/avinas/tempo/data/drive/DriveModels.kt @@ -22,7 +22,9 @@ data class DriveBackupInfo( data class GoogleAccount( val email: String, val displayName: String?, - val photoUrl: String? + val photoUrl: String?, + /** Immutable Google account identifier from the verified ID token. */ + val subject: String? = null ) /** diff --git a/app/src/main/java/me/avinas/tempo/data/drive/GoogleAuthManager.kt b/app/src/main/java/me/avinas/tempo/data/drive/GoogleAuthManager.kt index d87bcb769..87270df54 100644 --- a/app/src/main/java/me/avinas/tempo/data/drive/GoogleAuthManager.kt +++ b/app/src/main/java/me/avinas/tempo/data/drive/GoogleAuthManager.kt @@ -13,6 +13,7 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import androidx.work.WorkManager import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.AuthorizationResult import com.google.android.gms.auth.api.identity.Identity @@ -38,71 +39,70 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Manages Google authentication using the modern Credential Manager API. - * - * IMPORTANT: Credential Manager requires an Activity context for UI operations. - * The signIn() and restoreSession() methods require an Activity to be passed. - * - * This replaces the deprecated GoogleSignInClient approach and provides: - * - Google Sign-In via Credential Manager bottom sheet - * - Authorization for Google Drive API access - * - Token management for API calls + * Manages Google identity and the Drive scopes used by Tempo. + * + * Two least-privilege Drive scopes are requested together: + * - drive.file: existing user-visible .tempo backups created by Tempo + * - drive.appdata: hidden cross-device listening-history transport + * + * Keeping them in one authorization request is important because Google access + * tokens are refreshed as a unit by Play services. A background refresh must not + * silently drop one feature's permission while keeping the other. */ @Singleton class GoogleAuthManager @Inject constructor( @param:ApplicationContext private val context: Context, - private val tokenStorage: GoogleDriveTokenStorage + private val tokenStorage: GoogleDriveTokenStorage, + private val historySyncSettings: DriveHistorySyncSettingsManager ) { companion object { private const val TAG = "GoogleAuthManager" - - // Using drive.file scope - only accesses files created by this app - // This avoids CASA security assessment requirements - private val DRIVE_SCOPE = Scope(DriveScopes.DRIVE_FILE) + // Keep these names aligned with DriveHistorySyncWorker. They live here as + // strings deliberately so the authentication/data layer does not depend + // on the worker package just to clean up scheduled work at sign-out. + private const val HISTORY_SYNC_WORK_NAME = "drive_history_sync" + private const val HISTORY_SYNC_MANUAL_WORK_NAME = "drive_history_sync_manual" + + private val REQUIRED_DRIVE_SCOPES = listOf( + Scope(DriveScopes.DRIVE_FILE), + Scope(DriveScopes.DRIVE_APPDATA) + ) + private val REQUIRED_DRIVE_SCOPE_URIS = setOf( + DriveScopes.DRIVE_FILE, + DriveScopes.DRIVE_APPDATA + ) internal fun failedTokenIsStillCurrent( failedAccessToken: String, currentAccessToken: String? ): Boolean = failedAccessToken.isNotBlank() && failedAccessToken == currentAccessToken } - - // CredentialManager is created per-call with Activity context + private val authorizationClient = Identity.getAuthorizationClient(context) - + private val _currentAccount = MutableStateFlow(null) val currentAccount: StateFlow = _currentAccount.asStateFlow() - + private val _isSignedIn = MutableStateFlow(false) val isSignedIn: StateFlow = _isSignedIn.asStateFlow() - - // Indicates that Drive authorization requires user consent via pendingIntent + private val _needsDriveConsent = MutableStateFlow(false) val needsDriveConsent: StateFlow = _needsDriveConsent.asStateFlow() - - // Cached authorization result for Drive API access. + @Volatile private var authorizationResult: AuthorizationResult? = null - // ApiException can carry a consent resolution even when no AuthorizationResult - // is returned. Keep that PendingIntent separately so the UI can always launch - // the required Drive consent flow. @Volatile private var authorizationResolution: PendingIntent? = null - // Google Play Services authorization calls mutate the same account/token - // state. Serialize interactive sign-in, silent restore, refresh, consent and - // sign-out so a background worker cannot overwrite a newly selected account. + // Every authorization path mutates the same account/token state. Serializing + // them prevents a background refresh or late HTTP failure from overwriting a + // newer interactive sign-in, consent result, account switch, or sign-out. private val authOperationMutex = Mutex() private fun configuredWebClientId(): String? = BuildConfig.GOOGLE_WEB_CLIENT_ID.trim().takeIf { it.isNotEmpty() } - - /** - * Sign in with Google using Credential Manager. - * Shows the explicit Sign in with Google account chooser. - * - * @param activity The Activity context REQUIRED for Credential Manager UI - */ + suspend fun signIn(activity: Activity): GoogleSignInResult = authOperationMutex.withLock { signInUnlocked(activity) } @@ -115,188 +115,106 @@ class GoogleAuthManager @Inject constructor( } try { - Log.d(TAG, "Starting explicit Google Sign-In with Credential Manager") - - // Create CredentialManager with Activity context val credentialManager = CredentialManager.create(activity) - - // A user tapping a dedicated "Sign in with Google" button should use - // GetSignInWithGoogleOption. GetGoogleIdOption is intended for the - // general credential selector / session-restore path and can return - // NoCredentialException even when a Google account exists on-device. - val googleSignInOption = GetSignInWithGoogleOption.Builder(webClientId) - .build() - + val googleSignInOption = GetSignInWithGoogleOption.Builder(webClientId).build() val request = GetCredentialRequest.Builder() .addCredentialOption(googleSignInOption) .build() - - // CRITICAL: Must use Activity context here, not Application context - val response = credentialManager.getCredential(activity, request) - - handleSignInResponse(response) + handleSignInResponse(credentialManager.getCredential(activity, request)) } catch (e: GetCredentialCancellationException) { - Log.d(TAG, "Sign-in cancelled by user") GoogleSignInResult.Cancelled } catch (e: NoCredentialException) { - // NoCredentialException does not mean there is no Google account on - // the device. It means Credential Manager could not return a usable - // credential for this request, so surface an actionable error instead - // of the misleading "No Google accounts found" message. Log.w(TAG, "No Google credential available for explicit sign-in", e) GoogleSignInResult.Error( "Google Sign-In is unavailable. Check Google Play services and your Google account settings, then try again.", e ) } catch (e: GetCredentialException) { - Log.e(TAG, "Sign-in failed", e) + Log.e(TAG, "Google sign-in failed", e) GoogleSignInResult.Error("Sign-in failed: ${e.message}", e) } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Unexpected error during sign-in", e) + Log.e(TAG, "Unexpected Google sign-in error", e) GoogleSignInResult.Error("Unexpected error: ${e.message}", e) } } - - /** - * Handle the credential response and extract Google account info. - */ + private suspend fun handleSignInResponse(response: GetCredentialResponse): GoogleSignInResult { val credential = response.credential - - return when (credential) { - is CustomCredential -> { - val isGoogleIdCredential = - credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL || - credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL - - if (isGoogleIdCredential) { - try { - val googleIdCredential = GoogleIdTokenCredential.createFrom(credential.data) - val email = googleIdCredential.email?.takeIf { it.isNotBlank() } - ?: return GoogleSignInResult.Error( - "Google Sign-In did not return an email address for the selected account." - ) - - val account = GoogleAccount( - email = email, - displayName = googleIdCredential.displayName, - photoUrl = googleIdCredential.profilePictureUri?.toString() - ) - - _currentAccount.value = account - _isSignedIn.value = true - - // Persist account info for background session restoration - tokenStorage.saveAccountInfo( - email = account.email, - displayName = account.displayName, - photoUrl = account.photoUrl - ) - - Log.i(TAG, "Google identity sign-in succeeded") - - // An explicit account selection starts a fresh Drive authorization - // lifecycle. Never allow a token from a previous account/session to - // survive if the new authorization later needs consent or fails. - authorizationResult = null - authorizationResolution = null - _needsDriveConsent.value = false - tokenStorage.clearToken() - - // Authorize for Drive access - requestDriveAuthorization() - - GoogleSignInResult.Success(account) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - _currentAccount.value = null - _isSignedIn.value = false - runCatching { tokenStorage.clearAll() } - Log.e(TAG, "Failed to parse Google ID credential", e) - GoogleSignInResult.Error("Failed to parse credential: ${e.message}", e) - } - } else { - GoogleSignInResult.Error("Unexpected credential type: ${credential.type}") - } - } - else -> { - GoogleSignInResult.Error("Unexpected credential class: ${credential::class.java.name}") - } + if (credential !is CustomCredential) { + return GoogleSignInResult.Error("Unexpected credential class: ${credential::class.java.name}") + } + + val isGoogleIdCredential = + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL || + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL + if (!isGoogleIdCredential) { + return GoogleSignInResult.Error("Unexpected credential type: ${credential.type}") + } + + return try { + val googleIdCredential = GoogleIdTokenCredential.createFrom(credential.data) + val email = googleIdCredential.email?.takeIf { it.isNotBlank() } + ?: return GoogleSignInResult.Error( + "Google Sign-In did not return an email address for the selected account." + ) + val account = GoogleAccount( + email = email, + displayName = googleIdCredential.displayName, + photoUrl = googleIdCredential.profilePictureUri?.toString(), + subject = googleIdCredential.uniqueId.trim().takeIf { it.isNotEmpty() } + ) + // A newly-selected identity starts a new authorization boundary. + // Clear the previous account's token before any consent/failure path + // can return control to the UI. + authorizationResult = null + authorizationResolution = null + _needsDriveConsent.value = false + tokenStorage.clearToken() + + tokenStorage.saveAccountInfo(account.email, account.displayName, account.photoUrl, account.subject) + _currentAccount.value = account + _isSignedIn.value = true + + // Identity and Drive authorization are intentionally separate. Sign-in + // succeeds even if the subsequent Drive consent still needs UI. + requestDriveAuthorization() + GoogleSignInResult.Success(account) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + authorizationResult = null + authorizationResolution = null + _currentAccount.value = null + _isSignedIn.value = false + _needsDriveConsent.value = false + runCatching { tokenStorage.clearAll() } + Log.e(TAG, "Failed to parse Google ID credential", e) + GoogleSignInResult.Error("Failed to parse credential: ${e.message}", e) } } - - /** - * Request authorization for Google Drive API access. - * This is called after successful sign-in. - * - * @return true if authorization was granted immediately, false if consent is needed - */ + private suspend fun requestDriveAuthorization(): Boolean = withContext(Dispatchers.IO) { try { - Log.d(TAG, "Requesting Drive authorization") - + authorizationResult = null authorizationResolution = null - authorizationResult = authorizationClient.authorize(buildDriveAuthRequest()).await() - - if (authorizationResult?.hasResolution() == true) { - authorizationResolution = authorizationResult?.pendingIntent - Log.d(TAG, "Drive authorization requires user consent - pendingIntent available") + _needsDriveConsent.value = false + val result = authorizationClient.authorize(buildDriveAuthRequest()).await() + authorizationResult = result + if (result.hasResolution()) { + authorizationResolution = result.pendingIntent _needsDriveConsent.value = true - false - } else { - authorizationResolution = null - val accessToken = authorizationResult?.accessToken - val grantedScopes = authorizationResult?.grantedScopes.orEmpty() - val hasDriveScope = grantedScopes.any { it.contains(DriveScopes.DRIVE_FILE) } - Log.i( - TAG, - "Drive authorization granted, accessToken present: ${accessToken != null}, " + - "drive.file scope granted: $hasDriveScope, scopes: $grantedScopes" - ) - _needsDriveConsent.value = false - - if (accessToken == null || !hasDriveScope) { - // This is the classic cause of "Permission denied. Check Drive access.": - // the token was issued but does not carry drive.file, so the Drive API - // answers 403. Do not persist a broken token. - Log.e( - TAG, - "Drive authorization did not grant the drive.file scope. " + - "Ensure the OAuth consent screen for client ${BuildConfig.GOOGLE_WEB_CLIENT_ID} " + - "includes https://www.googleapis.com/auth/drive.file" - ) - authorizationResult = null - authorizationResolution = null - tokenStorage.clearToken() - return@withContext false - } - - // Persist the access token for background workers - tokenStorage.saveAccessToken(accessToken) - Log.d(TAG, "Access token persisted to secure storage") - - true + return@withContext false } + persistAuthorizedToken(result) } catch (e: ApiException) { - // GMS may throw ApiException carrying the consent pendingIntent - val resolution = e.status.resolution - if (resolution != null) { - authorizationResult = null - authorizationResolution = resolution - Log.d(TAG, "Drive authorization requires user consent (ApiException) - pendingIntent available") - _needsDriveConsent.value = true - false - } else { - authorizationResult = null - authorizationResolution = null - _needsDriveConsent.value = false - tokenStorage.clearToken() - Log.e(TAG, "Failed to request Drive authorization (${e.status.statusCode}: ${e.status.statusMessage})", e) - false - } + authorizationResult = null + authorizationResolution = e.status.resolution + _needsDriveConsent.value = authorizationResolution != null + if (authorizationResolution == null) tokenStorage.clearToken() + Log.e(TAG, "Drive authorization failed (${e.status.statusCode})", e) + false } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -304,56 +222,33 @@ class GoogleAuthManager @Inject constructor( authorizationResolution = null _needsDriveConsent.value = false tokenStorage.clearToken() - Log.e(TAG, "Failed to request Drive authorization", e) + Log.e(TAG, "Drive authorization failed", e) false } } - - /** - * Called after user completes the consent flow via pendingIntent. - * Re-requests authorization to get the access token. - */ + suspend fun completeConsentFlow(): Boolean = authOperationMutex.withLock { completeConsentFlowUnlocked() } private suspend fun completeConsentFlowUnlocked(): Boolean = withContext(Dispatchers.IO) { try { - Log.d(TAG, "Completing consent flow, re-requesting authorization") _needsDriveConsent.value = false + authorizationResult = null authorizationResolution = null - - authorizationResult = authorizationClient.authorize(buildDriveAuthRequest()).await() - - if (authorizationResult?.hasResolution() == true) { - authorizationResolution = authorizationResult?.pendingIntent + val result = authorizationClient.authorize(buildDriveAuthRequest()).await() + authorizationResult = result + if (result.hasResolution()) { + authorizationResolution = result.pendingIntent _needsDriveConsent.value = true - Log.w(TAG, "Drive consent still requires user resolution after consent flow") return@withContext false } - - val accessToken = authorizationResult?.accessToken - val grantedScopes = authorizationResult?.grantedScopes.orEmpty() - val hasDriveScope = grantedScopes.any { it.contains(DriveScopes.DRIVE_FILE) } - val hasToken = accessToken != null && hasDriveScope - Log.i(TAG, "Consent flow completed, accessToken present: $hasToken, drive.file scope: $hasDriveScope") - - // Persist the access token for background workers - if (accessToken != null && hasDriveScope) { - tokenStorage.saveAccessToken(accessToken) - Log.d(TAG, "Access token persisted to secure storage") - } else { - authorizationResult = null - authorizationResolution = null - tokenStorage.clearToken() - } - - hasToken + persistAuthorizedToken(result) } catch (e: ApiException) { authorizationResult = null authorizationResolution = e.status.resolution _needsDriveConsent.value = authorizationResolution != null tokenStorage.clearToken() - Log.e(TAG, "Failed to complete consent flow (${e.status.statusCode}: ${e.status.statusMessage})", e) + Log.e(TAG, "Failed to complete Drive consent (${e.status.statusCode})", e) false } catch (e: CancellationException) { throw e @@ -362,130 +257,110 @@ class GoogleAuthManager @Inject constructor( authorizationResolution = null _needsDriveConsent.value = false tokenStorage.clearToken() - Log.e(TAG, "Failed to complete consent flow", e) + Log.e(TAG, "Failed to complete Drive consent", e) false } } - - /** - * Build the Drive authorization request. - * - * Binds the token to the OAuth Web client (GOOGLE_WEB_CLIENT_ID) and to the - * account the user signed in with, so Google Play services issues an access - * token against the consent screen that lists the `drive.file` scope. - * - * A Drive authorization request must never fall back to an implicit OAuth - * client: that can mint a token without the required `drive.file` scope and - * later surface as a misleading Drive permission failure. - */ + + private fun persistAuthorizedToken(result: AuthorizationResult?): Boolean { + val accessToken = result?.accessToken + val grantedScopes = result?.grantedScopes.orEmpty() + val hasScopes = hasAllRequiredDriveScopes(grantedScopes) + val valid = accessToken != null && hasScopes + + if (accessToken != null && hasScopes) { + tokenStorage.saveAccessToken(accessToken, grantedScopes) + authorizationResolution = null + _needsDriveConsent.value = false + Log.i(TAG, "Drive authorization granted for backup + appData sync") + } else { + authorizationResult = null + authorizationResolution = null + tokenStorage.clearToken() + if (!hasScopes) { + Log.w( + TAG, + "Drive authorization is missing one or more required least-privilege scopes. Granted: $grantedScopes" + ) + } + } + return valid + } + + private fun hasAllRequiredDriveScopes(grantedScopes: Collection): Boolean = + REQUIRED_DRIVE_SCOPE_URIS.all(grantedScopes::contains) + + fun getAuthorizationResult(): AuthorizationResult? = authorizationResult + private fun buildDriveAuthRequest(): AuthorizationRequest { val webClientId = requireNotNull(configuredWebClientId()) { "GOOGLE_WEB_CLIENT_ID is required for Google Drive authorization" } - val builder = AuthorizationRequest.Builder() - .setRequestedScopes(listOf(DRIVE_SCOPE)) + .setRequestedScopes(REQUIRED_DRIVE_SCOPES) - // Pin the authorization to the exact account used for sign-in. _currentAccount.value?.let { account -> builder.setAccount(Account(account.email, "com.google")) } - - // Bind every Drive token request to the configured OAuth Web client. builder.requestOfflineAccess(webClientId) - return builder.build() } - - /** - * Check if Drive authorization requires user consent. - * Returns the pending intent if consent is needed. - */ + fun getDriveAuthorizationPendingIntent(): PendingIntent? = authorizationResolution ?: authorizationResult?.pendingIntent - - /** - * Get access token for Google Drive API calls. - * Returns null if not signed in or not authorized. - * - * Falls back to persisted token storage if in-memory token is unavailable. - */ + + fun updateAuthorizationResult(result: AuthorizationResult) { + authorizationResult = result + authorizationResolution = result.pendingIntent.takeIf { result.hasResolution() } + _needsDriveConsent.value = result.hasResolution() + if (!result.hasResolution()) { + persistAuthorizedToken(result) + } + } + suspend fun getAccessToken(): String? = withContext(Dispatchers.IO) { try { - // First, try to get from in-memory authorization result - val result = authorizationResult - if (result != null && !result.hasResolution()) { - val token = result.accessToken - val hasDriveScope = result.grantedScopes.orEmpty() - .any { it.contains(DriveScopes.DRIVE_FILE) } - if (token != null && hasDriveScope) { - return@withContext token - } - if (token != null && !hasDriveScope) { - Log.w(TAG, "Discarding in-memory token without drive.file scope") - authorizationResult = null - authorizationResolution = null - tokenStorage.clearToken() + val current = authorizationResult + if (current != null && !current.hasResolution() && + hasAllRequiredDriveScopes(current.grantedScopes.orEmpty()) + ) { + current.accessToken?.takeIf { it.isNotBlank() }?.let { + return@withContext it } } - - // Fallback to persisted token storage (for background workers) - val persistedToken = tokenStorage.getAccessToken() - if (persistedToken != null) { - // Refresh before the usual one-hour OAuth lifetime is reached. - // If Google says consent is required, or the token has crossed our - // hard-expiry threshold, never hand the old token to Drive. + + val persisted = tokenStorage.getAccessToken() + if (persisted != null && tokenStorage.hasGrantedScopes(REQUIRED_DRIVE_SCOPE_URIS)) { if (tokenStorage.isTokenStale()) { - Log.w(TAG, "Persisted token is stale, attempting silent refresh") if (refreshAccessToken()) { - val freshToken = tokenStorage.getAccessToken() - if (freshToken != null) { - Log.d(TAG, "Got fresh token via silent refresh") - return@withContext freshToken - } + return@withContext tokenStorage.getAccessToken() } - if (_needsDriveConsent.value || tokenStorage.isTokenExpired()) { - Log.w(TAG, "Stale Drive token cannot be reused; authorization is required") tokenStorage.clearToken() return@withContext null } - - // A transient Play Services failure may happen slightly before - // the token's normal expiry. Keep that previously scope-validated - // token only while it remains below the hard-expiry threshold; - // Drive will still reject/refresh it on a real 401. - Log.w(TAG, "Silent refresh failed; temporarily using cached Drive token") - return@withContext persistedToken } - Log.d(TAG, "Using persisted token from storage") - return@withContext persistedToken + return@withContext persisted } - - Log.w(TAG, "No access token available (memory or storage)") + if (persisted != null) tokenStorage.clearToken() null } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Failed to get access token", e) + Log.e(TAG, "Failed to obtain Drive access token", e) null } } - /** - * Force refresh of the access token. - * Call this when a 401 Unauthorized error is encountered. - */ suspend fun refreshAccessToken(): Boolean = authOperationMutex.withLock { refreshAccessTokenUnlocked() } /** * Refresh after a concrete Drive request was rejected with HTTP 401. * - * Another concurrent request may already have rotated the token while the - * failed HTTP response was in flight. In that case the replacement token is - * kept and the caller can retry immediately. Only the exact rejected token is - * cleared/refreshed, so a late 401 can never erase a newer authorization. + * The HTTP response can arrive after another request has already rotated the + * token. In that case the newer token is preserved and the caller can retry + * immediately. Only the exact rejected token may be cleared. */ suspend fun refreshAccessTokenAfterFailure(failedAccessToken: String): Boolean = authOperationMutex.withLock { @@ -510,9 +385,9 @@ class GoogleAuthManager @Inject constructor( } /** - * Invalidate a token rejected for a known missing-scope authorization error. - * Returns false when the failure belongs to an older token that another - * request has already replaced. + * Invalidate an authorization rejected for missing Drive scopes. Returns + * false when the response belongs to an older token that has already been + * replaced, so the caller can retry without damaging the new session. */ suspend fun invalidateAuthorizationForAccessToken(failedAccessToken: String): Boolean = authOperationMutex.withLock { @@ -533,9 +408,9 @@ class GoogleAuthManager @Inject constructor( private fun currentScopedAccessToken(): String? { val inMemory = authorizationResult - val hasDriveScope = inMemory?.grantedScopes.orEmpty() - .any { it.contains(DriveScopes.DRIVE_FILE) } - return if (inMemory != null && !inMemory.hasResolution() && hasDriveScope) { + return if (inMemory != null && !inMemory.hasResolution() && + hasAllRequiredDriveScopes(inMemory.grantedScopes.orEmpty()) + ) { inMemory.accessToken?.takeIf { it.isNotBlank() } } else { tokenStorage.getAccessToken() @@ -544,46 +419,22 @@ class GoogleAuthManager @Inject constructor( private suspend fun refreshAccessTokenUnlocked(): Boolean = withContext(Dispatchers.IO) { try { - Log.d(TAG, "Refreshing access token") - - // Re-authorize to get a fresh token. Clear the old result before - // awaiting so an exception cannot leave a stale token readable. authorizationResult = null authorizationResolution = null _needsDriveConsent.value = false - authorizationResult = authorizationClient.authorize(buildDriveAuthRequest()).await() - - if (authorizationResult?.hasResolution() == true) { - authorizationResolution = authorizationResult?.pendingIntent - Log.d(TAG, "Refresh requires user consent") + val result = authorizationClient.authorize(buildDriveAuthRequest()).await() + authorizationResult = result + if (result.hasResolution()) { + authorizationResolution = result.pendingIntent _needsDriveConsent.value = true - false - } else { - authorizationResolution = null - _needsDriveConsent.value = false - val accessToken = authorizationResult?.accessToken - val grantedScopes = authorizationResult?.grantedScopes.orEmpty() - val hasDriveScope = grantedScopes.any { it.contains(DriveScopes.DRIVE_FILE) } - val hasToken = accessToken != null && hasDriveScope - Log.i(TAG, "Token refreshed successfully: $hasToken (drive.file scope: $hasDriveScope)") - - // Persist the refreshed token - if (accessToken != null && hasDriveScope) { - tokenStorage.saveAccessToken(accessToken) - Log.d(TAG, "Refreshed token persisted to secure storage") - } else { - authorizationResult = null - authorizationResolution = null - tokenStorage.clearToken() - } - - hasToken + return@withContext false } + persistAuthorizedToken(result) } catch (e: ApiException) { - Log.w(TAG, "Refresh failed (${e.status.statusCode}: ${e.status.statusMessage})", e) authorizationResult = null authorizationResolution = e.status.resolution _needsDriveConsent.value = authorizationResolution != null + Log.w(TAG, "Drive token refresh failed (${e.status.statusCode})", e) false } catch (e: CancellationException) { throw e @@ -591,223 +442,156 @@ class GoogleAuthManager @Inject constructor( authorizationResult = null authorizationResolution = null _needsDriveConsent.value = false - Log.e(TAG, "Failed to refresh token", e) + Log.e(TAG, "Drive token refresh failed", e) false } } - - /** - * Sign out and clear all credentials. - */ + + fun invalidateAuthorization() { + authorizationResult = null + authorizationResolution = null + _needsDriveConsent.value = false + } + + fun clearPersistedAccessToken() { + tokenStorage.clearToken() + } + suspend fun signOut() = withContext(Dispatchers.IO + NonCancellable) { authOperationMutex.withLock { signOutUnlocked() } } private suspend fun signOutUnlocked() = withContext(Dispatchers.IO + NonCancellable) { - Log.d(TAG, "Signing out") - - // Credential Manager cleanup is best effort. It must not gate the local - // credential/token cleanup below, otherwise one provider exception can - // leave Tempo looking signed in with stale Drive credentials. - try { - val credentialManager = CredentialManager.create(context) - credentialManager.clearCredentialState(ClearCredentialStateRequest()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Credential Manager cleanup failed during sign out", e) - } - try { - tokenStorage.clearAll() - Log.d(TAG, "Cleared persisted tokens from secure storage") - } catch (e: CancellationException) { - throw e + CredentialManager.create(context) + .clearCredentialState(ClearCredentialStateRequest()) } catch (e: Exception) { - Log.e(TAG, "Failed to clear persisted Google credentials", e) + Log.w(TAG, "Credential Manager cleanup failed during sign-out", e) } finally { - authorizationResult = null - authorizationResolution = null - _currentAccount.value = null - _isSignedIn.value = false - _needsDriveConsent.value = false - } + // Google sign-out is also a privacy boundary for the optional history + // transport. Clear its opt-in/status and remove periodic/manual work + // immediately so no background task keeps trying to use a signed-out + // account. Local listening history and the stable device id remain. + try { + historySyncSettings.clearForSignOut() + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(HISTORY_SYNC_WORK_NAME) + workManager.cancelUniqueWork(HISTORY_SYNC_MANUAL_WORK_NAME) + } catch (e: Exception) { + Log.w(TAG, "Failed to clear Drive history sync state during sign-out", e) + } - Log.i(TAG, "Finished Google sign out cleanup") + try { + tokenStorage.clearAll() + } catch (e: Exception) { + Log.e(TAG, "Failed to clear persisted Google credentials", e) + } finally { + authorizationResult = null + authorizationResolution = null + _currentAccount.value = null + _isSignedIn.value = false + _needsDriveConsent.value = false + } + } } - - /** - * Restore session on app launch. - * Attempts to silently sign in with previously authorized account. - * - * @param activity The Activity context REQUIRED for Credential Manager UI - */ + suspend fun restoreSession(activity: Activity): Boolean = authOperationMutex.withLock { restoreSessionUnlocked(activity) } private suspend fun restoreSessionUnlocked(activity: Activity): Boolean = withContext(Dispatchers.Main) { - val webClientId = configuredWebClientId() - if (webClientId == null) { - Log.w(TAG, "Skipping session restore: GOOGLE_WEB_CLIENT_ID is blank") - return@withContext false - } - + val webClientId = configuredWebClientId() ?: return@withContext false try { - Log.d(TAG, "Attempting to restore session") - val credentialManager = CredentialManager.create(activity) - - // Session restore intentionally uses GetGoogleIdOption so Credential - // Manager can restrict the request to previously authorized accounts. val googleIdOption = GetGoogleIdOption.Builder() .setServerClientId(webClientId) - .setFilterByAuthorizedAccounts(true) // Only previously authorized accounts - .setAutoSelectEnabled(true) // Auto-select if only one account + .setFilterByAuthorizedAccounts(true) + .setAutoSelectEnabled(true) .build() - val request = GetCredentialRequest.Builder() .addCredentialOption(googleIdOption) .build() - - val response = credentialManager.getCredential(activity, request) - val result = handleSignInResponse(response) - - result is GoogleSignInResult.Success + handleSignInResponse(credentialManager.getCredential(activity, request)) is GoogleSignInResult.Success } catch (e: NoCredentialException) { - Log.d(TAG, "No saved credentials to restore") false } catch (e: GetCredentialCancellationException) { - Log.d(TAG, "Session restore cancelled") false } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.w(TAG, "Failed to restore session", e) + Log.w(TAG, "Failed to restore Google session", e) false } } - + /** - * Attempt silent session restore without showing UI for background operations (e.g. WorkManager). - * Restores account info from encrypted storage and attempts silent re-authorization with Play Services. + * Background-safe restoration used by WorkManager. No UI is shown; if Google + * needs fresh consent for drive.appdata after upgrading from an older Tempo + * version, this returns false and the settings screen can request it later. */ suspend fun restoreSessionSilently(): Boolean = authOperationMutex.withLock { restoreSessionSilentlyUnlocked() } private suspend fun restoreSessionSilentlyUnlocked(): Boolean = withContext(Dispatchers.IO) { - // Step 1: Check if a drive.file-scoped session is already active in memory. - val activeAuthorization = authorizationResult - val activeHasDriveScope = activeAuthorization?.grantedScopes.orEmpty() - .any { it.contains(DriveScopes.DRIVE_FILE) } - if (_isSignedIn.value && activeAuthorization?.accessToken != null && activeHasDriveScope) { - Log.d(TAG, "Session already active in memory") - return@withContext true - } + val active = authorizationResult + if (_isSignedIn.value && active?.accessToken != null && + hasAllRequiredDriveScopes(active.grantedScopes.orEmpty()) + ) return@withContext true _isSignedIn.value = false + if (configuredWebClientId() == null || !tokenStorage.hasAccountInfo()) return@withContext false - // Never attempt background authorization against an implicit OAuth client. - // Builds without the configured Web client ID must require an interactive, - // correctly configured build instead of silently minting the wrong token. - if (configuredWebClientId() == null) { - Log.w(TAG, "Skipping silent session restore: GOOGLE_WEB_CLIENT_ID is blank") - return@withContext false - } - - // Step 2: Try to restore from encrypted storage - val storageStatus = tokenStorage.getStorageStatus() - Log.d(TAG, "Token storage status: $storageStatus") - - if (!tokenStorage.hasAccountInfo()) { - Log.d(TAG, "No stored account info - user needs to sign in via UI") + val storedAccount = tokenStorage.getStoredAccount() ?: return@withContext false + if (storedAccount.subject.isNullOrBlank()) { + // An older app saved GoogleIdTokenCredential.id (the email) as its + // account subject. Require interactive Google sign-in to establish + // uniqueId before any background Drive access can resume. + tokenStorage.clearToken() + _currentAccount.value = null return@withContext false } - - // Restore account info to state - val storedAccount = tokenStorage.getStoredAccount() - if (storedAccount != null) { - _currentAccount.value = storedAccount - Log.d(TAG, "Restored Google account info from secure storage") - } - - // Step 3: Attempt silent re-authorization with Google Play Services - // This works because Google Play Services caches the authorization + _currentAccount.value = storedAccount + try { - Log.d(TAG, "Attempting silent re-authorization with Google Play Services") - authorizationResult = null authorizationResolution = null - authorizationResult = authorizationClient.authorize(buildDriveAuthRequest()).await() - - // Check if we got a token without needing user consent - if (authorizationResult?.hasResolution() == true) { - authorizationResolution = authorizationResult?.pendingIntent + _needsDriveConsent.value = false + val result = authorizationClient.authorize(buildDriveAuthRequest()).await() + authorizationResult = result + if (result.hasResolution()) { + authorizationResolution = result.pendingIntent _needsDriveConsent.value = true - Log.d(TAG, "Re-authorization requires user consent - cannot complete silently") return@withContext false } - authorizationResolution = null - _needsDriveConsent.value = false - - val accessToken = authorizationResult?.accessToken - val grantedScopes = authorizationResult?.grantedScopes.orEmpty() - val hasDriveScope = grantedScopes.any { it.contains(DriveScopes.DRIVE_FILE) } - if (accessToken != null && hasDriveScope) { - // Successfully got a fresh token - tokenStorage.saveAccessToken(accessToken) + if (persistAuthorizedToken(result)) { _isSignedIn.value = true - Log.i(TAG, "Successfully restored the Google session silently") return@withContext true } - - Log.w(TAG, "Re-authorization completed but no drive.file-scoped access token received (scopes: $grantedScopes)") - authorizationResult = null - authorizationResolution = null - tokenStorage.clearToken() - return@withContext false - } catch (e: ApiException) { authorizationResult = null authorizationResolution = e.status.resolution _needsDriveConsent.value = authorizationResolution != null - Log.w( - TAG, - "Silent re-authorization failed (${e.status.statusCode}: ${e.status.statusMessage})", - e - ) - - // A resolution is an explicit instruction from Google that the user - // must consent again. Do not mask it with a cached token. + Log.w(TAG, "Silent Drive authorization failed (${e.status.statusCode})", e) if (authorizationResolution != null) { tokenStorage.clearToken() return@withContext false } - - // For a non-consent Play Services failure, a still-valid token that - // Tempo previously persisted only after verifying drive.file scope may - // remain usable; Drive itself will reject it if Google invalidated it. - if (tokenStorage.hasToken() && !tokenStorage.isTokenExpired()) { - Log.d(TAG, "Using previously validated cached Drive token") - _isSignedIn.value = true - return@withContext true - } - return@withContext false } catch (e: CancellationException) { throw e } catch (e: Exception) { authorizationResult = null authorizationResolution = null _needsDriveConsent.value = false - Log.w(TAG, "Silent re-authorization failed: ${e.message}", e) + Log.w(TAG, "Silent Drive authorization failed", e) + } - // Non-Google failures (for example a transient local Play Services - // issue) may still use a non-expired token that was previously saved - // only after verifying drive.file scope. - if (tokenStorage.hasToken() && !tokenStorage.isTokenExpired()) { - Log.d(TAG, "Using previously validated cached Drive token") - _isSignedIn.value = true - return@withContext true - } - return@withContext false + // A non-expired cached token can keep existing features alive, but after + // this release tokens are normally refreshed with both required scopes. + if (tokenStorage.hasToken() && + tokenStorage.hasGrantedScopes(REQUIRED_DRIVE_SCOPE_URIS) && + !tokenStorage.isTokenExpired() + ) { + _isSignedIn.value = true + return@withContext true } + false } } diff --git a/app/src/main/java/me/avinas/tempo/data/drive/GoogleDriveTokenStorage.kt b/app/src/main/java/me/avinas/tempo/data/drive/GoogleDriveTokenStorage.kt index 267a116d8..a7ee00e2d 100644 --- a/app/src/main/java/me/avinas/tempo/data/drive/GoogleDriveTokenStorage.kt +++ b/app/src/main/java/me/avinas/tempo/data/drive/GoogleDriveTokenStorage.kt @@ -10,8 +10,15 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Stores Google Drive OAuth tokens in EncryptedSharedPreferences. - * Keys and values are encrypted via Android Keystore. + * Secure storage for Google Drive OAuth tokens using EncryptedSharedPreferences. + * + * Uses AES-256-GCM encryption for values and AES-256-SIV for keys, + * backed by Android Keystore for key management. This ensures tokens + * are stored securely and cannot be read by other apps. + * + * This enables background workers (like DriveBackupWorker) to restore + * user sessions without requiring UI interaction, making scheduled + * backups reliable even when the app has been killed. */ @Singleton class GoogleDriveTokenStorage @Inject constructor( @@ -24,9 +31,11 @@ class GoogleDriveTokenStorage @Inject constructor( // Token keys private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_TOKEN_TIMESTAMP = "token_timestamp" + private const val KEY_GRANTED_SCOPES = "granted_scopes" // Account info keys private const val KEY_ACCOUNT_EMAIL = "account_email" + private const val KEY_ACCOUNT_SUBJECT = "account_subject" private const val KEY_ACCOUNT_DISPLAY_NAME = "account_display_name" private const val KEY_ACCOUNT_PHOTO_URL = "account_photo_url" @@ -65,11 +74,12 @@ class GoogleDriveTokenStorage @Inject constructor( * Note: Google's OAuth for Drive only provides access tokens via the * Authorization API. Refresh tokens are managed internally by Google Play Services. */ - fun saveAccessToken(accessToken: String) { - require(accessToken.isNotBlank()) { "Google Drive access token must not be blank" } + fun saveAccessToken(accessToken: String, grantedScopes: Collection = emptyList()) { + require(accessToken.isNotBlank()) { "Google Drive access token cannot be blank" } encryptedPrefs.edit().apply { putString(KEY_ACCESS_TOKEN, accessToken) putLong(KEY_TOKEN_TIMESTAMP, System.currentTimeMillis()) + putStringSet(KEY_GRANTED_SCOPES, grantedScopes.filter { it.isNotBlank() }.toSet()) apply() } Log.d(TAG, "Access token saved") @@ -83,6 +93,11 @@ class GoogleDriveTokenStorage @Inject constructor( return encryptedPrefs.getString(KEY_ACCESS_TOKEN, null) ?.takeIf { it.isNotBlank() } } + + fun hasGrantedScopes(requiredScopes: Set): Boolean { + val persisted = encryptedPrefs.getStringSet(KEY_GRANTED_SCOPES, emptySet()).orEmpty() + return requiredScopes.all(persisted::contains) + } /** * Get the timestamp when the token was saved. @@ -130,6 +145,7 @@ class GoogleDriveTokenStorage @Inject constructor( encryptedPrefs.edit().apply { remove(KEY_ACCESS_TOKEN) remove(KEY_TOKEN_TIMESTAMP) + remove(KEY_GRANTED_SCOPES) apply() } Log.d(TAG, "Access token cleared") @@ -138,10 +154,12 @@ class GoogleDriveTokenStorage @Inject constructor( /** * Save the Google account information. */ - fun saveAccountInfo(email: String, displayName: String?, photoUrl: String?) { + fun saveAccountInfo(email: String, displayName: String?, photoUrl: String?, subject: String? = null) { require(email.isNotBlank()) { "Google account email must not be blank" } encryptedPrefs.edit().apply { putString(KEY_ACCOUNT_EMAIL, email) + if (!subject.isNullOrBlank()) putString(KEY_ACCOUNT_SUBJECT, subject) + else remove(KEY_ACCOUNT_SUBJECT) if (displayName != null) { putString(KEY_ACCOUNT_DISPLAY_NAME, displayName) } else { @@ -195,7 +213,12 @@ class GoogleDriveTokenStorage @Inject constructor( return GoogleAccount( email = email, displayName = getAccountDisplayName(), - photoUrl = getAccountPhotoUrl() + photoUrl = getAccountPhotoUrl(), + // Older releases accidentally persisted GoogleIdTokenCredential.id + // (an email) as subject. Do not reuse this mutable identifier as + // a verified account boundary after upgrading. + subject = encryptedPrefs.getString(KEY_ACCOUNT_SUBJECT, null) + ?.takeIf { it.isNotBlank() && !it.contains('@') } ) } @@ -205,6 +228,7 @@ class GoogleDriveTokenStorage @Inject constructor( fun clearAccountInfo() { encryptedPrefs.edit().apply { remove(KEY_ACCOUNT_EMAIL) + remove(KEY_ACCOUNT_SUBJECT) remove(KEY_ACCOUNT_DISPLAY_NAME) remove(KEY_ACCOUNT_PHOTO_URL) apply() diff --git a/app/src/main/java/me/avinas/tempo/data/importexport/ImportExportManager.kt b/app/src/main/java/me/avinas/tempo/data/importexport/ImportExportManager.kt index 65b3b1188..d89c1251f 100644 --- a/app/src/main/java/me/avinas/tempo/data/importexport/ImportExportManager.kt +++ b/app/src/main/java/me/avinas/tempo/data/importexport/ImportExportManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow import androidx.room.withTransaction import me.avinas.tempo.BuildConfig import me.avinas.tempo.data.local.AppDatabase +import me.avinas.tempo.data.local.dao.ListeningEventDao import me.avinas.tempo.data.local.entities.* import me.avinas.tempo.data.profile.ProfileIdentityManager import me.avinas.tempo.utils.ImageUrlHostAllowlist @@ -276,6 +277,8 @@ class ImportExportManager @Inject constructor( var lastEventId = 0L var eventsWritten = 0 var lastArchiveId = 0L + var lastOriginEventId = 0L + var lastOriginDevice = "" zipOut.putNextEntry(ZipEntry(TempoExportData.DATA_FILENAME)) val dataSink = zipOut.sink().buffer() @@ -297,6 +300,16 @@ class ImportExportManager @Inject constructor( .getArchivePage(lastArchiveId, maxArchiveId, EXPORT_PAGE_SIZE) if (page.isNotEmpty()) lastArchiveId = page.last().id page + }, + originPages = { + val page = database.listeningEventDao().getOriginPage( + lastOriginEventId, lastOriginDevice, maxEventId, EXPORT_PAGE_SIZE + ) + if (page.isNotEmpty()) { + lastOriginEventId = page.last().listeningEventId + lastOriginDevice = page.last().sourceDeviceId + } + page } ) dataSink.flush() @@ -363,8 +376,10 @@ class ImportExportManager @Inject constructor( // memory whole, so restores of huge libraries cannot OOM. val stagedEventsFile = File(context.cacheDir, "import_events.jsonl") val stagedArchiveFile = File(context.cacheDir, "import_archive.jsonl") + val stagedOriginsFile = File(context.cacheDir, "import_origins.jsonl") stagedEventsFile.delete() stagedArchiveFile.delete() + stagedOriginsFile.delete() try { _progress.value = ImportExportProgress("Reading backup file...", 0, 100, true) @@ -375,6 +390,7 @@ class ImportExportManager @Inject constructor( var totalExtractedImageBytes = 0L var stagedEventCount = 0 var stagedArchiveCount = 0 + var stagedOriginCount = 0 context.contentResolver.openInputStream(uri)?.use { inputStream -> ZipInputStream(BufferedInputStream(inputStream)).use { zipIn -> @@ -387,8 +403,10 @@ class ImportExportManager @Inject constructor( val reader = JsonReader.of(zipIn.source().buffer()) val eventAdapter = moshi.adapter(ListeningEvent::class.java) val archiveAdapter = moshi.adapter(ScrobbleArchive::class.java) + val originAdapter = moshi.adapter(ListeningEventOrigin::class.java) val eventSink = stagedEventsFile.sink().buffer() val archiveSink = stagedArchiveFile.sink().buffer() + val originSink = stagedOriginsFile.sink().buffer() try { exportData = codec.read( reader, @@ -402,6 +420,11 @@ class ImportExportManager @Inject constructor( archiveAdapter.toJson(archiveSink, row) archiveSink.writeUtf8("\n") stagedArchiveCount++ + }, + onListeningEventOrigin = { origin -> + originAdapter.toJson(originSink, origin) + originSink.writeUtf8("\n") + stagedOriginCount++ } ) ) @@ -409,6 +432,7 @@ class ImportExportManager @Inject constructor( // flush only — close() would close the ZipInputStream eventSink.flush() archiveSink.flush() + originSink.flush() } } entry.name.startsWith(IMAGES_DIR) && !entry.isDirectory -> { @@ -442,7 +466,7 @@ class ImportExportManager @Inject constructor( ) } - Log.i(TAG, "Extracted ${extractedImages.size} images, staged $stagedEventCount events and $stagedArchiveCount archive rows") + Log.i(TAG, "Extracted ${extractedImages.size} images, staged $stagedEventCount events, $stagedOriginCount origin claims and $stagedArchiveCount archive rows") // Build path mapping: old file:// path -> new file:// path val pathMapping = data.localImageManifest.mapNotNull { (bundledName, originalPath) -> @@ -621,7 +645,7 @@ class ImportExportManager @Inject constructor( // whose track could not be mapped are skipped. The pipeline deduplicates // against existing rows (fingerprint + temporal reconciliation), so a // re-import is a no-op and real listening data is never overwritten. - importedEvents = replayStagedEvents(stagedEventsFile, trackIdMap) + importedEvents = replayStagedEvents(stagedEventsFile, stagedOriginsFile, trackIdMap) Log.i(TAG, "Imported $importedEvents listening events from staged file") _progress.value = ImportExportProgress("Importing metadata...", 80, 100) @@ -826,6 +850,7 @@ class ImportExportManager @Inject constructor( } finally { stagedEventsFile.delete() stagedArchiveFile.delete() + stagedOriginsFile.delete() _progress.value = null operationLease.release() } @@ -838,38 +863,185 @@ class ImportExportManager @Inject constructor( */ private suspend fun replayStagedEvents( stagedFile: File, + stagedOriginsFile: File, trackIdMap: Map ): Int { if (!stagedFile.exists() || stagedFile.length() == 0L) return 0 + val dao = database.listeningEventDao() val eventAdapter = moshi.adapter(ListeningEvent::class.java) + val originAdapter = moshi.adapter(ListeningEventOrigin::class.java) var inserted = 0 - var chunk = ArrayList(EVENT_IMPORT_CHUNK) + var chunk = ArrayList>>(EVENT_IMPORT_CHUNK) + // Called inside the enclosing Room transaction. Restore aliases after + // resolving each event's NEW row ID; never reuse exported numeric IDs. suspend fun flush() { if (chunk.isEmpty()) return - val result = database.listeningEventDao().insertAllBatchedWithDedup(chunk) - inserted += result.inserted + // Re-imported producer identities may already belong to a row with + // different track metadata, source or timestamp. Replaying such an + // event through temporal dedup first can create a duplicate local + // listening row before the alias resolver finds the real original. + // Query these immutable identities once per chunk and do not insert + // a second playback for an already-represented origin. + val restoredOriginKeys = chunk.asSequence() + .flatMap { (_, aliases) -> aliases.asSequence().map { it.restoredKey() } } + .distinct().toList() + val restoredOriginIds = restoredOriginKeys.map { it.originEventId }.distinct() + val existingByOrigin = restoredOriginIds.chunked(900) + .flatMap { dao.getOriginClaimsByOriginIds(it) } + .associate { RestoredOriginKey(it.accountSubject, it.originEventId) to it.listeningEventId } + for ((event, aliases) in chunk) { + val expectedOwner = event.driveAccountSubject ?: "legacy-unverified" + check(aliases.all { it.accountSubject == expectedOwner }) { + "Backup contains producer aliases whose Google owner differs from the listening event" + } + } + val newEvents = chunk.filter { (_, aliases) -> + shouldImportOfflinePlayback(aliases, existingByOrigin) + }.map { it.first } + // A mixed-account ZIP must not use the global dedup scope. Without + // grouping, identical content fingerprints from Google A and B + // silently discard one archive before alias reconciliation. + for ((owner, ownedEvents) in newEvents.groupBy { it.driveAccountSubject }) { + inserted += dao.insertAllBatchedWithDedup(ownedEvents, owner).inserted + } + // The dedup insert may have created new claims. Refresh once per + // chunk, rather than issuing a separate query for every playback. + // Ten years of listening history can contain hundreds of thousands + // of aliases; round trips must scale with pages, not row count. + val currentOriginRows = restoredOriginIds.chunked(900) + .flatMap { dao.getOriginClaimsByOriginIds(it) } + .associate { RestoredOriginKey(it.accountSubject, it.originEventId) to it.listeningEventId } + // Resolve target rows first, then fetch every target's existing + // producer claims in bounded chunks. Avoid issuing one Room query + // per event during multi-year offline backup restores. + val resolvedTargets = mutableListOf>>() + for ((event, exportedAliases) in chunk) { + if (exportedAliases.isEmpty()) continue + // Prefer immutable producer IDs. A previously-restored event + // may be represented by a different source or timestamp after + // another device reconciled it. Only fall back to time when no + // saved origin identifies the playback. + val byOrigin = exportedAliases.mapNotNull { currentOriginRows[it.restoredKey()] }.distinct() + check(byOrigin.size <= 1) { + "Cannot safely restore aliases: producer IDs point to different listening events" + } + val targetId = if (byOrigin.size == 1) { + byOrigin.single() + } else { + val exact = dao.getBackupRestoredEventIds( + event.track_id, event.timestamp, event.source, + event.driveAccountSubject, + ) + if (exact.size == 1) { + exact.single() + } else if (exact.isEmpty()) { + // An alias-free target may have a different timestamp + // or source: use a bounded, unambiguous reconciliation. + val window = ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS + val nearby = dao.getEventsForReconciliation( + event.track_id, event.timestamp - window, event.timestamp + window + ).filter { it.driveAccountSubject == event.driveAccountSubject } + .filter { ref -> + val refDur = ref.estimatedDurationMs + val evDur = event.estimatedDurationMs + if (refDur != null && refDur > 0L && evDur != null && evDur > 0L) { + kotlin.math.abs(refDur - evDur) <= 10_000L + } else true + } + check(nearby.size == 1) { + "Cannot safely restore producer aliases for event at ${event.timestamp}: ambiguous playback" + } + nearby.single().id + } else { + error("Cannot safely restore aliases: duplicate event identity") + } + } + + resolvedTargets.add(targetId to exportedAliases) + } + val savedClaims = resolvedTargets.asSequence().map { it.first }.distinct() + .toList().chunked(900) + .flatMap { dao.getOriginClaimsForEvents(it) } + .groupBy { it.listeningEventId } + val claimsByRestoredId = savedClaims.mapValues { (_, origins) -> + origins.associate { (it.accountSubject to it.sourceDeviceId) to it.originEventId }.toMutableMap() + }.toMutableMap() + val newAliases = mutableListOf() + for ((targetId, exportedAliases) in resolvedTargets) { + // Mutate the map as aliases are staged so repeated entries in + // the same chunk are checked against each other as well. + val claimed = claimsByRestoredId.getOrPut(targetId) { mutableMapOf() } + for (origin in exportedAliases) { + val claimKey = origin.accountSubject to origin.sourceDeviceId + val existing = claimed[claimKey] + if (existing != null) { + check(existing == origin.originEventId) { + "Two different origins claim the same producer and playback" + } + continue + } + check(origin.originEventId.matches(Regex("^[0-9a-f]{64}$")) && + origin.sourceDeviceId.matches(Regex("^[A-Za-z0-9._-]{1,200}$")) + ) { + "Backup contains an invalid producer identity" + } + newAliases.add(origin.copy(listeningEventId = targetId)) + claimed[claimKey] = origin.originEventId + } + } + if (newAliases.isNotEmpty()) dao.insertOriginAliases(newAliases) chunk = ArrayList(EVENT_IMPORT_CHUNK) } - stagedFile.bufferedReader().useLines { lines -> - for (line in lines) { - if (line.isBlank()) continue - val event = try { - eventAdapter.fromJson(line) - } catch (e: Exception) { - Log.w(TAG, "Skipping malformed staged listening event", e) - null - } ?: continue - val newTrackId = trackIdMap[event.track_id] ?: continue - chunk.add(event.copy(id = 0, track_id = newTrackId)) - if (chunk.size >= EVENT_IMPORT_CHUNK) { - flush() + val originReader = stagedOriginsFile.takeIf { it.length() > 0L }?.bufferedReader() + try { + val originLines = originReader?.lineSequence()?.iterator() + fun readNextOrigin(): ListeningEventOrigin? { + if (originLines?.hasNext() != true) return null + return requireNotNull(originAdapter.fromJson(originLines.next())) { + "Unexpected null origin entry in Tempo backup" } } + var pendingOrigin = readNextOrigin() + stagedFile.bufferedReader().useLines { lines -> + for (line in lines) { + if (line.isBlank()) continue + val event = try { + eventAdapter.fromJson(line) + } catch (e: Exception) { + Log.w(TAG, "Skipping malformed staged listening event", e) + null + } ?: continue + // Both arrays are written in listening-event ID order. + // Consume sidecar origins together with their original row. + check(pendingOrigin == null || + requireNotNull(pendingOrigin).listeningEventId >= event.id + ) { "Backup contains a producer alias with no corresponding listening event" } + val aliases = mutableListOf() + while (pendingOrigin?.listeningEventId == event.id) { + aliases.add(requireNotNull(pendingOrigin)) + pendingOrigin = readNextOrigin() + } + val newTrackId = trackIdMap[event.track_id] + if (newTrackId == null) { + check(aliases.isEmpty()) { + "Cannot restore producer aliases: the original track is missing" + } + continue + } + chunk.add(event.copy(id = 0, track_id = newTrackId) to aliases) + if (chunk.size >= EVENT_IMPORT_CHUNK) flush() + } + } + check(pendingOrigin == null) { + "Backup contains a producer alias after the last listening event" + } + flush() + } finally { + originReader?.close() } - flush() return inserted } diff --git a/app/src/main/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentity.kt b/app/src/main/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentity.kt new file mode 100644 index 000000000..4638e91a3 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentity.kt @@ -0,0 +1,25 @@ +package me.avinas.tempo.data.importexport + +import me.avinas.tempo.data.local.entities.ListeningEventOrigin + +/** An immutable producer event ID is unique only within its Google owner. */ +internal data class RestoredOriginKey(val accountSubject: String, val originEventId: String) + +internal fun ListeningEventOrigin.restoredKey(): RestoredOriginKey = + RestoredOriginKey(accountSubject, originEventId) + +/** + * Offline archives may contain the same origin ID from distinct Google + * accounts. Replay only if none of this playback's **account-qualified** aliases + * is already present, and reject conflicting claims within that account. + */ +internal fun shouldImportOfflinePlayback( + aliases: List, + existingByOrigin: Map, +): Boolean { + val targets = aliases.mapNotNull { existingByOrigin[it.restoredKey()] }.distinct() + check(targets.size <= 1) { + "Cannot safely restore aliases: producer IDs point to different listening events" + } + return targets.isEmpty() +} diff --git a/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportData.kt b/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportData.kt index 6476de8a2..bf79a1760 100644 --- a/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportData.kt +++ b/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportData.kt @@ -18,6 +18,7 @@ import me.avinas.tempo.data.local.entities.* * - v7: Added userName (display name) from DataStore * - v8: Added userKnownArtists and dailyChallenges tables * - v9: Added userProfileImagePath from DataStore-backed profile identity + * - v10: Streamed persistent listening-event producer aliases for offline recovery */ @JsonClass(generateAdapter = true) data class TempoExportData( @@ -36,6 +37,8 @@ data class TempoExportData( val albums: List = emptyList(), val trackArtists: List = emptyList(), val listeningEvents: List = emptyList(), + // v10: Cross-device origin claims, streamed in .tempo ZIP exports. + val listeningEventOrigins: List = emptyList(), val enrichedMetadata: List = emptyList(), val userPreferences: UserPreferences? = null, @@ -80,7 +83,7 @@ data class TempoExportData( val imageManifest: Map = emptyMap() ) { companion object { - const val CURRENT_VERSION = 9 + const val CURRENT_VERSION = 10 const val DATA_FILENAME = "data.json" } } diff --git a/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportJsonCodec.kt b/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportJsonCodec.kt index fd9eed591..77b693fed 100644 --- a/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportJsonCodec.kt +++ b/app/src/main/java/me/avinas/tempo/data/importexport/TempoExportJsonCodec.kt @@ -14,6 +14,7 @@ import me.avinas.tempo.data.local.entities.DailyChallenge import me.avinas.tempo.data.local.entities.EnrichedMetadata import me.avinas.tempo.data.local.entities.LastFmImportMetadata import me.avinas.tempo.data.local.entities.ListeningEvent +import me.avinas.tempo.data.local.entities.ListeningEventOrigin import me.avinas.tempo.data.local.entities.ManualContentMark import me.avinas.tempo.data.local.entities.ScrobbleArchive import me.avinas.tempo.data.local.entities.Track @@ -45,6 +46,7 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { private val albumAdapter: JsonAdapter = moshi.adapter(Album::class.java) private val trackArtistAdapter: JsonAdapter = moshi.adapter(TrackArtist::class.java) private val listeningEventAdapter: JsonAdapter = moshi.adapter(ListeningEvent::class.java) + private val originAdapter: JsonAdapter = moshi.adapter(ListeningEventOrigin::class.java) private val enrichedMetadataAdapter: JsonAdapter = moshi.adapter(EnrichedMetadata::class.java) private val userPreferencesAdapter: JsonAdapter = moshi.adapter(UserPreferences::class.java) private val userLevelAdapter: JsonAdapter = moshi.adapter(UserLevel::class.java) @@ -70,7 +72,8 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { */ data class StreamHandlers( val onListeningEvent: (suspend (ListeningEvent) -> Unit)? = null, - val onScrobbleArchiveRow: (suspend (ScrobbleArchive) -> Unit)? = null + val onScrobbleArchiveRow: (suspend (ScrobbleArchive) -> Unit)? = null, + val onListeningEventOrigin: (suspend (ListeningEventOrigin) -> Unit)? = null ) /** @@ -84,7 +87,8 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { writer: JsonWriter, shell: TempoExportData, eventPages: suspend () -> List?, - archivePages: suspend () -> List? + archivePages: suspend () -> List?, + originPages: suspend () -> List? = { null }, ) { writer.beginObject() @@ -109,6 +113,15 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { } writer.endArray() + writer.name("listeningEventOrigins") + writer.beginArray() + while (true) { + val page = originPages() + if (page.isNullOrEmpty()) break + page.forEach { originAdapter.toJson(writer, it) } + } + writer.endArray() + writeArray(writer, "enrichedMetadata", enrichedMetadataAdapter, shell.enrichedMetadata) shell.userPreferences?.let { writer.name("userPreferences").also { _ -> userPreferencesAdapter.toJson(writer, it) } } shell.userLevel?.let { writer.name("userLevel").also { _ -> userLevelAdapter.toJson(writer, it) } } @@ -190,6 +203,11 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { if (handler != null) streamArray(reader, listeningEventAdapter, handler) else reader.skipValue() } + "listeningEventOrigins" -> { + val handler = handlers.onListeningEventOrigin + if (handler != null) streamArray(reader, originAdapter, handler) + else reader.skipValue() + } "enrichedMetadata" -> enrichedMetadata = readArray(reader, enrichedMetadataAdapter) "userPreferences" -> userPreferences = nextNullableObject(reader, userPreferencesAdapter) "userLevel" -> userLevel = nextNullableObject(reader, userLevelAdapter) @@ -226,6 +244,7 @@ internal class TempoExportJsonCodec(private val moshi: Moshi) { albums = albums, trackArtists = trackArtists, listeningEvents = emptyList(), + listeningEventOrigins = emptyList(), enrichedMetadata = enrichedMetadata, userPreferences = userPreferences, userLevel = userLevel, diff --git a/app/src/main/java/me/avinas/tempo/data/local/AppDatabase.kt b/app/src/main/java/me/avinas/tempo/data/local/AppDatabase.kt index fa120112a..36149dffc 100644 --- a/app/src/main/java/me/avinas/tempo/data/local/AppDatabase.kt +++ b/app/src/main/java/me/avinas/tempo/data/local/AppDatabase.kt @@ -15,6 +15,7 @@ import me.avinas.tempo.data.local.entities.DesktopPairingSession entities = [ Track::class, ListeningEvent::class, + ListeningEventOrigin::class, Artist::class, Album::class, EnrichedMetadata::class, @@ -32,7 +33,7 @@ import me.avinas.tempo.data.local.entities.DesktopPairingSession DailyChallenge::class, // Gamification: daily challenges DesktopPairingSession::class, // Desktop Satellite pairing sessions ], - version = 55, // Migration 55: dedupe listening_events by session identity (double-count fix) + version = 58, // Migration 58: scope producer aliases to Google account exportSchema = true, // Schema exported to app/schemas/ — commit these files so migration gaps are caught at build time ) @TypeConverters(Converters::class) @@ -75,7 +76,7 @@ abstract class AppDatabase : RoomDatabase() { private const val TAG = "AppDatabase" /** Current Room schema version — keep in sync with the @Database(version = ...) annotation. */ - const val VERSION = 55 + const val VERSION = 58 /** * Migration from version 6 to 7: Add enhanced tracking columns to listening_events. @@ -2604,9 +2605,108 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Add durable, one-to-one per-producer playback aliases. Previously the + * reconciler only remembered aliases in memory for its current batch. + * Backfill origins already attached directly to existing imported rows. + * + * This is additive and never rewrites or deletes listening_events. + * The SQL uses only SQLite constructs available on minSdk 26. + */ + val MIGRATION_55_56 = + object : Migration(55, 56) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `listening_event_origins` ( + `originEventId` TEXT NOT NULL, + `listeningEventId` INTEGER NOT NULL, + `sourceDeviceId` TEXT NOT NULL, + PRIMARY KEY(`originEventId`), + FOREIGN KEY(`listeningEventId`) REFERENCES `listening_events`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_listening_event_origins_listeningEventId` " + + "ON `listening_event_origins` (`listeningEventId`)", + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_listening_event_origins_listeningEventId_sourceDeviceId` " + + "ON `listening_event_origins` (`listeningEventId`, `sourceDeviceId`)", + ) + // Recover identities of surviving imported events from v55. + // Cross-device aliases previously discarded at reconciliation + // cannot be reconstructed; future batches persist them. + db.execSQL( + """ + INSERT OR IGNORE INTO listening_event_origins + (originEventId, listeningEventId, sourceDeviceId) + SELECT substr(content_fingerprint, 10), id, + substr( + source, + instr(source, ':') + 1, + instr(substr(source, instr(source, ':') + 1), ':') - 1 + ) + FROM listening_events + WHERE length(content_fingerprint) = 73 + AND content_fingerprint LIKE 'drive:v1:%' + AND (source LIKE 'drive:%:%' OR source LIKE 'lan:%:%') + AND instr(substr(source, instr(source, ':') + 1), ':') > 1 + """.trimIndent(), + ) + } + } + /** * All migrations in order. */ + /** The owner of older imported records cannot be verified from Room alone. */ + val MIGRATION_56_57 = object : Migration(56, 57) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE listening_events ADD COLUMN drive_account_subject TEXT DEFAULT NULL") + db.execSQL("UPDATE listening_events SET drive_account_subject = 'legacy-unverified' " + + "WHERE source LIKE 'drive:%'") + db.execSQL("CREATE INDEX IF NOT EXISTS index_listening_events_drive_account_subject " + + "ON listening_events(drive_account_subject)") + } + } + + /** Preserve all origin claims while replacing the old global primary key. + * A cloud event ID may legitimately appear in two distinct Google archives. + */ + val MIGRATION_57_58 = object : Migration(57, 58) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS listening_event_origins_new (" + + "originEventId TEXT NOT NULL, listeningEventId INTEGER NOT NULL, " + + "sourceDeviceId TEXT NOT NULL, " + + "accountSubject TEXT NOT NULL DEFAULT 'legacy-unverified', " + + "PRIMARY KEY(accountSubject, originEventId), " + + "FOREIGN KEY(listeningEventId) REFERENCES listening_events(id) " + + "ON UPDATE NO ACTION ON DELETE CASCADE)" + ) + db.execSQL( + "INSERT INTO listening_event_origins_new " + + "(originEventId, listeningEventId, sourceDeviceId, accountSubject) " + + "SELECT a.originEventId, a.listeningEventId, a.sourceDeviceId, " + + "COALESCE(NULLIF(e.drive_account_subject, ''), 'legacy-unverified') " + + "FROM listening_event_origins a " + + "JOIN listening_events e ON e.id = a.listeningEventId" + ) + db.execSQL("DROP TABLE listening_event_origins") + db.execSQL("ALTER TABLE listening_event_origins_new RENAME TO listening_event_origins") + db.execSQL("CREATE INDEX IF NOT EXISTS index_listening_event_origins_listeningEventId " + + "ON listening_event_origins(listeningEventId)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS " + + "index_listening_event_origins_listeningEventId_sourceDeviceId " + + "ON listening_event_origins(listeningEventId, sourceDeviceId)") + } + } + val ALL_MIGRATIONS = arrayOf( MIGRATION_6_7, @@ -2658,6 +2758,9 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_52_53, // Index tracks.musicbrainz_id (Last.fm/mbid track resolution) MIGRATION_53_54, // Unique daily_challenges(challenge_id,date) + user_level.banked_challenge_xp MIGRATION_54_55, // Dedupe listening_events by (session_id, track_id, timestamp) + MIGRATION_55_56, // Persist exact producer aliases for Drive/LAN reconciliation + MIGRATION_56_57, // Tag Drive imports with account identity and quarantine old unknowns + MIGRATION_57_58, // Scope producer origin IDs per verified Google account ) } } diff --git a/app/src/main/java/me/avinas/tempo/data/local/DatabaseOptimization.kt b/app/src/main/java/me/avinas/tempo/data/local/DatabaseOptimization.kt index 485b6cd4a..e1161c946 100644 --- a/app/src/main/java/me/avinas/tempo/data/local/DatabaseOptimization.kt +++ b/app/src/main/java/me/avinas/tempo/data/local/DatabaseOptimization.kt @@ -220,7 +220,11 @@ class DatabaseOptimization @Inject constructor( * @param retentionDays Number of days of data to keep * @return Number of events deleted */ - suspend fun cleanupOldData(retentionDays: Int = DEFAULT_RETENTION_DAYS): Int = withContext(Dispatchers.IO) { + /** Explicit destructive cleanup ONLY. Routine maintenance never calls this. + * The caller must deliberately specify a retention period so a one-year + * default cannot silently remove a user's decade-long listening history. + */ + suspend fun cleanupOldData(retentionDays: Int): Int = withContext(Dispatchers.IO) { try { val cutoffTimestamp = System.currentTimeMillis() - (retentionDays.toLong() * 24 * 60 * 60 * 1000) @@ -318,8 +322,11 @@ class DatabaseOptimization @Inject constructor( // Analyze for query optimization analyzeDatabase() - // Cleanup old data - val deletedEvents = cleanupOldData() + // Long-term history is user data, not a disposable cache. Routine + // maintenance must never silently erase events after one year. + // cleanupOldData(retentionDays) remains an explicitly-invoked operation, + // but it is NOT part of routine full maintenance. + val deletedEvents = 0 // Vacuum if needed val statsBeforeVacuum = getDatabaseStats() diff --git a/app/src/main/java/me/avinas/tempo/data/local/SourceAuthority.kt b/app/src/main/java/me/avinas/tempo/data/local/SourceAuthority.kt index e9bb37668..1788961e0 100644 --- a/app/src/main/java/me/avinas/tempo/data/local/SourceAuthority.kt +++ b/app/src/main/java/me/avinas/tempo/data/local/SourceAuthority.kt @@ -23,6 +23,8 @@ package me.avinas.tempo.data.local object SourceAuthority { fun rank(source: String): Int = when { + (source.startsWith("drive:") || source.startsWith("lan:")) && + driveDeviceId(source) != null -> rank(unwrapDriveSource(source)) source.startsWith("desktop:") -> 90 source.contains("import.reconstructed") -> 40 source.contains("fm.last.import") -> 50 @@ -34,4 +36,14 @@ object SourceAuthority { source.contains("import") -> 50 else -> 100 } + + fun driveDeviceId(source: String): String? { + if (!source.startsWith("drive:") && !source.startsWith("lan:")) return null + return source.split(':', limit = 3).takeIf { it.size == 3 }?.get(1)?.takeIf { it.isNotBlank() } + } + + private fun unwrapDriveSource(source: String): String = + source.split(':', limit = 3).takeIf { + it.size == 3 && (it[0] == "drive" || it[0] == "lan") + }?.get(2) ?: source } diff --git a/app/src/main/java/me/avinas/tempo/data/local/dao/ListeningEventDao.kt b/app/src/main/java/me/avinas/tempo/data/local/dao/ListeningEventDao.kt index cd85cf10f..bc63ba8b4 100644 --- a/app/src/main/java/me/avinas/tempo/data/local/dao/ListeningEventDao.kt +++ b/app/src/main/java/me/avinas/tempo/data/local/dao/ListeningEventDao.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow import me.avinas.tempo.data.local.EventFingerprint import me.avinas.tempo.data.local.SourceAuthority import me.avinas.tempo.data.local.entities.ListeningEvent +import me.avinas.tempo.data.local.entities.ListeningEventOrigin @Dao interface ListeningEventDao { @@ -24,6 +25,11 @@ interface ListeningEventDao { // using the tight [DUPLICATE_TOLERANCE_MS] so legitimate back-to-back plays // are never merged. const val RECONCILIATION_WINDOW_MS = 60_000L + // Drive clients record comparable playback completion timestamps. + // Re-using a 60s fallback for Drive imports silently removes genuine + // back-to-back plays (e.g. two 25s tracks) across separate batches. + // Align the Drive-specific fallback with Desktop and browser clients. Exact origin aliases handle retries; a short window reduces false merges of rapid replays. + const val DRIVE_RECONCILIATION_WINDOW_MS = 2_000L } @Query("SELECT * FROM listening_events WHERE id = :id") @@ -108,9 +114,73 @@ interface ListeningEventDao { """ SELECT DISTINCT content_fingerprint FROM listening_events WHERE content_fingerprint IN (:fingerprints) + AND (:accountSubject IS NULL OR drive_account_subject = :accountSubject) """, ) - suspend fun getExistingFingerprints(fingerprints: List): List + suspend fun getExistingFingerprints(fingerprints: List, accountSubject: String?): List + + /** An exact origin alias may represent a merged playback without being the + * primary row's content fingerprint. Query it before any temporal matching. + */ + @Query("SELECT originEventId FROM listening_event_origins WHERE originEventId IN (:ids) " + + "AND (:accountSubject IS NULL OR accountSubject = :accountSubject)") + suspend fun getKnownOriginAliases(ids: List, accountSubject: String?): List + + data class OriginClaim( + val listeningEventId: Long, val sourceDeviceId: String, + val originEventId: String, val accountSubject: String, + ) + + @Query( + "SELECT listeningEventId, sourceDeviceId, originEventId, accountSubject " + + "FROM listening_event_origins WHERE listeningEventId IN (:ids)", + ) + suspend fun getOriginClaimsForEvents(ids: List): List + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertOriginAliases(aliases: List) + + /** Batched origin lookup for offline restore; avoids one SQL round trip per play. */ + @Query( + "SELECT listeningEventId, sourceDeviceId, originEventId, accountSubject " + + "FROM listening_event_origins WHERE originEventId IN (:originIds)", + ) + suspend fun getOriginClaimsByOriginIds(originIds: List): List + + /** Keyset pagination for provenance backup; an event can have several origins. + * Sorting by (row ID, producer ID) avoids skips at page boundaries. + */ + @Query( + "SELECT * FROM listening_event_origins WHERE " + + "(listeningEventId > :afterId OR " + + "(listeningEventId = :afterId AND sourceDeviceId > :afterDevice)) " + + "AND listeningEventId <= :maxEventId " + + "ORDER BY listeningEventId ASC, sourceDeviceId ASC LIMIT :limit", + ) + suspend fun getOriginPage( + afterId: Long, + afterDevice: String, + maxEventId: Long, + limit: Int, + ): List + + /** A restored row may have a different auto-generated primary key. Only + * recover its aliases when its identity can be matched unambiguously. + */ + @Query( + "SELECT id FROM listening_events WHERE track_id = :trackId " + + "AND timestamp = :timestamp AND source = :source " + + "AND ((:accountSubject IS NULL AND drive_account_subject IS NULL) " + + "OR drive_account_subject = :accountSubject) " + + "ORDER BY id ASC LIMIT 2", + ) + suspend fun getBackupRestoredEventIds( + trackId: Long, + timestamp: Long, + source: String, + accountSubject: String?, + ): List + /** * Layer 2: fetch a lightweight view of existing events for one track within a @@ -119,10 +189,11 @@ interface ListeningEventDao { */ @Query( """ - SELECT id, track_id, timestamp, playDuration, source, content_fingerprint, end_timestamp + SELECT id, track_id, timestamp, playDuration, source, content_fingerprint, end_timestamp, drive_account_subject, estimated_duration_ms FROM listening_events WHERE track_id = :trackId AND timestamp BETWEEN :tsMin AND :tsMax + ORDER BY timestamp ASC, id ASC """, ) suspend fun getEventsForReconciliation( @@ -161,125 +232,262 @@ interface ListeningEventDao { * Same-source comparisons keep using the tight [DUPLICATE_TOLERANCE_MS] so * legitimate back-to-back plays of a track are never merged. */ + /** + * Recover a stable producer ID from the original Drive/LAN wire event. + * Non-Drive import fingerprints remain untouched by this transport table. + */ + private fun originOf(event: ListeningEvent): Pair? { + val device = SourceAuthority.driveDeviceId(event.source) ?: return null + val fingerprint = event.contentFingerprint ?: return null + if (!fingerprint.startsWith("drive:v1:")) return null + val id = fingerprint.removePrefix("drive:v1:") + if (id.length != 64 || !id.all { it in '0'..'9' || it in 'a'..'f' }) return null + return device to id + } + + @Query("UPDATE listening_events SET drive_account_subject = :subject WHERE drive_account_subject IS NULL") + suspend fun claimUnownedDriveHistoryRows(subject: String): Int + + @Query("UPDATE listening_event_origins SET accountSubject = :subject " + + "WHERE accountSubject = 'legacy-unverified' AND listeningEventId IN " + + "(SELECT id FROM listening_events WHERE drive_account_subject = :subject)") + suspend fun claimUnownedDriveOriginAliases(subject: String): Int + @Transaction - suspend fun insertAllBatchedWithDedup(events: List): InsertResult { - if (events.isEmpty()) return InsertResult(0, 0) + suspend fun claimUnownedDriveHistory(subject: String): Int { + val claimed = claimUnownedDriveHistoryRows(subject) + claimUnownedDriveOriginAliases(subject) + return claimed + } - // ── Layer 1: fingerprint every incoming event ────────────────────── - val withFp = - events.map { e -> - if (e.contentFingerprint != null) { - e - } else { - e.copy(contentFingerprint = EventFingerprint.compute(e)) - } - } + /** Upgrades the deprecated GoogleIdTokenCredential.id (email) ownership + * to its immutable uniqueId when the same verified email signs in again. + * Never touches imported rows quarantined as legacy-unverified. + */ + @Query("UPDATE listening_events SET drive_account_subject = :subject " + + "WHERE drive_account_subject = :oldEmail") + suspend fun upgradeLegacyEmailOwnerRows(oldEmail: String, subject: String): Int + + @Query("UPDATE listening_event_origins SET accountSubject = :subject " + + "WHERE accountSubject = :oldEmail") + suspend fun upgradeLegacyEmailOriginAliases(oldEmail: String, subject: String): Int + + @Transaction + suspend fun upgradeLegacyEmailOwner(oldEmail: String, subject: String): Int { + val count = upgradeLegacyEmailOwnerRows(oldEmail, subject) + upgradeLegacyEmailOriginAliases(oldEmail, subject) + return count + } + + /** A verified cloud copy of an exact producer event establishes the owner + * of a previously paired LAN-only record. Do not infer ownership from + * title/time or from a different Google subject. + */ + @Query( + "UPDATE listening_events SET drive_account_subject = :subject " + + "WHERE source LIKE 'lan:%' AND drive_account_subject = 'lan-unverified' " + + "AND content_fingerprint IN (:fingerprints) " + + "AND NOT EXISTS (SELECT 1 FROM listening_events owned " + + "WHERE owned.drive_account_subject = :subject " + + "AND owned.content_fingerprint = listening_events.content_fingerprint)" + ) + suspend fun assignVerifiedLanOwners(fingerprints: List, subject: String): Int + @Query( + "UPDATE listening_event_origins SET accountSubject = :subject " + + "WHERE accountSubject = 'lan-unverified' " + + "AND listeningEventId IN (SELECT id FROM listening_events " + + "WHERE drive_account_subject = :subject AND source LIKE 'lan:%' " + + "AND content_fingerprint IN (:fingerprints)) " + + "AND NOT EXISTS (SELECT 1 FROM listening_event_origins claimed " + + "WHERE claimed.accountSubject = :subject " + + "AND claimed.originEventId = listening_event_origins.originEventId)" + ) + suspend fun assignVerifiedLanAliases(fingerprints: List, subject: String): Int + + @Query("SELECT * FROM listening_events WHERE id IN (:ids)") + suspend fun getDriveRetryRows(ids: List): List + + @Transaction + suspend fun insertAllBatchedWithDedup( + events: List, + accountSubject: String? = null, + ): InsertResult { + if (events.isEmpty()) return InsertResult(0, 0) + + val withFp = events.map { event -> + if (event.contentFingerprint != null) event + else event.copy(contentFingerprint = EventFingerprint.compute(event)) + } val incomingFps = withFp.mapNotNull { it.contentFingerprint }.distinct() - val existingFps: Set = - if (incomingFps.isEmpty()) { - emptySet() - } else { - incomingFps.chunked(900).flatMap { getExistingFingerprints(it) }.toSet() + // A paired LAN play may have reached this phone before the sender + // enabled Drive. Its source and exact 64-hex producer ID survive the LAN + // transfer, but its cloud owner was unknowable at that point. When the + // *same exact producer event* later arrives from a verified Drive account, + // claim that LAN row and its aliases rather than importing a second copy. + // No temporal/title-based attribution and never reassign an A/B owner. + if (accountSubject != null) { + val cloudFingerprints = withFp.filter { it.source.startsWith("drive:") && + originOf(it) != null }.mapNotNull { it.contentFingerprint }.distinct() + for (fingerprints in cloudFingerprints.chunked(900)) { + assignVerifiedLanOwners(fingerprints, accountSubject) + assignVerifiedLanAliases(fingerprints, accountSubject) } + } + val existingFps = incomingFps.chunked(900) + .flatMap { getExistingFingerprints(it, accountSubject) }.toSet() + val originIds = withFp.mapNotNull { originOf(it)?.second }.distinct() + val knownAliases = originIds.chunked(900) + .flatMap { getKnownOriginAliases(it, accountSubject) }.toSet() - // Drop exact-fingerprint duplicates (DB or earlier in this batch). val seenFp = HashSet(incomingFps.size) - val layer1Survivors = ArrayList(withFp.size) + val survivors = ArrayList(withFp.size) var skipped = 0 - for (e in withFp) { - val fp = e.contentFingerprint - if (fp != null && (fp in existingFps || !seenFp.add(fp))) { + for (event in withFp) { + val fp = event.contentFingerprint + val originId = originOf(event)?.second + if ((fp != null && (fp in existingFps || !seenFp.add(fp))) || + (originId != null && originId in knownAliases) + ) { skipped++ } else { - layer1Survivors.add(e) + survivors.add(event) } } - // ── Layer 2: cross-source temporal reconciliation ────────────────── - val toInsert = ArrayList(layer1Survivors.size) + val toInsert = ArrayList(survivors.size) val toDelete = mutableSetOf() - - // Group by track for bounded per-track queries (uses the (track_id, - // timestamp) index). Process higher-authority events first within each - // track so they claim the slot and lower-authority siblings are dropped. - val byTrack = layer1Survivors.groupBy { it.track_id } - for ((trackId, trackEvents) in byTrack) { - val minTs = trackEvents.minOf { it.timestamp } - RECONCILIATION_WINDOW_MS - val maxTs = trackEvents.maxOf { it.timestamp } + RECONCILIATION_WINDOW_MS - val existing = getEventsForReconciliation(trackId, minTs, maxTs) - - // Existing refs not yet marked for deletion. - val existingAlive = existing.filter { it.id !in toDelete }.toMutableList() - - // Incoming events for this track, most authoritative first (tie → earliest). - val sortedIncoming = - trackEvents.sortedWith( - compareByDescending { SourceAuthority.rank(it.source) } - .thenBy { it.timestamp }, - ) - - // Timestamps already accepted (existing-kept + incoming-accepted) for - // same-play conflict checks on this track. - val acceptedSlots = ArrayList(existingAlive.size + sortedIncoming.size) - for (ex in existingAlive) { - acceptedSlots.add(Slot(ex.timestamp, ex.end_timestamp, ex.playDuration, ex.source, true, ex.id)) + val aliasesForExisting = mutableListOf() + val newSlots = mutableMapOf() + for ((trackId, allTrackEvents) in survivors.groupBy { it.track_id }) { + // Drive/LAN clocks use a strict two-second fuzzy window. + // A sparse ten-year import must not query every local play between + // its oldest and newest event (nor build a gigantic candidates list). + // Split only transport-origin events: legacy local imports retain + // their broader cross-source reconciliation semantics. + val transportOnly = allTrackEvents.all { + SourceAuthority.driveDeviceId(it.source) != null } - - for (incoming in sortedIncoming) { - val incomingAuth = SourceAuthority.rank(incoming.source) - val conflictIdx = acceptedSlots.indexOfFirst { slot -> isSamePlay(slot, incoming) } - - if (conflictIdx < 0) { - // No conflict → accept the incoming event. - toInsert.add(incoming) - acceptedSlots.add( - Slot(incoming.timestamp, incoming.endTimestamp, incoming.playDuration, incoming.source, false), - ) - continue + val windows = if (transportOnly) clusterDrivePlaybackWindows( + allTrackEvents, + DRIVE_RECONCILIATION_WINDOW_MS, + timestampOf = { it.timestamp }, + ) else listOf(allTrackEvents) + for (trackEvents in windows) { + val radius = if (transportOnly) DRIVE_RECONCILIATION_WINDOW_MS else RECONCILIATION_WINDOW_MS + val minTs = trackEvents.minOf { it.timestamp } - radius + val maxTs = trackEvents.maxOf { it.timestamp } + radius + val existingAlive = getEventsForReconciliation(trackId, minTs, maxTs) + // A verified cloud import can reconcile only against events + // already proven to belong to that Google account. Previously + // unowned LAN captures are adopted above ONLY on exact producer + // identity, not merely because title/timestamp happen to match. + .filter { it.id !in toDelete && + (accountSubject == null || it.driveAccountSubject == accountSubject) + }.toMutableList() + val claims = if (existingAlive.isEmpty()) emptyList() + else existingAlive.map { it.id }.chunked(900) + .flatMap { getOriginClaimsForEvents(it) } + val claimsByRow = claims.groupBy { it.listeningEventId } + val acceptedSlots = ArrayList(existingAlive.size + trackEvents.size) + for (ref in existingAlive) { + val origins = claimsByRow[ref.id].orEmpty() + .associate { it.sourceDeviceId to it.originEventId }.toMutableMap() + SourceAuthority.driveDeviceId(ref.source)?.let { origins.putIfAbsent(it, "") } + acceptedSlots.add(Slot( + ref.timestamp, ref.end_timestamp, ref.playDuration, ref.source, + true, ref.id, origins, ref.estimatedDurationMs, + )) } - val conflict = acceptedSlots[conflictIdx] - if (conflict.isExisting && incomingAuth > SourceAuthority.rank(conflict.source)) { - // Incoming is more authoritative → it replaces the existing event. - // Match by primary key, not by (timestamp, endTimestamp, source): two - // legacy rows can share all three, and `first {}` would then delete the - // wrong one. Every isExisting slot carries the id it came from. - val ref = existingAlive.first { it.id == conflict.existingId } - toDelete.add(ref.id) - existingAlive.remove(ref) - // Replace the slot with the incoming event so later comparisons - // see the new (higher-authority) representation. - acceptedSlots[conflictIdx] = - Slot( + for (incoming in trackEvents.sortedWith( + compareByDescending { SourceAuthority.rank(it.source) } + .thenBy { it.timestamp }, + )) { + val incomingOrigin = originOf(incoming) + // A track can have two distinct quick replays inside this + // window. A first match depends on SQLite's unspecified row + // order and can bind the origin to the wrong playback. + val conflictIdx = if (incomingOrigin != null) { + uniqueEligiblePlaybackIndex(acceptedSlots) { isSamePlay(it, incoming) } + } else { + nearestEligiblePlaybackIndex( + acceptedSlots, incoming.timestamp, - incoming.endTimestamp, - incoming.playDuration, - incoming.source, - false, + timestampOf = { it.timestamp }, + eligible = { isSamePlay(it, incoming) }, + ) + } + if (conflictIdx < 0) { + toInsert.add(incoming) + val originMap = mutableMapOf() + incomingOrigin?.let { (dev, id) -> originMap[dev] = id } + val slot = Slot( + incoming.timestamp, incoming.endTimestamp, incoming.playDuration, + incoming.source, false, null, originMap, incoming.estimatedDurationMs, + ) + acceptedSlots.add(slot) + incoming.contentFingerprint?.let { newSlots[it] = slot } + continue + } + + val conflict = acceptedSlots[conflictIdx] + if (conflict.isExisting && + SourceAuthority.rank(incoming.source) > SourceAuthority.rank(conflict.source) + ) { + val ref = existingAlive.first { it.id == conflict.existingId } + toDelete.add(ref.id) + existingAlive.remove(ref) + // Move aliases to the replacement within this Room transaction. + // Otherwise the foreign-key cascade would drop their identities. + val replacementOrigins = conflict.origins.toMutableMap() + incomingOrigin?.let { (dev, id) -> replacementOrigins[dev] = id } + val replacementSlot = Slot( + incoming.timestamp, incoming.endTimestamp, incoming.playDuration, + incoming.source, false, null, replacementOrigins, incoming.estimatedDurationMs, ) - toInsert.add(incoming) - } else { - // Existing/equal authority wins, or the slot was already taken - // by an equal-higher-authority incoming sibling → drop this one. - skipped++ + acceptedSlots[conflictIdx] = replacementSlot + toInsert.add(incoming) + incoming.contentFingerprint?.let { newSlots[it] = replacementSlot } + } else { + incomingOrigin?.let { (dev, id) -> + conflict.origins[dev] = id + if (conflict.isExisting) { + aliasesForExisting.add(ListeningEventOrigin(id, requireNotNull(conflict.existingId), dev, + accountSubject ?: "legacy-unverified")) + } + } + skipped++ + } } } } - // Delete the lower-authority events that were replaced. if (toDelete.isNotEmpty()) { - toDelete.chunked(900).forEach { ids -> deleteByIds(ids) } + toDelete.chunked(900).forEach { deleteByIds(it) } } - // Insert the survivors (fingerprint already set on each). - val inserted = - if (toInsert.isEmpty()) { - 0 - } else { - toInsert.chunked(BATCH_SIZE).sumOf { batch -> insertAll(batch).size } + val aliasWrites = aliasesForExisting.filter { it.listeningEventId !in toDelete } + .toMutableList() + var inserted = 0 + for (batch in toInsert.chunked(BATCH_SIZE)) { + val rowIds = insertAll(batch) + inserted += rowIds.size + for ((index, event) in batch.withIndex()) { + val slot = newSlots[event.contentFingerprint] + if (slot != null) { + for ((dev, originId) in slot.origins) { + if (originId.isNotBlank()) { + aliasWrites.add(ListeningEventOrigin(originId, rowIds[index], dev, + accountSubject ?: event.driveAccountSubject ?: "legacy-unverified")) + } + } + } } - + } + if (aliasWrites.isNotEmpty()) { + insertOriginAliases(aliasWrites) + } return InsertResult(inserted = inserted, skipped = skipped, replaced = toDelete.size) } @@ -292,6 +500,9 @@ interface ListeningEventDao { val isExisting: Boolean, /** listening_events.id when this slot came from the database; null for incoming events. */ val existingId: Long? = null, + /** Durable producer -> origin identity claims attached to this playback. */ + val origins: MutableMap = mutableMapOf(), + val estimatedDurationMs: Long? = null, ) /** @@ -300,19 +511,45 @@ interface ListeningEventDao { * legitimate back-to-back plays); different sources use a generous window that * absorbs cross-source timestamp drift. */ - private fun isSamePlay( - slot: Slot, - incoming: ListeningEvent, - ): Boolean { - val sameSource = slot.source == incoming.source - val window: Long = - if (sameSource) { - DUPLICATE_TOLERANCE_MS - } else { - val half = maxOf(slot.playDuration, incoming.playDuration) / 2L - if (half < RECONCILIATION_WINDOW_MS) RECONCILIATION_WINDOW_MS else half - } - return kotlin.math.abs(slot.timestamp - incoming.timestamp) <= window + private fun isSamePlay(slot: Slot, incoming: ListeningEvent): Boolean { + val slotDriveDevice = SourceAuthority.driveDeviceId(slot.source) + val incomingDriveDevice = SourceAuthority.driveDeviceId(incoming.source) + // Layer 1 already removed an exact Drive event id. Distinct ids from the + // same originating device are real rapid replays, not temporal dupes. + if (slotDriveDevice != null && slotDriveDevice == incomingDriveDevice) return false + if (incomingDriveDevice != null && incomingDriveDevice in slot.origins) return false + + // Different versions of a same-titled track can start within the reconciliation + // window. If both devices know the duration and it differs by more than ten + // seconds, this cannot be the same playback. + val slotDuration = slot.estimatedDurationMs + val incomingDuration = incoming.estimatedDurationMs + if (slotDuration != null && slotDuration > 0L && incomingDuration != null && incomingDuration > 0L) { + if (kotlin.math.abs(slotDuration - incomingDuration) > 10_000L) return false + } + + // Distinct listening lengths are evidence against a shared playback; + // exact producer aliases remain the authoritative way to deduplicate. + // Apply this guard only to cross-client Drive/LAN data so established + // local import reconciliation rules keep their existing behavior. + if ((slotDriveDevice != null || incomingDriveDevice != null) && + slot.playDuration > 0L && incoming.playDuration > 0L && + kotlin.math.abs(slot.playDuration - incoming.playDuration) > 10_000L) return false + + val sameSource = slot.source == incoming.source && + slotDriveDevice == null && incomingDriveDevice == null + val window: Long = if (sameSource) { + DUPLICATE_TOLERANCE_MS + } else { + val half = maxOf(slot.playDuration, incoming.playDuration) / 2L + if (half < RECONCILIATION_WINDOW_MS) RECONCILIATION_WINDOW_MS else half + } + val effectiveWindow = if (slotDriveDevice != null || incomingDriveDevice != null) { + minOf(window, DRIVE_RECONCILIATION_WINDOW_MS) + } else { + window + } + return kotlin.math.abs(slot.timestamp - incoming.timestamp) <= effectiveWindow } /** @@ -365,6 +602,8 @@ interface ListeningEventDao { val source: String, val content_fingerprint: String?, val end_timestamp: Long?, + @ColumnInfo(name = "drive_account_subject") val driveAccountSubject: String?, + @ColumnInfo(name = "estimated_duration_ms") val estimatedDurationMs: Long? = null, ) /** Desktop source → count breakdown. */ @@ -421,6 +660,41 @@ interface ListeningEventDao { limit: Int, ): List + /** + * A LAN pairing authenticates a device, NOT its Google account. + * Never promote 'lan-unverified' plays during a background Drive sync. + * Only the separate, user-confirmed "Upload older local history" action + * may claim this exact snapshot for the currently verified account. + */ + @Query( + "UPDATE listening_events SET drive_account_subject = :accountSubject " + + "WHERE id <= :throughEventId AND drive_account_subject = 'lan-unverified' " + + "AND source LIKE 'lan:%' AND content_fingerprint LIKE 'drive:v1:%'" + ) + suspend fun claimExplicitLanRelays(accountSubject: String, throughEventId: Long): Int + + /** Move only aliases of explicitly approved local LAN captures. */ + @Query( + "UPDATE listening_event_origins SET accountSubject = :accountSubject " + + "WHERE accountSubject = 'lan-unverified' AND listeningEventId IN (" + + "SELECT id FROM listening_events WHERE id <= :throughEventId " + + "AND drive_account_subject = :accountSubject " + + "AND source LIKE 'lan:%' AND content_fingerprint LIKE 'drive:v1:%') " + + "AND NOT EXISTS (SELECT 1 FROM listening_event_origins owned " + + "WHERE owned.accountSubject = :accountSubject " + + "AND owned.originEventId = listening_event_origins.originEventId)" + ) + suspend fun claimExplicitLanRelayAliases(accountSubject: String, throughEventId: Long): Int + + @Transaction + suspend fun authorizeLanRelaysForAccount(accountSubject: String, throughEventId: Long): Int { + require(accountSubject.isNotBlank()) { "Verified Google subject required" } + require(throughEventId >= 0L) { "LAN authorization boundary must not be negative" } + val claimed = claimExplicitLanRelays(accountSubject, throughEventId) + claimExplicitLanRelayAliases(accountSubject, throughEventId) + return claimed + } + /** Highest event id at the moment of the call — export snapshot boundary. */ @Query("SELECT COALESCE(MAX(id), 0) FROM listening_events") suspend fun getMaxEventId(): Long diff --git a/app/src/main/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatch.kt b/app/src/main/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatch.kt new file mode 100644 index 000000000..62a619b1e --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatch.kt @@ -0,0 +1,76 @@ +package me.avinas.tempo.data.local.dao + +import kotlin.math.abs + +/** + * Choose the closest matching physical playback among the candidates allowed + * by source and producer-origin constraints. Looking up the first matching row + * can attach a producer identity to the wrong rapid replay, because SQLite + * does not guarantee row order without an ORDER BY. + * + * Equal-distance ties retain the first eligible index, so callers can provide + * candidates in a stable timestamp-and-id ordering. + */ +internal inline fun nearestEligiblePlaybackIndex( + slots: List, + incomingTimestamp: Long, + timestampOf: (T) -> Long, + eligible: (T) -> Boolean, +): Int { + var best = -1 + var bestDistance = Long.MAX_VALUE + for ((index, slot) in slots.withIndex()) { + if (!eligible(slot)) continue + val distance = abs(timestampOf(slot) - incomingTimestamp) + if (best == -1 || distance < bestDistance) { + best = index + bestDistance = distance + } + } + return best +} + +/** + * A fuzzy Drive match is safe only when it identifies one eligible playback. + * Multiple nearby plays can represent independent replays: preserve incoming + * provenance instead of binding it to an arbitrary historical row. + */ +internal inline fun uniqueEligiblePlaybackIndex( + slots: List, + eligible: (T) -> Boolean, +): Int { + var result = -1 + for ((index, slot) in slots.withIndex()) { + if (!eligible(slot)) continue + if (result != -1) return -1 + result = index + } + return result +} + +/** + * Partition chronological Drive imports into disjoint conflict windows. + * A local play can match incoming events up to [radiusMs] away, so a gap + * strictly greater than twice that radius cannot share a match. Avoid a + * single multi-year SQL range when one Drive batch contains sparse history. + */ +internal inline fun clusterDrivePlaybackWindows( + events: List, + radiusMs: Long, + crossinline timestampOf: (T) -> Long, +): List> { + if (events.isEmpty()) return emptyList() + require(radiusMs >= 0) + val sorted = events.sortedBy(timestampOf) + val groups = ArrayList>() + var previousTimestamp: Long? = null + for (event in sorted) { + val timestamp = timestampOf(event) + if (previousTimestamp == null || timestamp - previousTimestamp > radiusMs * 2) { + groups.add(ArrayList()) + } + groups.last().add(event) + previousTimestamp = timestamp + } + return groups +} diff --git a/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEvent.kt b/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEvent.kt index a9cd6a7ee..cc194f10d 100644 --- a/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEvent.kt +++ b/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEvent.kt @@ -29,7 +29,8 @@ import androidx.room.PrimaryKey Index(name = "index_listening_events_timestamp_track_id", value = ["timestamp", "track_id"]), Index(name = "index_listening_events_track_id_timestamp", value = ["track_id", "timestamp"]), Index(name = "index_listening_events_stats", value = ["timestamp", "track_id", "playDuration", "completionPercentage"]), - Index(value = ["content_fingerprint"]) // Layer 1 idempotency lookups + Index(value = ["content_fingerprint"]), // Layer 1 idempotency lookups + Index(value = ["drive_account_subject"]) // Room schema must match migration 56->57 ] ) data class ListeningEvent( @@ -62,7 +63,11 @@ data class ListeningEvent( // Import reconciliation: deterministic SHA-256 of (source|track_id|timestamp| // playDuration|endTimestamp). Guarantees re-importing the same data is a no-op. // NULL for legacy rows written before this column existed (fall back to temporal dedup). - @ColumnInfo(name = "content_fingerprint", defaultValue = "NULL") val contentFingerprint: String? = null + @ColumnInfo(name = "content_fingerprint", defaultValue = "NULL") val contentFingerprint: String? = null, + // Identity of the Google account that imported or initially owned this + // event. Unattributed historic Drive imports are quarantined by migration. + @ColumnInfo(name = "drive_account_subject", defaultValue = "NULL") + val driveAccountSubject: String? = null ) { /** * Check if volume was explicitly 0 (muted). Legacy records (null) are treated as audible. diff --git a/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEventOrigin.kt b/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEventOrigin.kt new file mode 100644 index 000000000..fb1ac84ed --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/data/local/entities/ListeningEventOrigin.kt @@ -0,0 +1,38 @@ +package me.avinas.tempo.data.local.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.ColumnInfo + +/** + * Stable producer identity for one physical listening event. + * + * Several devices can report the same playback; one device can only claim a + * single origin for that playback. The foreign key ensures an alias cannot + * outlive the listening event it refers to (including authority replacements). + */ +@Entity( + tableName = "listening_event_origins", + primaryKeys = ["accountSubject", "originEventId"], + foreignKeys = [ + ForeignKey( + entity = ListeningEvent::class, + parentColumns = ["id"], + childColumns = ["listeningEventId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["listeningEventId"]), + Index(value = ["listeningEventId", "sourceDeviceId"], unique = true), + ], +) +data class ListeningEventOrigin( + val originEventId: String, + val listeningEventId: Long, + val sourceDeviceId: String, + /** Immutable Google owner; unknown LAN/legacy origins remain quarantined. */ + @ColumnInfo(defaultValue = "'legacy-unverified'") + val accountSubject: String = "legacy-unverified", +) diff --git a/app/src/main/java/me/avinas/tempo/desktop/DesktopSatelliteServer.kt b/app/src/main/java/me/avinas/tempo/desktop/DesktopSatelliteServer.kt index eb9fc52d4..a53882131 100644 --- a/app/src/main/java/me/avinas/tempo/desktop/DesktopSatelliteServer.kt +++ b/app/src/main/java/me/avinas/tempo/desktop/DesktopSatelliteServer.kt @@ -543,7 +543,10 @@ class DesktopSatelliteServer @Inject constructor( entry.optString("artist").length > MAX_FIELD_LENGTH || entry.optString("album").length > MAX_FIELD_LENGTH || entry.optString("source_app").length > MAX_FIELD_LENGTH || - entry.optString("device_name").length > MAX_FIELD_LENGTH) { + entry.optString("device_name").length > MAX_FIELD_LENGTH || + entry.optString("origin_device_id").length > 200 || + entry.optString("origin_event_id").length > 64 || + entry.optString("origin_source").length > MAX_FIELD_LENGTH) { return errorResponse(Response.Status.BAD_REQUEST, "field_too_long") } } @@ -577,7 +580,15 @@ class DesktopSatelliteServer @Inject constructor( is IngestionResult.Error -> { val escaped = JSONObject.quote(result.message) syncFailureCount++ - IngestOutcome(Response.Status.BAD_REQUEST, """{"ok":false,"error":$escaped}""") + // Storage failures are retryable: the sender must + // keep the full LAN batch rather than moving its + // listens to a permanently failed queue. + val status = if (result.message == "ingestion_failed_retry_batch") { + Response.Status.SERVICE_UNAVAILABLE + } else { + Response.Status.BAD_REQUEST + } + IngestOutcome(status, """{"ok":false,"error":$escaped}""") } } } diff --git a/app/src/main/java/me/avinas/tempo/desktop/DesktopScrobbleIngestionService.kt b/app/src/main/java/me/avinas/tempo/desktop/DesktopScrobbleIngestionService.kt index 38bed3e4f..707e1a96d 100644 --- a/app/src/main/java/me/avinas/tempo/desktop/DesktopScrobbleIngestionService.kt +++ b/app/src/main/java/me/avinas/tempo/desktop/DesktopScrobbleIngestionService.kt @@ -7,6 +7,7 @@ import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import me.avinas.tempo.data.repository.ArtistLinkingService import me.avinas.tempo.data.repository.EnrichedMetadataRepository +import me.avinas.tempo.data.local.dao.ListeningEventDao import me.avinas.tempo.data.local.entities.ListeningEvent import me.avinas.tempo.data.local.entities.Track import me.avinas.tempo.data.repository.ListeningRepository @@ -59,6 +60,7 @@ class DesktopPlayIngestionService @Inject constructor( private val pairingManager: DesktopPairingManager, private val trackRepository: TrackRepository, private val listeningRepository: ListeningRepository, + private val listeningEventDao: ListeningEventDao, private val enrichedMetadataRepository: EnrichedMetadataRepository, private val artistLinkingService: ArtistLinkingService, private val refreshCoordinator: RefreshCoordinator @@ -67,10 +69,10 @@ class DesktopPlayIngestionService @Inject constructor( private const val TAG = "DesktopIngestion" /** Deduplication window: skip if same track exists within ±5 min. */ - private const val DEDUP_WINDOW_MS = 300_000L + private const val DEDUP_WINDOW_MS = 10_000L + private val DRIVE_DEVICE_ID = Regex("^[A-Za-z0-9._-]{1,200}$") + private val DRIVE_EVENT_ID = Regex("^[0-9a-f]{64}$") - /** Minimum sensible play duration to accept (5 seconds). */ - private const val MIN_PLAY_DURATION_MS = 5_000L /** Assumed completion percentage for desktop plays (90 %, stricter than Last.fm's * 50 % threshold, since desktop clients only log finished tracks). */ @@ -121,25 +123,52 @@ class DesktopPlayIngestionService @Inject constructor( var duplicates = 0 val newTrackIds = mutableSetOf() + // Reject malformed batches instead of acknowledging plays we silently + // discarded. LAN senders remove whole batches on a successful response, + // so a missing title/artist/timestamp would otherwise be lost forever. for (i in 0 until playsArray.length()) { - val entry = playsArray.optJSONObject(i) ?: continue + val entry = playsArray.optJSONObject(i) + ?: return IngestionResult.Error("invalid_play_at_index_$i") + if (entry.optString("title").isBlank() || + entry.optString("artist").isBlank() || + entry.optLong("timestamp_utc", 0L) <= 0L + ) { + return IngestionResult.Error("invalid_play_at_index_$i") + } + } - val title = entry.optString("title").trim().takeIf { it.isNotBlank() } ?: continue - val artist = entry.optString("artist").trim().takeIf { it.isNotBlank() } ?: continue + for (i in 0 until playsArray.length()) { + val entry = playsArray.getJSONObject(i) + + val title = entry.getString("title").trim() + val artist = entry.getString("artist").trim() val album = entry.optString("album").takeIf { it.isNotBlank() } - val timestampUtc = entry.optLong("timestamp_utc", 0L).takeIf { it > 0L } ?: continue + val timestampUtc = entry.getLong("timestamp_utc") val durationMs = entry.optLong("duration_ms", 0L) // listened_ms is sent by the browser extension and represents actual listened time. // Fall back to duration_ms (full track duration) for desktop app plays that // don't send this field. val listenedMs = entry.optLong("listened_ms", 0L).takeIf { it > 0L } ?: durationMs val sourceApp = entry.optString("source_app", "Desktop").trim() + val declaredDeviceId = entry.optString("origin_device_id", "") + val declaredEventId = entry.optString("origin_event_id", "") + // An authenticated pairing proves the sender device, not ownership + // of a Google account. Only explicitly supplied provenance can + // authorise a future cloud relay. Missing/invalid stays LAN-only. + val senderAccount = entry.optString("origin_account_subject", "").trim() + .takeIf { it.isNotBlank() && it.length <= 255 && + !it.contains('@') && it != "legacy-unverified" } + // Older LAN senders omit these fields, so keep their existing + // heuristic path. New senders reuse their exact Google Drive event + // identity, eliminating LAN -> Android -> Drive publication loops. + val stableOrigin = if ( + DRIVE_DEVICE_ID.matches(declaredDeviceId) && + DRIVE_EVENT_ID.matches(declaredEventId) + ) declaredDeviceId to declaredEventId else null - // Guard: ignore implausibly short plays - if (durationMs in 1 until MIN_PLAY_DURATION_MS) { - Log.d(TAG, "Skipping short play ($durationMs ms): $title") - continue - } + // The LAN transport preserves the sender's history as-is. + // Duration filtering is a separate user preference: silently + // skipping short plays would make a successful batch incomplete. try { // 3. Find or create the Track record @@ -186,7 +215,7 @@ class DesktopPlayIngestionService @Inject constructor( } // 4. Deduplication: skip if an event for this track exists within ±60 s - val isDuplicate = checkDuplicate(trackId, timestampUtc) + val isDuplicate = stableOrigin == null && checkDuplicate(trackId, timestampUtc) if (isDuplicate) { Log.d(TAG, "Duplicate skipped: $title @ $timestampUtc") duplicates++ @@ -202,26 +231,55 @@ class DesktopPlayIngestionService @Inject constructor( } else { DESKTOP_COMPLETION_PERCENT } + val originSource = entry.optString("origin_source", "desktop:$sourceApp") val event = ListeningEvent( track_id = trackId, timestamp = timestampUtc, playDuration = listenedMs.coerceAtLeast(0L), completionPercentage = completionPct, - source = "desktop:$sourceApp", + // A LAN-delivered play is not necessarily present on Drive: + // the sender may have disabled Google sync. Keep its exact + // producer/event ID, but distinguish LAN from cloud imports + // so Android can relay this play without generating a new ID. + source = stableOrigin?.let { "lan:${it.first}:$originSource" } ?: "desktop:$sourceApp", wasSkipped = completionPct < 30, isReplay = false, - estimatedDurationMs = durationMs.takeIf { it > 0L } + estimatedDurationMs = durationMs.takeIf { it > 0L }, + contentFingerprint = stableOrigin?.let { "drive:v1:${it.second}" }, + driveAccountSubject = if (stableOrigin == null) "lan-unverified" + else senderAccount ?: "lan-unverified" ) - listeningRepository.insert(event) - accepted++ + if (stableOrigin == null) { + listeningRepository.insert(event) + accepted++ + } else { + val result = listeningEventDao.insertAllBatchedWithDedup( + listOf(event), event.driveAccountSubject + ) + accepted += result.inserted + duplicates += result.skipped + } Log.d(TAG, "Accepted: $title by $artist @ $timestampUtc") + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { - Log.e(TAG, "Error processing play [$title / $artist]", e) - // Continue with remaining entries rather than aborting the whole batch + Log.e(TAG, "Could not durably ingest LAN play [$title / $artist] at index $i", e) + // The sender acknowledges and clears the entire LAN batch on + // success. Return a non-2xx error instead; already committed + // events are safe to retry because of their stable origin IDs. + // Never rotate the pairing token on a failed batch. + return IngestionResult.Error("ingestion_failed_retry_batch") } } + // Never rotate the pairing token or return ok:true when the whole + // request was not accounted for. Desktop now checks these counts. + // Already committed events are safe to resend by stable event ID. + if (accepted.toLong() + duplicates.toLong() != playsArray.length().toLong()) { + return IngestionResult.Error("ingestion_failed_retry_batch") + } + // 6. Update pairing session last-seen pairingManager.recordSuccessfulSync( token = token, diff --git a/app/src/main/java/me/avinas/tempo/di/DriveModule.kt b/app/src/main/java/me/avinas/tempo/di/DriveModule.kt index 80a66d7a9..c528a61b4 100644 --- a/app/src/main/java/me/avinas/tempo/di/DriveModule.kt +++ b/app/src/main/java/me/avinas/tempo/di/DriveModule.kt @@ -7,6 +7,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import me.avinas.tempo.data.drive.BackupSettingsManager +import me.avinas.tempo.data.drive.DriveHistorySyncSettingsManager import me.avinas.tempo.data.drive.GoogleAuthManager import me.avinas.tempo.data.drive.GoogleDriveService import me.avinas.tempo.data.drive.GoogleDriveTokenStorage @@ -32,8 +33,9 @@ object DriveModule { @Singleton fun provideGoogleAuthManager( @ApplicationContext context: Context, - tokenStorage: GoogleDriveTokenStorage - ): GoogleAuthManager = GoogleAuthManager(context, tokenStorage) + tokenStorage: GoogleDriveTokenStorage, + historySyncSettings: DriveHistorySyncSettingsManager + ): GoogleAuthManager = GoogleAuthManager(context, tokenStorage, historySyncSettings) @Provides @Singleton diff --git a/app/src/main/java/me/avinas/tempo/ui/components/SettingsComponents.kt b/app/src/main/java/me/avinas/tempo/ui/components/SettingsComponents.kt index 35312c529..91652c40b 100644 --- a/app/src/main/java/me/avinas/tempo/ui/components/SettingsComponents.kt +++ b/app/src/main/java/me/avinas/tempo/ui/components/SettingsComponents.kt @@ -74,7 +74,8 @@ fun SettingsSwitch( title: String, subtitle: String? = null, checked: Boolean, - onCheckedChange: (Boolean) -> Unit + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true ) { Row( modifier = Modifier @@ -100,6 +101,7 @@ fun SettingsSwitch( Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = TextOnAccent, checkedTrackColor = TempoPrimary, diff --git a/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreScreen.kt b/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreScreen.kt index 2421d6bc7..9b2166260 100644 --- a/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreScreen.kt +++ b/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreScreen.kt @@ -50,7 +50,6 @@ import me.avinas.tempo.ui.components.TempoSnackbar import me.avinas.tempo.ui.theme.* import me.avinas.tempo.ui.components.TempoDropdownMenu import me.avinas.tempo.ui.components.TempoDropdownMenuItem -import me.avinas.tempo.ui.components.TempoIcons import me.avinas.tempo.utils.FormatUtils.formatBytes import java.text.SimpleDateFormat import java.util.* @@ -97,7 +96,8 @@ fun BackupRestoreScreen( driveOperation is DriveOperationState.Restoring || driveOperation is DriveOperationState.Uploading || importExportProgress != null - BackHandler(enabled = isOperationActive) { } + var confirmLeaveDuringOperation by remember { mutableStateOf(false) } + BackHandler(enabled = isOperationActive) { confirmLeaveDuringOperation = true } // Handle sign-in request from ViewModel val signInRequested by viewModel.signInRequested.collectAsState() val sessionRestoreRequested by viewModel.sessionRestoreRequested.collectAsState() @@ -199,6 +199,25 @@ fun BackupRestoreScreen( } } + if (confirmLeaveDuringOperation) { + AlertDialog( + onDismissRequest = { confirmLeaveDuringOperation = false }, + title = { Text(stringResource(R.string.backup_leave_title)) }, + text = { Text(stringResource(R.string.backup_leave_warning)) }, + confirmButton = { + TextButton(onClick = { + confirmLeaveDuringOperation = false + onNavigateBack() + }) { Text(stringResource(R.string.backup_leave_confirm)) } + }, + dismissButton = { + TextButton(onClick = { confirmLeaveDuringOperation = false }) { + Text(stringResource(R.string.drive_cancel)) + } + } + ) + } + Scaffold( containerColor = Color.Transparent, topBar = { @@ -206,8 +225,10 @@ fun BackupRestoreScreen( title = { Text(stringResource(R.string.settings_backup_restore), color = TextPrimary) }, navigationIcon = { IconButton( - onClick = onNavigateBack, - enabled = !isOperationActive + onClick = { + if (isOperationActive) confirmLeaveDuringOperation = true + else onNavigateBack() + } ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, @@ -238,7 +259,16 @@ fun BackupRestoreScreen( Spacer(modifier = Modifier.height(24.dp)) - // Google Drive Section + // Cross-device listening history is independent from device + // backups and visible even before Google sign-in. + DriveHistorySyncSection( + isGoogleSignedIn = isSignedIn, + accountEmail = googleAccount?.email, + onRequestGoogleSignIn = viewModel::requestSignIn + ) + Spacer(modifier = Modifier.height(24.dp)) + + // Full-device Google Drive backup/restore. GoogleDriveSection( isSignedIn = isSignedIn, googleAccount = googleAccount, @@ -247,6 +277,7 @@ fun BackupRestoreScreen( driveOperation = driveOperation, onSignIn = viewModel::requestSignIn, onSignOut = viewModel::signOut, + onSetDriveBackupEnabled = viewModel::setDriveBackupEnabled, onBackupNow = viewModel::backupToDrive, onRestore = viewModel::startDriveRestore, onDelete = viewModel::deleteDriveBackup, @@ -355,7 +386,7 @@ fun BackupRestoreScreen( TextButton( onClick = { viewModel.importData(uri, ImportConflictStrategy.REPLACE) } ) { - Text("Replace Existing", color = TempoPrimary, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.settings_replace_existing), color = TempoPrimary, fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -366,7 +397,7 @@ fun BackupRestoreScreen( TextButton( onClick = { viewModel.importData(uri, ImportConflictStrategy.SKIP) } ) { - Text("Skip Duplicates", color = TextSecondary) + Text(stringResource(R.string.settings_skip_duplicates), color = TextSecondary) } } } @@ -380,10 +411,10 @@ fun BackupRestoreScreen( containerColor = TempoSurfaceDialog, shape = RoundedCornerShape(24.dp), icon = { Icon(Icons.Default.CloudDownload, contentDescription = null, tint = TempoPrimary) }, - title = { Text("Restore from Cloud", color = TextPrimary, fontWeight = FontWeight.SemiBold) }, + title = { Text(stringResource(R.string.drive_device_restore_title), color = TextPrimary, fontWeight = FontWeight.SemiBold) }, text = { Column { - Text("Restore backup from ${formatDate(backup.createdAt)}?", color = TextSecondary) + Text(stringResource(R.string.drive_device_restore_question, formatDate(backup.createdAt)), color = TextSecondary) Spacer(modifier = Modifier.height(8.dp)) Text( text = "Size: ${formatBytes(backup.sizeBytes)}", @@ -399,7 +430,7 @@ fun BackupRestoreScreen( } Spacer(modifier = Modifier.height(12.dp)) Text( - text = "How should duplicates be handled?", + text = stringResource(R.string.restore_handle_conflicts), style = MaterialTheme.typography.bodyMedium, color = TextSecondary ) @@ -409,7 +440,7 @@ fun BackupRestoreScreen( TextButton( onClick = { viewModel.restoreFromDrive(backup, ImportConflictStrategy.REPLACE) } ) { - Text("Replace Existing", color = TempoPrimary, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.settings_replace_existing), color = TempoPrimary, fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -420,7 +451,7 @@ fun BackupRestoreScreen( TextButton( onClick = { viewModel.restoreFromDrive(backup, ImportConflictStrategy.SKIP) } ) { - Text("Skip Duplicates", color = TextSecondary) + Text(stringResource(R.string.settings_skip_duplicates), color = TextSecondary) } } } @@ -558,12 +589,19 @@ private fun GoogleDriveSection( driveOperation: DriveOperationState, onSignIn: () -> Unit, onSignOut: () -> Unit, + onSetDriveBackupEnabled: (Boolean) -> Unit, onBackupNow: () -> Unit, onRestore: (DriveBackupInfo) -> Unit, onDelete: (DriveBackupInfo) -> Unit, onRefreshBackups: () -> Unit ) { - SettingsSectionHeader("Google Drive") + SettingsSectionHeader(stringResource(R.string.drive_device_backup_heading)) + Text( + text = stringResource(R.string.drive_device_backup_explanation), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) GlassCard( modifier = Modifier.fillMaxWidth(), @@ -601,12 +639,12 @@ private fun GoogleDriveSection( Column(modifier = Modifier.weight(1f)) { Text( - text = "Connect Google Account", + text = stringResource(R.string.restore_connect_google), style = MaterialTheme.typography.bodyLarge, color = TextPrimary ) Text( - text = "Back up your data to Google Drive", + text = stringResource(R.string.drive_device_backup_action_hint), style = MaterialTheme.typography.bodySmall, color = TextSecondary ) @@ -674,7 +712,14 @@ private fun GoogleDriveSection( } HorizontalDivider(color = GlassBorderSoft) - + SettingsSwitch( + title = stringResource(R.string.drive_device_backup_toggle), + subtitle = stringResource(R.string.drive_device_backup_toggle_hint), + checked = backupSettings.isGoogleDriveEnabled, + onCheckedChange = onSetDriveBackupEnabled + ) + HorizontalDivider(color = GlassBorderSoft) + // Backup Now button Row( modifier = Modifier @@ -706,7 +751,7 @@ private fun GoogleDriveSection( ) { Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("Backup Now") + Text(stringResource(R.string.drive_device_backup_now)) } } @@ -722,7 +767,7 @@ private fun GoogleDriveSection( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Backup History (${driveBackups.size})", + text = stringResource(R.string.backup_history, driveBackups.size), style = MaterialTheme.typography.titleSmall, color = TextSecondary ) @@ -756,7 +801,7 @@ private fun AutomaticBackupSection( onSetInterval: (BackupInterval) -> Unit, onSetWifiOnly: (Boolean) -> Unit ) { - SettingsSectionHeader("Automatic Backups") + SettingsSectionHeader(stringResource(R.string.backup_auto)) GlassCard( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreViewModel.kt b/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreViewModel.kt index 009ea581c..655dc49b6 100644 --- a/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreViewModel.kt +++ b/app/src/main/java/me/avinas/tempo/ui/settings/BackupRestoreViewModel.kt @@ -56,6 +56,7 @@ class BackupRestoreViewModel @Inject constructor( private val googleAuthManager: GoogleAuthManager, private val driveService: GoogleDriveService, private val backupSettingsManager: BackupSettingsManager, + private val driveHistorySyncManager: DriveHistorySyncManager, private val applicationScope: CoroutineScope, private val tracker: AnalyticsTracker ) : ViewModel() { @@ -422,7 +423,9 @@ class BackupRestoreViewModel @Inject constructor( fun importData(uri: Uri, strategy: ImportConflictStrategy) { _showConflictDialog.value = null applicationScope.launch { - val result = importExportManager.importData(uri, strategy) + val result = driveHistorySyncManager.withLocalHistoryRestore { + importExportManager.importData(uri, strategy) + } _importExportResult.value = result // Refresh stats after import calculateStats() @@ -491,6 +494,16 @@ class BackupRestoreViewModel @Inject constructor( // and folder cache before any operation under the selected // account, including the no-consent fast path. driveService.clearCache() + // Merely authorizing Google must not opt the user into + // device backups (history sync is a separate opt-in). + // Never carry an old account's backup consent into a + // different Google identity. + val previousBackup = backupSettingsManager.settings.first() + if (!previousBackup.googleAccountEmail.equals( + result.account.email, ignoreCase = true + )) { + backupSettingsManager.setGoogleDriveEnabled(false) + } backupSettingsManager.setGoogleAccountEmail(result.account.email) // Google identity sign-in and Drive authorization are separate. @@ -503,7 +516,8 @@ class BackupRestoreViewModel @Inject constructor( _consentRequested.value = true _driveOperation.value = DriveOperationState.SigningIn } else if (googleAuthManager.getAccessToken() != null) { - backupSettingsManager.setGoogleDriveEnabled(true) + // Google sign-in authorizes Drive only: backup + // uploads start after an explicit opt-in below. scheduleAutomaticBackups(backupSettingsManager.settings.first()) refreshDriveBackups() .onSuccess { @@ -561,8 +575,8 @@ class BackupRestoreViewModel @Inject constructor( // Verify that we actually have an access token now val hasToken = googleAuthManager.getAccessToken() != null if (hasToken) { - // Drive becomes enabled only after consent produced a real token. - backupSettingsManager.setGoogleDriveEnabled(true) + // Do not enable device backups when the user only + // connected Google for cross-device music history. driveService.clearCache() scheduleAutomaticBackups(backupSettingsManager.settings.first()) refreshDriveBackups() @@ -842,14 +856,15 @@ class BackupRestoreViewModel @Inject constructor( _driveOperation.value = DriveOperationState.Restoring val importResult = try { - importExportManager.importData( - Uri.fromFile(downloadResult.localFile), - strategy - ) + driveHistorySyncManager.withLocalHistoryRestore { + importExportManager.importData( + Uri.fromFile(downloadResult.localFile), + strategy + ) + } } finally { downloadResult.localFile.delete() } - when (importResult) { is ImportExportResult.Success -> { calculateStats() @@ -909,6 +924,24 @@ class BackupRestoreViewModel @Inject constructor( } } + /** + * Device backups require their own opt-in, independent from authentication + * and the Google Drive listening-history transport. + */ + fun setDriveBackupEnabled(enabled: Boolean) { + viewModelScope.launch { + if (enabled && (!googleAuthManager.isSignedIn.value || + googleAuthManager.getAccessToken() == null)) { + _driveOperation.value = DriveOperationState.Error( + "Connect and authorize Google Drive before enabling device backups." + ) + return@launch + } + backupSettingsManager.setGoogleDriveEnabled(enabled) + scheduleAutomaticBackups(backupSettingsManager.settings.first()) + } + } + /** * Set backup interval and schedule the automatic device/Drive worker. */ diff --git a/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncSection.kt b/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncSection.kt new file mode 100644 index 000000000..0ba8df530 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncSection.kt @@ -0,0 +1,296 @@ +package me.avinas.tempo.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.semantics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import me.avinas.tempo.R +import androidx.compose.ui.res.stringResource +import me.avinas.tempo.data.drive.DriveHistorySyncSettings +import me.avinas.tempo.data.drive.DriveHistorySyncStatus +import me.avinas.tempo.ui.components.GlassCard +import me.avinas.tempo.ui.components.GlassCardVariant +import me.avinas.tempo.ui.components.SettingsSwitch +import me.avinas.tempo.ui.theme.TempoPrimary +import me.avinas.tempo.ui.theme.TempoRed +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun DriveHistorySyncSection( + isGoogleSignedIn: Boolean, + accountEmail: String?, + onRequestGoogleSignIn: () -> Unit, + viewModel: DriveHistorySyncViewModel = hiltViewModel() +) { + val settings by viewModel.settings.collectAsState() + val operation by viewModel.operation.collectAsState() + var confirmDelete by remember { mutableStateOf(false) } + var confirmShare by remember { mutableStateOf(false) } + LaunchedEffect(accountEmail, isGoogleSignedIn) { + // A prior account's success/error must not remain attached to a + // newly signed-in identity. The shared coordinator retains a genuine + // running operation across screen recreation. + viewModel.clearOperation() + } + + val running = operation is DriveHistoryUiOperation.Running || + settings.lastStatus == DriveHistorySyncStatus.RUNNING + + Text( + text = stringResource(R.string.drive_heading), + style = MaterialTheme.typography.titleSmall, + color = TempoRed, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) + ) + + GlassCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp), + variant = GlassCardVariant.LowProminence + ) { + Column { + SettingsSwitch( + title = stringResource(R.string.drive_toggle_title), + subtitle = stringResource(R.string.drive_toggle_subtitle), + checked = settings.enabled, + onCheckedChange = viewModel::setEnabled, + enabled = isGoogleSignedIn && !running + ) + // Remote deletion can stop Drive sync in the background, outside + // this ViewModel's UI operation. Keep its persisted reason visible + // when the toggle becomes disabled, including after reopening. + if (!settings.enabled && !settings.lastMessage.isNullOrBlank() && + operation is DriveHistoryUiOperation.Idle + ) { + Text( + text = settings.lastMessage.orEmpty(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.85f) + ) + } + if (!isGoogleSignedIn) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + stringResource(R.string.drive_signin_hint), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.75f) + ) + Button( + onClick = onRequestGoogleSignIn, + enabled = !running, + modifier = Modifier.padding(top = 8.dp), + colors = ButtonDefaults.buttonColors(containerColor = TempoPrimary) + ) { + Text(stringResource(R.string.drive_signin_action)) + } + } + } + if (running && !settings.enabled) { + Row(modifier = Modifier.padding(start = 16.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.drive_connecting), + style = MaterialTheme.typography.bodySmall) + } + } + + if (settings.enabled && isGoogleSignedIn) { + HorizontalDivider(color = Color.White.copy(alpha = 0.1f)) + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.drive_status_title), + style = MaterialTheme.typography.bodyLarge, + color = Color.White + ) + Text( + text = historySyncSubtitle(settings), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.65f) + ) + } + Button( + onClick = viewModel::syncNow, + enabled = !running, + colors = ButtonDefaults.buttonColors(containerColor = TempoPrimary) + ) { + if (running) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } else { + Icon(Icons.Default.Sync, contentDescription = null) + } + Spacer(modifier = Modifier.padding(horizontal = 3.dp)) + Text(stringResource(R.string.drive_sync_now)) + } + } + + OutlinedButton( + onClick = viewModel::restoreFullHistory, + enabled = !running, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(stringResource(R.string.drive_restore)) + } + Text( + stringResource(R.string.drive_restore_hint), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.65f), + modifier = Modifier.padding(top = 4.dp) + ) + + OutlinedButton( + onClick = { confirmShare = true }, + enabled = !running, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(stringResource(R.string.drive_share)) + } + Text( + stringResource(R.string.drive_share_hint), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.65f), + modifier = Modifier.padding(top = 4.dp) + ) + + } + + HorizontalDivider(color = Color.White.copy(alpha = 0.1f)) + Row(modifier = Modifier.padding(16.dp)) { + OutlinedButton(onClick = { confirmDelete = true }, enabled = !running) { + Icon(Icons.Default.DeleteForever, contentDescription = null) + Spacer(modifier = Modifier.padding(horizontal = 3.dp)) + Text(stringResource(R.string.drive_delete)) + } + } + } + + // Keep feedback visible even when sync has just been disabled. + when (val op = operation) { + is DriveHistoryUiOperation.Success -> Text( + text = op.message, + modifier = Modifier.padding(16.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.85f) + ) + is DriveHistoryUiOperation.Error -> Text( + text = op.message, + modifier = Modifier.padding(16.dp) + .semantics { liveRegion = LiveRegionMode.Assertive }, + style = MaterialTheme.typography.bodySmall, + color = TempoRed + ) + else -> Unit + } + } + } + + if (confirmShare) { + AlertDialog( + onDismissRequest = { confirmShare = false }, + title = { Text(stringResource(R.string.drive_share_confirm_title)) }, + text = { + Text(stringResource(R.string.drive_share_confirm_body)) + }, + confirmButton = { + TextButton(onClick = { + confirmShare = false + viewModel.shareOlderLocalHistory() + }) { Text(stringResource(R.string.drive_share_confirm)) } + }, + dismissButton = { + TextButton(onClick = { confirmShare = false }) { Text(stringResource(R.string.drive_cancel)) } + } + ) + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + icon = { Icon(Icons.Default.CloudSync, contentDescription = null, tint = TempoRed) }, + title = { Text(stringResource(R.string.drive_delete_confirm_title)) }, + text = { + Text( + stringResource(R.string.drive_delete_confirm_body) + ) + }, + confirmButton = { + TextButton(onClick = { + confirmDelete = false + viewModel.deleteCloudHistory() + }) { + Text(stringResource(R.string.drive_delete_confirm), color = TempoRed) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.drive_cancel)) } + } + ) + } +} + +@Composable +private fun historySyncSubtitle(settings: DriveHistorySyncSettings): String { + if (!settings.enabled) return stringResource(R.string.drive_status_disabled) + val last = settings.lastSyncTime?.let { + SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(it)) + } ?: stringResource(R.string.drive_never) + return when (settings.lastStatus) { + DriveHistorySyncStatus.RUNNING -> stringResource(R.string.drive_status_running) + DriveHistorySyncStatus.FAILED -> settings.lastMessage?.let { + stringResource(R.string.drive_status_failed_detail, it) + } ?: stringResource(R.string.drive_status_failed) + DriveHistorySyncStatus.SUCCESS -> if (settings.lastMessage != null) { + stringResource(R.string.drive_last_message, last, settings.lastMessage) + } else { + stringResource(R.string.drive_last_counts, last, settings.lastUploaded, settings.lastImported) + } + DriveHistorySyncStatus.NEVER -> stringResource(R.string.drive_schedule_hint) + } +} diff --git a/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncViewModel.kt b/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncViewModel.kt new file mode 100644 index 000000000..815791635 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/ui/settings/DriveHistorySyncViewModel.kt @@ -0,0 +1,158 @@ +package me.avinas.tempo.ui.settings + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import me.avinas.tempo.R +import me.avinas.tempo.data.drive.DriveHistorySyncManager +import me.avinas.tempo.data.drive.DriveHistorySyncResult +import me.avinas.tempo.data.drive.DriveHistorySyncSettings +import me.avinas.tempo.data.drive.DriveHistorySyncSettingsManager +import me.avinas.tempo.worker.DriveHistorySyncWorker +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** Keep an in-flight UI action visible if Android recreates the settings screen. + * The actual sync work already runs in applicationScope; the matching progress + * must not be tied to an individual ViewModel instance. + */ +@Singleton +class DriveHistoryUiOperationCoordinator @Inject constructor() { + private val current = MutableStateFlow(DriveHistoryUiOperation.Idle) + val operation: StateFlow = current.asStateFlow() + private val busy = AtomicBoolean(false) + + fun begin(): Boolean { + if (!busy.compareAndSet(false, true)) return false + current.value = DriveHistoryUiOperation.Running + return true + } + + fun finish(result: DriveHistoryUiOperation) { + current.value = result + busy.set(false) + } + + fun clear() { + if (!busy.get()) current.value = DriveHistoryUiOperation.Idle + } +} + +@HiltViewModel +class DriveHistorySyncViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val settingsManager: DriveHistorySyncSettingsManager, + private val syncManager: DriveHistorySyncManager, + private val operationCoordinator: DriveHistoryUiOperationCoordinator, + private val applicationScope: CoroutineScope +) : ViewModel() { + val settings: StateFlow = settingsManager.settings.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + DriveHistorySyncSettings() + ) + + val operation: StateFlow = operationCoordinator.operation + + fun setEnabled(enabled: Boolean) { + launchExclusive { + if (enabled) { + if (!syncManager.enableSync()) { + return@launchExclusive DriveHistoryUiOperation.Error( + context.getString(R.string.drive_auth_required) + ) + } + DriveHistorySyncWorker.schedule(context) + syncResultToUi(syncManager.syncNow()) + } else { + syncManager.disableSync() + DriveHistorySyncWorker.cancel(context) + DriveHistoryUiOperation.Idle + } + } + } + + fun syncNow() { + launchExclusive { + syncResultToUi(syncManager.syncNow()) + } + } + + fun restoreFullHistory() { + launchExclusive { + syncResultToUi(syncManager.restoreFullHistory()) + } + } + + fun shareOlderLocalHistory() { + launchExclusive { + when (val result = syncManager.shareOlderLocalHistory()) { + is DriveHistorySyncResult.Success -> syncResultToUi(syncManager.syncNow()) + else -> syncResultToUi(result) + } + } + } + + fun deleteCloudHistory() { + launchExclusive { + try { + val deleted = syncManager.deleteCloudHistoryAndReset() + DriveHistorySyncWorker.cancel(context) + DriveHistoryUiOperation.Success( + context.getString(R.string.drive_deleted_success, deleted) + ) + } catch (error: Exception) { + // Account-boundary protection can deliberately turn sync off + // before refusing to delete from an unexpected Google account. + if (!settingsManager.settings.first().enabled) { + DriveHistorySyncWorker.cancel(context) + } + throw error + } + } + } + + fun clearOperation() = operationCoordinator.clear() + + private fun launchExclusive(block: suspend () -> DriveHistoryUiOperation) { + if (!operationCoordinator.begin()) return + applicationScope.launch { + val result = try { + block() + } catch (e: Exception) { + DriveHistoryUiOperation.Error(e.message ?: context.getString(R.string.drive_operation_failed)) + } + operationCoordinator.finish(result) + } + } + + private fun syncResultToUi(result: DriveHistorySyncResult): DriveHistoryUiOperation = when (result) { + DriveHistorySyncResult.Disabled -> DriveHistoryUiOperation.Idle + is DriveHistorySyncResult.RemoteDisabled -> { + DriveHistorySyncWorker.cancel(context) + DriveHistoryUiOperation.Error(result.message) + } + is DriveHistorySyncResult.Success -> DriveHistoryUiOperation.Success( + context.getString(R.string.drive_sync_success, result.uploaded, result.imported) + ) + is DriveHistorySyncResult.Error -> DriveHistoryUiOperation.Error(result.message) + } +} + +sealed class DriveHistoryUiOperation { + data object Idle : DriveHistoryUiOperation() + data object Running : DriveHistoryUiOperation() + data class Success(val message: String) : DriveHistoryUiOperation() + data class Error(val message: String) : DriveHistoryUiOperation() +} diff --git a/app/src/main/java/me/avinas/tempo/ui/settings/SettingsViewModel.kt b/app/src/main/java/me/avinas/tempo/ui/settings/SettingsViewModel.kt index 6806a5070..2904c336a 100644 --- a/app/src/main/java/me/avinas/tempo/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/me/avinas/tempo/ui/settings/SettingsViewModel.kt @@ -19,6 +19,7 @@ import me.avinas.tempo.BuildConfig import me.avinas.tempo.data.analytics.AnalyticsConsent import me.avinas.tempo.data.analytics.AnalyticsDefaults import me.avinas.tempo.data.analytics.AnalyticsGate +import me.avinas.tempo.data.drive.DriveHistorySyncManager import me.avinas.tempo.data.importexport.ImportConflictStrategy import me.avinas.tempo.data.importexport.ImportExportManager import me.avinas.tempo.data.importexport.ImportExportProgress @@ -39,6 +40,7 @@ class SettingsViewModel @param:ApplicationContext private val context: Context, private val database: AppDatabase, private val importExportManager: ImportExportManager, + private val driveHistorySyncManager: DriveHistorySyncManager, private val profileIdentityManager: ProfileIdentityManager, private val userPreferencesDao: me.avinas.tempo.data.local.dao.UserPreferencesDao, private val analyticsConsent: AnalyticsConsent, @@ -392,7 +394,9 @@ class SettingsViewModel ) { _showConflictDialog.value = null viewModelScope.launch { - val result = importExportManager.importData(uri, strategy) + val result = driveHistorySyncManager.withLocalHistoryRestore { + importExportManager.importData(uri, strategy) + } _importExportResult.value = result } } diff --git a/app/src/main/java/me/avinas/tempo/worker/DriveHistorySyncWorker.kt b/app/src/main/java/me/avinas/tempo/worker/DriveHistorySyncWorker.kt new file mode 100644 index 000000000..5c5862a67 --- /dev/null +++ b/app/src/main/java/me/avinas/tempo/worker/DriveHistorySyncWorker.kt @@ -0,0 +1,128 @@ +package me.avinas.tempo.worker + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first +import me.avinas.tempo.data.drive.DriveException +import me.avinas.tempo.data.drive.DriveHistorySyncManager +import me.avinas.tempo.data.drive.DriveHistorySyncResult +import me.avinas.tempo.data.drive.DriveHistorySyncSettingsManager +import java.util.concurrent.TimeUnit + +@HiltWorker +class DriveHistorySyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val syncManager: DriveHistorySyncManager, + private val settingsManager: DriveHistorySyncSettingsManager +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "DriveHistorySyncWorker" + const val WORK_NAME = "drive_history_sync" + private const val MANUAL_WORK_NAME = "drive_history_sync_manual" + private const val DEFAULT_INTERVAL_HOURS = 6L + + fun schedule(context: Context, intervalHours: Long = DEFAULT_INTERVAL_HOURS) { + val request = PeriodicWorkRequestBuilder( + intervalHours.coerceAtLeast(1L), TimeUnit.HOURS, + 30, TimeUnit.MINUTES + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .addTag(WORK_NAME) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } + + fun scheduleOneTime(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .addTag(MANUAL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + MANUAL_WORK_NAME, + ExistingWorkPolicy.KEEP, + request + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(MANUAL_WORK_NAME) + } + } + + override suspend fun doWork(): Result { + val settings = settingsManager.settings.first() + if (!settings.enabled) return Result.success() + + return when (val result = syncManager.syncNow()) { + DriveHistorySyncResult.Disabled -> Result.success() + is DriveHistorySyncResult.RemoteDisabled -> { + Log.i(TAG, result.message) + // The manager already cleared the opt-in after it safely cleaned + // any remaining cloud batches. Remove future wakeups immediately. + cancel(applicationContext) + Result.success() + } + is DriveHistorySyncResult.Success -> { + Log.i( + TAG, + "Drive history sync complete: uploaded=${result.uploaded}, imported=${result.imported}, duplicates=${result.duplicates}" + ) + Result.success() + } + is DriveHistorySyncResult.Error -> { + Log.w(TAG, "Drive history sync failed: ${result.message}", result.exception) + + // Consent/session problems require a user gesture in the settings + // screen. Re-running the same WorkManager attempt cannot fix them + // and would only waste battery. The periodic schedule remains and + // will try again later after the user reconnects. + val authFailure = result.exception is DriveException.Auth || + result.message.contains("authorization", ignoreCase = true) || + result.message.contains("reconnect", ignoreCase = true) || + result.message.contains("session expired", ignoreCase = true) + if (authFailure || runAttemptCount >= 3) Result.success() else Result.retry() + } + } + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8b708c6c6..8d245d110 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,5 +1,31 @@ + + Browser-Wiedergaben importieren + Füge exportierte Wiedergaben aus der Tempo Stats-Erweiterung zu deinem Verlauf hinzu + Wiedergaben aus Erweiterung importieren + Übertrage deine Browser-Wiedergaben zu Tempo + 1. Öffne die Tempo Stats-Browser-Erweiterung\n2. Öffne den Tab Einstellungen und tippe auf Wiedergaben exportieren\n3. Wähle unten die heruntergeladene tempo-plays-Datei aus + Exportdatei auswählen + %1$d Datei(en) ausgewählt + Wiedergaben importieren + Importieren… + %1$s wird gelesen… + Datei %1$d von %2$d + %1$d Wiedergaben verarbeitet + Bisher %1$d hinzugefügt + Import abgeschlossen + Hinzugefügte Wiedergaben + Bereits in deinem Verlauf + Neue Titel + Zu kurz zum Zählen + Unlesbare Zeilen + Gelesene Dateien + Diese Datei enthält mehr Wiedergaben, als ein einzelner Import liest. Importiere sie erneut, um an der letzten Stelle fortzufahren. + Fertig + Import fehlgeschlagen + Erneut versuchen + Tempo @@ -378,7 +404,7 @@ Auf Gerät exportieren Vom Gerät importieren 💡 Backup-Tipps - • Google-Drive-Backups erfolgen automatisch und verschlüsselt\n + • Google-Drive-Backups erfolgen automatisch und werden in Ihrem Drive-Konto gespeichert\n Ein anderes Konto verwenden Letztes Backup: %1$s @@ -1070,4 +1096,49 @@ Fortschrittsmeldungen für den Import eines Deezer-Datenexports Deezer-Daten werden importiert %1$d/%2$d Einträge werden importiert + + + Vor der Synchronisierung ist eine Google Drive-Anmeldung erforderlich. + %1$d Verlaufsdateien aus Google Drive gelöscht. Die Synchronisierung wird bei erneuter Verbindung anderer Geräte deaktiviert. + Google Drive-Vorgang fehlgeschlagen. + Synchronisiert: %1$d gesendet, %2$d empfangen. + GERÄTEÜBERGREIFENDER VERLAUF + Hörverlauf synchronisieren + Hörverlauf zwischen Android, Desktop und Browsern über private Google Drive-App-Daten austauschen. Kein Tempo-Server. + Verbindung zu Google Drive… + Google Drive-Synchronisierung + Jetzt synchronisieren + Vollständigen Verlauf wiederherstellen + Prüft alle älteren Drive-Daten, ohne lokale Wiedergaben zu löschen. + Älteren lokalen Verlauf hochladen + Veröffentlicht ältere Wiedergaben erneut, auch LAN-Daten ohne bestätigtes Google-Konto. + Cloud-Verlauf löschen + Älteren Verlauf hochladen? + Ältere Wiedergaben werden an das ausgewählte Google-Konto übertragen, auch zuvor gelöschte Daten und LAN-Einträge ohne bestätigten Eigentümer. Einige können zu einem anderen Konto gehören. Künftige LAN-Einträge bleiben ausgeschlossen. + Älteren Verlauf hochladen + Abbrechen + Cloud-Verlauf löschen? + Löscht geteilte Tempo-Verlaufsdaten aus Google Drive und stoppt die Synchronisierung auf verbundenen Geräten bei deren Wiederverbindung. Lokale Wiedergaben bleiben erhalten. Synchronisierung kann später erneut aktiviert werden. + Löschen und deaktivieren + Nie + Letzte Synchronisierung: %1$s • %2$s + Letzte Synchronisierung: %1$s • %2$d gesendet • %3$d empfangen + Automatisch etwa alle sechs Stunden und beim Tippen auf Jetzt synchronisieren. + Verbinden Sie Google, um den Hörverlauf zwischen Geräten zu synchronisieren. Dies ist unabhängig von Gerätesicherungen. Lokale Daten bleiben auf dem Telefon. + Google für den Hörverlauf verbinden + GOOGLE DRIVE-GERÄTESICHERUNGEN + Gerätesicherungen speichern einen Stand zur Wiederherstellung. Sie sind von der automatischen Synchronisierung des Hörverlaufs getrennt. + Gerätesicherung wiederherstellen + Gerätesicherung vom %1$s wiederherstellen? Das unterscheidet sich vom Wiederherstellen des synchronisierten Hörverlaufs. + Vollständige Gerätesicherung in Google Drive speichern + Jetzt sichern + Gerätesicherungen auf Google Drive aktivieren + Unabhängig vom synchronisierten Hörverlauf. Aktivieren Sie dies nur für vollständige Gerätesicherungen. + Laufenden Vorgang verlassen? + Beim Verlassen kann ein lokaler Import oder Export abgebrochen werden. Einige Cloud-Vorgänge laufen im Hintergrund weiter. + Bildschirm verlassen + Synchronisierung ausgeschaltet + Hörverlauf wird synchronisiert… + Synchronisierung fehlgeschlagen + Synchronisierung fehlgeschlagen: %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 902490889..ea02142dd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,5 +1,31 @@ + + Importer les écoutes du navigateur + Ajoutez à votre historique les écoutes exportées depuis l’extension Tempo Stats + Importer les écoutes de l’extension + Ajoutez vos écoutes du navigateur à Tempo + 1. Ouvrez l’extension de navigateur Tempo Stats\n2. Dans son onglet Paramètres, appuyez sur Exporter les écoutes\n3. Sélectionnez ci-dessous le fichier tempo-plays téléchargé + Choisir le fichier exporté + %1$d fichier(s) sélectionné(s) + Importer les écoutes + Importation… + Lecture de %1$s… + Fichier %1$d sur %2$d + %1$d écoutes traitées + %1$d ajoutées pour le moment + Importation terminée + Écoutes ajoutées + Déjà dans votre historique + Nouveaux titres + Trop courtes pour être comptées + Lignes illisibles + Fichiers lus + Ce fichier contient plus d’écoutes qu’une seule importation ne peut lire. Importez-le à nouveau pour reprendre là où elle s’est arrêtée. + Terminé + Échec de l’importation + Réessayer + Tempo @@ -378,7 +404,7 @@ Exporter sur l\'appareil Importer depuis l\'appareil 💡 Astuces de sauvegarde - • Les sauvegardes Google Drive sont automatiques et chiffrées\n + • Les sauvegardes Google Drive sont automatiques et stockées dans votre compte Drive\n Utiliser un autre compte Dernière sauvegarde : %1$s @@ -1068,4 +1094,49 @@ Notifications de progression pour l\'import d\'export Deezer Import des données Deezer Import de %1$d/%2$d entrées + + + Autorisez Google Drive avant d’activer la synchronisation. + %1$d lots d’historique supprimés de Google Drive. La synchronisation s’arrêtera sur les appareils associés à leur reconnexion. + Échec de l’opération sur l’historique Google Drive. + Synchronisation : %1$d écoutes envoyées et %2$d reçues. + HISTORIQUE MULTIAPPAREIL + Synchroniser les écoutes + Partagez votre historique entre Android, ordinateur et navigateurs via les données privées de Google Drive. Aucun serveur Tempo n’est utilisé. + Connexion à Google Drive… + Synchronisation Google Drive + Synchroniser + Restaurer tout l’historique Drive + Vérifie les anciennes données Drive sans supprimer les écoutes locales. + Envoyer l’ancien historique local + Republie les anciennes écoutes, y compris celles reçues par réseau local sans compte Google vérifié. + Supprimer l’historique cloud + Envoyer les anciennes écoutes ? + Cette action partage les anciennes écoutes avec le compte Google sélectionné, y compris celles supprimées du cloud et celles dont le propriétaire Google est inconnu. Certaines peuvent appartenir à un autre compte. Les futures écoutes reçues par réseau local restent exclues. + Envoyer l’ancien historique + Annuler + Supprimer l’historique cloud ? + Supprime l’historique Tempo partagé sur Google Drive et désactive la synchronisation des appareils associés à leur reconnexion. Vos écoutes locales sont conservées. La synchronisation pourra être réactivée explicitement. + Supprimer et désactiver + Jamais + Dernière synchro : %1$s • %2$s + Dernière synchro : %1$s • %2$d envoyées • %3$d reçues + Exécution environ toutes les six heures ou lorsque vous choisissez Synchroniser. + Connectez Google pour partager vos écoutes entre appareils. Cette fonction est distincte des sauvegardes complètes. Votre historique local reste sur le téléphone. + Connecter Google pour synchroniser l’historique + SAUVEGARDES DE L’APPAREIL SUR GOOGLE DRIVE + Les sauvegardes créent une copie pour restaurer l’appareil. Elles sont distinctes de la synchronisation automatique de vos écoutes. + Restaurer une sauvegarde de l’appareil + Restaurer la sauvegarde de l’appareil du %1$s ? Cette opération est différente de la récupération des écoutes synchronisées. + Enregistrer une sauvegarde complète de l’appareil sur Google Drive + Sauvegarder + Activer les sauvegardes de l’appareil sur Drive + Indépendant des écoutes synchronisées. Activez uniquement pour sauvegarder les données de l’appareil sur Drive. + Quitter pendant une opération ? + Quitter cet écran peut interrompre un import ou un export local. Certaines opérations cloud continuent en arrière-plan. + Quitter l’écran + Synchronisation désactivée + Synchronisation de l’historique… + Échec de la synchronisation + Échec de la synchronisation : %1$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c66c7dea9..09303f5a8 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,5 +1,31 @@ + + Böngészős lejátszások importálása + Add hozzá az előzményeidhez a Tempo Stats bővítményből exportált lejátszásokat + Lejátszások importálása a bővítményből + Vidd át a böngészős lejátszásaidat a Tempóba + 1. Nyisd meg a Tempo Stats böngészőbővítményt\n2. A Beállítások lapon koppints a Lejátszások exportálása gombra\n3. Válaszd ki alább a letöltött tempo-plays fájlt + Exportált fájl kiválasztása + %1$d fájl kiválasztva + Lejátszások importálása + Importálás… + %1$s olvasása… + %1$d. fájl, összesen %2$d + %1$d lejátszás feldolgozva + Eddig %1$d hozzáadva + Importálás kész + Hozzáadott lejátszások + Már szerepel az előzményeidben + Új számok + Túl rövidek a beszámításhoz + Olvashatatlan sorok + Beolvasott fájlok + Ez a fájl több lejátszást tartalmaz, mint amennyit egyetlen importálás beolvas. Importáld újra a folytatáshoz onnan, ahol megállt. + Kész + Az importálás sikertelen + Újrapróbálás + Tempo @@ -704,4 +730,49 @@ Folyamatértesítések a Deezer-adatexport importálásához Deezer-adatok importálása %1$d/%2$d bejegyzés importálása + + + A Google Drive-szinkronizálás engedélyezéséhez be kell jelentkezni. + %1$d előzménycsomag törölve a Google Drive-ból. A kapcsolt eszközök újbóli csatlakozáskor leállítják a szinkronizálást. + Nem sikerült a Google Drive-művelet. + Szinkronizálva: %1$d elküldve, %2$d fogadva. + ESZKÖZÖK KÖZÖTTI ELŐZMÉNYEK + Hallgatási előzmények szinkronizálása + Előzmények megosztása Android, asztali gép és böngészők között a Google Drive privát alkalmazásadataival, Tempo-szerver nélkül. + Csatlakozás a Google Drive-hoz… + Google Drive-szinkronizálás + Szinkronizálás most + Teljes Drive-előzmény visszaállítása + A régi Drive-adatok ellenőrzése a helyi előzmények törlése nélkül. + Régi helyi előzmények feltöltése + Korábbi lejátszások ismételt feltöltése, ismeretlen Google-tulajdonosú LAN-adatokkal együtt. + Felhőelőzmények törlése + Régi előzmények feltöltése? + A korábbi lejátszásokat a kiválasztott Google-fiókba tölti fel, beleértve a felhőből törölt és ismeretlen tulajdonosú LAN-előzményeket. Egyes adatok másik fiókhoz tartozhatnak. A jövőbeli LAN-adatok kimaradnak. + Régi előzmények feltöltése + Mégse + Felhőelőzmények törlése? + Törli a megosztott Tempo-előzményeket a Google Drive-ból, és a kapcsolt eszközök újracsatlakozáskor leállítják a szinkronizálást. A helyi előzmények megmaradnak. Később újra engedélyezhető. + Törlés és kikapcsolás + Soha + Utolsó szinkron: %1$s • %2$s + Utolsó szinkron: %1$s • %2$d küldve • %3$d fogadva + Körülbelül hatóránként automatikusan, vagy kézi indítással. + Csatlakoztassa a Google-fiókját a hallgatási előzmények eszközök közötti megosztásához. Ez különbözik a teljes eszközmentéstől. A helyi előzmények megmaradnak. + Google csatlakoztatása az előzményekhez + GOOGLE DRIVE ESZKÖZMENTÉSEK + Az eszközmentés visszaállítható pillanatképet készít. Ez nem azonos a hallgatási előzmények automatikus szinkronizálásával. + Eszközmentés visszaállítása + Visszaállítja a(z) %1$s dátumú eszközmentést? Ez eltér a szinkronizált hallgatási előzmények visszaállításától. + Teljes eszközmentés mentése a Google Drive-ba + Mentés most + Eszközmentések engedélyezése a Google Drive-on + A hallgatási előzmények szinkronizálásától független. Csak teljes eszközmentéshez kapcsolja be. + Kilépés folyamatban lévő művelet közben? + Kilépéskor a helyi import vagy export megszakadhat. Néhány felhőművelet a háttérben folytatódhat. + Kilépés + Szinkronizálás kikapcsolva + Előzmények szinkronizálása… + Szinkronizálás sikertelen + Szinkronizálás sikertelen: %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 061786b09..97a8dd390 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,5 +1,31 @@ + + Importar reproduções do navegador + Adicione ao seu histórico as reproduções exportadas da extensão Tempo Stats + Importar reproduções da extensão + Traga as suas reproduções do navegador para o Tempo + 1. Abra a extensão Tempo Stats no navegador\n2. No separador Definições, toque em Exportar reproduções\n3. Selecione abaixo o ficheiro tempo-plays transferido + Escolher ficheiro exportado + %1$d ficheiro(s) selecionado(s) + Importar reproduções + A importar… + A ler %1$s… + Ficheiro %1$d de %2$d + %1$d reproduções processadas + %1$d adicionadas até agora + Importação concluída + Reproduções adicionadas + Já no seu histórico + Novas faixas + Demasiado curtas para contar + Linhas ilegíveis + Ficheiros lidos + Este ficheiro contém mais reproduções do que uma única importação consegue ler. Importe-o novamente para continuar de onde parou. + Concluído + A importação falhou + Tentar novamente + Tempo @@ -379,7 +405,7 @@ Exportar para dispositivo Importar do dispositivo 💡 Dicas de backup - • Os backups do Google Drive são automáticos e criptografados\n + • Os backups do Google Drive são automáticos e armazenados na sua conta do Drive\n Use uma conta diferente Último backup: %1$s @@ -1179,4 +1205,49 @@ Notificações de progresso da importação da exportação de dados do Deezer Importando dados do Deezer Importando %1$d/%2$d entradas + + + É necessária autorização do Google Drive antes de ativar a sincronização. + %1$d lotes de histórico excluídos do Google Drive. A sincronização será desativada nos dispositivos vinculados quando reconectarem. + Falha na operação do histórico do Google Drive. + Sincronizado: %1$d enviados e %2$d recebidos. + HISTÓRICO ENTRE DISPOSITIVOS + Sincronizar histórico de reprodução + Compartilhe seu histórico entre Android, Desktop e navegadores usando dados privados do Google Drive. Nenhum servidor Tempo é usado. + Conectando ao Google Drive… + Sincronização do Google Drive + Sincronizar agora + Restaurar todo o histórico do Drive + Verifica os dados antigos no Drive sem apagar as reproduções locais. + Enviar histórico local antigo + Republica reproduções antigas, inclusive dados LAN sem conta Google verificada. + Excluir histórico da nuvem + Enviar histórico antigo? + Compartilha as reproduções antigas com a conta Google selecionada, inclusive histórico excluído do Drive e dados LAN sem proprietário verificado. Algumas reproduções podem pertencer a outra conta. Novos dados LAN permanecem excluídos. + Enviar histórico antigo + Cancelar + Excluir histórico da nuvem? + Exclui os dados compartilhados do Tempo no Google Drive e desativa a sincronização dos dispositivos vinculados quando reconectarem. O histórico local permanece intacto. Você pode reativar a sincronização depois. + Excluir e desativar + Nunca + Última sincronização: %1$s • %2$s + Última sincronização: %1$s • %2$d enviados • %3$d recebidos + Executa a cada seis horas, aproximadamente, ou ao tocar em Sincronizar agora. + Conecte o Google para sincronizar o histórico entre dispositivos. Este recurso é separado dos backups do aparelho. Seu histórico local permanece no telefone. + Conectar Google para sincronizar histórico + BACKUPS DO DISPOSITIVO NO GOOGLE DRIVE + Os backups salvam uma cópia para restauração. Eles são separados da sincronização automática do histórico de reprodução. + Restaurar backup do dispositivo + Restaurar o backup do dispositivo de %1$s? Isso é diferente de restaurar o histórico de reprodução sincronizado. + Salvar backup completo do dispositivo no Google Drive + Fazer backup + Ativar backups do dispositivo no Google Drive + Separado da sincronização do histórico musical. Ative apenas para fazer backup completo do dispositivo. + Sair durante uma operação? + Sair desta tela pode interromper a importação ou exportação local. Algumas operações na nuvem continuam em segundo plano. + Sair da tela + Sincronização desativada + Sincronizando histórico de reprodução… + Falha na sincronização + Falha na sincronização: %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 15da63440..fdde2ac40 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,31 @@ + + Импорт прослушиваний из браузера + Добавьте в историю прослушивания, экспортированные из расширения Tempo Stats + Импорт из расширения + Перенесите прослушивания из браузера в Tempo + 1. Откройте расширение Tempo Stats в браузере\n2. На вкладке настроек нажмите «Экспорт прослушиваний»\n3. Выберите ниже скачанный файл tempo-plays + Выбрать файл экспорта + Выбрано файлов: %1$d + Импортировать прослушивания + Импорт… + Чтение %1$s… + Файл %1$d из %2$d + Обработано прослушиваний: %1$d + Добавлено: %1$d + Импорт завершён + Добавлено прослушиваний + Уже в вашей истории + Новые треки + Слишком короткие + Нечитаемые строки + Прочитано файлов + В этом файле больше прослушиваний, чем можно прочитать за один импорт. Импортируйте его снова, чтобы продолжить с места остановки. + Готово + Ошибка импорта + Повторить + Tempo @@ -410,7 +436,7 @@ Экспорт на устройство Импорт с устройства 💡 Советы по резервному копированию - • Копии на Google Диске автоматические и зашифрованные\n + • Копии на Google Диске создаются автоматически и хранятся в вашем аккаунте Drive\n Использовать другой аккаунт Последняя копия: %1$s @@ -1296,4 +1322,49 @@ Уведомления о ходе импорта экспорта данных Deezer Импорт данных Deezer Импорт %1$d/%2$d записей + + + Для синхронизации требуется авторизация в Google Drive. + Удалено пакетов истории Google Drive: %1$d. Синхронизация на связанных устройствах отключится при повторном подключении. + Не удалось выполнить операцию с историей Google Drive. + Синхронизировано: отправлено %1$d, получено %2$d. + ИСТОРИЯ НА РАЗНЫХ УСТРОЙСТВАХ + Синхронизировать историю прослушиваний + Обменивайтесь историей между Android, компьютером и браузерами через закрытые данные приложения Google Drive. Сервер Tempo не используется. + Подключение к Google Drive… + Синхронизация Google Drive + Синхронизировать + Восстановить всю историю Drive + Проверяет старые записи Drive, не удаляя локальную историю. + Загрузить старую локальную историю + Повторно публикует старые записи, включая данные LAN без подтверждённого аккаунта Google. + Удалить облачную историю + Загрузить старую историю? + Старые записи будут переданы в выбранный аккаунт Google, включая удалённую облачную историю и данные LAN с неподтверждённым владельцем. Часть записей может относиться к другому аккаунту. Новые передачи LAN не включаются. + Загрузить старую историю + Отмена + Удалить облачную историю? + Удаляет общую историю Tempo из Google Drive и отключает синхронизацию на связанных устройствах при их повторном подключении. Локальная история сохраняется. Синхронизацию можно снова включить вручную. + Удалить и отключить + Никогда + Последняя синхронизация: %1$s • %2$s + Последняя синхронизация: %1$s • %2$d отправлено • %3$d получено + Автоматически примерно каждые шесть часов или после нажатия Синхронизировать. + Подключите Google, чтобы синхронизировать историю прослушиваний между устройствами. Это отдельная функция от резервного копирования телефона. Локальная история сохраняется. + Подключить Google для истории + РЕЗЕРВНЫЕ КОПИИ УСТРОЙСТВА В GOOGLE DRIVE + Резервная копия сохраняет снимок для восстановления устройства. Это не то же самое, что автоматическая синхронизация истории прослушиваний. + Восстановление резервной копии устройства + Восстановить резервную копию устройства от %1$s? Это не то же самое, что восстановить синхронизированную историю прослушиваний. + Сохранить полную резервную копию устройства в Google Drive + Создать копию + Включить резервные копии устройства в Google Drive + Отдельно от синхронизации истории прослушиваний. Включайте только для сохранения резервных копий устройства. + Выйти во время операции? + При выходе локальный импорт или экспорт может прерваться. Некоторые облачные операции продолжаются в фоне. + Выйти + Синхронизация выключена + Синхронизация истории прослушиваний… + Ошибка синхронизации + Ошибка синхронизации: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5dbf9c8fa..742add716 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -440,7 +440,7 @@ Export to Device Import from Device 💡 Backup Tips - • Google Drive backups are automatic and encrypted\n + • Google Drive backups are automatic and stored in your Drive account\n Use a different account Last backup: %1$s @@ -1516,4 +1516,49 @@ Remove existing plays and block future matches Treat %1$s as video / non-music Remove all matching history and block future plays + + + Google Drive authorization is required before enabling sync. + Deleted %1$d Google Drive history batches. Sync is turned off on linked devices when they reconnect. + Google Drive history operation failed. + Synced %1$d outgoing and %2$d incoming plays. + CROSS-DEVICE HISTORY + Sync listening history + Share your listening history across Android, Desktop and browsers using private Google Drive app data. No Tempo server is used. + Connecting to Google Drive… + Google Drive sync + Sync now + Restore all Drive history + Checks older Drive records without deleting local listening history. + Upload older local history + Republishes older recordings, including LAN plays with unverified Google ownership. + Delete cloud history + Upload older local history? + This shares older local recordings with the selected Google account, including previously deleted Drive history and LAN plays with unverified ownership. Some plays may belong to another Google account. Later LAN deliveries remain excluded. + Upload older history + Cancel + Delete cloud history? + Deletes shared Tempo history from Google Drive and turns off sync on linked devices when they reconnect. Local listening history stays on every device. You can explicitly enable sync again later. + Delete and turn off + Never + Last sync: %1$s • %2$s + Last sync: %1$s • %2$d sent • %3$d received + Runs about every six hours and whenever you choose Sync now. + Connect Google to share listening history across devices. This is separate from full-device backups. Your local history stays on your phone. + Connect Google for history sync + GOOGLE DRIVE DEVICE BACKUPS + Device backups save a snapshot for recovery. They are separate from automatic cross-device listening-history sync. + Restore device backup + Restore the device backup from %1$s? This is different from restoring your listening history via cross-device sync. + Save a full-device backup to Google Drive + Back up now + Enable Google Drive device backups + Separate from listening-history sync. Turn on only if you want whole-device backups saved to Drive. + Leave while work is in progress? + Leaving this screen may interrupt a local import or export. Some cloud operations can continue in the background. + Leave screen + Sync disabled + Syncing listening history… + Sync failed + Sync failed: %1$s diff --git a/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSessionTest.kt b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSessionTest.kt new file mode 100644 index 000000000..bbb6854a8 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSessionTest.kt @@ -0,0 +1,54 @@ +package me.avinas.tempo.data.drive + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveAccountSessionTest { + @Test + fun `account change after suspension prevents further requests`() = runTest { + var email: String? = "first@example.com" + val session = DriveAccountSession { email } + var requests = 0 + session.withAccount { original -> + assertEquals("first@example.com", original) + session.requireUnchangedAccount() + requests++ + email = "second@example.com" + val failure = withContext(Dispatchers.Default) { + runCatching { session.requireUnchangedAccount(); requests++ }.exceptionOrNull() + } + assertTrue(failure is DriveException.Auth) + } + assertEquals(1, requests) + } + + @Test + fun `sign out invalidates the active operation and blank identity fails closed`() = runTest { + var email: String? = "first@example.com" + val session = DriveAccountSession { email } + session.withAccount { + email = null + assertTrue(runCatching { session.requireUnchangedAccount() }.exceptionOrNull() is DriveException.Auth) + } + email = " " + var entered = false + val failure = runCatching { session.withAccount { entered = true } }.exceptionOrNull() + assertTrue(failure is DriveException.Auth) + assertTrue(!entered) + } + + @Test + fun `email case normalization and ordinary token refresh preserve the binding`() = runTest { + var email: String? = " FIRST@example.com " + val session = DriveAccountSession { email } + session.withAccount { bound -> + assertEquals("first@example.com", bound) + email = "first@example.com" + withContext(Dispatchers.Default) { session.requireUnchangedAccount() } + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSwitchingTest.kt b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSwitchingTest.kt new file mode 100644 index 000000000..d1e4c17c5 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountSwitchingTest.kt @@ -0,0 +1,109 @@ +package me.avinas.tempo.data.drive + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriveAccountSwitchingTest { + + @Test + fun `rejected batch record serializes and parses properly`() { + val record = DriveHistorySyncManager.Companion.RejectedBatchRecord( + fileId = "file-123", + fileName = "tempo_history_device_1_0.json.gz", + reason = "Malformed\tpayload with\nnewline", + recordedAt = 1700000000000L + ) + val serialized = record.serialize() + val parsed = DriveHistorySyncManager.Companion.RejectedBatchRecord.parse(serialized) + + assertNotNull(parsed) + assertEquals("file-123", parsed?.fileId) + assertEquals("tempo_history_device_1_0.json.gz", parsed?.fileName) + // Tabs and newlines should be sanitized to single spaces + assertEquals("Malformed payload with newline", parsed?.reason) + assertEquals(1700000000000L, parsed?.recordedAt) + } + + @Test + fun `rejected batch record handles corrupt raw strings gracefully`() { + assertNull(DriveHistorySyncManager.Companion.RejectedBatchRecord.parse("invalid")) + assertNull(DriveHistorySyncManager.Companion.RejectedBatchRecord.parse("id\tname")) + } + + @Test + fun `incremental listing never clears older rejected file warnings`() { + val older = "file-older\tcorrupt.json.gz\tBad metadata\t1700000000000" + val recent = "file-recent\tbroken.json.gz\tBad payload\t1700100000000" + val records = setOf(older, recent) + + assertEquals(records, DriveHistorySyncManager.retainedRejectedRecords( + records, setOf("file-recent"), completeListing = false)) + assertEquals(setOf(recent), DriveHistorySyncManager.retainedRejectedRecords( + records, setOf("file-recent"), completeListing = true)) + assertTrue(DriveHistorySyncManager.retainedRejectedRecords( + records, emptySet(), completeListing = true).isEmpty()) + } + + @Test + fun `restore clears row-id progress but preserves accepted cloud deletion generations`() { + val resetKeys = listOf( + "download_created_cursor:google-a", + "drive_rejected_files:google-a", + "upload_cursor:google-a", + "invalid_drive_export_ids:google-a", + ) + for (key in resetKeys) { + assertTrue("Key $key must be reset after a local database restore", + DriveHistorySyncManager.isPerAccountRestoreStateKey(key)) + } + + for (key in listOf( + "accepted_disable_marker_version:google-a", + "accepted_disable_marker_version:google-b", + )) { + assertFalse("Key $key must survive restores to prevent stale re-deletion", + DriveHistorySyncManager.isPerAccountRestoreStateKey(key)) + } + } + @Test + fun `cleanup best effort does not block history recovery on failed Drive DELETE`() = runBlocking { + val failures = mutableListOf() + val cleaned = DriveHistorySyncManager.cleanupStaleBatchBestEffort( + delete = { Unit }, + onError = { failure -> failures.add(failure); Unit }, + ) + assertTrue(cleaned) + assertTrue(failures.isEmpty()) + + val retryable = IllegalStateException("temporary Drive DELETE failure") + val ignored = DriveHistorySyncManager.cleanupStaleBatchBestEffort( + delete = { throw retryable }, + onError = { failure -> failures.add(failure); Unit }, + ) + assertFalse(ignored) + assertEquals(listOf(retryable), failures) + } + + @Test + fun `cleanup best effort never swallows coroutine cancellation`() = runBlocking { + val cancellation = CancellationException("worker stopped") + var failureReported = false + try { + DriveHistorySyncManager.cleanupStaleBatchBestEffort( + delete = { throw cancellation }, + onError = { failureReported = true }, + ) + throw AssertionError("Cancellation must propagate") + } catch (actual: CancellationException) { + assertTrue(actual === cancellation) + assertFalse(failureReported) + } + } + +} diff --git a/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountUploadCursorTest.kt b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountUploadCursorTest.kt new file mode 100644 index 000000000..1d8efd29e --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/drive/DriveAccountUploadCursorTest.kt @@ -0,0 +1,92 @@ +package me.avinas.tempo.data.drive + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Account-scoped upload progress must survive A -> B -> A switching. */ +class DriveAccountUploadCursorTest { + @Test + fun pendingOldAccountHistorySurvivesRoundTrip() { + val select = { scoped: Long?, legacy: Long?, switched: Boolean, current: Long, max: Long -> + DriveHistorySyncManager.chooseAccountUploadCursor(scoped, legacy, switched, current, max) + } + + // A has only uploaded rows <=90, but has 100 local rows. + val newB = select(null, null, true, 90L, 100L) + assertEquals(100L, newB) // B may not publish A's old rows. + + // B records and uploads additional history, but A still needs 91..100. + val returnA = select(90L, null, true, 110L, 110L) + assertEquals(90L, returnA) + assertEquals(110L, select(110L, null, true, 101L, 120L)) + } + + @Test + fun legacyEmailCursorCanBeRecoveredAfterStableIdMigration() { + val select = { scoped: Long?, legacy: Long?, switched: Boolean, current: Long, max: Long -> + DriveHistorySyncManager.chooseAccountUploadCursor(scoped, legacy, switched, current, max) + } + assertEquals(90L, select(null, 90L, true, 130L, 135L)) + assertEquals(75L, select(75L, 90L, true, 130L, 135L)) + } + + @Test + fun deletionSuppressesOldRecordsButAllowsLaterOfflineCaptures() { + val deletedThrough = 500L + val cloudDeletionUtc = 1_700_000_000_000L + assertEquals(true, DriveHistorySyncManager.isCloudSuppressedRow( + 1L, deletedThrough, cloudDeletionUtc - 1_000, cloudDeletionUtc, 0L, + )) + assertEquals(true, DriveHistorySyncManager.isCloudSuppressedRow( + 500L, deletedThrough, cloudDeletionUtc, cloudDeletionUtc, 0L, + )) + assertEquals(false, DriveHistorySyncManager.isCloudSuppressedRow( + 501L, deletedThrough, cloudDeletionUtc - 1_000, cloudDeletionUtc, 0L, + )) + assertEquals(false, DriveHistorySyncManager.isCloudSuppressedRow( + 250L, deletedThrough, cloudDeletionUtc + 1_000, cloudDeletionUtc, 0L, + )) + assertEquals(false, DriveHistorySyncManager.isCloudSuppressedRow( + 1L, 0L, cloudDeletionUtc - 1_000, cloudDeletionUtc, 0L, + )) + assertEquals(true, DriveHistorySyncManager.isCloudSuppressedRow( + 10L, deletedThrough, cloudDeletionUtc + 5_000, cloudDeletionUtc, 100L, + )) // Already uploaded before delete: do not resurrect clock-skewed tracks. + } + + @Test + fun lanRelayNeverChangesGoogleOwnerWithoutExplicitConsent() { + val permits = DriveHistorySyncManager::canRelayLanToCloud + val marker = 1_700_000_000_000L + val oldPlay = marker - 5_000L + val newPlay = marker + 5_000L + + // A device pairing proves the sender, not the account that owns its music. + assertEquals(false, permits("lan-unverified", "google-b", oldPlay, 501L, 0L, 0L)) + assertEquals(false, permits("google-a", "google-b", oldPlay, 501L, marker, 0L)) + assertEquals(false, permits(null, "google-b", newPlay, 501L, marker, 0L)) + + // A late LAN delivery created before cloud deletion must not resurrect it. + assertEquals(false, permits("google-a", "google-a", oldPlay, 501L, marker, 0L)) + assertEquals(true, permits("google-a", "google-a", newPlay, 501L, marker, 0L)) + + // The user may authorize only records present in the approved snapshot. + assertEquals(true, permits("google-a", "google-a", oldPlay, 500L, marker, 500L)) + assertEquals(false, permits("google-a", "google-a", oldPlay, 501L, marker, 500L)) + assertEquals(true, permits("google-a", "google-a", oldPlay, 10L, 0L, 0L)) + } + + @Test + fun firstExplicitOptInDoesNotDiscardExistingLocalHistory() { + assertEquals(0L, DriveHistorySyncManager.chooseAccountUploadCursor( + null, null, false, 0L, 5000L, + )) + } + + @Test + fun deletionResetRestartsOnlySelectedAccountsCursor() { + assertEquals(0L, DriveHistorySyncManager.chooseAccountUploadCursor( + null, null, false, 0L, 100L, + )) + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/drive/DriveHistoryProtocolTest.kt b/app/src/test/java/me/avinas/tempo/data/drive/DriveHistoryProtocolTest.kt new file mode 100644 index 000000000..fa7e82d35 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/drive/DriveHistoryProtocolTest.kt @@ -0,0 +1,471 @@ +package me.avinas.tempo.data.drive + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class DriveHistoryProtocolTest { + + /** + * Cross-branch CI passes the browser's actual compressed wire payload to + * the Android decoder. Without the fixture env var, normal local unit + * test execution keeps using the self-contained protocol tests below. + */ + @Test + fun `browser Chrome and Firefox history batches decode on Android`() { + val directory = System.getenv("TEMPO_BROWSER_FIXTURE_DIR") ?: return + val firstTimestamp = 1_700_000_000_000L + for (platform in listOf("chrome_extension", "firefox_extension")) { + val bytes = java.io.File(directory, platform + ".json.gz").readBytes() + val batch = DriveHistoryProtocol.decodeCompressed(bytes) + assertEquals(1, batch.schemaVersion) + assertEquals("interop-browser", batch.sourceDeviceId) + assertEquals(platform, batch.sourcePlatform) + assertEquals(2, batch.events.size) + assertEquals(batch.batchId, DriveHistoryProtocol.createBatchId(batch.events)) + assertEquals("Été 🔊", batch.events[0].title) + assertEquals("Björk", batch.events[0].artist) + assertEquals(firstTimestamp, batch.events[0].timestampUtc) + assertEquals(firstTimestamp + 800L, batch.events[1].timestampUtc) + assertNotEquals(batch.events[0].eventId, batch.events[1].eventId) + assertEquals( + DriveHistoryProtocol.createEventId( + "interop-browser", 42L, firstTimestamp, "Été 🔊", "Björk" + ), + batch.events[0].eventId + ) + } + } + + + @Test + fun `Drive batch creation does not retry an uncertain POST outcome`() { + // A timeout or HTTP 5xx does not prove that Google Drive rejected + // the create. The next sync checks the exact filename/checksum first. + assertFalse(DriveAppDataClient.canRetryAfterAmbiguousFailure("POST")) + assertFalse(DriveAppDataClient.canRetryAfterAmbiguousFailure("post")) + assertTrue(DriveAppDataClient.canRetryAfterAmbiguousFailure("GET")) + assertTrue(DriveAppDataClient.canRetryAfterAmbiguousFailure("DELETE")) + // A PATCH on the shared deletion marker changes its server generation. + assertFalse(DriveAppDataClient.canRetryAfterAmbiguousFailure("PATCH")) + } + + @Test + fun `LAN-only plays relay with original Drive event identity`() { + val id = "a".repeat(64) + assertEquals( + "desktop:Spotify" to id, + DriveHistorySyncManager.lanRelayIdentity( + "lan:desktop-device:desktop:Spotify", "drive:v1:$id" + ) + ) + assertEquals( + "browser:YouTube Music" to id, + DriveHistorySyncManager.lanRelayIdentity( + "lan:browser-device:browser:YouTube Music", "drive:v1:$id" + ) + ) + // Cloud-downloaded events must never be re-uploaded by the Android relay. + assertNull(DriveHistorySyncManager.lanRelayIdentity( + "drive:desktop-device:desktop:Spotify", "drive:v1:$id" + )) + assertNull(DriveHistorySyncManager.lanRelayIdentity( + "lan::desktop:Spotify", "drive:v1:$id" + )) + assertNull(DriveHistorySyncManager.lanRelayIdentity( + "lan:desktop-device:desktop:Spotify", "invalid" + )) + assertNull(DriveHistorySyncManager.lanRelayIdentity( + "lan:desktop-device:desktop:Spotify", null + )) + } + + @Test + fun `Android reupload keeps the original event ID after track metadata edits`() { + val created = DriveHistorySyncManager.stableLocalEventId( + null, "android-device", 142L, 1_700_000_000_000L, "Uncorrected title", "Artist" + ) + val reuploaded = DriveHistorySyncManager.stableLocalEventId( + created, "android-device", 142L, 1_700_000_000_000L, + "Corrected title", "Corrected artist" + ) + val regeneratedWithoutAlias = DriveHistorySyncManager.stableLocalEventId( + null, "android-device", 142L, 1_700_000_000_000L, + "Corrected title", "Corrected artist" + ) + assertEquals(created, reuploaded) + assertNotEquals(created, regeneratedWithoutAlias) + } + + @Test + fun `LAN relay batches preserve actual desktop and browser producers`() { + assertEquals( + DriveHistorySyncManager.BatchProducer("desktop-device", "desktop"), + DriveHistorySyncManager.lanBatchProducer("lan:desktop-device:desktop:Spotify") + ) + assertEquals( + DriveHistorySyncManager.BatchProducer("browser-device", "browser"), + DriveHistorySyncManager.lanBatchProducer("lan:browser-device:browser:YouTube Music") + ) + assertEquals( + DriveHistorySyncManager.BatchProducer("legacy-device", "android"), + DriveHistorySyncManager.lanBatchProducer("lan:legacy-device:unclassified-source") + ) + assertNull(DriveHistorySyncManager.lanBatchProducer("drive:desktop-device:desktop:Spotify")) + assertNull(DriveHistorySyncManager.lanBatchProducer("lan::desktop:Spotify")) + } + + @Test + fun `text truncation preserves complete emoji at the UTF16 boundary`() { + val prefix = "x".repeat(999) + assertEquals(prefix, DriveHistoryProtocol.truncateText(prefix + "🔊")) + val fitting = "x".repeat(998) + "🔊" + assertEquals(fitting, DriveHistoryProtocol.truncateText(fitting)) + assertEquals("", DriveHistoryProtocol.truncateText("🔊", 1)) + assertEquals("", DriveHistoryProtocol.truncateText("text", 0)) + val events = listOf(event("boundary", 1_700_000_000_000L).copy( + title = DriveHistoryProtocol.truncateText(prefix + "🔊") + )) + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(events), sourceDeviceId = "remote-device", + sourceDeviceName = "Tempo device", sourcePlatform = "test", + createdAtUtc = 1_700_000_000_000L, events = events + ) + assertEquals(prefix, DriveHistoryProtocol.decodeCompressed( + DriveHistoryProtocol.encodeCompressed(batch)).events.single().title) + } + + @Test + fun `encoder rejects unpaired Unicode surrogates rather than replacing characters`() { + val good = event("unicode", 1_700_000_000_000L) + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(listOf(good)), sourceDeviceId = "remote-device", + sourceDeviceName = "Tempo device", sourcePlatform = "test", + createdAtUtc = 1_700_000_000_000L, events = listOf(good) + ) + for (invalid in listOf("bad\uD800", "bad\uDC00")) { + for (malformed in listOf(good.copy(title = invalid), good.copy(album = invalid))) { + assertTrue(runCatching { + DriveHistoryProtocol.encodeCompressed(batch.copy(events = listOf(malformed))) + }.exceptionOrNull() is IllegalArgumentException) + } + } + } + + @Test + fun `decoder rejects escaped unpaired Unicode surrogates in otherwise valid UTF8`() { + val events = listOf(event("escaped", 1_700_000_000_000L).copy(title = "placeholder")) + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(events), sourceDeviceId = "remote-device", + sourceDeviceName = "Tempo device", sourcePlatform = "test", + createdAtUtc = 1_700_000_000_000L, events = events + ) + val text = GZIPInputStream(ByteArrayInputStream(DriveHistoryProtocol.encodeCompressed(batch))) + .bufferedReader(Charsets.UTF_8).use { it.readText() } + for (escape in listOf("\\ud800", "\\udc00")) { + val malformed = text.replace("placeholder", "bad$escape") + val compressed = ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { it.write(malformed.toByteArray(Charsets.UTF_8)) } + output.toByteArray() + } + assertTrue(runCatching { DriveHistoryProtocol.decodeCompressed(compressed) } + .exceptionOrNull() is IllegalArgumentException) + } + } + + @Test + fun `every deletion marker timestamp must be valid even when another marker is newer`() { + assertEquals(0L, DriveAppDataClient.markerVersion(emptyList())) + assertEquals(200L, DriveAppDataClient.markerVersion(listOf(100L, 200L))) + for (invalid in listOf(null, 0L, -1L)) { + assertTrue(runCatching { DriveAppDataClient.markerVersion(listOf(200L, invalid)) } + .exceptionOrNull() is DriveException.Server) + } + } + + @Test + fun `decoder preserves valid unicode and rejects malformed UTF8 instead of changing the title`() { + val events = listOf(event("unicode", 1_700_000_000_000L).copy(title = "é 🔊")) + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(events), sourceDeviceId = "remote-device", + sourceDeviceName = "Tempo device", sourcePlatform = "test", + createdAtUtc = 1_700_000_000_000L, events = events + ) + val valid = DriveHistoryProtocol.encodeCompressed(batch) + assertEquals("é 🔊", DriveHistoryProtocol.decodeCompressed(valid).events.single().title) + val bytes = GZIPInputStream(ByteArrayInputStream(valid)).use { it.readBytes() } + val index = bytes.indexOf(0xc3.toByte()) + assertTrue(index >= 0) + bytes[index] = 0xff.toByte() + val corrupt = ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { it.write(bytes) } + output.toByteArray() + } + assertTrue(runCatching { DriveHistoryProtocol.decodeCompressed(corrupt) }.exceptionOrNull() + is java.nio.charset.CharacterCodingException) + } + + @Test + fun `generation metadata matches the safe cross-client integer grammar`() { + assertEquals(0L, DriveHistoryProtocol.parseGeneration(null)) + assertEquals(42L, DriveHistoryProtocol.parseGeneration("42")) + assertEquals(DriveHistoryProtocol.MAX_WIRE_INTEGER, DriveHistoryProtocol.parseGeneration("9007199254740991")) + for (invalid in listOf("", "-1", "+42", " 42", "42.0", "NaN", "9007199254740992", "999999999999999999999999")) { + assertEquals(invalid, null, DriveHistoryProtocol.parseGeneration(invalid)) + } + } + + @Test + fun `event id is stable for the same originating row`() { + val first = DriveHistoryProtocol.createEventId( + deviceId = "device-a", + localEventId = 42L, + timestampUtc = 1_700_000_000_000L, + title = " Track Name ", + artist = "Artist" + ) + val second = DriveHistoryProtocol.createEventId( + deviceId = "device-a", + localEventId = 42L, + timestampUtc = 1_700_000_000_000L, + title = "track name", + artist = "artist" + ) + + assertEquals(first, second) + assertEquals(64, first.length) + } + + @Test + fun `event id separates devices and local rows`() { + val base = DriveHistoryProtocol.createEventId("device-a", 42L, 1000L, "Song", "Artist") + val otherDevice = DriveHistoryProtocol.createEventId("device-b", 42L, 1000L, "Song", "Artist") + val otherRow = DriveHistoryProtocol.createEventId("device-a", 43L, 1000L, "Song", "Artist") + + assertNotEquals(base, otherDevice) + assertNotEquals(base, otherRow) + } + + @Test + fun `batch id is deterministic for the ordered event set`() { + val first = event("event-1", 1000L) + val second = event("event-2", 2000L) + + val idA = DriveHistoryProtocol.createBatchId(listOf(first, second)) + val idB = DriveHistoryProtocol.createBatchId(listOf(first, second)) + val reversed = DriveHistoryProtocol.createBatchId(listOf(second, first)) + + assertEquals(idA, idB) + assertNotEquals(idA, reversed) + assertEquals(64, idA.length) + } + + @Test + fun `protocol ids match the cross-client golden vectors`() { + val eventId = DriveHistoryProtocol.createEventId( + deviceId = "device-1", + localEventId = 42L, + timestampUtc = 1_700_000_000_000L, + title = " Song ", + artist = " Artist " + ) + assertEquals( + "69bd5521a322b3d1aaeca431b7380bd49f3a28e1c1d1b1dc0a754ca37e6a06b4", + eventId + ) + + val batchId = DriveHistoryProtocol.createBatchId( + listOf(event(eventId, 1_700_000_000_000L)) + ) + assertEquals( + "785b57b5c9e86c35176a413093df3c9fce37eb266c70485a0f9e8fff66e95d43", + batchId + ) + } + + @Test + fun `batch filename stays inside Tempo history namespace`() { + val name = DriveHistoryProtocol.fileName("device-a", "a".repeat(64)) + assertTrue(name.startsWith(DriveHistoryProtocol.FILE_PREFIX)) + assertTrue(name.endsWith(".json.gz")) + } + + @Test + fun `batch filename includes accepted deletion generation`() { + assertEquals( + "tempo_history_v1_g1234_device-a_${"a".repeat(64)}.json.gz", + DriveHistoryProtocol.fileName("device-a", "a".repeat(64), 1234L) + ) + // Pre-generation clients/files map to generation zero during migration. + assertEquals( + "tempo_history_v1_g0_device-a_${"a".repeat(64)}.json.gz", + DriveHistoryProtocol.fileName("device-a", "a".repeat(64)) + ) + } + + @Test + fun `Drive cleanup protects current batches with missing or corrupt generation metadata`() { + val id = "a".repeat(64) + val current = DriveHistoryProtocol.fileName("device-a", id, 200L) + val newer = DriveHistoryProtocol.fileName("device-a", id, 201L) + val older = DriveHistoryProtocol.fileName("device-a", id, 100L) + for (ownerDelete in listOf(false, true)) { + assertFalse(DriveHistoryProtocol.shouldDeleteHistoryBatch(current, null, 200L, ownerDelete)) + assertFalse(DriveHistoryProtocol.shouldDeleteHistoryBatch(newer, "invalid", 200L, ownerDelete)) + } + assertFalse(DriveHistoryProtocol.shouldDeleteHistoryBatch(older, "invalid", 200L, false)) + assertTrue(DriveHistoryProtocol.shouldDeleteHistoryBatch(older, "invalid", 200L, true)) + assertTrue(DriveHistoryProtocol.shouldDeleteHistoryBatch(older, null, 200L, false)) + assertTrue(DriveHistoryProtocol.shouldDeleteHistoryBatch( + "tempo_history_v1_legacy.json.gz", null, 200L, false)) + assertFalse(DriveHistoryProtocol.shouldDeleteHistoryBatch( + "tempo_history_v1_corrupt-no-extension", "invalid", 200L, false)) + assertTrue(DriveHistoryProtocol.shouldDeleteHistoryBatch( + "tempo_history_v1_corrupt-no-extension", "invalid", 200L, true)) + assertFalse(DriveHistoryProtocol.shouldDeleteHistoryBatch( + "unrelated.json.gz", "invalid", 200L, true)) + } + + @Test + fun `late auth failures match only the token that actually failed`() { + assertTrue(GoogleAuthManager.failedTokenIsStillCurrent("token-a", "token-a")) + assertFalse(GoogleAuthManager.failedTokenIsStillCurrent("token-a", "token-b")) + assertFalse(GoogleAuthManager.failedTokenIsStillCurrent("token-a", null)) + assertFalse(GoogleAuthManager.failedTokenIsStillCurrent("", "")) + } + + @Test + fun `Android stream volume never masquerades as a protocol percentage`() { + assertEquals(0, DriveHistorySyncManager.protocolVolumeLevel(0)) + assertNull(DriveHistorySyncManager.protocolVolumeLevel(8)) + assertNull(DriveHistorySyncManager.protocolVolumeLevel(null)) + } + + @Test + fun `decoder rejects a batch with too many events`() { + val events = listOf(event("event-0", 1_700_000_000_000L)) + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(events), + sourceDeviceId = "remote-device", + sourceDeviceName = "Tempo device", + sourcePlatform = "test", + createdAtUtc = 1_700_000_000_000L, + events = events + ) + val validCompressed = DriveHistoryProtocol.encodeCompressed(batch) + val root = JSONObject( + GZIPInputStream(ByteArrayInputStream(validCompressed)) + .bufferedReader(Charsets.UTF_8) + .use { it.readText() } + ) + val eventsJson = root.getJSONArray("events") + val template = eventsJson.getJSONObject(0) + while (eventsJson.length() <= DriveHistoryProtocol.MAX_EVENTS_PER_BATCH) { + eventsJson.put(template) + } + val compressed = ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { it.write(root.toString().toByteArray(Charsets.UTF_8)) } + output.toByteArray() + } + + val failure = runCatching { DriveHistoryProtocol.decodeCompressed(compressed) }.exceptionOrNull() + assertTrue(failure is IllegalArgumentException) + assertTrue(failure?.message.orEmpty().contains("too many events")) + } + + @Test + fun `decoder rejects an oversized compressed payload before inflating it`() { + val oversized = ByteArray(DriveHistoryProtocol.MAX_COMPRESSED_BYTES + 1) + + val failure = runCatching { DriveHistoryProtocol.decodeCompressed(oversized) }.exceptionOrNull() + assertTrue(failure is IllegalArgumentException) + assertTrue(failure?.message.orEmpty().contains("compressed size limit")) + } + + @Test + fun `100 thousand listens over ten years roundtrip in bounded Drive batches`() { + val total = 100_000 + val pageSize = DriveHistoryProtocol.MAX_EVENTS_PER_BATCH + val seen = HashSet(total) + val firstTimestamp = 1_451_606_400_000L // January 2016 + val stepMs = 3_153_600L // Approximately 10 years in 100k listens + var restored = 0 + var maxBatch = 0 + for (start in 0 until total step pageSize) { + val events = (start until minOf(start + pageSize, total)).map { index -> + val timestamp = firstTimestamp + index.toLong() * stepMs + event("history", timestamp).copy( + eventId = DriveHistoryProtocol.createEventId( + "android-history-source", index.toLong() + 1, timestamp, "Song", "Artist" + ), + title = "Song" + ) + } + val batch = DriveHistoryBatch( + batchId = DriveHistoryProtocol.createBatchId(events), + sourceDeviceId = "android-history-source", + sourceDeviceName = "Archive fixture", + sourcePlatform = "android", + createdAtUtc = events.last().timestampUtc, + events = events, + ) + val decoded = DriveHistoryProtocol.decodeCompressed(DriveHistoryProtocol.encodeCompressed(batch)) + assertEquals(batch.batchId, decoded.batchId) + assertEquals(events.size, decoded.events.size) + assertTrue(decoded.events.size <= pageSize) + assertTrue(decoded.events.zipWithNext().all { (a, b) -> a.timestampUtc < b.timestampUtc }) + maxBatch = maxOf(maxBatch, decoded.events.size) + restored += decoded.events.size + decoded.events.forEach { assertTrue("distinct historic producer identity", seen.add(it.eventId)) } + } + assertEquals(total, restored) + assertEquals(total, seen.size) + assertEquals(pageSize, maxBatch) + // Re-decoding a historical batch does not create new producer IDs. + val oldest = event("history", firstTimestamp).copy( + eventId = DriveHistoryProtocol.createEventId( + "android-history-source", 1L, firstTimestamp, "Song", "Artist" + ), title = "Song" + ) + assertTrue(oldest.eventId in seen) + } + + private fun event(id: String, timestamp: Long) = DriveHistoryEvent( + eventId = if (id.matches(Regex("^[0-9a-f]{64}$"))) id else { + DriveHistoryProtocol.createEventId( + "fixture-device", + id.hashCode().toLong() and 0x7fff_ffffL, + timestamp, + "Song $id", + "Artist" + ) + }, + title = "Song $id", + artist = "Artist", + album = null, + timestampUtc = timestamp, + durationMs = 180_000L, + listenedMs = 170_000L, + sourceApp = "test", + source = "test", + skipped = false, + replayCount = 0, + completionPercentage = 94, + pauseCount = 0, + seekCount = 0, + sessionId = null, + site = null, + contentType = "MUSIC", + volumeLevel = 50, + totalPauseDurationMs = 0L, + positionUpdatesCount = 10 + ) +} diff --git a/app/src/test/java/me/avinas/tempo/data/drive/DriveRestoreCursorInvalidationTest.kt b/app/src/test/java/me/avinas/tempo/data/drive/DriveRestoreCursorInvalidationTest.kt new file mode 100644 index 000000000..f249f1b43 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/drive/DriveRestoreCursorInvalidationTest.kt @@ -0,0 +1,36 @@ +package me.avinas.tempo.data.drive + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Importing a database may reassign every Room event ID, for all accounts. */ +class DriveRestoreCursorInvalidationTest { + @Test fun `all account progress and invalid IDs must be invalidated after restore`() { + val oldKeys = listOf( + "upload_cursor:google-a", + "upload_cursor:google-b", + "invalid_drive_export_ids:google-a", + "invalid_drive_export_ids:google-b", + ) + for (key in oldKeys) { + assertTrue("$key must be invalidated after database restore", + DriveHistorySyncManager.isPerAccountRestoreStateKey(key)) + } + } + + @Test fun `restore preserves global and per-account cloud deletion checkpoints`() { + for (key in listOf( + "google_account_subject", + "google_account_email", + "accepted_disable_marker_version", + "accepted_disable_marker_version:google-a", + "accepted_disable_marker_version:google-b", + "device_id", + "download_created_cursor", + )) { + assertFalse("$key is not a per-account old row-ID reference", + DriveHistorySyncManager.isPerAccountRestoreStateKey(key)) + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentityTest.kt b/app/src/test/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentityTest.kt new file mode 100644 index 000000000..efaf7c220 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/importexport/RestoredPlaybackIdentityTest.kt @@ -0,0 +1,51 @@ +package me.avinas.tempo.data.importexport + +import me.avinas.tempo.data.local.entities.ListeningEventOrigin +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** An offline backup may contain several verified Google accounts' histories. */ +class RestoredPlaybackIdentityTest { + private val desktop = ListeningEventOrigin("a".repeat(64), 12L, "desktop-one", "google-a") + private val browser = ListeningEventOrigin("b".repeat(64), 12L, "browser-two", "google-a") + private val sameIdOtherAccount = desktop.copy(listeningEventId = 99L, accountSubject = "google-b") + + @Test fun `new playback stays importable without known account-qualified origins`() { + assertTrue(shouldImportOfflinePlayback(listOf(desktop), emptyMap())) + assertTrue(shouldImportOfflinePlayback(emptyList(), emptyMap())) + } + + @Test fun `restore with metadata drift does not duplicate known producer`() { + assertFalse(shouldImportOfflinePlayback(listOf(desktop), mapOf(desktop.restoredKey() to 101L))) + } + + @Test fun `parallel producers of same physical play remain idempotent`() { + val known = mapOf(desktop.restoredKey() to 101L, browser.restoredKey() to 101L) + assertFalse(shouldImportOfflinePlayback(listOf(desktop, browser), known)) + } + + @Test fun `same producer origin in Google A must not swallow Google B`() { + val known = mapOf(desktop.restoredKey() to 101L) + assertTrue(shouldImportOfflinePlayback(listOf(sameIdOtherAccount), known)) + assertFalse(shouldImportOfflinePlayback(listOf(desktop), known)) + } + + @Test fun `aliases with equal IDs in separate accounts independently identify plays`() { + val known = mapOf(desktop.restoredKey() to 101L, + sameIdOtherAccount.restoredKey() to 202L) + assertFalse(shouldImportOfflinePlayback(listOf(desktop), known)) + assertFalse(shouldImportOfflinePlayback(listOf(sameIdOtherAccount), known)) + } + + @Test fun `contradictory claims within a Google account abort restore`() { + val contradiction = mapOf(desktop.restoredKey() to 101L, browser.restoredKey() to 102L) + try { + shouldImportOfflinePlayback(listOf(desktop, browser), contradiction) + fail("Conflicting producer aliases should fail") + } catch (expected: IllegalStateException) { + assertTrue(expected.message.orEmpty().contains("different listening events")) + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/importexport/TempoExportJsonCodecRoundTripTest.kt b/app/src/test/java/me/avinas/tempo/data/importexport/TempoExportJsonCodecRoundTripTest.kt index 5e5d2b0f8..4568b61e5 100644 --- a/app/src/test/java/me/avinas/tempo/data/importexport/TempoExportJsonCodecRoundTripTest.kt +++ b/app/src/test/java/me/avinas/tempo/data/importexport/TempoExportJsonCodecRoundTripTest.kt @@ -7,6 +7,7 @@ import me.avinas.tempo.data.local.entities.Album import me.avinas.tempo.data.local.entities.Artist import me.avinas.tempo.data.local.entities.ArtistRole import me.avinas.tempo.data.local.entities.ListeningEvent +import me.avinas.tempo.data.local.entities.ListeningEventOrigin import me.avinas.tempo.data.local.entities.ScrobbleArchive import me.avinas.tempo.data.local.entities.Track import me.avinas.tempo.data.local.entities.TrackArtist @@ -101,6 +102,7 @@ class TempoExportJsonCodecRoundTripTest { private suspend fun writeDocument(): Buffer { var eventPage = 0 var archivePage = 0 + var originPage = 0 val buffer = Buffer() val writer = JsonWriter.of(buffer) codec.write( @@ -118,6 +120,16 @@ class TempoExportJsonCodecRoundTripTest { 0 -> List(3) { archiveRow(0, it) } else -> null } + }, + originPages = { + when (originPage++) { + 0 -> listOf( + ListeningEventOrigin("a".repeat(64), 1L, "desktop-one"), + ListeningEventOrigin("b".repeat(64), 1L, "browser-two") + ) + 1 -> listOf(ListeningEventOrigin("c".repeat(64), 13L, "desktop-one")) + else -> null + } } ) writer.close() @@ -129,12 +141,14 @@ class TempoExportJsonCodecRoundTripTest { val expectedShell = sampleShell() val events = mutableListOf() val archive = mutableListOf() + val origins = mutableListOf() val data = codec.read( JsonReader.of(writeDocument()), TempoExportJsonCodec.StreamHandlers( onListeningEvent = { events.add(it) }, - onScrobbleArchiveRow = { archive.add(it) } + onScrobbleArchiveRow = { archive.add(it) }, + onListeningEventOrigin = { origins.add(it) } ) ) @@ -151,6 +165,11 @@ class TempoExportJsonCodecRoundTripTest { assertEquals(event(0, 0), events.first()) assertEquals(event(1, 6), events.last()) + assertEquals(3, origins.size) + assertEquals(ListeningEventOrigin("a".repeat(64), 1L, "desktop-one"), origins[0]) + assertEquals(ListeningEventOrigin("b".repeat(64), 1L, "browser-two"), origins[1]) + assertEquals(ListeningEventOrigin("c".repeat(64), 13L, "desktop-one"), origins[2]) + assertEquals(3, archive.size) assertEquals("hash-0-0", archive.first().trackHash) assertEquals("hash-0-2", archive.last().trackHash) @@ -162,6 +181,24 @@ class TempoExportJsonCodecRoundTripTest { // The shell never materializes the unbounded tables. assertTrue(data.listeningEvents.isEmpty()) assertTrue(data.scrobbleArchive.isEmpty()) + assertTrue(data.listeningEventOrigins.isEmpty()) + } + + @Test + fun `old backups without origin claims remain readable`() = runBlocking { + val raw = writeDocument().readUtf8() + val originsStart = raw.indexOf(",\"listeningEventOrigins\":") + val nextField = raw.indexOf(",\"enrichedMetadata\":", originsStart) + assertTrue(originsStart > 0 && nextField > originsStart) + val legacy = raw.removeRange(originsStart, nextField) + .replaceFirst("\"version\":${TempoExportData.CURRENT_VERSION}", + "\"version\":9") + val rows = mutableListOf() + val data = codec.read(JsonReader.of(Buffer().writeUtf8(legacy)), + TempoExportJsonCodec.StreamHandlers(onListeningEvent = { rows.add(it) })) + assertEquals(9, data.version) + assertEquals(17, rows.size) + assertTrue(data.listeningEventOrigins.isEmpty()) } @Test diff --git a/app/src/test/java/me/avinas/tempo/data/local/DriveSourceAuthorityTest.kt b/app/src/test/java/me/avinas/tempo/data/local/DriveSourceAuthorityTest.kt new file mode 100644 index 000000000..a8260a84c --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/DriveSourceAuthorityTest.kt @@ -0,0 +1,45 @@ +package me.avinas.tempo.data.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DriveSourceAuthorityTest { + + @Test + fun `Drive transport preserves the original source authority`() { + assertEquals( + SourceAuthority.rank("import.json.spotify"), + SourceAuthority.rank("drive:remote-device:import.json.spotify") + ) + assertEquals( + SourceAuthority.rank("desktop:windows"), + SourceAuthority.rank("drive:remote-device:desktop:windows") + ) + assertEquals( + SourceAuthority.rank("desktop:Spotify"), + SourceAuthority.rank("lan:desktop-device:desktop:Spotify") + ) + assertEquals( + SourceAuthority.rank("browser:Spotify"), + SourceAuthority.rank("drive:android-relay:browser:Spotify") + ) + assertEquals(100, SourceAuthority.rank("drive:remote-device")) + assertEquals(100, SourceAuthority.rank("drive::android")) + } + + @Test + fun `Drive device id is extracted only from a complete transport source`() { + assertEquals( + "remote-device", + SourceAuthority.driveDeviceId("drive:remote-device:android") + ) + assertEquals( + "desktop-device", + SourceAuthority.driveDeviceId("lan:desktop-device:desktop:Spotify") + ) + assertNull(SourceAuthority.driveDeviceId("android")) + assertNull(SourceAuthority.driveDeviceId("drive::android")) + assertNull(SourceAuthority.driveDeviceId("drive:remote-device")) + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/local/LanOriginPromotionTest.kt b/app/src/test/java/me/avinas/tempo/data/local/LanOriginPromotionTest.kt new file mode 100644 index 000000000..274b31f28 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/LanOriginPromotionTest.kt @@ -0,0 +1,146 @@ +package me.avinas.tempo.data.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.sql.DriverManager + +/** + * SQLite regression fixtures for explicit LAN history consent and the + * separate exact-origin promotion when a verified Drive batch arrives. + */ +class LanOriginPromotionTest { + @Test fun `unverified LAN records require explicit snapshot consent before Drive relay`() { + DriverManager.getConnection("jdbc:sqlite::memory:").use { db -> + db.createStatement().use { sql -> + sql.execute("CREATE TABLE listening_events (" + + "id INTEGER PRIMARY KEY, source TEXT NOT NULL, " + + "content_fingerprint TEXT, drive_account_subject TEXT)") + sql.execute("CREATE TABLE listening_event_origins (" + + "accountSubject TEXT NOT NULL, originEventId TEXT NOT NULL, " + + "listeningEventId INTEGER NOT NULL, sourceDeviceId TEXT NOT NULL, " + + "PRIMARY KEY(accountSubject, originEventId))") + val first = "a".repeat(64) + val other = "b".repeat(64) + val later = "c".repeat(64) + sql.execute("INSERT INTO listening_events VALUES " + + "(1, 'lan:desktop-one:desktop:spotify', 'drive:v1:$first', 'lan-unverified')," + + "(2, 'desktop:legacy', NULL, 'lan-unverified')," + + "(3, 'lan:desktop-one:desktop:spotify', 'drive:v1:$other', 'google-b')," + + "(4, 'lan:desktop-two:desktop:spotify', 'drive:v1:$later', 'lan-unverified')") + sql.execute("INSERT INTO listening_event_origins VALUES " + + "('lan-unverified', '$first', 1, 'desktop-one')," + + "('google-b', '$other', 3, 'desktop-one')") + val earliest = sql.executeQuery( + "SELECT MIN(id) FROM listening_events WHERE drive_account_subject = 'lan-unverified' " + + "AND source LIKE 'lan:%' AND content_fingerprint LIKE 'drive:v1:%'" + ).use { rows -> if (rows.next()) rows.getLong(1) else 0L } + assertEquals(1L, earliest) + // User deliberately approved rows 1..3; a late row 4 must + // remain quarantined even if it arrived before the SQL update. + val claimed = db.prepareStatement( + "UPDATE listening_events SET drive_account_subject = ? " + + "WHERE id <= ? AND drive_account_subject = 'lan-unverified' " + + "AND source LIKE 'lan:%' AND content_fingerprint LIKE 'drive:v1:%'" + ).use { stmt -> + stmt.setString(1, "google-a") + stmt.setLong(2, 3L) + stmt.executeUpdate() + } + assertEquals(1, claimed) + val aliases = db.prepareStatement( + "UPDATE listening_event_origins SET accountSubject = ? " + + "WHERE accountSubject = 'lan-unverified' AND listeningEventId IN (" + + "SELECT id FROM listening_events WHERE id <= ? " + + "AND drive_account_subject = ? " + + "AND source LIKE 'lan:%' AND content_fingerprint LIKE 'drive:v1:%') " + + "AND NOT EXISTS (SELECT 1 FROM listening_event_origins owned " + + "WHERE owned.accountSubject = ? " + + "AND owned.originEventId = listening_event_origins.originEventId)" + ).use { stmt -> + stmt.setString(1, "google-a") + stmt.setLong(2, 3L) + stmt.setString(3, "google-a") + stmt.setString(4, "google-a") + stmt.executeUpdate() + } + assertEquals(1, aliases) + sql.executeQuery("SELECT id, drive_account_subject FROM listening_events ORDER BY id") + .use { rows -> + val owners = mutableListOf() + while (rows.next()) owners += "${rows.getLong(1)}:${rows.getString(2)}" + assertEquals(listOf( + "1:google-a", "2:lan-unverified", "3:google-b", "4:lan-unverified", + ), owners) + } + sql.executeQuery("SELECT accountSubject FROM listening_event_origins WHERE originEventId = '$first'") + .use { rows -> + assertTrue(rows.next()) + assertEquals("google-a", rows.getString(1)) + } + } + } + } + + @Test fun `verified Drive origin adopts exact LAN capture and its alias only`() { + DriverManager.getConnection("jdbc:sqlite::memory:").use { db -> + db.createStatement().use { sql -> + sql.execute("CREATE TABLE listening_events (" + + "id INTEGER PRIMARY KEY, source TEXT NOT NULL, " + + "content_fingerprint TEXT, drive_account_subject TEXT)") + sql.execute("CREATE TABLE listening_event_origins (" + + "accountSubject TEXT NOT NULL, originEventId TEXT NOT NULL, " + + "listeningEventId INTEGER NOT NULL, sourceDeviceId TEXT NOT NULL, " + + "PRIMARY KEY(accountSubject, originEventId))") + val origin = "a".repeat(64) + val fingerprint = "drive:v1:$origin" + sql.execute("INSERT INTO listening_events VALUES " + + "(1, 'lan:desktop-one:desktop:spotify', '$fingerprint', 'lan-unverified')," + + "(2, 'lan:other:desktop:spotify', 'drive:v1:${"b".repeat(64)}', 'lan-unverified')," + + "(3, 'drive:desktop-one:desktop:spotify', '$fingerprint', 'google-a')") + sql.execute("INSERT INTO listening_event_origins VALUES " + + "('lan-unverified', '$origin', 1, 'desktop-one')") + // Same producer event in A should not prevent attribution to B, + // nor should this operation reassign A's already verified row. + val count = db.prepareStatement( + "UPDATE listening_events SET drive_account_subject = ? " + + "WHERE source LIKE 'lan:%' AND drive_account_subject = 'lan-unverified' " + + "AND content_fingerprint IN (?) AND NOT EXISTS (" + + "SELECT 1 FROM listening_events owned " + + "WHERE owned.drive_account_subject = ? " + + "AND owned.content_fingerprint = listening_events.content_fingerprint)" + ).use { stmt -> + stmt.setString(1, "google-b") + stmt.setString(2, fingerprint) + stmt.setString(3, "google-b") + stmt.executeUpdate() + } + assertEquals(1, count) + db.prepareStatement( + "UPDATE listening_event_origins SET accountSubject = ? " + + "WHERE accountSubject = 'lan-unverified' AND listeningEventId IN (" + + "SELECT id FROM listening_events WHERE drive_account_subject = ? " + + "AND source LIKE 'lan:%' AND content_fingerprint IN (?)) " + + "AND NOT EXISTS (SELECT 1 FROM listening_event_origins claimed " + + "WHERE claimed.accountSubject = ? " + + "AND claimed.originEventId = listening_event_origins.originEventId)" + ).use { stmt -> + stmt.setString(1, "google-b") + stmt.setString(2, "google-b") + stmt.setString(3, fingerprint) + stmt.setString(4, "google-b") + assertEquals(1, stmt.executeUpdate()) + } + sql.executeQuery("SELECT id, drive_account_subject FROM listening_events ORDER BY id").use { rows -> + val owners = mutableListOf() + while (rows.next()) owners += "${rows.getInt(1)}:${rows.getString(2)}" + assertEquals(listOf("1:google-b", "2:lan-unverified", "3:google-a"), owners) + } + sql.executeQuery("SELECT accountSubject FROM listening_event_origins WHERE originEventId = '$origin'").use { rows -> + assertTrue(rows.next()) + assertEquals("google-b", rows.getString(1)) + } + } + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/local/Migration55To56Test.kt b/app/src/test/java/me/avinas/tempo/data/local/Migration55To56Test.kt new file mode 100644 index 000000000..1b6b4d1e3 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/Migration55To56Test.kt @@ -0,0 +1,129 @@ +package me.avinas.tempo.data.local + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import java.sql.Connection +import java.sql.DriverManager +import java.sql.SQLException + +/** Exercises the REAL Room 55->56 SQL with minSdk-26-compatible SQLite. */ +class Migration55To56Test { + private lateinit var connection: Connection + + @Before + fun setUp() { + connection = DriverManager.getConnection("jdbc:sqlite::memory:") + connection.createStatement().use { + it.execute("PRAGMA foreign_keys = ON") + it.execute( + """CREATE TABLE listening_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + source TEXT NOT NULL, + content_fingerprint TEXT + )""", + ) + it.execute( + """INSERT INTO listening_events (id, source, content_fingerprint) + VALUES (1, 'drive:producer-one:desktop:Spotify', + 'drive:v1:' || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')""", + ) + it.execute("INSERT INTO listening_events (id, source) VALUES (2, 'android')") + } + } + + @After + fun tearDown() { + connection.close() + } + + private fun migrate() = AppDatabase.MIGRATION_55_56.migrate(proxyDatabase()) + + private fun proxyDatabase(): SupportSQLiteDatabase { + val handler = InvocationHandler { _, method, args -> + when (method.name) { + "execSQL" -> { + connection.createStatement().use { it.execute(args[0] as String) } + null + } + else -> throw UnsupportedOperationException("Unexpected migration method: ${method.name}") + } + } + return Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + handler, + ) as SupportSQLiteDatabase + } + + private fun count(): Int = connection.createStatement().use { + it.executeQuery("SELECT COUNT(*) FROM listening_event_origins").use { rs -> + rs.next() + rs.getInt(1) + } + } + + @Test + fun `backfill is idempotent and preserves every listening event`() { + migrate() + assertEquals(1, count()) + connection.createStatement().use { + it.executeQuery( + "SELECT listeningEventId, sourceDeviceId, originEventId FROM listening_event_origins", + ).use { rs -> + assertTrue(rs.next()) + assertEquals(1L, rs.getLong("listeningEventId")) + assertEquals("producer-one", rs.getString("sourceDeviceId")) + assertEquals("a".repeat(64), rs.getString("originEventId")) + } + } + migrate() + assertEquals(1, count()) + connection.createStatement().use { + it.executeQuery("SELECT COUNT(*) FROM listening_events").use { rs -> + rs.next() + assertEquals(2, rs.getInt(1)) + } + } + } + + @Test + fun `same producer cannot claim two events for one playback across batches`() { + migrate() + val rejected = try { + connection.createStatement().use { + it.execute( + """INSERT INTO listening_event_origins + (originEventId, listeningEventId, sourceDeviceId) + VALUES ('${"b".repeat(64)}', 1, 'producer-one')""", + ) + } + false + } catch (_: SQLException) { + true + } + assertTrue("One event per producer per playback is enforced in SQLite", rejected) + connection.createStatement().use { + it.execute( + """INSERT INTO listening_event_origins + (originEventId, listeningEventId, sourceDeviceId) + VALUES ('${"b".repeat(64)}', 1, 'producer-two')""", + ) + } + assertEquals(2, count()) + } + + @Test + fun `deleting a listening event cascades to its aliases`() { + migrate() + connection.createStatement().use { + it.execute("DELETE FROM listening_events WHERE id = 1") + } + assertEquals(0, count()) + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/local/Migration56To57Test.kt b/app/src/test/java/me/avinas/tempo/data/local/Migration56To57Test.kt new file mode 100644 index 000000000..45eb5c917 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/Migration56To57Test.kt @@ -0,0 +1,69 @@ +package me.avinas.tempo.data.local + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import java.sql.Connection +import java.sql.DriverManager + +/** Test the actual v56 -> v57 migration with a SQLite file-shaped fixture. */ +class Migration56To57Test { + private lateinit var conn: Connection + + @Before fun setUp() { + conn = DriverManager.getConnection("jdbc:sqlite::memory:") + conn.createStatement().use { + it.execute("CREATE TABLE listening_events (id INTEGER PRIMARY KEY, source TEXT NOT NULL)") + it.execute("INSERT INTO listening_events VALUES (1, 'drive:producer-a:desktop:spotify')") + it.execute("INSERT INTO listening_events VALUES (2, 'local:spotify')") + it.execute("INSERT INTO listening_events VALUES (3, 'drive:producer-b:browser')") + } + } + + @After fun tearDown() = conn.close() + + private fun proxy(): SupportSQLiteDatabase { + val handler = InvocationHandler { _, method, args -> + if (method.name == "execSQL") { + conn.createStatement().use { it.execute(args[0] as String) } + null + } else throw UnsupportedOperationException("Unexpected migration method: ${method.name}") + } + return Proxy.newProxyInstance( + javaClass.classLoader, arrayOf(SupportSQLiteDatabase::class.java), handler + ) as SupportSQLiteDatabase + } + + @Test fun `legacy cloud imports are quarantined but local history is preserved`() { + AppDatabase.MIGRATION_56_57.migrate(proxy()) + conn.createStatement().use { statement -> + statement.executeQuery( + "SELECT id, drive_account_subject FROM listening_events ORDER BY id" + ).use { rows -> + assertTrue(rows.next()) + assertEquals(1L, rows.getLong(1)) + assertEquals("legacy-unverified", rows.getString(2)) + assertTrue(rows.next()) + assertEquals(2L, rows.getLong(1)) + assertEquals(null, rows.getString(2)) + assertTrue(rows.next()) + assertEquals(3L, rows.getLong(1)) + assertEquals("legacy-unverified", rows.getString(2)) + } + statement.executeQuery("SELECT COUNT(*) FROM listening_events").use { rows -> + assertTrue(rows.next()) + assertEquals(3, rows.getInt(1)) + } + statement.executeQuery("PRAGMA index_list(listening_events)").use { rows -> + val indices = mutableListOf() + while (rows.next()) indices += rows.getString("name") + assertTrue(indices.contains("index_listening_events_drive_account_subject")) + } + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/local/Migration57To58Test.kt b/app/src/test/java/me/avinas/tempo/data/local/Migration57To58Test.kt new file mode 100644 index 000000000..7540ff063 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/Migration57To58Test.kt @@ -0,0 +1,72 @@ +package me.avinas.tempo.data.local + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import java.sql.DriverManager + +/** Real SQLite rebuild: preserves old producer claims and allows account reuse. */ +class Migration57To58Test { + @Test fun `producer alias belongs to its Google account not the entire device`() { + DriverManager.getConnection("jdbc:sqlite::memory:").use { conn -> + conn.createStatement().use { stmt -> + stmt.execute("PRAGMA foreign_keys=ON") + stmt.execute("CREATE TABLE listening_events " + + "(id INTEGER PRIMARY KEY, drive_account_subject TEXT)") + stmt.execute("INSERT INTO listening_events VALUES (1, 'google-a')") + stmt.execute("INSERT INTO listening_events VALUES (2, 'google-b')") + stmt.execute("INSERT INTO listening_events VALUES (3, NULL)") + stmt.execute("CREATE TABLE listening_event_origins (" + + "originEventId TEXT NOT NULL PRIMARY KEY, " + + "listeningEventId INTEGER NOT NULL, sourceDeviceId TEXT NOT NULL, " + + "FOREIGN KEY(listeningEventId) REFERENCES listening_events(id) ON DELETE CASCADE)") + stmt.execute("CREATE INDEX index_listening_event_origins_listeningEventId " + + "ON listening_event_origins(listeningEventId)") + stmt.execute("CREATE UNIQUE INDEX index_listening_event_origins_listeningEventId_sourceDeviceId " + + "ON listening_event_origins(listeningEventId, sourceDeviceId)") + stmt.execute("INSERT INTO listening_event_origins VALUES ('same-origin', 1, 'source-a')") + stmt.execute("INSERT INTO listening_event_origins VALUES ('unknown-origin', 3, 'source-z')") + } + val proxy = Proxy.newProxyInstance( + javaClass.classLoader, arrayOf(SupportSQLiteDatabase::class.java), + InvocationHandler { _, method, args -> + if (method.name == "execSQL") { + conn.createStatement().use { it.execute(args[0] as String) } + null + } else throw UnsupportedOperationException(method.name) + }, + ) as SupportSQLiteDatabase + AppDatabase.MIGRATION_57_58.migrate(proxy) + conn.createStatement().use { stmt -> + stmt.executeQuery("SELECT accountSubject FROM listening_event_origins " + + "WHERE originEventId = 'same-origin'").use { rows -> + assertTrue(rows.next()) + assertEquals("google-a", rows.getString(1)) + } + stmt.executeQuery("SELECT accountSubject FROM listening_event_origins " + + "WHERE originEventId = 'unknown-origin'").use { rows -> + assertTrue(rows.next()) + assertEquals("legacy-unverified", rows.getString(1)) + } + // Same producer event can independently exist in B. + stmt.execute("INSERT INTO listening_event_origins " + + "(accountSubject, originEventId, listeningEventId, sourceDeviceId) " + + "VALUES ('google-b', 'same-origin', 2, 'source-a')") + stmt.executeQuery("SELECT COUNT(*) FROM listening_event_origins " + + "WHERE originEventId = 'same-origin'").use { rows -> + assertTrue(rows.next()) + assertEquals(2, rows.getInt(1)) + } + stmt.execute("DELETE FROM listening_events WHERE id=1") + stmt.executeQuery("SELECT COUNT(*) FROM listening_event_origins " + + "WHERE accountSubject = 'google-a'").use { rows -> + assertTrue(rows.next()) + assertEquals(0, rows.getInt(1)) + } + } + } + } +} diff --git a/app/src/test/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatchTest.kt b/app/src/test/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatchTest.kt new file mode 100644 index 000000000..145c17f99 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/data/local/dao/NearestPlaybackMatchTest.kt @@ -0,0 +1,138 @@ +package me.avinas.tempo.data.local.dao + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NearestPlaybackMatchTest { + private data class Candidate( + val timestamp: Long, + val claimedByProducer: Boolean = false, + ) + + @Test + fun `assigns secondary producer to nearest of two rapid replays`() { + val candidates = listOf( + Candidate(1_700_000_000_000L), + Candidate(1_700_000_008_000L), + ) + val target = nearestEligiblePlaybackIndex( + candidates, 1_700_000_007_850L, + timestampOf = { it.timestamp }, + eligible = { true }, + ) + assertEquals("The later replay, not the first database row, owns the origin", 1, target) + } + + @Test + fun `already claimed playback is not eligible even when closer`() { + val candidates = listOf( + Candidate(1_700_000_000_000L), + Candidate(1_700_000_008_000L, claimedByProducer = true), + ) + assertEquals(0, nearestEligiblePlaybackIndex( + candidates, 1_700_000_007_850L, + timestampOf = { it.timestamp }, + eligible = { !it.claimedByProducer }, + )) + } + + @Test + fun `no eligible playback results in a new event`() { + val candidates = listOf(Candidate(100L, claimedByProducer = true)) + assertEquals(-1, nearestEligiblePlaybackIndex( + candidates, 100L, + timestampOf = { it.timestamp }, + eligible = { !it.claimedByProducer }, + )) + } + + @Test + fun `Drive imports preserve independent seven-second replays`() { + val origin = 1_700_000_000_000L + val slots = listOf(Candidate(origin)) + val withinWindow = nearestEligiblePlaybackIndex( + slots, origin + 1_000L, + timestampOf = { it.timestamp }, + eligible = { kotlin.math.abs(it.timestamp - (origin + 1_000L)) <= + ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS }, + ) + assertEquals(0, withinWindow) + + val independentReplay = nearestEligiblePlaybackIndex( + slots, origin + 7_000L, + timestampOf = { it.timestamp }, + eligible = { kotlin.math.abs(it.timestamp - (origin + 7_000L)) <= + ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS }, + ) + assertEquals(-1, independentReplay) + } + + @Test + fun `Drive origin is not assigned when two rapid replays are plausible`() { + val first = Candidate(1_700_000_000_000L) + val replay = Candidate(1_700_000_001_000L) + val slots = listOf(first, replay) + val incoming = 1_700_000_000_500L + assertEquals(-1, uniqueEligiblePlaybackIndex(slots) { + kotlin.math.abs(it.timestamp - incoming) <= ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS + }) + assertEquals(1, uniqueEligiblePlaybackIndex(slots) { it.timestamp == replay.timestamp }) + assertEquals(-1, uniqueEligiblePlaybackIndex(slots) { false }) + } + + @Test + fun `equidistant candidates deterministically keep first ordering`() { + assertEquals(0, nearestEligiblePlaybackIndex( + listOf(Candidate(1_000L), Candidate(3_000L)), 2_000L, + timestampOf = { it.timestamp }, + eligible = { true }, + )) + } + + @Test + fun `Drive windows never join sparse decades of the same song`() { + val firstTimestamp = 1_451_606_400_000L + val historical = (0 until 100_000).map { + firstTimestamp + it.toLong() * 3_153_600L + } + val windows = clusterDrivePlaybackWindows( + historical, ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS, + timestampOf = { it }, + ) + assertEquals(100_000, windows.size) + assertEquals(100_000, windows.sumOf { it.size }) + assertEquals(firstTimestamp, windows.first().single()) + assertEquals(historical.last(), windows.last().single()) + + val overlap = clusterDrivePlaybackWindows( + listOf(0L, 1_500L, 4_500L, 20_000L), 2_000L, + timestampOf = { it }, + ) + assertEquals(listOf(listOf(0L, 1_500L, 4_500L), listOf(20_000L)), overlap) + } + + @Test + fun `playback match rejects candidates when duration differs by more than 10 seconds`() { + data class PlaybackCandidate(val timestamp: Long, val durationMs: Long) + val candidates = listOf(PlaybackCandidate(1_700_000_000_000L, 180_000L)) + val targetDifferentDuration = nearestEligiblePlaybackIndex( + candidates, 1_700_000_001_000L, + timestampOf = { it.timestamp }, + eligible = { + kotlin.math.abs(it.timestamp - 1_700_000_001_000L) <= ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS && + kotlin.math.abs(it.durationMs - 200_000L) <= 10_000L + }, + ) + assertEquals(-1, targetDifferentDuration) + + val targetMatchingDuration = nearestEligiblePlaybackIndex( + candidates, 1_700_000_001_000L, + timestampOf = { it.timestamp }, + eligible = { + kotlin.math.abs(it.timestamp - 1_700_000_001_000L) <= ListeningEventDao.DRIVE_RECONCILIATION_WINDOW_MS && + kotlin.math.abs(it.durationMs - 185_000L) <= 10_000L + }, + ) + assertEquals(0, targetMatchingDuration) + } +} diff --git a/app/src/test/java/me/avinas/tempo/ui/settings/DriveHistoryUiOperationCoordinatorTest.kt b/app/src/test/java/me/avinas/tempo/ui/settings/DriveHistoryUiOperationCoordinatorTest.kt new file mode 100644 index 000000000..e7a32f134 --- /dev/null +++ b/app/src/test/java/me/avinas/tempo/ui/settings/DriveHistoryUiOperationCoordinatorTest.kt @@ -0,0 +1,34 @@ +package me.avinas.tempo.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The coordinator survives ViewModel recreation while application-scope Drive work runs. */ +class DriveHistoryUiOperationCoordinatorTest { + @Test + fun `progress and result remain visible across screen recreation`() { + val coordinator = DriveHistoryUiOperationCoordinator() + val originalView = coordinator.operation + assertEquals(DriveHistoryUiOperation.Idle, originalView.value) + assertTrue(coordinator.begin()) + assertEquals(DriveHistoryUiOperation.Running, originalView.value) + + // A recreated ViewModel observes the same shared coordinator. + val recreatedView = coordinator.operation + assertEquals(DriveHistoryUiOperation.Running, recreatedView.value) + assertFalse(coordinator.begin()) + coordinator.clear() + assertEquals(DriveHistoryUiOperation.Running, recreatedView.value) + + coordinator.finish(DriveHistoryUiOperation.Success("History synchronized")) + assertEquals( + DriveHistoryUiOperation.Success("History synchronized"), + recreatedView.value + ) + coordinator.clear() + assertEquals(DriveHistoryUiOperation.Idle, originalView.value) + assertTrue(coordinator.begin()) + } +} diff --git a/docs/DRIVE_HISTORY_SYNC_V1.md b/docs/DRIVE_HISTORY_SYNC_V1.md new file mode 100644 index 000000000..5fb9dff4e --- /dev/null +++ b/docs/DRIVE_HISTORY_SYNC_V1.md @@ -0,0 +1,299 @@ +# Tempo Drive History Sync Protocol v1 + +This document defines the cross-client wire contract used by Tempo Android and the browser extension. It is deliberately transport-focused so another compatible client, including a future Tempo Desktop build, can convert its own local database model to and from this canonical representation. + +## Goals + +- optional cross-network history convergence through Google Drive `appDataFolder` +- keep the existing LAN transport available and independent +- immutable, retry-safe batches +- deterministic identity so retries are idempotent +- prevent imported events from bouncing back to Drive as new events +- coordinate explicit cloud-history deletion across devices without allowing a stale client to erase a newly re-enabled history generation +- keep Drive cursors scoped to the authorized Google account + +## Drive namespace + +History batch filename prefix: + +`tempo_history_v1_` + +Current filename form: + +`tempo_history_v1_g__.json.gz` + +Shared control marker: + +`tempo_history_control_v1.json` + +The marker is not a history batch. Its Google-server `modifiedTime` is used as the cross-device disable/deletion version and as the accepted **history generation**. Every new batch also stores that generation in the Drive `appProperties` field: + +`tempo_generation=` + +Each batch also stores the lowercase SHA-256 of its exact compressed bytes as +`tempo_sha256=<64 lowercase hex characters>`. Readers verify both the declared +file size and this checksum before decompressing. A deterministic same-name +upload is idempotent only when its size, checksum, filename and all producer/schema/generation metadata match. + +Files produced before generation metadata existed are treated as generation `0` for migration compatibility. +Present generation metadata must contain only decimal digits and fit in `0..9007199254740991`. Invalid metadata is skipped rather than treated as a legacy generation during import or deletion. + +The generation is Drive transport metadata; it does not change the v1 JSON payload schema. + +## Batch encoding + +A batch is UTF-8 JSON compressed with gzip. +Readers must reject malformed UTF-8 instead of replacing invalid byte sequences with substitute characters. Valid accents and emoji are preserved across clients. +Text fields also reject unpaired UTF-16 surrogates, including those written as JSON escapes. The 1,000-unit UTF-16 field limit must never split a supplementary character when preparing local history for upload. + +```json +{ + "schema_version": 1, + "batch_id": "", + "source_device_id": "", + "source_device_name": "Tempo Desktop", + "source_platform": "desktop", + "created_at_utc": 1700000000000, + "events": [] +} +``` + +All timestamps and durations are integer milliseconds. Wire field names are snake_case. + +Current clients batch at most 50 locally-produced events per upload. Readers reject empty batches, more than 1,000 events, non-integral or out-of-range numeric fields, malformed device/event/batch IDs, payload IDs that do not reproduce the deterministic batch hash, and payload identity that does not match Drive filename/app metadata. + +## Event object + +```json +{ + "event_id": "", + "title": "Song", + "artist": "Artist", + "album": "Album", + "timestamp_utc": 1700000000000, + "duration_ms": 180000, + "listened_ms": 170000, + "source_app": "Spotify", + "source": "browser:Spotify", + "skipped": false, + "replay_count": 0, + "completion_percentage": 94, + "pause_count": 0, + "seek_count": 0, + "session_id": null, + "site": null, + "content_type": "MUSIC", + "volume_level": 50, + "total_pause_duration_ms": 0, + "position_updates_count": 10 +} +``` + +`album`, `session_id`, `site`, and `volume_level` may be `null`. `volume_level` uses a 0–100 protocol scale; `0` means muted and `null` means unknown. +Replay, pause, seek and position-update counters must fit in `0..2147483647`. Browser clients convert protocol volume back to their local 0–1 scale. Android's device-specific audio stream index can represent known mute (`0`); other remote levels remain unknown locally. + +## Stable event ID + +For a locally-owned row, construct this exact UTF-8 string: + +`tempo-history-v1|||||` + +Title and artist normalization for v1 is trim + lowercase. Hash the complete string with SHA-256 and encode lowercase hexadecimal. + +### Golden vector + +Inputs: + +- device ID: `device-1` +- local row ID: `42` +- timestamp: `1700000000000` +- title before normalization: ` Song ` +- artist before normalization: ` Artist ` + +Canonical string: + +`tempo-history-v1|device-1|42|1700000000000|song|artist` + +Expected `event_id`: + +`69bd5521a322b3d1aaeca431b7380bd49f3a28e1c1d1b1dc0a754ca37e6a06b4` + +## Deterministic batch ID + +For an ordered non-empty event list, construct: + +`tempo-batch-v1|||...` + +Hash it with SHA-256 and encode lowercase hexadecimal. Event order is significant. + +For a one-event batch containing the golden event above, expected `batch_id` is: + +`785b57b5c9e86c35176a413093df3c9fce37eb266c70485a0f9e8fff66e95d43` + +The batch ID intentionally does not include the generation. The generation is included in the filename, so retrying the same event set within one generation remains idempotent while deliberately re-seeding after a deletion creates a distinct Drive filename. + +## Android origin-alias persistence + +Android Room schema v56 introduces the `listening_event_origins` table with a foreign key to existing listening events, a unique canonical origin event ID and a unique (listening event, producer device) constraint. When several clients capture the same physical play, the secondary event ID is retained on that same local row, even if the duplicate arrived through a different Drive/LAN batch or after a restart. A later, *different* event from the same producer is therefore never temporally merged into that claimed playback. Aliases of an event replaced by a higher-authority record are transferred transactionally; deletions cascade to the alias rows. Migration 55→56 adds the table without deleting listening history and backfills origin IDs already present in imported events. + + + +The independent `.tempo` local backup format is now **v10**: both listening events and producer-alias rows are streamed in keyset pages. During import, aliases are restored against the new local listening-event IDs in a Room transaction, and ambiguous or orphaned claims cause a visible error rather than silently attaching to the wrong play. Backups made with the previous v9 format remain readable (they do not contain producer-alias rows). + +## Stable retry payloads and historical recovery + +A retry of the same producer/event-ID sequence uses an event-derived, stable `created_at_utc` timestamp. The immutable file name and compressed payload must remain stable across retries. If a legacy same-name object has different bytes, preserve it until a verified replacement has been uploaded rather than deleting the only cloud copy first. Consumers verify each batch independently and suppress duplicate event IDs. + +Normal incremental receivers scan files by Drive creation time with a 24-hour overlap. A user-requested **Restore full history** resets only the receive cursor and enumerates all available batches, without clearing local listening history. Local pruning must never be mistaken for a confirmed durable backup. Google appDataFolder contents remain removable by the user/provider, so multi-year disaster recovery requires an independent export/backup as well. + +## Optional Android relay of LAN-only plays + +When Desktop or a browser extension sends plays over the paired local network, it can +include its canonical Drive event ID and producer ID even if Google Drive is disabled. +Android retains that origin identity as a local `lan::` event. +Unlike a history batch already downloaded from Drive, a LAN-only event may have +**no cloud copy**. If Android's own Drive sync is enabled, it may therefore upload +an Android-produced batch that preserves the original sender's `event_id` and +unwrapped music source. A later upload by the original device uses the same event +ID, so consumers treat these two transport copies as one listen. The batch +`source_device_id` identifies the **uploader** (Android for the relay), while +`event_id` remains the immutable identity of the original playback producer. +Never replace this event ID with a new Android row identity or re-export normal +Drive-downloaded events, as either would create unwanted duplicate histories. + +## Import and deduplication + +Readers must treat `event_id` as the primary idempotency key. A successfully imported remote event records its origin event/device identity locally and must not be re-uploaded as a newly-owned event. +An import is successful only after the local database transaction commits; a successful write request alone must not advance the download cursor. A malformed batch, including a JSON `null` root, is skipped so later valid batches can still be imported. + +A temporal title/artist reconciliation may be used as a secondary duplicate guard when independent capture sources recorded the same playback and therefore legitimately have different origin IDs. All three Drive importers (Android, Desktop, Chrome/Firefox extension) use a conservative **±2-second** cross-producer fallback. Source-specific session IDs are not shared playback identifiers. Real independent listens closer than two seconds can remain ambiguous, so exact origin IDs and persistent aliases take priority. Distinct event IDs from the same originating device must not be collapsed merely because they occur close together; they can represent legitimate rapid replays. + +Readers use an overlap around their Drive created-time cursor so delayed/out-of-order files can still be discovered. Re-reading overlapping files must be harmless because event IDs are idempotent. + +Before importing a batch, a generation-aware reader compares its `tempo_generation` with the generation it explicitly accepted. A batch from an older generation must be ignored and may be deleted best-effort. This prevents an upload that completed after a deletion request from resurrecting old history. + +## Account boundary + +Upload/download cursors and the accepted disable-marker/generation version belong to one Google account. When a client detects that the authorized Google account changed, it must reset Drive-only cursors/flags before accepting the new account. + +Credentials from one Google account must never be reused for another account. +Android binds the complete history operation, including token refresh and retries, to its initial account identity. An account change or sign-out aborts the operation before another request or response can be accepted. + +## Shared deletion and generation semantics + +Explicit deletion of shared cloud history follows this order: + +1. create/update `tempo_history_control_v1.json`; +2. obtain its Google-server `modifiedTime` as the new generation `N`; +3. disable Drive history sync locally, store `N` as the accepted generation and reset Drive-only cursors/flags; +4. delete history batches whose `tempo_generation` is **less than `N`**. + +Local sync remains disabled if cloud cleanup fails or is cancelled. All clients page through same-name control markers, reject missing server timestamps and use the latest version when concurrent first-use clients have created duplicates. Browser request deadlines cover response bodies as well as headers, so a stalled download cannot indefinitely block deletion or disconnect. + +Before uploading, every linked client compares the marker version with the generation it last explicitly accepted. If the remote marker is newer, the client must stop before uploading, remove only batches from generations older than the new marker, reset Drive-only state, and require explicit user re-enablement. + +When the user deliberately re-enables sync after generation `N` exists, newly uploaded batches carry `tempo_generation=N` and include `gN` in their filename. A stale device that wakes later and is still honoring the same deletion may remove generations `< N`, but it must never delete generation `N`. This closes the race where a late stale client could otherwise erase history that another client had intentionally started re-seeding after the delete. + +Marker-first ordering remains required. Deleting files before publishing the marker creates a race in which another linked device can republish old history before learning that deletion was requested. + +## Privacy requirements + +- Drive history sync is opt-in. +- Use the least-privilege `https://www.googleapis.com/auth/drive.appdata` scope for this transport. +- Do not upload an operating-system hostname or other unnecessary personally identifying device label. A random Tempo device ID plus a generic display name is sufficient. +- Do not commit OAuth client secrets. Desktop/browser clients are public clients. +- Long-lived native-client refresh tokens should use the operating system credential store rather than plaintext application SQLite. + +## Versioning + +Do not silently change the semantics of v1 JSON fields, hashing, units, marker ordering, generation metadata, or filename namespace. An incompatible wire change requires a new schema/file namespace so old and new clients can coexist predictably. + +## OAuth setup and release validation + +Use public OAuth clients from the same Google Cloud project for Android, Chrome, +Firefox and Desktop, and enable the Google Drive API. Clients from unrelated +projects do not share an application's hidden Drive namespace even when they use +the same Google account. + +| Client | Build configuration | Required setup | +|---|---|---| +| Android | `GOOGLE_WEB_CLIENT_ID` in local.properties, or `TEMPO_GOOGLE_WEB_CLIENT_ID` in the build environment | Register the Android package and signing certificate, and configure the associated Web client. Request `drive.file` and `drive.appdata`. | +| Chrome | `TEMPO_GOOGLE_OAUTH_CLIENT_ID_CHROME` | Register the released extension identity. Request `openid`, `email` and `drive.appdata`. | +| Firefox | `TEMPO_GOOGLE_OAUTH_CLIENT_ID_FIREFOX` | Configure the Firefox extension's exact `http://127.0.0.1/mozoauth2/` loopback redirect with a compatible public Google OAuth client. Use authorization code + S256 PKCE (no implicit grant or bundled secret), and request `openid`, `email`, `drive.appdata`. Validate real-account code exchange and silent renewal on Firefox. | +| Desktop | `TEMPO_GOOGLE_OAUTH_CLIENT_ID_DESKTOP` | Register a Desktop public client with a loopback callback. Request `openid`, `email` and `drive.appdata`. | + +Client IDs are public identifiers. Do not put OAuth client secrets or refresh +tokens in source control. CI uses dummy client IDs to compile and test the code; +those values cannot validate real Google sign-in. + +Before release, test Android, Chrome, Firefox and Desktop with a real test account: + +1. On separate networks, send one browser/Desktop play to Android, then retry and + restart each client. The history must contain one event, with no sync loop. +2. Switch Google accounts. No old-account cursor, deletion generation or credential + may be applied to the new account. +3. Delete cloud history, explicitly re-enable one client, then wake a stale client. + The stale client must stop and preserve the newly accepted history generation. +4. Restore an Android backup while a sync is pending. Restored local history must + be rescanned after restoration without duplicating remote events. +5. Disconnect with the native credential store unavailable. Local Drive sync must + be disabled and any cleanup failure reported. + +These real-account checks remain required even when compilation, unit tests and +extension builds pass. Keep the PRs as drafts until configuration and these +checks have been completed. + + +## Account-safe ownership and multi-year performance (Android v57, extension v3) + +- Android binds Drive sessions and cursors to the stable user identifier from the Google ID token instead of relying on a mutable email. On a verified account switch, existing unowned history is claimed for the previous verified account and the new account starts exporting only later Room rows. Room schema 57 stores `drive_account_subject`; legacy cloud imports without provable ownership are quarantined as `legacy-unverified` while retained for local playback/statistics +- Android records missing/invalid export rows in a persistent **per-account** retry queue, skips them without deleting the underlying events and retries them if metadata is later repaired. It attempts incoming sync even if the outgoing transfer fails; retry IDs are fetched in bounded SQLite chunks +- Chrome/Firefox now verify Google's immutable `userinfo.sub` (including cached Firefox sessions), persist the owner alongside each playback, and preserve earlier account upload bookkeeping. Source aliases and imports from one Google owner cannot silently absorb another owner's listening records +- Browser IndexedDB version 3 adds a persistent multi-entry origin-ID index. Routine downloads use individual indexed lookups instead of collecting every origin in a multi-year archive; the one-time index migration retains existing producer aliases. An explicit own-device full restore may still scan legacy unpinned source events +- Both browser extensions skip individual malformed local rows during upload while retaining the original entry. A failed outgoing transfer does not suppress the independent inbound download attempt + +**Security note:** If Android was previously signed in without a stored immutable Google identifier, an interactive Google sign-in can be required to establish the account before Drive history sync is resumed. Automatic transfer of old account-owned recordings to another Google account is intentionally disabled. + + +## Account-boundary follow-up after v57 audit + +- Android reads `GoogleIdTokenCredential.uniqueId` (the immutable OpenID subject); the deprecated `id` is an email and must **never** be treated as a stable subject. Persisted email-shaped subjects fail closed and require fresh sign-in +- Android persists both a global active-account upload cursor and durable `upload_cursor:` checkpoints. A newly selected account starts after the existing local rows, while reconnecting a previous account resumes its **own** checkpoint (including not-yet-uploaded plays). First-time opt-in still includes the user's existing local history +- Existing email-keyed Google-owned rows and cursors are migrated only when the newly verified identity matches their email. Legacy unknown cloud imports remain quarantined, not reassigned +- Invalid Android export retries are processed in 200-row pages throughout serialization and origin-alias SQLite lookups; no whole-retry-list `IN (...)` can exceed Android's SQLite variable limit +- Browser cloud-deletion/disable markers clear upload acknowledgements only for the verified account that received the marker. Unowned legacy rows, imports and another Google account's upload flags remain untouched +- The canonical KSP-generated Room v57 schema includes `index_listening_events_drive_account_subject`, matching the 56→57 migration. CI runs the SQLite migration and A→B→A cursor regressions. Release still requires a real existing-v56 database migration and real-device Drive recovery tests + + +## Verified-origin isolation and indexed sending (Room 58, IndexedDB 4) + +Room migration **57→58** rebuilds `listening_event_origins` with composite primary key +`(accountSubject, originEventId)`, preserving older aliases using each event's +stored Google owner or `legacy-unverified` for unattributed history. Exact event +fingerprints and origin-alias queries now filter by verified subject, so the same +producer ID may legitimately appear in A and B without either archive swallowing +the other. Migration 56→57 remains part of the upgrade path. + +LAN transmissions may include `origin_account_subject` only when the sender +has enabled Drive for that verified account and the *individual play* belongs to +it. Android preserves the sender's account on the received play and must not +relay account-A or unknown-origin LAN events into account B. Missing provenance +uses the local-only `lan-unverified` quarantine identity. This is deliberate: +a source with Drive disabled can still share plays with a paired phone locally, +but automatic cloud re-publication requires verified matching provenance. A +future separate opt-in sharing UI may explicitly authorize cross-account export. + +Browser IndexedDB v4 materializes `drivePendingIndexKey = [owner, timestamp]` +only for unsent local plays, including freshly captured unowned rows. Normal +Drive sync performs two bounded indexed lookups (verified owner and newly +unowned captures) instead of traversing the user's entire listening archive. +Account claims, uploads, remote-reconciliation aliases, and cloud deletions +update the pending key transactionally. Index upgrades retain v3 origin aliases. + + +## Restoration safety follow-up (2026-10-10) + +- Multi-account offline ZIP backups key producer claims by **(Google subject, origin ID)**. A and B may carry the same 64-character origin without deleting or merging each other's listening rows. Import deduplication is grouped by the original per-event Google owner, and fallback track/time lookup never crosses to another account. +- A paired LAN play with `drive_account_subject = lan-unverified` stays local-only until an independently verified Drive import of **exactly the same origin ID** arrives. Only then does Android adopt its owner and producer aliases in a single Room transaction. No title/time heuristic may assign an unknown LAN record to a Google account. +- A local database restore can remap every event primary key. The restore mutex invalidates the active/global download and upload cursors **and all per-account upload and invalid-row retry sets** so A→B→A cannot silently miss restored events. Cloud deletion markers continue to reset the active account only. +- Regression checks cover multi-account backup origin keys, exact LAN/Drive ownership reconciliation in SQLite, and per-account restore-state key invalidation. CI does not replace a real 5–10-year archive restore and live Google-account tests. diff --git a/docs/DRIVE_MULTI_CLIENT_ACCEPTANCE.md b/docs/DRIVE_MULTI_CLIENT_ACCEPTANCE.md new file mode 100644 index 000000000..ab41669e3 --- /dev/null +++ b/docs/DRIVE_MULTI_CLIENT_ACCEPTANCE.md @@ -0,0 +1,162 @@ +# Tempo Google Drive v1 — release acceptance matrix + +This checklist is a **real-account** validation complement to CI. A green Kotlin/Rust/TypeScript +build using dummy OAuth client IDs is not proof that cross-platform Google sign-in works. + +## Preconditions +- Register Android, Desktop, Chrome and Firefox OAuth clients in **one Google Cloud project**; + enable the Google Drive API and configure the consent screen/test users. +- Build/sign the clients with their actual public OAuth client IDs (never commit a client secret). +- Use a dedicated Google test account without valuable existing appDataFolder Tempo history. +- Back up local databases and export any existing test data before cloud-deletion experiments. +- Record each client's app version, account email, random Tempo device ID and UTC clock. +- Verify all four clients can operate without Android and with Drive disabled. + +## Cross-client matrix +| Scenario | Action | Pass criterion | +| --- | --- | --- | +| Android -> Desktop | Track on Android; synchronize Drive; restore on Desktop | One event; track, timestamp and listen duration preserved | +| Desktop -> Android | Track desktop-native music; sync via Drive | One event in Android, including after retry | +| Chrome -> Firefox | Track Spotify Web in Chrome; sync on different network | One imported event in Firefox | +| Firefox -> Chrome | Track YouTube Music in Firefox; sync | One imported event in Chrome | +| Browser -> Desktop | Capture browser music on another machine; sync | Exactly one Desktop event | +| LAN incomplete persistence | Fail one Android database write in the middle of a paired LAN batch | Android returns HTTP 503; sender retries without treating the incomplete batch as delivered | +| LAN invalid acknowledgment | Return HTTP 200 with empty, malformed, or `ok:false` JSON from a test phone | Desktop and browser both retain every queued play; neither counts the response as success | +| LAN token rotation | Send two successive batches from Desktop via each available discovery method | Desktop persists `next_token` before acknowledging each batch, and the second batch authenticates without re-pairing | +| LAN + Drive same origin | Send a Desktop/browser play to Android over LAN, then publish the sender's Drive batch | Same exact event ID is retained; Android counts once and never republishes it under an Android-owned ID | +| LAN with Drive disabled | Send a Desktop/browser play to Android with Google Drive disabled on sender | No Google OAuth or connection is required merely to attach LAN provenance; local sync still works | +| LAN-only origin with unknown Google owner | Disable Drive on Desktop/extension, send LAN plays to an Android device with Drive enabled, and sync Android | Phone retains plays locally but does **not** upload unknown-owner history into its Google account | +| Original sender later enables Drive | After Android received LAN plays without account provenance, enable sender's Drive with the same account; import its verified Drive batch | Android promotes the exact matching LAN producer origin, without importing a second physical play or reassigning unrelated LAN events | +| Mixed-version LAN | Use a previous Desktop/browser sender without origin fields | Legacy payloads still import through the existing bounded temporal fallback | +| Desktop + Chrome same computer | Run both detectors for one physical play | One logical play after receiving both producers' batches | +| Two quick replays | Play same 25-second song twice while both detectors are on | Exactly **two** plays, never zero, one or four | +| Android cross-batch origin claims | Device A captures a play. Device B sends its same-play capture within 1 second in batch 1, then sends a different short-track replay seven seconds later in batch 2 | The first matches the existing physical play; Android persists B's origin alias and inserts the second distinct B event (two total), including after app restart | +| Android v55→v56 upgrade | Update a populated v55 Room database containing LAN/Drive imports, then restart and delete one listening row | All previous plays survive; existing producer IDs are backfilled; unrelated events remain; deleting a play cascades to its aliases | +| Background/offline | Disable Wi-Fi, queue events, restart, reconnect | No loss; pending counts resolve; repeat sync adds zero events | +| Post-create crash | Server accepts upload, then simulate client timeout before local acknowledgement | Retry preserves one logical event and a verified cloud copy | +| Historical recovery | Import ten years of fixture batches, reset local receiver, choose Restore full history | All recoverable years and event IDs restored; no locally owned event deleted | +| Own-device recovery | Keep the Tempo device identity, delete only its local play rows, then restore from the same device's Drive batches | Previously locally owned tracks are recovered; surviving local plays are not duplicated | +| Local retention | Leave synced local plays older than 1 year/30 days/7 days; run routine maintenance | No listening-event rows automatically deleted | +| Cloud delete with stale client | Disconnect one client, delete cloud history on another, re-enable a third, reconnect stale client | Stale client does not resurrect history or delete the new generation | +| Failed obsolete-batch cleanup | After accepting deletion generation N, make DELETE of an older batch return HTTP 403/503 while a valid generation-N batch exists | Android, Desktop, Chrome and Firefox import the valid batch, never resurrect the old one, keep a visible account-scoped warning, and clear it after a successful full-scan cleanup | +| Delete and ordinary reconnect | Record A's history on all four clients, delete Drive batches, then simply reconnect without opting in to republish | None of the pre-delete local recordings reappear on Drive, even if an old LAN batch is delivered after deletion; new account-verified recordings after reconnect do sync | +| Explicit deleted-history republish | After deletion, choose **Upload older local history** on one client and confirm the account-warning | Old local history is republished only through explicit confirmation. Android may then assign its previously unverified LAN snapshot to the selected account; **new** unknown-owner LAN deliveries remain quarantined | +| Restore after deletion | Delete cloud history, restore an old Android database with remapped Room IDs, reconnect | Deletion fence remains effective even when restored rows receive larger database IDs | +| Shortcut LAN concurrency | Trigger Ctrl/Cmd+Shift+S while automatic LAN backlog delivery is in flight | Only one stream of authenticated batches, no duplicated acknowledgments or token-rotation race | +| Missing Drive credentials | Invalidate Desktop's Google credential without disabling its persisted Drive state | Desktop prompts to reconnect; LAN and listening capture remain available | +| Drive listing at scale | Restore 100,000+ events where Drive list API pages arrive out of creation order | All pages are processed in timestamp order before cursor checkpointing; no server-side `orderBy` timeout | +| Account switching | Connect account A, switch to B on one client | No A history uploaded to B; explicit authorization needed | +| Auth expiration | Expire/revoke access tokens; sync again | Safe refresh/reconnect, no silent account switch | +| Firefox OAuth PKCE | Register signed Firefox redirect, connect with real Google account, let token expire, reconnect and simulate forged callback | Auth-code + S256 PKCE works without a client secret; state and callback URI are validated and implicit tokens are never accepted | +| Own-browser restore without duplicates | Upload browser plays, keep some local records, then restore all same-device batches | Exact original event IDs prevent creating second copies of surviving plays | +| Consent rejected | Decline Google sign-in and Firefox optional permission | Local/LAN tracking continues; no Drive requests | +| Corrupt batch | Insert truncated gzip, invalid SHA, malformed Unicode, enormous payload in test account | Valid batches continue safely; corrupt data never committed | +| Same-name legacy batch | Upload old same-name payload and retry with corrected deterministic payload | Last valid backup never removed before replacement is verified | +| Big history | At least 100,000 events across multiple producers | No OOM/quota surprises, bounded download buffers, resumable restores | + +## Automated regression coverage added October 16, 2026 + +- Android protocol: 100,000 unique events spanning about ten years are + encoded/decoded in separate bounded Drive batches, checking chronological + integrity and preservation of each immutable producer event ID. +- Browser protocol: 100,000 unique events likewise round-trip through + compressed batches of at most 1,000 events. Browser IndexedDB smoke tests + independently exercise lifetime statistics across more than 100,000 rows. +- Browser full-restore integration fixture: the actual restore orchestration + reads 100,000 events from 100 checksum-verified Drive batches served over + out-of-order paginated mock HTTP responses. A simulated storage interruption + after 70,123 committed rows is followed by a full retry and a second replay: + exactly 100,000 distinct origin IDs remain, with zero repeated rows. + Transport and persisted-origin lookup are mocks, not live Google/IndexedDB. +- Desktop database: 100,000 persisted historical listening rows are fetched + by chronological keyset pages with no duplicate/omitted IDs, including a + simulated process restart after 42,000 rows; all rows still exist after + reopening SQLite. +- On all three clients, a substantially different *listened duration* + vetoes a fuzzy timestamp-based match. When more than one candidate remains + plausible, clients preserve the incoming play instead of choosing one + arbitrarily; original producer IDs still decide exact retries. +- A mathematically indistinguishable pair of independent same-song listens + and two captures of one playback (same timing and lengths, different + producers) cannot be perfectly classified without shared authoritative + playback identity. Conservatively protecting uncertain events may retain + occasional duplicates, rather than silently losing an independent listen. + +**Not a real-device sign-off:** fixture-level round trips and SQLite +pagination do not demonstrate 100,000-event Google Drive network restoration +on signed Android, Desktop, Chrome and Firefox builds. The cross-client OAuth, +network interruption, account-switch, mobile database migration and +full 100k-event end-to-end acceptance cases in the matrix remain required. + +## Publication blockers + +1. Fix every event-loss, bad deduplication or cross-account leak found above. +2. Verify Chrome, Firefox, Android and Desktop with **real** OAuth credentials and signatures. +3. Verify the exact fork PR head SHAs in GitHub Actions, on all listed operating systems. +4. Test historic migration from old LAN-only/Desktop and legacy browser history. +5. Review project licenses, permissions, privacy language and changes against current upstream. +6. Do not publish Google credentials or real listening-history archives in test logs/PR artifacts. + +## Limits + +Drive `appDataFolder` is not a guaranteed permanent archival backup. A user can remove the +application's hidden data or lose access to the account. Keep an independent user-controlled +backup/export and test restoration from it. The extension's long-lived IndexedDB store can +also be evicted or cleared by the browser; never interpret a cloud-upload flag alone as a +guarantee of recoverability. + +### Shared Drive deduplication — acceptance update + +- Import the same song from independent devices seven seconds apart: Desktop, Android and both browser extensions must retain **two** plays, regardless of the order the batches arrive. +- Import parallel captures of one playback one second apart: all importers may reconcile to one play while preserving the two producer/origin aliases, including when the apps use different local session IDs. +- Switch accounts and verify previously imported records (or imports whose previous owner is unknown) are not eligible for temporal matching against the new account. Older local events must not upload across account boundaries without explicit consent. +- Exact origin IDs are mandatory for idempotency. Two truly independent plays within two seconds can still be indistinguishable using only title, artist and timestamp; avoid treating the temporal fallback as proof of exact playback identity. + + +### Google-account ownership and invalid-row recovery (v57) + +- Android: create old-account history, then switch to another Google `sub`. Verify the existing rows are tagged with the old owner, the next account starts at the current maximum Room row ID, and only new captures upload. Changing an email for the same Google `sub` must not be treated as a new account +- Android: migrate Room from schema 56 to 57. Imported Drive rows without provable ownership become `legacy-unverified` and stay visible locally; local rows remain intact. A restored older account must not supply temporal dedup matches for a different account +- Android: delete a local track while retaining its listening row or create malformed metadata; verify the record is retained, separately recorded for retry, valid later plays still upload, and incoming Drive batches download despite an outgoing error +- Browser: switch Google accounts with a mix of uploaded, never-uploaded, imported and reconciled plays. Existing rows must retain their original account identity and upload status. Reconnect the original account and verify no cross-account publishing +- Browser: upgrade IndexedDB v2 to v3 with an archive of reconciled producers. Every origin ID must be present in the new multi-entry index. Normal Drive sync must use indexed lookups instead of scanning the entire ten-year play collection; full own-device restore may explicitly scan missing legacy origins +- Browser: one malformed local play must not prevent uploading later valid plays or receiving inbound history. The invalid original record must remain available for repair +- Both: verify current user identity from Google's immutable subject rather than mutable email, including sign-in restart, token refresh and Firefox consent + + +### Multi-account source identities and schema migrations (v58/v4) + +- Upgrade a non-empty real Android Room v57 database to v58; assert the original + aliases survive and `(subject A, event X)` and `(subject B, event X)` can both + exist while original `listening_events` rows and foreign-key cascade remain valid +- Import exactly the same verified remote producer ID first from Google A, then + from Google B: Android and Desktop must retain separate records; replay within + each account must remain idempotent. Legacy unverified aliases must not count as + proof of a duplicate in a newly authorized account +- Pair Desktop on A with an Android phone on B over LAN; the phone retains the + play locally but never uploads it to B. Repeat with matching A accounts; when + the sending client explicitly disables Drive, Android must quarantine its + unverifiable ownership until the user explicitly consents. Test legacy LAN + payloads without provenance, and ensure they never upload automatically +- Upgrade Chrome/Firefox IndexedDB v3 to v4 with 100,000+ uploaded plays and a + few pending owner/unowned plays. Verify the indexed pending query returns only + relevant unsent records, sorted by timestamp/keyset, and that a cloud deletion + affects only its own owner. Confirm all older play IDs and origin aliases survive +- Keep independent offline backups. Passing CI cannot substitute for actual + multi-client OAuth, years-old archive restore or mobile database migration tests + + +### Restore and LAN/Drive race regressions (2026-10-10) + +- Restore an offline ZIP containing the same event ID for two Google accounts A and B; both listening rows and their distinct producer aliases must survive, including altered metadata and repeated restore. +- Pair an extension/Desktop while its cloud sync is disabled. Ingest a play by LAN as `lan-unverified`, then later enable the sender's cloud sync and download exactly that producer ID to Android. The phone must retain **one** listening record, adopt the verified account, and preserve its alias; a merely similar title/time must not cause ownership promotion. Repeat with distinct A/B owners. +- Restore an Android database whose auto-incremented IDs change while older A and B upload cursors and failed-row retry sets exist. Switch A→B→A and ensure no eligible restored plays are lost. Do not reset the server deletion-generation marker when resetting local row-ID state. + + +### LAN account-boundary and delayed-deletion regression (2026-10-11) + +- Desktop was previously on Google A and disconnected Drive; its LAN payload omits the old owner. An Android phone using Google B must keep this play locally as `lan-unverified`, **not** upload it to B. It remains quarantined on normal/background sync. +- The user selects "Upload older local history" in Android while on B. The confirmation explicitly warns that unverified LAN recordings may have originated from another account. Only the local row-ID snapshot taken during that action may then be claimed for B; any later-arriving LAN row must remain unverified. A transactional claim must carry origin aliases with it. +- A LAN client queues a verified-A playback made **before** the cloud deletion marker but sends it to Android **after** the deletion. Even though the Android row ID is new, the stale LAN event must not regenerate the Drive history. A verified-A playback genuinely made **after** the marker remains eligible. +- Following explicit authorization, previously deleted local events can be republished by choice; a later separate Drive deletion resets that old-LAN consent. Database restore/migration must not reuse the previous row-ID-based consent. +- Pairing credentials attest the LAN peer, not independently its Google login. Subject strings supplied by that peer are not cryptographic Google proof; real-device tests must include stale/revoked sender credentials and account switches.