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