From ad2c398d78c96ca47a1cfdb893d6fc91a699a3c6 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Thu, 26 Feb 2026 19:06:01 +0100 Subject: [PATCH] Multi-account support and streaming fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow multiple linked accounts per service (YouTube, Twitch) - LinkedAccount PK changed from serviceId to backend UUID - StreamDestination now references linkedAccountId - Room DB migration v2→v3 for new schema - Unlink endpoint changed to DELETE by account ID - Accounts UI always shows "Add Account" for all providers - preparePlan matches destinations by ID instead of serviceId --- .../lckcontrol/data/local/LckDatabase.kt | 29 ++++++++++++- .../data/local/dao/LinkedAccountDao.kt | 8 +++- .../data/local/entity/LinkedAccountEntity.kt | 3 +- .../local/entity/StreamDestinationEntity.kt | 1 + .../lckcontrol/data/remote/ApiModels.kt | 4 +- .../lckcontrol/data/remote/LckApiService.kt | 4 +- .../data/repository/AccountRepository.kt | 15 ++++--- .../data/repository/StreamPlanRepository.kt | 25 +++++------ .../omixlab/lckcontrol/di/DatabaseModule.kt | 2 +- .../lckcontrol/ui/accounts/AccountsScreen.kt | 43 ++++++++----------- .../ui/accounts/AccountsViewModel.kt | 7 +-- .../lckcontrol/shared/LinkedAccount.kt | 3 ++ .../lckcontrol/shared/StreamDestination.kt | 3 ++ 13 files changed, 90 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt index dbb89b7..fe65515 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt @@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity StreamPlanEntity::class, StreamDestinationEntity::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class LckDatabase : RoomDatabase() { @@ -69,5 +69,32 @@ abstract class LckDatabase : RoomDatabase() { db.execSQL("ALTER TABLE stream_destinations_new RENAME TO stream_destinations") } } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // 1. Rebuild linked_accounts with 'id' as PK instead of 'serviceId' + db.execSQL(""" + CREATE TABLE linked_accounts_new ( + id TEXT NOT NULL PRIMARY KEY, + serviceId TEXT NOT NULL, + displayName TEXT NOT NULL, + accountId TEXT NOT NULL, + avatarUrl TEXT + ) + """.trimIndent()) + // Migrate existing rows: use accountId as temporary id (will be + // replaced on next sync from backend) + db.execSQL(""" + INSERT INTO linked_accounts_new (id, serviceId, displayName, accountId, avatarUrl) + SELECT serviceId || '_' || accountId, serviceId, displayName, accountId, avatarUrl + FROM linked_accounts + """.trimIndent()) + db.execSQL("DROP TABLE linked_accounts") + db.execSQL("ALTER TABLE linked_accounts_new RENAME TO linked_accounts") + + // 2. Add linkedAccountId column to stream_destinations + db.execSQL("ALTER TABLE stream_destinations ADD COLUMN linkedAccountId TEXT NOT NULL DEFAULT ''") + } + } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt index 475f523..0b02ea2 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/dao/LinkedAccountDao.kt @@ -17,7 +17,10 @@ interface LinkedAccountDao { suspend fun getAll(): List @Query("SELECT * FROM linked_accounts WHERE serviceId = :serviceId") - suspend fun getByService(serviceId: String): LinkedAccountEntity? + suspend fun getByService(serviceId: String): List + + @Query("SELECT * FROM linked_accounts WHERE id = :id") + suspend fun getById(id: String): LinkedAccountEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(account: LinkedAccountEntity) @@ -25,6 +28,9 @@ interface LinkedAccountDao { @Delete suspend fun delete(account: LinkedAccountEntity) + @Query("DELETE FROM linked_accounts WHERE id = :id") + suspend fun deleteById(id: String) + @Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId") suspend fun deleteByService(serviceId: String) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt index 837663a..95d69e1 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt @@ -5,7 +5,8 @@ import androidx.room.PrimaryKey @Entity(tableName = "linked_accounts") data class LinkedAccountEntity( - @PrimaryKey val serviceId: String, + @PrimaryKey val id: String, + val serviceId: String, val displayName: String, val accountId: String, val avatarUrl: String? = null, diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamDestinationEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamDestinationEntity.kt index 0d89106..faf20a2 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamDestinationEntity.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/StreamDestinationEntity.kt @@ -22,6 +22,7 @@ data class StreamDestinationEntity( @PrimaryKey val id: String = UUID.randomUUID().toString(), val planId: String, val service: String, + val linkedAccountId: String = "", val title: String, val description: String = "", val privacyStatus: String = "public", diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt index 1b9b351..5a58f83 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt @@ -64,7 +64,7 @@ data class CreateStreamPlanRequest( @JsonClass(generateAdapter = true) data class CreateDestinationRequest( - val serviceId: String, + val linkedAccountId: String, val title: String, val description: String? = null, val privacyStatus: String? = null, @@ -86,6 +86,7 @@ data class StreamPlanResponse( data class StreamDestinationResponse( val id: String, val serviceId: String, + val linkedAccountId: String, val title: String, val description: String, val privacyStatus: String, @@ -105,6 +106,7 @@ data class PrepareResponse( @JsonClass(generateAdapter = true) data class PreparedDestination( + val id: String, val serviceId: String, val rtmpUrl: String, val streamKey: String, diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt index fbfb0cc..363ee04 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt @@ -35,8 +35,8 @@ interface LckApiService { @POST("providers/twitch/callback") suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse - @DELETE("providers/{serviceId}") - suspend fun unlinkAccount(@Path("serviceId") serviceId: String): SuccessResponse + @DELETE("providers/accounts/{id}") + suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse // ── Streams ────────────────────────────────────────── diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt index a3bcd60..427ccd3 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt @@ -26,9 +26,9 @@ class AccountRepository @Inject constructor( /** Fetch accounts from backend and sync to Room cache */ suspend fun syncAccounts() { val remote = apiService.getLinkedAccounts() - // Clear local and replace with remote data val entities = remote.map { account -> LinkedAccountEntity( + id = account.id, serviceId = account.serviceId, displayName = account.displayName, accountId = account.accountId, @@ -37,10 +37,10 @@ class AccountRepository @Inject constructor( } // Get current local accounts to detect removals val local = accountDao.getAll() - val remoteServiceIds = entities.map { it.serviceId }.toSet() + val remoteIds = entities.map { it.id }.toSet() for (localAccount in local) { - if (localAccount.serviceId !in remoteServiceIds) { - accountDao.deleteByService(localAccount.serviceId) + if (localAccount.id !in remoteIds) { + accountDao.deleteById(localAccount.id) } } for (entity in entities) { @@ -73,12 +73,13 @@ class AccountRepository @Inject constructor( } /** Unlink account via backend and update local cache */ - suspend fun unlinkAccount(serviceId: String) { - apiService.unlinkAccount(serviceId) - accountDao.deleteByService(serviceId) + suspend fun unlinkAccount(accountId: String) { + apiService.unlinkAccount(accountId) + accountDao.deleteById(accountId) } private fun LinkedAccountEntity.toLinkedAccount() = LinkedAccount( + id = id, serviceId = serviceId, displayName = displayName, accountId = accountId, diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt index 61c1cf2..dd4ca81 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt @@ -47,7 +47,7 @@ class StreamPlanRepository @Inject constructor( name = name, destinations = destinations.map { dest -> CreateDestinationRequest( - serviceId = dest.service, + linkedAccountId = dest.linkedAccountId, title = dest.title, description = dest.description, privacyStatus = dest.privacyStatus, @@ -64,20 +64,15 @@ class StreamPlanRepository @Inject constructor( /** Prepare plan via backend — returns RTMP info */ suspend fun preparePlan(planId: String): PrepareResponse { val response = apiService.prepareStreamPlan(planId) - // Update local cache with RTMP info + // Update local cache with RTMP info by destination ID for (dest in response.destinations) { - // Find local destination by serviceId within this plan - val local = planDao.getById(planId)?.destinations - ?.find { it.service == dest.serviceId } - if (local != null) { - planDao.updateDestinationStream( - id = local.id, - rtmpUrl = dest.rtmpUrl, - streamKey = dest.streamKey, - broadcastId = dest.broadcastId, - status = "READY", - ) - } + planDao.updateDestinationStream( + id = dest.id, + rtmpUrl = dest.rtmpUrl, + streamKey = dest.streamKey, + broadcastId = dest.broadcastId, + status = "READY", + ) } planDao.updateStatus(planId, "READY") return response @@ -107,6 +102,7 @@ class StreamPlanRepository @Inject constructor( id = d.id, planId = remote.id, service = d.serviceId, + linkedAccountId = d.linkedAccountId, title = d.title, description = d.description, privacyStatus = d.privacyStatus, @@ -130,6 +126,7 @@ class StreamPlanRepository @Inject constructor( private fun StreamDestinationEntity.toStreamDestination() = StreamDestination( service = service, + linkedAccountId = linkedAccountId, title = title, description = description, privacyStatus = privacyStatus, diff --git a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt index eab0780..c79581e 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt @@ -20,7 +20,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LckDatabase = Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db") - .addMigrations(LckDatabase.MIGRATION_1_2) + .addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3) .build() @Provides diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt index 48cfbbb..1271ff2 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt @@ -68,7 +68,7 @@ fun AccountsScreen( ) { item { Spacer(Modifier.height(8.dp)) } - items(accounts, key = { it.serviceId }) { account -> + items(accounts, key = { it.id }) { account -> ElevatedCard(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -80,36 +80,31 @@ fun AccountsScreen( Text(account.displayName, style = MaterialTheme.typography.titleSmall) Text(account.serviceId, style = MaterialTheme.typography.bodySmall) } - IconButton(onClick = { viewModel.unlinkAccount(account.serviceId) }) { + IconButton(onClick = { viewModel.unlinkAccount(account.id) }) { Icon(Icons.Default.LinkOff, contentDescription = "Unlink") } } } } - // Show link buttons for providers not yet linked - val linkedServiceIds = accounts.map { it.serviceId }.toSet() - val unlinked = viewModel.getUnlinkedProviders(linkedServiceIds) + // Always show link buttons — users can add multiple accounts per service + item { + Spacer(Modifier.height(8.dp)) + Text("Add Account", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + } - if (unlinked.isNotEmpty()) { - item { - Spacer(Modifier.height(8.dp)) - Text("Add Account", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(4.dp)) - } - - items(unlinked, key = { it.serviceId }) { provider -> - OutlinedButton( - onClick = { - val activity = context as? Activity ?: return@OutlinedButton - viewModel.linkAccount(activity, provider.serviceId) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.padding(4.dp)) - Text("Link ${provider.displayName}") - } + items(ALL_PROVIDERS, key = { it.serviceId }) { provider -> + OutlinedButton( + onClick = { + val activity = context as? Activity ?: return@OutlinedButton + viewModel.linkAccount(activity, provider.serviceId) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.padding(4.dp)) + Text("Link ${provider.displayName}") } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt index 05c45a9..d604ea1 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt @@ -48,9 +48,6 @@ class AccountsViewModel @Inject constructor( } } - fun getUnlinkedProviders(linkedServiceIds: Set): List = - ALL_PROVIDERS.filter { it.serviceId !in linkedServiceIds } - fun linkAccount(activity: Activity, serviceId: String) { viewModelScope.launch { _linkError.value = null @@ -68,10 +65,10 @@ class AccountsViewModel @Inject constructor( } } - fun unlinkAccount(serviceId: String) { + fun unlinkAccount(accountId: String) { viewModelScope.launch { try { - accountRepository.unlinkAccount(serviceId) + accountRepository.unlinkAccount(accountId) } catch (e: Exception) { _linkError.value = e.message ?: "Failed to unlink account" } diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt index 427a90f..8e41cee 100644 --- a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt @@ -4,6 +4,7 @@ import android.os.Parcel import android.os.Parcelable data class LinkedAccount( + val id: String, val serviceId: String, val displayName: String, val accountId: String, @@ -12,6 +13,7 @@ data class LinkedAccount( ) : Parcelable { constructor(parcel: Parcel) : this( + id = parcel.readString()!!, serviceId = parcel.readString()!!, displayName = parcel.readString()!!, accountId = parcel.readString()!!, @@ -20,6 +22,7 @@ data class LinkedAccount( ) override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(id) parcel.writeString(serviceId) parcel.writeString(displayName) parcel.writeString(accountId) diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamDestination.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamDestination.kt index 5abc95c..8389f4f 100644 --- a/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamDestination.kt +++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/StreamDestination.kt @@ -5,6 +5,7 @@ import android.os.Parcelable data class StreamDestination( val service: String, + val linkedAccountId: String = "", val title: String, val description: String = "", val privacyStatus: String = "public", @@ -18,6 +19,7 @@ data class StreamDestination( constructor(parcel: Parcel) : this( service = parcel.readString()!!, + linkedAccountId = parcel.readString() ?: "", title = parcel.readString()!!, description = parcel.readString() ?: "", privacyStatus = parcel.readString() ?: "public", @@ -31,6 +33,7 @@ data class StreamDestination( override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(service) + parcel.writeString(linkedAccountId) parcel.writeString(title) parcel.writeString(description) parcel.writeString(privacyStatus)