feat: scope content

- refactor image loader
- rules
This commit is contained in:
rhunk
2023-08-20 15:27:42 +02:00
parent cb301f8a42
commit 31570694a0
10 changed files with 326 additions and 207 deletions

View File

@ -5,6 +5,10 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import coil.ImageLoader
import coil.decode.VideoFrameDecoder
import coil.memory.MemoryCache
import kotlinx.coroutines.Dispatchers
import me.rhunk.snapenhance.bridge.BridgeService
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
@ -35,6 +39,17 @@ class RemoteSideContext(
val downloadTaskManager = DownloadTaskManager()
val modDatabase = ModDatabase(this)
//used to load bitmoji selfies and download previews
val imageLoader by lazy {
ImageLoader.Builder(androidContext)
.dispatcher(Dispatchers.IO)
.memoryCache {
MemoryCache.Builder(androidContext)
.maxSizePercent(0.25)
.build()
}.components { add(VideoFrameDecoder.Factory()) }.build()
}
init {
runCatching {
config.loadFromContext(androidContext)

View File

@ -12,7 +12,7 @@ import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.MessagingScope
import me.rhunk.snapenhance.core.messaging.SocialScope
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.util.SerializableDataObject
@ -112,7 +112,7 @@ class BridgeService : Service() {
}
override fun getRules(objectType: String, uuid: String): MutableList<String> {
remoteSideContext.modDatabase.getRulesFromId(MessagingScope.valueOf(objectType), uuid)
remoteSideContext.modDatabase.getRulesFromId(SocialScope.valueOf(objectType), uuid)
.let { rules ->
return rules.map { it.toJson() }.toMutableList()
}

View File

@ -7,8 +7,7 @@ import me.rhunk.snapenhance.core.messaging.FriendStreaks
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.MessagingRule
import me.rhunk.snapenhance.core.messaging.Mode
import me.rhunk.snapenhance.core.messaging.MessagingScope
import me.rhunk.snapenhance.core.messaging.SocialScope
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.ktx.getInteger
@ -50,8 +49,7 @@ class ModDatabase(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"scope VARCHAR",
"targetUuid VARCHAR",
"enabled BOOLEAN",
"mode VARCHAR",
//"mode VARCHAR",
"subject VARCHAR"
),
"streaks" to listOf(
@ -153,16 +151,14 @@ class ModDatabase(
}
}
fun getRulesFromId(type: MessagingScope, targetUuid: String): List<MessagingRule> {
return database.rawQuery("SELECT * FROM rules WHERE objectType = ? AND targetUuid = ?", arrayOf(type.name, targetUuid)).use { cursor ->
fun getRulesFromId(type: SocialScope, targetUuid: String): List<MessagingRule> {
return database.rawQuery("SELECT * FROM rules WHERE scope = ? AND targetUuid = ?", arrayOf(type.name, targetUuid)).use { cursor ->
val rules = mutableListOf<MessagingRule>()
while (cursor.moveToNext()) {
rules.add(MessagingRule(
id = cursor.getInteger("id"),
messagingScope = MessagingScope.valueOf(cursor.getStringOrNull("scope")!!),
socialScope = SocialScope.valueOf(cursor.getStringOrNull("scope")!!),
targetUuid = cursor.getStringOrNull("targetUuid")!!,
enabled = cursor.getInteger("enabled") == 1,
mode = Mode.valueOf(cursor.getStringOrNull("mode")!!),
subject = cursor.getStringOrNull("subject")!!
))
}
@ -170,6 +166,24 @@ class ModDatabase(
}
}
fun toggleRuleFor(type: SocialScope, targetUuid: String, subject: String, enabled: Boolean) {
executeAsync {
if (enabled) {
database.execSQL("INSERT OR REPLACE INTO rules (scope, targetUuid, subject) VALUES (?, ?, ?)", arrayOf(
type.name,
targetUuid,
subject
))
} else {
database.execSQL("DELETE FROM rules WHERE scope = ? AND targetUuid = ? AND subject = ?", arrayOf(
type.name,
targetUuid,
subject
))
}
}
}
fun getFriendInfo(userId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends WHERE userId = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null

View File

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
@ -49,34 +48,19 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.VideoFrameDecoder
import coil.memory.MemoryCache
import coil.request.ImageRequest
import coil.size.Precision
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.DownloadObject
import me.rhunk.snapenhance.download.data.MediaFilter
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.ImageRequestHelper
class DownloadsSection : Section() {
private val loadedDownloads = mutableStateOf(mapOf<Int, DownloadObject>())
private var currentFilter = mutableStateOf(MediaFilter.NONE)
private val imageLoader by lazy {
ImageLoader.Builder(context.androidContext)
.dispatcher(Dispatchers.IO)
.memoryCache {
MemoryCache.Builder(context.androidContext)
.maxSizePercent(0.25)
.build()
}.components { add(VideoFrameDecoder.Factory()) }.build()
}
override fun onResumed() {
super.onResumed()
loadByFilter(currentFilter.value)
@ -156,12 +140,11 @@ class DownloadsSection : Section() {
Box(modifier = Modifier.height(100.dp)) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context.androidContext)
.data(download.outputFile)
.memoryCacheKey(download.outputFile)
.crossfade(true)
.build(),
imageLoader = imageLoader
model = ImageRequestHelper.newDownloadPreviewImageRequest(
context.androidContext,
download.outputFile
),
imageLoader = context.imageLoader
),
modifier = Modifier
.matchParentSize()
@ -187,25 +170,7 @@ class DownloadsSection : Section() {
.padding(15.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context.androidContext)
.data(download.metadata.iconUrl)
.fallback(R.drawable.bitmoji_blank)
.precision(Precision.INEXACT)
.crossfade(true)
.memoryCacheKey(download.metadata.iconUrl)
.build(),
imageLoader = imageLoader
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredWidthIn(min = 0.dp, max = 48.dp)
.height(48.dp)
.clip(MaterialTheme.shapes.medium)
)
BitmojiImage(context = context, url = download.metadata.iconUrl, size = 48)
Column(
modifier = Modifier
.padding(start = 10.dp),

View File

@ -0,0 +1,116 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.core.messaging.MessagingRuleType
import me.rhunk.snapenhance.core.messaging.SocialScope
class ScopeContent(
private val context: RemoteSideContext,
private val section: SocialSection,
private val navController: NavController,
private val scope: SocialScope,
private val id: String
) {
@Composable
private fun DeleteScopeEntityButton() {
val coroutineScope = rememberCoroutineScope()
OutlinedButton(onClick = {
when (scope) {
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
}
context.modDatabase.executeAsync {
coroutineScope.launch {
section.onResumed()
navController.popBackStack()
}
}
}) {
Text(text = "Delete ${scope.key}")
}
}
@Composable
fun Content() {
Column {
when (scope) {
SocialScope.FRIEND -> Friend()
SocialScope.GROUP -> Group()
}
Spacer(modifier = Modifier.height(16.dp))
val scopeRules = context.modDatabase.getRulesFromId(scope, id)
Text(text = "Rules", maxLines = 1)
Spacer(modifier = Modifier.height(16.dp))
//manager anti features etc
MessagingRuleType.values().forEach { feature ->
var featureEnabled by remember {
mutableStateOf(scopeRules.any { it.subject == feature.key })
}
val featureEnabledText = if (featureEnabled) "Enabled" else "Disabled"
Row {
Text(text = "${feature.key}: $featureEnabledText", maxLines = 1)
Switch(checked = featureEnabled, onCheckedChange = {
context.modDatabase.toggleRuleFor(scope, id, feature.key, it)
featureEnabled = it
})
}
}
}
}
@Composable
private fun Friend() {
//fetch the friend from the database
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
Text(text = "Friend not found")
return
}
Column {
Text(text = friend.displayName ?: "No display name", maxLines = 1)
Text(text = "bitmojiId: ${friend.bitmojiId ?: "No bitmojiId"}", maxLines = 1)
Text(text = "selfieId: ${friend.selfieId ?: "No selfieId"}", maxLines = 1)
Spacer(modifier = Modifier.height(16.dp))
DeleteScopeEntityButton()
}
}
@Composable
private fun Group() {
//fetch the group from the database
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
Text(text = "Group not found")
return
}
Column {
Text(text = group.name, maxLines = 1)
Text(text = "participantsCount: ${group.participantsCount}", maxLines = 1)
Spacer(modifier = Modifier.height(16.dp))
DeleteScopeEntityButton()
}
}
}

View File

@ -1,72 +0,0 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import me.rhunk.snapenhance.RemoteSideContext
class ScopeTab(
private val context: RemoteSideContext,
private val section: SocialSection,
private val navController: NavController,
private val id: String
) {
@Composable
fun Friend() {
//fetch the friend from the database
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
Text(text = "Friend not found")
return
}
Column {
Text(text = friend.displayName ?: "No display name", maxLines = 1)
Text(text = "bitmojiId: ${friend.bitmojiId ?: "No bitmojiId"}", maxLines = 1)
Text(text = "selfieId: ${friend.selfieId ?: "No selfieId"}", maxLines = 1)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = {
context.modDatabase.deleteFriend(id)
section.onResumed()
navController.popBackStack()
}) {
Text(text = "Delete friend")
}
}
}
@Composable
fun Group() {
//fetch the group from the database
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
Text(text = "Group not found")
return
}
Column {
Text(text = group.name, maxLines = 1)
Text(text = "participantsCount: ${group.participantsCount}", maxLines = 1)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = {
context.modDatabase.deleteGroup(id)
section.onResumed()
navController.popBackStack()
}) {
Text(text = "Delete group")
}
}
}
}

View File

@ -2,9 +2,9 @@ package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
@ -26,12 +25,13 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -41,8 +41,11 @@ import androidx.navigation.navigation
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.SocialScope
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
class SocialSection : Section() {
private lateinit var friendList: List<MessagingFriendInfo>
@ -54,7 +57,7 @@ class SocialSection : Section() {
const val GROUP_INFO_ROUTE = "group_info/{id}"
}
private var currentScopeTab: ScopeTab? = null
private var currentScopeContent: ScopeContent? = null
private val addFriendDialog by lazy {
AddFriendDialog(context, this)
@ -69,23 +72,96 @@ class SocialSection : Section() {
override fun canGoBack() = navController.currentBackStackEntry?.destination?.route != MAIN_ROUTE
override fun build(navGraphBuilder: NavGraphBuilder) {
fun switchTab(id: String) = ScopeTab(context, this, navController, id).also { tab ->
currentScopeTab = tab
}
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) {
composable(MAIN_ROUTE) {
Content()
}
composable(FRIEND_INFO_ROUTE) {
SocialScope.values().forEach { scope ->
composable(scope.tabRoute) {
val id = it.arguments?.getString("id") ?: return@composable
remember { switchTab(id) }.Friend()
remember {
ScopeContent(context, this@SocialSection, navController, scope, id).also { tab ->
currentScopeContent = tab
}
}.Content()
}
}
}
}
composable(GROUP_INFO_ROUTE) {
val id = it.arguments?.getString("id") ?: return@composable
remember { switchTab(id) }.Group()
@Composable
private fun ScopeList(scope: SocialScope) {
LazyColumn(
modifier = Modifier
.padding(2.dp)
.fillMaxWidth()
.fillMaxHeight()
) {
//check if scope list is empty
val listSize = when (scope) {
SocialScope.GROUP -> groupList.size
SocialScope.FRIEND -> friendList.size
}
if (listSize == 0) {
item {
//TODO: i18n
Text(text = "No ${scope.key.lowercase()}s found")
}
}
items(listSize) { index ->
val id = when (scope) {
SocialScope.GROUP -> groupList[index].conversationId
SocialScope.FRIEND -> friendList[index].userId
}
Card(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
.height(80.dp)
.clickable {
navController.navigate(
scope.tabRoute.replace("{id}", id)
)
},
) {
when (scope) {
SocialScope.GROUP -> {
val group = groupList[index]
Column {
Text(text = group.name, maxLines = 1)
Text(text = "participantsCount: ${group.participantsCount}", maxLines = 1)
}
}
SocialScope.FRIEND -> {
val friend = friendList[index]
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
val bitmojiUrl = (friend.selfieId to friend.bitmojiId).let { (selfieId, bitmojiId) ->
if (selfieId == null || bitmojiId == null) return@let null
BitmojiSelfie.getBitmojiSelfie(selfieId, bitmojiId, BitmojiSelfie.BitmojiSelfieType.STANDARD)
}
BitmojiImage(context = context, url = bitmojiUrl)
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
) {
Text(text = friend.displayName ?: friend.mutableUsername, maxLines = 1)
Text(text = friend.userId, maxLines = 1)
}
}
}
}
}
}
}
}
@ -145,60 +221,10 @@ class SocialSection : Section() {
}
}
HorizontalPager(modifier = Modifier.padding(paddingValues), state = pagerState) { page ->
when (page) {
0 -> {
LazyColumn(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
) {
if (friendList.isEmpty()) {
item {
Text(text = "No friends found")
}
}
items(friendList.size) { index ->
val friend = friendList[index]
Card(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
.height(100.dp)
.clickable {
navController.navigate(
FRIEND_INFO_ROUTE.replace(
"{id}",
friend.userId
)
)
},
) {
Text(text = friend.displayName ?: friend.mutableUsername)
}
}
}
}
1 -> {
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize()
.scrollable(rememberScrollState(), Orientation.Vertical)
) {
groupList.forEach {
Card(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
.height(100.dp),
) {
Text(text = it.name)
}
}
}
}
0 -> ScopeList(SocialScope.FRIEND)
1 -> ScopeList(SocialScope.GROUP)
}
}
}

View File

@ -0,0 +1,53 @@
package me.rhunk.snapenhance.ui.util
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import coil.size.Precision
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext
@Composable
fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size: Int = 48, url: String?) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequestHelper.newBitmojiImageRequest(
context.androidContext,
url
),
imageLoader = context.imageLoader
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredWidthIn(min = 0.dp, max = size.dp)
.height(size.dp)
.clip(MaterialTheme.shapes.medium)
.then(modifier)
)
}
object ImageRequestHelper {
fun newBitmojiImageRequest(context: Context, url: String?) = ImageRequest.Builder(context)
.data(url)
.fallback(R.drawable.bitmoji_blank)
.precision(Precision.INEXACT)
.crossfade(true)
.memoryCacheKey(url)
.build()
fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context)
.data(filePath)
.memoryCacheKey(filePath)
.crossfade(true)
.build()
}

