Multi-account support and streaming fixes

- 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
This commit is contained in:
2026-02-26 19:06:01 +01:00
parent 609802dd92
commit ad2c398d78
13 changed files with 90 additions and 57 deletions

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class, StreamPlanEntity::class,
StreamDestinationEntity::class, StreamDestinationEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class LckDatabase : RoomDatabase() { abstract class LckDatabase : RoomDatabase() {
@@ -69,5 +69,32 @@ abstract class LckDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE stream_destinations_new RENAME TO stream_destinations") 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 ''")
}
}
} }
} }

View File

@@ -17,7 +17,10 @@ interface LinkedAccountDao {
suspend fun getAll(): List<LinkedAccountEntity> suspend fun getAll(): List<LinkedAccountEntity>
@Query("SELECT * FROM linked_accounts WHERE serviceId = :serviceId") @Query("SELECT * FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun getByService(serviceId: String): LinkedAccountEntity? suspend fun getByService(serviceId: String): List<LinkedAccountEntity>
@Query("SELECT * FROM linked_accounts WHERE id = :id")
suspend fun getById(id: String): LinkedAccountEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(account: LinkedAccountEntity) suspend fun upsert(account: LinkedAccountEntity)
@@ -25,6 +28,9 @@ interface LinkedAccountDao {
@Delete @Delete
suspend fun delete(account: LinkedAccountEntity) 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") @Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun deleteByService(serviceId: String) suspend fun deleteByService(serviceId: String)
} }

View File

@@ -5,7 +5,8 @@ import androidx.room.PrimaryKey
@Entity(tableName = "linked_accounts") @Entity(tableName = "linked_accounts")
data class LinkedAccountEntity( data class LinkedAccountEntity(
@PrimaryKey val serviceId: String, @PrimaryKey val id: String,
val serviceId: String,
val displayName: String, val displayName: String,
val accountId: String, val accountId: String,
val avatarUrl: String? = null, val avatarUrl: String? = null,

View File

@@ -22,6 +22,7 @@ data class StreamDestinationEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(), @PrimaryKey val id: String = UUID.randomUUID().toString(),
val planId: String, val planId: String,
val service: String, val service: String,
val linkedAccountId: String = "",
val title: String, val title: String,
val description: String = "", val description: String = "",
val privacyStatus: String = "public", val privacyStatus: String = "public",

View File

@@ -64,7 +64,7 @@ data class CreateStreamPlanRequest(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class CreateDestinationRequest( data class CreateDestinationRequest(
val serviceId: String, val linkedAccountId: String,
val title: String, val title: String,
val description: String? = null, val description: String? = null,
val privacyStatus: String? = null, val privacyStatus: String? = null,
@@ -86,6 +86,7 @@ data class StreamPlanResponse(
data class StreamDestinationResponse( data class StreamDestinationResponse(
val id: String, val id: String,
val serviceId: String, val serviceId: String,
val linkedAccountId: String,
val title: String, val title: String,
val description: String, val description: String,
val privacyStatus: String, val privacyStatus: String,
@@ -105,6 +106,7 @@ data class PrepareResponse(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PreparedDestination( data class PreparedDestination(
val id: String,
val serviceId: String, val serviceId: String,
val rtmpUrl: String, val rtmpUrl: String,
val streamKey: String, val streamKey: String,

View File

@@ -35,8 +35,8 @@ interface LckApiService {
@POST("providers/twitch/callback") @POST("providers/twitch/callback")
suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse suspend fun twitchCallback(@Body body: ProviderCallbackRequest): LinkedAccountResponse
@DELETE("providers/{serviceId}") @DELETE("providers/accounts/{id}")
suspend fun unlinkAccount(@Path("serviceId") serviceId: String): SuccessResponse suspend fun unlinkAccount(@Path("id") id: String): SuccessResponse
// ── Streams ────────────────────────────────────────── // ── Streams ──────────────────────────────────────────

View File

@@ -26,9 +26,9 @@ class AccountRepository @Inject constructor(
/** Fetch accounts from backend and sync to Room cache */ /** Fetch accounts from backend and sync to Room cache */
suspend fun syncAccounts() { suspend fun syncAccounts() {
val remote = apiService.getLinkedAccounts() val remote = apiService.getLinkedAccounts()
// Clear local and replace with remote data
val entities = remote.map { account -> val entities = remote.map { account ->
LinkedAccountEntity( LinkedAccountEntity(
id = account.id,
serviceId = account.serviceId, serviceId = account.serviceId,
displayName = account.displayName, displayName = account.displayName,
accountId = account.accountId, accountId = account.accountId,
@@ -37,10 +37,10 @@ class AccountRepository @Inject constructor(
} }
// Get current local accounts to detect removals // Get current local accounts to detect removals
val local = accountDao.getAll() val local = accountDao.getAll()
val remoteServiceIds = entities.map { it.serviceId }.toSet() val remoteIds = entities.map { it.id }.toSet()
for (localAccount in local) { for (localAccount in local) {
if (localAccount.serviceId !in remoteServiceIds) { if (localAccount.id !in remoteIds) {
accountDao.deleteByService(localAccount.serviceId) accountDao.deleteById(localAccount.id)
} }
} }
for (entity in entities) { for (entity in entities) {
@@ -73,12 +73,13 @@ class AccountRepository @Inject constructor(
} }
/** Unlink account via backend and update local cache */ /** Unlink account via backend and update local cache */
suspend fun unlinkAccount(serviceId: String) { suspend fun unlinkAccount(accountId: String) {
apiService.unlinkAccount(serviceId) apiService.unlinkAccount(accountId)
accountDao.deleteByService(serviceId) accountDao.deleteById(accountId)
} }
private fun LinkedAccountEntity.toLinkedAccount() = LinkedAccount( private fun LinkedAccountEntity.toLinkedAccount() = LinkedAccount(
id = id,
serviceId = serviceId, serviceId = serviceId,
displayName = displayName, displayName = displayName,
accountId = accountId, accountId = accountId,

View File

@@ -47,7 +47,7 @@ class StreamPlanRepository @Inject constructor(
name = name, name = name,
destinations = destinations.map { dest -> destinations = destinations.map { dest ->
CreateDestinationRequest( CreateDestinationRequest(
serviceId = dest.service, linkedAccountId = dest.linkedAccountId,
title = dest.title, title = dest.title,
description = dest.description, description = dest.description,
privacyStatus = dest.privacyStatus, privacyStatus = dest.privacyStatus,
@@ -64,20 +64,15 @@ class StreamPlanRepository @Inject constructor(
/** Prepare plan via backend — returns RTMP info */ /** Prepare plan via backend — returns RTMP info */
suspend fun preparePlan(planId: String): PrepareResponse { suspend fun preparePlan(planId: String): PrepareResponse {
val response = apiService.prepareStreamPlan(planId) 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) { for (dest in response.destinations) {
// Find local destination by serviceId within this plan planDao.updateDestinationStream(
val local = planDao.getById(planId)?.destinations id = dest.id,
?.find { it.service == dest.serviceId } rtmpUrl = dest.rtmpUrl,
if (local != null) { streamKey = dest.streamKey,
planDao.updateDestinationStream( broadcastId = dest.broadcastId,
id = local.id, status = "READY",
rtmpUrl = dest.rtmpUrl, )
streamKey = dest.streamKey,
broadcastId = dest.broadcastId,
status = "READY",
)
}
} }
planDao.updateStatus(planId, "READY") planDao.updateStatus(planId, "READY")
return response return response
@@ -107,6 +102,7 @@ class StreamPlanRepository @Inject constructor(
id = d.id, id = d.id,
planId = remote.id, planId = remote.id,
service = d.serviceId, service = d.serviceId,
linkedAccountId = d.linkedAccountId,
title = d.title, title = d.title,
description = d.description, description = d.description,
privacyStatus = d.privacyStatus, privacyStatus = d.privacyStatus,
@@ -130,6 +126,7 @@ class StreamPlanRepository @Inject constructor(
private fun StreamDestinationEntity.toStreamDestination() = StreamDestination( private fun StreamDestinationEntity.toStreamDestination() = StreamDestination(
service = service, service = service,
linkedAccountId = linkedAccountId,
title = title, title = title,
description = description, description = description,
privacyStatus = privacyStatus, privacyStatus = privacyStatus,

View File

@@ -20,7 +20,7 @@ object DatabaseModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): LckDatabase = fun provideDatabase(@ApplicationContext context: Context): LckDatabase =
Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db") Room.databaseBuilder(context, LckDatabase::class.java, "lck_control.db")
.addMigrations(LckDatabase.MIGRATION_1_2) .addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3)
.build() .build()
@Provides @Provides

View File

@@ -68,7 +68,7 @@ fun AccountsScreen(
) { ) {
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
items(accounts, key = { it.serviceId }) { account -> items(accounts, key = { it.id }) { account ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -80,36 +80,31 @@ fun AccountsScreen(
Text(account.displayName, style = MaterialTheme.typography.titleSmall) Text(account.displayName, style = MaterialTheme.typography.titleSmall)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall) 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") Icon(Icons.Default.LinkOff, contentDescription = "Unlink")
} }
} }
} }
} }
// Show link buttons for providers not yet linked // Always show link buttons — users can add multiple accounts per service
val linkedServiceIds = accounts.map { it.serviceId }.toSet() item {
val unlinked = viewModel.getUnlinkedProviders(linkedServiceIds) Spacer(Modifier.height(8.dp))
Text("Add Account", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
if (unlinked.isNotEmpty()) { items(ALL_PROVIDERS, key = { it.serviceId }) { provider ->
item { OutlinedButton(
Spacer(Modifier.height(8.dp)) onClick = {
Text("Add Account", style = MaterialTheme.typography.titleMedium) val activity = context as? Activity ?: return@OutlinedButton
Spacer(Modifier.height(4.dp)) viewModel.linkAccount(activity, provider.serviceId)
} },
modifier = Modifier.fillMaxWidth(),
items(unlinked, key = { it.serviceId }) { provider -> ) {
OutlinedButton( Icon(Icons.Default.Add, contentDescription = null)
onClick = { Spacer(Modifier.padding(4.dp))
val activity = context as? Activity ?: return@OutlinedButton Text("Link ${provider.displayName}")
viewModel.linkAccount(activity, provider.serviceId)
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.padding(4.dp))
Text("Link ${provider.displayName}")
}
} }
} }

View File

@@ -48,9 +48,6 @@ class AccountsViewModel @Inject constructor(
} }
} }
fun getUnlinkedProviders(linkedServiceIds: Set<String>): List<AvailableProvider> =
ALL_PROVIDERS.filter { it.serviceId !in linkedServiceIds }
fun linkAccount(activity: Activity, serviceId: String) { fun linkAccount(activity: Activity, serviceId: String) {
viewModelScope.launch { viewModelScope.launch {
_linkError.value = null _linkError.value = null
@@ -68,10 +65,10 @@ class AccountsViewModel @Inject constructor(
} }
} }
fun unlinkAccount(serviceId: String) { fun unlinkAccount(accountId: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
accountRepository.unlinkAccount(serviceId) accountRepository.unlinkAccount(accountId)
} catch (e: Exception) { } catch (e: Exception) {
_linkError.value = e.message ?: "Failed to unlink account" _linkError.value = e.message ?: "Failed to unlink account"
} }

View File

@@ -4,6 +4,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
data class LinkedAccount( data class LinkedAccount(
val id: String,
val serviceId: String, val serviceId: String,
val displayName: String, val displayName: String,
val accountId: String, val accountId: String,
@@ -12,6 +13,7 @@ data class LinkedAccount(
) : Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
id = parcel.readString()!!,
serviceId = parcel.readString()!!, serviceId = parcel.readString()!!,
displayName = parcel.readString()!!, displayName = parcel.readString()!!,
accountId = parcel.readString()!!, accountId = parcel.readString()!!,
@@ -20,6 +22,7 @@ data class LinkedAccount(
) )
override fun writeToParcel(parcel: Parcel, flags: Int) { override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(id)
parcel.writeString(serviceId) parcel.writeString(serviceId)
parcel.writeString(displayName) parcel.writeString(displayName)
parcel.writeString(accountId) parcel.writeString(accountId)

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
data class StreamDestination( data class StreamDestination(
val service: String, val service: String,
val linkedAccountId: String = "",
val title: String, val title: String,
val description: String = "", val description: String = "",
val privacyStatus: String = "public", val privacyStatus: String = "public",
@@ -18,6 +19,7 @@ data class StreamDestination(
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
service = parcel.readString()!!, service = parcel.readString()!!,
linkedAccountId = parcel.readString() ?: "",
title = parcel.readString()!!, title = parcel.readString()!!,
description = parcel.readString() ?: "", description = parcel.readString() ?: "",
privacyStatus = parcel.readString() ?: "public", privacyStatus = parcel.readString() ?: "public",
@@ -31,6 +33,7 @@ data class StreamDestination(
override fun writeToParcel(parcel: Parcel, flags: Int) { override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(service) parcel.writeString(service)
parcel.writeString(linkedAccountId)
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(description) parcel.writeString(description)
parcel.writeString(privacyStatus) parcel.writeString(privacyStatus)