android: add multi-account switching foundation in settings
Some checks failed
Android CI / android (push) Failing after 4m43s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 23:44:53 +03:00
parent 19471ac736
commit daddbfd2a0
11 changed files with 513 additions and 22 deletions

View File

@@ -731,3 +731,19 @@
- primary profile info card,
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.
### Step 110 - Multi-account foundation (switch active account)
- Extended `TokenRepository` to support account list and active-account switching:
- observe/list stored accounts,
- get active account id,
- switch/remove account,
- clear all tokens.
- Reworked `EncryptedPrefsTokenRepository` storage model:
- stores tokens per `userId` and account metadata list in encrypted prefs,
- migrates legacy single-account keys on first run,
- preserves active account pointer.
- `NetworkAuthRepository` now upserts account metadata after auth/me calls.
- Added `Settings` UI account section:
- shows saved accounts,
- allows switch/remove,
- triggers auth recheck + chats reload on switch.

View File

@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
preferences.toTokenBundleOrNull()
}
override fun observeAccounts(): Flow<List<StoredAccount>> {
return observeTokens().map { tokens ->
if (tokens == null) emptyList() else {
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
listOf(
StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = tokens.savedAtMillis,
)
)
}
}
}
override fun observeActiveUserId(): Flow<Long?> {
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
}
override suspend fun getTokens(): TokenBundle? {
return observeTokens().first()
}
override suspend fun getAccounts(): List<StoredAccount> {
return observeAccounts().first()
}
override suspend fun getActiveUserId(): Long? {
return observeActiveUserId().first()
}
override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
@@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor(
}
}
override suspend fun upsertAccount(account: StoredAccount) {
// DataStoreTokenRepository is not used in production DI currently.
}
override suspend fun switchAccount(userId: Long): Boolean {
return getActiveUserId() == userId
}
override suspend fun removeAccount(userId: Long) {
if (getActiveUserId() == userId) {
clearTokens()
}
}
override suspend fun clearTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY)
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
}
}
override suspend fun clearAllTokens() {
clearTokens()
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_TOKEN_KEY]
@@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor(
)
}
private fun String.extractUserIdFromJwt(): Long? {
val payload = split('.').getOrNull(1) ?: return null
val normalized = payload
.replace('-', '+')
.replace('_', '/')
.let { source ->
when (source.length % 4) {
0 -> source
2 -> source + "=="
3 -> source + "="
else -> return null
}
}
return runCatching {
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
val marker = "\"sub\":\""
val start = json.indexOf(marker)
if (start < 0) null
else {
val valueStart = start + marker.length
val valueEnd = json.indexOf('"', valueStart)
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
}
}.getOrNull()
}
private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")

View File