View File

@ -16,7 +16,7 @@ import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.types.FileActionType
import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.messaging.MessagingRule
import me.rhunk.snapenhance.core.messaging.MessagingScope
import me.rhunk.snapenhance.core.messaging.SocialScope
import me.rhunk.snapenhance.data.LocalePair
import me.rhunk.snapenhance.util.SerializableDataObject
import java.util.concurrent.CompletableFuture
@ -136,7 +136,7 @@ class BridgeClient(
fun passGroupsAndFriends(groups: List<String>, friends: List<String>) = service.passGroupsAndFriends(groups, friends)
fun getRulesFromId(type: MessagingScope, targetUuid: String): List<MessagingRule> {
fun getRulesFromId(type: SocialScope, targetUuid: String): List<MessagingRule> {
return service.getRules(type.name, targetUuid).map {
SerializableDataObject.fromJson(it, MessagingRule::class.java)
}.toList()

View File

@ -8,18 +8,21 @@ enum class Mode {
WHITELIST
}
enum class MessagingScope {
FRIEND,
GROUP
enum class SocialScope(
val key: String,
val tabRoute: String,
) {
FRIEND("friend", "friend_info/{id}"),
GROUP("group", "group_info/{id}"),
}
enum class ConversationFeature(
val value: String,
val messagingScope: MessagingScope,
enum class MessagingRuleType(
val key: String,
val socialScope: SocialScope,
) {
DOWNLOAD("download", MessagingScope.FRIEND),
STEALTH("stealth", MessagingScope.GROUP),
AUTO_SAVE("auto_save", MessagingScope.GROUP);
DOWNLOAD("download", SocialScope.FRIEND),
STEALTH("stealth", SocialScope.GROUP),
AUTO_SAVE("auto_save", SocialScope.GROUP);
}
data class FriendStreaks(
@ -47,9 +50,8 @@ data class MessagingFriendInfo(
data class MessagingRule(
val id: Int,
val messagingScope: MessagingScope,
val socialScope: SocialScope,
val targetUuid: String,
val enabled: Boolean,
val mode: Mode?,
//val mode: Mode?,
val subject: String
) : SerializableDataObject()