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,
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 ''")
}
}
}
}

View File

@@ -17,7 +17,10 @@ interface LinkedAccountDao {
suspend fun getAll(): List<LinkedAccountEntity>
@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)
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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) {
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"
}

View File

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

View File

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