@@ -1,9 +1,12 @@
package ru.daemonlord.messenger.core.token
import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
import ru.daemonlord.messenger.di.TokenPrefs
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
@TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenRepository {
private val tokensFlow = MutableStateFlow(readTokens())
private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
init {
migrateLegacyIfNeeded()
refreshFlows()
}
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
override suspend fun saveTokens(tokens: TokenBundle) {
sharedPreferences.edit()
.putString(ACCESS_TOKEN_KEY, tokens.accessToken)
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken)
.putLong(SAVED_AT_KEY, tokens.savedAtMillis)
.apply()
tokensFlow.value = tokens
val userId = tokens.accessToken.extractUserIdFromJwt()
?: activeUserIdFlow.value
?: return
val allTokens = readAllTokenEntries().toMutableMap()
allTokens[userId] = tokens
writeAllTokenEntries(allTokens)
writeActiveUserId(userId)
ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis)
refreshFlows()
}
override suspend fun upsertAccount(account: StoredAccount) {
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val existing = accounts[account.userId]
accounts[account.userId] = account.copy(
lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt),
)
writeAccounts(accounts.values.toList())
refreshFlows()
}
override suspend fun switchAccount(userId: Long): Boolean {
val allTokens = readAllTokenEntries()
if (!allTokens.containsKey(userId)) {
return false
}
writeActiveUserId(userId)
refreshFlows()
return true
}
override suspend fun removeAccount(userId: Long) {
val allTokens = readAllTokenEntries().toMutableMap()
allTokens.remove(userId)
writeAllTokenEntries(allTokens)
val accounts = readAccounts().filterNot { it.userId == userId }
writeAccounts(accounts)
val active = readActiveUserId()
if (active == userId) {
val nextUserId = allTokens.entries
.maxByOrNull { it.value.savedAtMillis }
?.key
writeActiveUserId(nextUserId)
}
refreshFlows()
}
override suspend fun clearTokens() {
val active = readActiveUserId() ?: return
removeAccount(active)
}
override suspend fun clearAllTokens() {
sharedPreferences.edit()
.remove(TOKENS_JSON_KEY)
.remove(ACCOUNTS_JSON_KEY)
.remove(ACTIVE_USER_ID_KEY)
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY)
.apply()
refreshFlows()
}
private fun refreshFlows() {
val activeUserId = readActiveUserId()
activeUserIdFlow.value = activeUserId
tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] }
accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt }
}
private fun migrateLegacyIfNeeded() {
val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY)
if (hasModernStorage) return
val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) {
return
}
val userId = legacyAccess.extractUserIdFromJwt() ?: return
val token = TokenBundle(
accessToken = legacyAccess,
refreshToken = legacyRefresh,
savedAtMillis = legacySavedAt,
)
writeAllTokenEntries(mapOf(userId to token))
writeActiveUserId(userId)
writeAccounts(
listOf(
StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = legacySavedAt,
)
)
)
sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY)
.apply()
tokensFlow.value = null
}
private fun readTokens(): TokenBundle? {
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) {
return null
private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val existing = accounts[userId]
if (existing == null) {
accounts[userId] = StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = lastActiveAt,
)
} else {
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
writeAccounts(accounts.values.toList())
}
private fun readActiveUserId(): Long? {
val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L)
return value.takeIf { it > 0L }
}
private fun writeActiveUserId(userId: Long?) {
sharedPreferences.edit().apply {
if (userId == null) {
remove(ACTIVE_USER_ID_KEY)
} else {
putLong(ACTIVE_USER_ID_KEY, userId)
}
}.apply()
}
private fun readAllTokenEntries(): Map<Long, TokenBundle> {
val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty()
if (raw.isBlank()) return emptyMap()
return runCatching {
val root = JSONObject(raw)
root.keys().asSequence().mapNotNull { key ->
val userId = key.toLongOrNull() ?: return@mapNotNull null
val node = root.optJSONObject(key) ?: return@mapNotNull null
val access = node.optString("access", "")
val refresh = node.optString("refresh", "")
val savedAt = node.optLong("savedAt", -1L)
if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) {
null
} else {
userId to TokenBundle(access, refresh, savedAt)
}
}.toMap()
}.getOrDefault(emptyMap())
}
private fun writeAllTokenEntries(tokens: Map<Long, TokenBundle>) {
val root = JSONObject()
tokens.forEach { (userId, token) ->
root.put(
userId.toString(),
JSONObject().apply {
put("access", token.accessToken)
put("refresh", token.refreshToken)
put("savedAt", token.savedAtMillis)
}
)
}
sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply()
}
private fun readAccounts(): List<StoredAccount> {
val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty()
if (raw.isBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
val node = array.optJSONObject(index) ?: continue
val userId = node.optLong("userId", -1L)
if (userId <= 0L) continue
add(
StoredAccount(
userId = userId,
email = node.optString("email", "").ifBlank { null },
name = node.optString("name", "User #$userId").ifBlank { "User #$userId" },
username = node.optString("username", "").ifBlank { null },
avatarUrl = node.optString("avatarUrl", "").ifBlank { null },
lastActiveAt = node.optLong("lastActiveAt", 0L),
)
)
}
}
}.getOrDefault(emptyList())
}
private fun writeAccounts(accounts: List<StoredAccount>) {
val array = JSONArray()
accounts.forEach { account ->
array.put(
JSONObject().apply {
put("userId", account.userId)
put("email", account.email.orEmpty())
put("name", account.name)
put("username", account.username.orEmpty())
put("avatarUrl", account.avatarUrl.orEmpty())
put("lastActiveAt", account.lastActiveAt)
}
)
}
sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply()
}
private fun String.extractUserIdFromJwt(): Long? {
val parts = split('.')
if (parts.size < 2) return null
val payload = parts[1]
val normalized = payload
.replace('-', '+')
.replace('_', '/')
.let { source ->
when (source.length % 4) {
0 -> source
2 -> source + "=="
3 -> source + "="
else -> return null
}
}
return runCatching {
val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8)
JSONObject(payloadJson).optString("sub").toLongOrNull()
}.getOrNull()
}
private companion object {
const val ACCESS_TOKEN_KEY = "access_token"
const val REFRESH_TOKEN_KEY = "refresh_token"
const val SAVED_AT_KEY = "tokens_saved_at"
const val TOKENS_JSON_KEY = "tokens_json"
const val ACCOUNTS_JSON_KEY = "accounts_json"
const val ACTIVE_USER_ID_KEY = "active_user_id"
}
}

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.core.token
data class StoredAccount(
val userId: Long,
val email: String?,
val name: String,
val username: String?,
val avatarUrl: String?,
val lastActiveAt: Long,
)

View File

