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:
@@ -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 ''")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user