@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?>
fun observeAccounts(): Flow<List<StoredAccount>>
fun observeActiveUserId(): Flow<Long?>
suspend fun getTokens(): TokenBundle?
suspend fun getAccounts(): List<StoredAccount>
suspend fun getActiveUserId(): Long?
suspend fun saveTokens(tokens: TokenBundle)
suspend fun upsertAccount(account: StoredAccount)
suspend fun switchAccount(userId: Long): Boolean
suspend fun removeAccount(userId: Long)
suspend fun clearTokens()
suspend fun clearAllTokens()
}

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.StoredAccount
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
@@ -45,7 +46,13 @@ class NetworkAuthRepository @Inject constructor(
)
)
pushTokenSyncManager.triggerBestEffortSync()
getMe()
when (val meResult = getMe()) {
is AppResult.Success -> {
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
meResult
}
is AppResult.Error -> meResult
}
} catch (error: Throwable) {
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
}
@@ -76,6 +83,7 @@ class NetworkAuthRepository @Inject constructor(
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val user = authApiService.me().toDomain()
tokenRepository.upsertAccount(user.toStoredAccount())
AppResult.Success(user)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
@@ -92,7 +100,10 @@ class NetworkAuthRepository @Inject constructor(
}
when (val meResult = getMe()) {
is AppResult.Success -> meResult
is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
@@ -150,6 +161,17 @@ class NetworkAuthRepository @Inject constructor(
)
}
private fun AuthUser.toStoredAccount(): StoredAccount {
return StoredAccount(
userId = id,
email = email,
name = name,
username = username,
avatarUrl = avatarUrl,
lastActiveAt = System.currentTimeMillis(),
)
}
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,

View File

@@ -14,6 +14,16 @@ data class AccountUiState(
val twoFactorOtpAuthUrl: String? = null,
val recoveryCodes: List<String> = emptyList(),
val recoveryCodesRemaining: Int? = null,
val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(),
val message: String? = null,
val errorMessage: String? = null,
)
data class StoredAccountUi(
val userId: Long,
val title: String,
val subtitle: String,
val avatarUrl: String?,
val isActive: Boolean,
)

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@@ -16,6 +17,7 @@ import javax.inject.Inject
@HiltViewModel
class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -30,12 +32,27 @@ class AccountViewModel @Inject constructor(
val me = accountRepository.getMe()
val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers()
val activeUserId = tokenRepository.getActiveUserId()
val storedAccounts = tokenRepository.getAccounts()
_uiState.update { state ->
state.copy(
isLoading = false,
profile = (me as? AppResult.Success)?.data ?: state.profile,
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
activeUserId = activeUserId,
storedAccounts = storedAccounts.map { account ->
StoredAccountUi(
userId = account.userId,
title = account.name.ifBlank { "User #${account.userId}" },
subtitle = listOfNotNull(
account.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
account.email?.takeIf { it.isNotBlank() },
).joinToString("").ifBlank { "User #${account.userId}" },
avatarUrl = account.avatarUrl,
isActive = activeUserId == account.userId,
)
},
errorMessage = listOf(me, sessions, blocked)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
@@ -46,6 +63,26 @@ class AccountViewModel @Inject constructor(
}
}
fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) {
viewModelScope.launch {
val switched = tokenRepository.switchAccount(userId)
if (!switched) {
_uiState.update { it.copy(errorMessage = "No saved session for this account.") }
onSwitched(false)
return@launch
}
refresh()
onSwitched(true)
}
}
fun removeStoredAccount(userId: Long) {
viewModelScope.launch {
tokenRepository.removeAccount(userId)
refresh()
}
}
fun updateProfile(
name: String,
username: String,

View File

@@ -86,6 +86,10 @@ class AuthViewModel @Inject constructor(
}
}
fun recheckSession() {
restoreSession()
}
private fun restoreSession() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }

View File

@@ -241,6 +241,13 @@ fun MessengerNavHost(
SettingsRoute(
onBackToChats = { navController.navigate(Routes.Chats) },
onOpenProfile = { navController.navigate(Routes.Profile) },
onSwitchAccount = {
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
popUpTo(Routes.Chats) { inclusive = true }
launchSingleTop = true
}
},
onLogout = viewModel::logout,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)

View File

@@ -71,6 +71,7 @@ private val SettingsMuted = Color(0xFF9EA3B0)
fun SettingsRoute(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
@@ -78,6 +79,7 @@ fun SettingsRoute(
SettingsScreen(
onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel,
@@ -88,6 +90,7 @@ fun SettingsRoute(
fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel,
@@ -203,6 +206,73 @@ fun SettingsScreen(
}
}
SettingsSectionCard {
Text("Accounts", style = MaterialTheme.typography.titleSmall, color = Color.White)
state.storedAccounts.forEach { account ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color(0x141F232B))
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (!account.avatarUrl.isNullOrBlank()) {
AsyncImage(
model = account.avatarUrl,
contentDescription = null,
modifier = Modifier.size(34.dp).clip(CircleShape),
)
} else {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(Color(0xFF2A2F38)),
contentAlignment = Alignment.Center,
) {
Text(
text = account.title.firstOrNull()?.uppercase() ?: "?",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.title,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = account.subtitle,
color = SettingsMuted,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (account.isActive) {
Text("Active", color = Color(0xFF9EDB9D), style = MaterialTheme.typography.labelSmall)
} else {
OutlinedButton(
onClick = {
viewModel.switchStoredAccount(account.userId) { switched ->
if (switched) onSwitchAccount()
}
},
) {
Text("Switch")
}
}
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) {
Text("Remove")
}
}
}
}
SettingsSectionCard {
SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() }
SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {}