diff --git a/.gitignore b/.gitignore index 084a3d6d..d0457ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ .idea /build /captures -/local.properties \ No newline at end of file +/local.properties +*.log diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4cbdf301..36361114 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,6 +138,7 @@ dependencies { Dependencies.AndroidxCore(this) Dependencies.AndroidxPreferences(this) Dependencies.AndroidxMedia3(this) + Dependencies.AndroidxRoom(this) Dependencies.Material(this) Dependencies.Compose(this) Dependencies.Accompanist(this) diff --git a/app/src/main/java/com/xinto/opencord/OpenCord.kt b/app/src/main/java/com/xinto/opencord/OpenCord.kt index a8a1dc28..29a89d9d 100644 --- a/app/src/main/java/com/xinto/opencord/OpenCord.kt +++ b/app/src/main/java/com/xinto/opencord/OpenCord.kt @@ -1,11 +1,17 @@ package com.xinto.opencord import android.app.Application +import com.xinto.opencord.db.database.CacheDatabase import com.xinto.opencord.di.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin class OpenCord : Application() { + val scope = MainScope() override fun onCreate() { super.onCreate() @@ -16,15 +22,26 @@ class OpenCord : Application() { gatewayModule, httpModule, managerModule, - repositoryModule, serviceModule, simpleAstModule, viewModelModule, loggerModule, providerModule, - hcaptchaModule + databaseModule, + storeModule, + hcaptchaModule, ) } - } -} \ No newline at end of file + scope.launch(Dispatchers.IO) { + val db = get() + + db.apply { + messages().clear() + embeds().clear() + attachments().clear() + users().deleteUnusedUsers() + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/db/Converters.kt b/app/src/main/java/com/xinto/opencord/db/Converters.kt new file mode 100644 index 00000000..f89e04aa --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/Converters.kt @@ -0,0 +1,20 @@ +package com.xinto.opencord.db + +import androidx.room.TypeConverter +import com.xinto.opencord.rest.dto.ApiEmbedField +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Suppress("unused") +class Converters { + @TypeConverter + fun fromEmbedFields(fields: List?): String? { + return Json.encodeToString(fields ?: return null) + } + + @TypeConverter + fun toEmbedFields(fields: String?): List? { + return Json.decodeFromString(fields ?: return null) + } +} diff --git a/app/src/main/java/com/xinto/opencord/db/dao/AttachmentsDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/AttachmentsDao.kt new file mode 100644 index 00000000..351e0491 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/AttachmentsDao.kt @@ -0,0 +1,28 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.message.EntityAttachment + +@Dao +interface AttachmentsDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityAttachment::class + ) + fun insertAttachments(attachments: List) + + // --------------- Deletes --------------- + @Query("DELETE FROM attachments WHERE message_id = :messageId") + fun deleteAttachments(messageId: Long) + + @Query("DELETE FROM attachments") + fun clear() + + // --------------- Queries --------------- + @Query("SELECT * FROM attachments WHERE id = :messageId") + fun getAttachments(messageId: Long): List +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/db/dao/ChannelsDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/ChannelsDao.kt new file mode 100644 index 00000000..47b26a3f --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/ChannelsDao.kt @@ -0,0 +1,41 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.channel.EntityChannel + +@Dao +interface ChannelsDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityChannel::class, + ) + fun insertChannels(channels: List) + + // --------------- Inserts --------------- + @Query("UPDATE channels SET is_pins_stored = :isStored WHERE id = :channelId") + fun setChannelPinsStored(channelId: Long, isStored: Boolean = true) + + // --------------- Deletes --------------- + @Query("DELETE FROM channels WHERE id = :channelId") + fun deleteChannel(channelId: Long) + + @Query("DELETE FROM channels WHERE guild_id = :guildId") + fun deleteChannelsByGuild(guildId: Long) + + @Query("DELETE FROM channels") + fun clear() + + // --------------- Queries --------------- + @Query("SELECT * FROM channels WHERE id = :channelId LIMIT 1") + fun getChannel(channelId: Long): EntityChannel? + + @Query("SELECT * FROM channels WHERE guild_id = :guildId") + fun getChannels(guildId: Long): List + + @Query("SELECT is_pins_stored FROM channels WHERE id = :channelId") + fun isChannelPinsStored(channelId: Long): Boolean? +} diff --git a/app/src/main/java/com/xinto/opencord/db/dao/EmbedsDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/EmbedsDao.kt new file mode 100644 index 00000000..fcdee6b2 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/EmbedsDao.kt @@ -0,0 +1,31 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.message.EntityEmbed + +@Dao +interface EmbedsDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityEmbed::class + ) + fun insertEmbeds(embeds: List) + + // --------------- Deletes --------------- + @Query("DELETE FROM embeds WHERE message_id = :messageId") + fun deleteEmbeds(messageId: Long) + + @Query("DELETE FROM embeds WHERE message_id = :messageId AND embed_index >= :embedCount") + fun deleteTrailingEmbeds(messageId: Long, embedCount: Int) + + @Query("DELETE FROM embeds") + fun clear() + + // --------------- Queries --------------- + @Query("SELECT * FROM embeds WHERE message_id = :messageId ORDER BY embed_index ASC") + fun getEmbeds(messageId: Long): List +} diff --git a/app/src/main/java/com/xinto/opencord/db/dao/GuildsDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/GuildsDao.kt new file mode 100644 index 00000000..c327e414 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/GuildsDao.kt @@ -0,0 +1,31 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.guild.EntityGuild + +@Dao +interface GuildsDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityGuild::class, + ) + fun insertGuilds(guilds: List) + + // --------------- Deletes --------------- + @Query("DELETE FROM guilds WHERE id = :guildId") + fun deleteGuild(guildId: Long) + + @Query("DELETE FROM guilds") + fun clear() + + // --------------- Queries --------------- + @Query("SELECT * FROM guilds WHERE id = :guildId LIMIT 1") + fun getGuild(guildId: Long): EntityGuild? + + @Query("SELECT * FROM guilds WHERE id IN(:guildIds)") + fun getGuilds(guildIds: List): List +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/db/dao/MessagesDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/MessagesDao.kt new file mode 100644 index 00000000..336926bf --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/MessagesDao.kt @@ -0,0 +1,46 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.message.EntityMessage + +@Dao +interface MessagesDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityMessage::class, + ) + fun insertMessages(messages: List) + + // --------------- Deletes --------------- + @Query("DELETE FROM messages WHERE id = :messageId") + fun deleteMessage(messageId: Long) + + @Query("DELETE FROM messages WHERE channel_id = :channelId") + fun deleteByChannel(channelId: Long) + + @Query("DELETE FROM messages") + fun clear() + + // --------------- Queries --------------- + @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") + fun getMessage(id: Long): EntityMessage? + + @Query("SELECT * FROM messages WHERE channel_id = :channelId ORDER BY id DESC LIMIT :limit") + fun getMessagesLast(channelId: Long, limit: Int): List + + @Query("SELECT * FROM messages WHERE channel_id = :channelId AND id < :beforeId ORDER BY id DESC LIMIT :limit") + fun getMessagesBefore(channelId: Long, limit: Int, beforeId: Long): List + + @Query("SELECT * FROM messages WHERE channel_id = :channelId AND id > :afterId ORDER BY id ASC LIMIT :limit") + fun getMessagesAfter(channelId: Long, limit: Int, afterId: Long): List + + @Query("SELECT * FROM messages WHERE channel_id = :channelId AND id >= :aroundId - ROUND(:limit / 2, 0) ORDER BY id ASC LIMIT :limit") + fun getMessagesAround(channelId: Long, limit: Int, aroundId: Long): List + + @Query("SELECT * FROM messages WHERE pinned = 1 AND channel_id = :channelId") + fun getPinnedMessages(channelId: Long): List +} diff --git a/app/src/main/java/com/xinto/opencord/db/dao/UsersDao.kt b/app/src/main/java/com/xinto/opencord/db/dao/UsersDao.kt new file mode 100644 index 00000000..fbdee2bc --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/dao/UsersDao.kt @@ -0,0 +1,25 @@ +package com.xinto.opencord.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.xinto.opencord.db.entity.user.EntityUser + +@Dao +interface UsersDao { + // --------------- Inserts --------------- + @Insert( + onConflict = OnConflictStrategy.REPLACE, + entity = EntityUser::class, + ) + fun insertUsers(users: List) + + // --------------- Deletes --------------- + @Query("DELETE FROM USERS WHERE id NOT IN(SELECT author_id FROM MESSAGES)") + fun deleteUnusedUsers() + + // --------------- Queries --------------- + @Query("SELECT * FROM users WHERE id = :userId LIMIT 1") + fun getUser(userId: Long): EntityUser? +} diff --git a/app/src/main/java/com/xinto/opencord/db/database/CacheDatabase.kt b/app/src/main/java/com/xinto/opencord/db/database/CacheDatabase.kt new file mode 100644 index 00000000..ad720342 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/database/CacheDatabase.kt @@ -0,0 +1,37 @@ +package com.xinto.opencord.db.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.xinto.opencord.db.Converters +import com.xinto.opencord.db.dao.* +import com.xinto.opencord.db.entity.channel.EntityChannel +import com.xinto.opencord.db.entity.guild.EntityGuild +import com.xinto.opencord.db.entity.message.EntityAttachment +import com.xinto.opencord.db.entity.message.EntityEmbed +import com.xinto.opencord.db.entity.message.EntityMessage +import com.xinto.opencord.db.entity.user.EntityUser + +@Database( + version = 1, + entities = [ + EntityChannel::class, + EntityGuild::class, + EntityAttachment::class, + EntityEmbed::class, + EntityMessage::class, + EntityUser::class, + ], + exportSchema = false, +) +@TypeConverters(Converters::class) +abstract class CacheDatabase : RoomDatabase() { + abstract fun channels(): ChannelsDao + abstract fun guilds(): GuildsDao + + abstract fun messages(): MessagesDao + abstract fun attachments(): AttachmentsDao + abstract fun embeds(): EmbedsDao + + abstract fun users(): UsersDao +} diff --git a/app/src/main/java/com/xinto/opencord/db/entity/channel/EntityChannel.kt b/app/src/main/java/com/xinto/opencord/db/entity/channel/EntityChannel.kt new file mode 100644 index 00000000..d698b8b1 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/channel/EntityChannel.kt @@ -0,0 +1,40 @@ +package com.xinto.opencord.db.entity.channel + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "channels", + indices = [ + Index(value = ["guild_id"]), + ], +) +data class EntityChannel( + // -------- Discord data -------- // + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "guild_id") + val guildId: Long, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "type") + val type: Int, + + @ColumnInfo(name = "position") + val position: Int, + + @ColumnInfo(name = "parent_id") + val parentId: Long?, + + @ColumnInfo(name = "nsfw") + val nsfw: Boolean, + + // -------- DB relational data -------- // + @ColumnInfo(name = "is_pins_stored") + val pinsStored: Boolean, +) diff --git a/app/src/main/java/com/xinto/opencord/db/entity/guild/EntityGuild.kt b/app/src/main/java/com/xinto/opencord/db/entity/guild/EntityGuild.kt new file mode 100644 index 00000000..6e06f55e --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/guild/EntityGuild.kt @@ -0,0 +1,28 @@ +package com.xinto.opencord.db.entity.guild + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity( + tableName = "guilds", +) +data class EntityGuild( + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "icon") + val icon: String?, + + @ColumnInfo(name = "banner") + val banner: String? = null, + + @ColumnInfo(name = "premium_tier") + val premiumTier: Int, + + @ColumnInfo(name = "premium_subscription_count") + val premiumSubscriptionCount: Int? +) diff --git a/app/src/main/java/com/xinto/opencord/db/entity/message/EntityAttachment.kt b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityAttachment.kt new file mode 100644 index 00000000..c6348d23 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityAttachment.kt @@ -0,0 +1,41 @@ +package com.xinto.opencord.db.entity.message + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "attachments", + indices = [ + Index(value = ["message_id"]), + ], +) +data class EntityAttachment( + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "message_id") + val messageId: Long, + + @ColumnInfo(name = "file_name") + val fileName: String, + + @ColumnInfo(name = "size") + val size: Int, + + @ColumnInfo(name = "url") + val url: String, + + @ColumnInfo(name = "proxy_url") + val proxyUrl: String, + + @ColumnInfo(name = "width") + val width: Int?, + + @ColumnInfo(name = "height") + val height: Int?, + + @ColumnInfo(name = "content_type") + val contentType: String?, +) diff --git a/app/src/main/java/com/xinto/opencord/db/entity/message/EntityEmbed.kt b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityEmbed.kt new file mode 100644 index 00000000..d8df8c58 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityEmbed.kt @@ -0,0 +1,45 @@ +package com.xinto.opencord.db.entity.message + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import com.xinto.opencord.rest.dto.ApiEmbedField + +@Entity( + tableName = "embeds", + indices = [ + Index(value = ["message_id"]), + ], + primaryKeys = [ + "embed_index", + "message_id", + ], +) +data class EntityEmbed( + @ColumnInfo(name = "embed_index") + val embedIndex: Int, + + @ColumnInfo(name = "message_id") + val messageId: Long, + + @ColumnInfo(name = "title") + val title: String?, + + @ColumnInfo(name = "description") + val description: String?, + + @ColumnInfo(name = "url") + val url: String?, + + @ColumnInfo(name = "color") + val color: Int?, + + @ColumnInfo(name = "timestamp") + val timestamp: Long?, + + @ColumnInfo(name = "author_name") + val authorName: String?, + + @ColumnInfo(name = "fields") + val fields: List?, +) diff --git a/app/src/main/java/com/xinto/opencord/db/entity/message/EntityMessage.kt b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityMessage.kt new file mode 100644 index 00000000..f689d661 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/message/EntityMessage.kt @@ -0,0 +1,60 @@ +package com.xinto.opencord.db.entity.message + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "messages", + indices = [ + Index(value = ["channel_id"]), + Index(value = ["author_id"]), + ], +) +data class EntityMessage( + // -------- Discord data -------- // + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "channel_id") + val channelId: Long, + + @ColumnInfo(name = "type") + val type: Int, + + @ColumnInfo(name = "timestamp") + val timestamp: Long, + + @ColumnInfo(name = "pinned") + val pinned: Boolean, + + @ColumnInfo(name = "content") + val content: String, + + @ColumnInfo(name = "author_id") + val authorId: Long, + + @ColumnInfo(name = "edited_timestamp") + val editedTimestamp: Long?, + + @ColumnInfo(name = "referenced_message") + val referencedMessageId: Long?, + + @ColumnInfo(name = "mentions_everyone") + val mentionsEveryone: Boolean, + + // TODO: add user mentions + // TODO: add role mentions + + // -------- OpenCord data -------- // +// @ColumnInfo(name = "deleted") +// val deleted: Boolean, + + // -------- DB relational data -------- // + @ColumnInfo(name = "has_attachments") + val hasAttachments: Boolean, + + @ColumnInfo(name = "has_embeds") + val hasEmbeds: Boolean, +) diff --git a/app/src/main/java/com/xinto/opencord/db/entity/user/EntityUser.kt b/app/src/main/java/com/xinto/opencord/db/entity/user/EntityUser.kt new file mode 100644 index 00000000..c3484444 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/db/entity/user/EntityUser.kt @@ -0,0 +1,37 @@ +package com.xinto.opencord.db.entity.user + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity( + tableName = "users", +) +data class EntityUser( + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "username") + val username: String, + + @ColumnInfo(name = "discriminator") + val discriminator: String, + + @ColumnInfo(name = "avatar_hash") + val avatarHash: String?, + + @ColumnInfo(name = "bot") + val bot: Boolean, + + @ColumnInfo(name = "pronouns") + val pronouns: String?, + + @ColumnInfo(name = "bio") + val bio: String?, + + @ColumnInfo(name = "banner_url") + val bannerUrl: String?, + + @ColumnInfo(name = "public_flags") + val publicFlags: Int, +) diff --git a/app/src/main/java/com/xinto/opencord/di/DatabaseModule.kt b/app/src/main/java/com/xinto/opencord/di/DatabaseModule.kt new file mode 100644 index 00000000..7a9c5d8b --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/di/DatabaseModule.kt @@ -0,0 +1,45 @@ +package com.xinto.opencord.di + +import android.content.Context +import androidx.room.Room +import com.xinto.opencord.BuildConfig +import com.xinto.opencord.db.database.CacheDatabase +import com.xinto.opencord.util.Logger +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import java.util.concurrent.Executors + +val databaseModule = module { + fun provideCacheDatabase(context: Context, logger: Logger): CacheDatabase { + val dbPath = context.cacheDir.resolve("cache.db").absolutePath + + val db = Room + .databaseBuilder(context, CacheDatabase::class.java, dbPath) + .fallbackToDestructiveMigration() + + if (BuildConfig.DEBUG) { + val blacklist = arrayOf( + "BEGIN DEFERRED TRANSACTION", + "TRANSACTION SUCCESSFUL", + "END TRANSACTION" + ) + + db.setQueryCallback( + { sql, args -> + if (sql !in blacklist) { + logger.debug("CacheDatabase", "SQL: $sql") + + if (args.isNotEmpty()) { + logger.debug("CacheDatabase", "SQL args: $args") + } + } + }, + Executors.newSingleThreadExecutor() + ) + } + + return db.build() + } + + singleOf(::provideCacheDatabase) +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/di/HttpModule.kt b/app/src/main/java/com/xinto/opencord/di/HttpModule.kt index 9558c6f7..190eebfe 100644 --- a/app/src/main/java/com/xinto/opencord/di/HttpModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/HttpModule.kt @@ -58,7 +58,7 @@ val httpModule = module { } } - fun OkHttpConfig.addStripKtorHeadersInterceptor() { + fun OkHttpConfig.addStripKtorHeadersInterceptor(stripJsonHeader: Boolean) { addInterceptor { chain -> val request = chain.request() val requestBuilder = request @@ -66,7 +66,7 @@ val httpModule = module { .removeHeader(HttpHeaders.Accept) .removeHeader(HttpHeaders.AcceptCharset) - if (request.header(HttpHeaders.ContentType) == "application/json") { + if (stripJsonHeader && request.header(HttpHeaders.ContentType) == "application/json") { requestBuilder.removeHeader(HttpHeaders.ContentType) } @@ -111,7 +111,7 @@ val httpModule = module { installLogging(logger) engine { - addStripKtorHeadersInterceptor() + addStripKtorHeadersInterceptor(false) } } } @@ -157,7 +157,7 @@ val httpModule = module { installLogging(logger) engine { - addStripKtorHeadersInterceptor() + addStripKtorHeadersInterceptor(true) } } } @@ -193,7 +193,7 @@ val httpModule = module { installLogging(logger) engine { - addStripKtorHeadersInterceptor() + addStripKtorHeadersInterceptor(false) } } } diff --git a/app/src/main/java/com/xinto/opencord/di/ManagerModule.kt b/app/src/main/java/com/xinto/opencord/di/ManagerModule.kt index aa3ac975..2e0c4c13 100644 --- a/app/src/main/java/com/xinto/opencord/di/ManagerModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ManagerModule.kt @@ -31,16 +31,7 @@ val managerModule = module { ) } - fun provideCacheManager( - gateway: DiscordGateway - ): CacheManager { - return CacheManagerImpl( - gateway = gateway, - ) - } - single { provideAccountManager(androidContext()) } single { provideActivityManager(androidContext()) } single { providePersistentDataManager(androidContext()) } - single { provideCacheManager(get()) } } diff --git a/app/src/main/java/com/xinto/opencord/di/RepositoryModule.kt b/app/src/main/java/com/xinto/opencord/di/RepositoryModule.kt deleted file mode 100644 index 0b97b942..00000000 --- a/app/src/main/java/com/xinto/opencord/di/RepositoryModule.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.xinto.opencord.di - -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.domain.repository.DiscordApiRepositoryImpl -import com.xinto.opencord.domain.repository.DiscordAuthRepository -import com.xinto.opencord.domain.repository.DiscordAuthRepositoryImpl -import com.xinto.opencord.rest.service.DiscordApiService -import com.xinto.opencord.rest.service.DiscordAuthService -import org.koin.dsl.module - -val repositoryModule = module { - - fun provideDiscordAuthRepository( - service: DiscordAuthService - ): DiscordAuthRepository { - return DiscordAuthRepositoryImpl( - service = service - ) - } - - fun provideDiscordApiRepository( - service: DiscordApiService - ): DiscordApiRepository { - return DiscordApiRepositoryImpl( - service = service - ) - } - - single { provideDiscordAuthRepository(get()) } - single { provideDiscordApiRepository(get()) } -} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/di/ServiceModule.kt b/app/src/main/java/com/xinto/opencord/di/ServiceModule.kt index aefacf50..c259b076 100644 --- a/app/src/main/java/com/xinto/opencord/di/ServiceModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ServiceModule.kt @@ -1,6 +1,5 @@ package com.xinto.opencord.di -import com.xinto.opencord.gateway.DiscordGateway import com.xinto.opencord.rest.service.DiscordApiService import com.xinto.opencord.rest.service.DiscordApiServiceImpl import com.xinto.opencord.rest.service.DiscordAuthService @@ -10,7 +9,6 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val serviceModule = module { - fun provideDiscordAuthService( client: HttpClient ): DiscordAuthService { @@ -20,15 +18,13 @@ val serviceModule = module { } fun provideDiscordApiService( - gateway: DiscordGateway, - client: HttpClient + client: HttpClient, ): DiscordApiService { return DiscordApiServiceImpl( - gateway = gateway, - client = client + client = client, ) } single { provideDiscordAuthService(get(named("auth"))) } - single { provideDiscordApiService(get(), get(named("api"))) } -} \ No newline at end of file + single { provideDiscordApiService(get(named("api"))) } +} diff --git a/app/src/main/java/com/xinto/opencord/di/StoreModule.kt b/app/src/main/java/com/xinto/opencord/di/StoreModule.kt new file mode 100644 index 00000000..472199ad --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/di/StoreModule.kt @@ -0,0 +1,75 @@ +package com.xinto.opencord.di + +import com.xinto.opencord.db.database.CacheDatabase +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.rest.service.DiscordApiService +import com.xinto.opencord.store.* +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val storeModule = module { + fun provideMessageStore( + gateway: DiscordGateway, + api: DiscordApiService, + cache: CacheDatabase, + ): MessageStore { + return MessageStoreImpl( + gateway = gateway, + api = api, + cache = cache, + ) + } + + fun provideChannelStore( + gateway: DiscordGateway, + cache: CacheDatabase, + ): ChannelStore { + return ChannelStoreImpl( + gateway = gateway, + cache = cache, + ) + } + + fun provideGuildStore( + gateway: DiscordGateway, + cache: CacheDatabase, + ): GuildStore { + return GuildStoreImpl( + gateway = gateway, + cache = cache, + ) + } + + fun provideUserSettingsStore( + gateway: DiscordGateway, + api: DiscordApiService, + ): UserSettingsStore { + return UserSettingsStoreImpl( + gateway = gateway, + api = api, + ) + } + + fun provideCurrentUserStore( + gateway: DiscordGateway, + ): CurrentUserStore { + return CurrentUserStoreImpl( + gateway = gateway, + ) + } + + fun provideSessionStore( + gateway: DiscordGateway, + ): SessionStore { + return SessionStoreImpl( + gateway = gateway, + ) + } + + singleOf(::provideMessageStore) + singleOf(::provideChannelStore) + singleOf(::provideGuildStore) + singleOf(::provideUserSettingsStore) + singleOf(::provideCurrentUserStore) + singleOf(::provideSessionStore) +} diff --git a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt index 65b32119..91f1fce0 100644 --- a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt @@ -2,17 +2,16 @@ package com.xinto.opencord.di import com.xinto.opencord.domain.manager.AccountManager import com.xinto.opencord.domain.manager.ActivityManager -import com.xinto.opencord.domain.manager.CacheManager import com.xinto.opencord.domain.manager.PersistentDataManager -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.domain.repository.DiscordAuthRepository import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.rest.service.DiscordApiService +import com.xinto.opencord.rest.service.DiscordAuthService +import com.xinto.opencord.store.* import com.xinto.opencord.ui.viewmodel.* -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.dsl.module val viewModelModule = module { - fun provideMainViewModel( gateway: DiscordGateway ): MainViewModel { @@ -22,94 +21,91 @@ val viewModelModule = module { } fun provideLoginViewModel( - repository: DiscordAuthRepository, + api: DiscordAuthService, activityManager: ActivityManager, accountManager: AccountManager ): LoginViewModel { return LoginViewModel( - repository = repository, + api = api, activityManager = activityManager, accountManager = accountManager ) } - fun provideChatViewModel( - gateway: DiscordGateway, - repository: DiscordApiRepository, + messageStore: MessageStore, + channelStore: ChannelStore, + api: DiscordApiService, persistentDataManager: PersistentDataManager ): ChatViewModel { return ChatViewModel( - gateway = gateway, - repository = repository, + messageStore = messageStore, + channelStore = channelStore, + api = api, persistentDataManager = persistentDataManager ) } fun provideGuildsViewModel( - gateway: DiscordGateway, - repository: DiscordApiRepository, + guildStore: GuildStore, persistentDataManager: PersistentDataManager ): GuildsViewModel { return GuildsViewModel( - gateway = gateway, - repository = repository, + guildStore = guildStore, persistentDataManager = persistentDataManager ) } fun provideChannelsViewModel( - gateway: DiscordGateway, - repository: DiscordApiRepository, - persistentDataManager: PersistentDataManager + persistentDataManager: PersistentDataManager, + channelStore: ChannelStore, + guildStore: GuildStore, ): ChannelsViewModel { return ChannelsViewModel( - gateway = gateway, - repository = repository, - persistentDataManager = persistentDataManager + persistentDataManager = persistentDataManager, + channelStore = channelStore, + guildStore = guildStore, ) } fun provideMembersViewModel( persistentDataManager: PersistentDataManager, - gateway: DiscordGateway, - repository: DiscordApiRepository ): MembersViewModel { return MembersViewModel( persistentDataManager = persistentDataManager, - gateway = gateway, - repository = repository ) } fun provideCurrentUserViewModel( gateway: DiscordGateway, - repository: DiscordApiRepository, - cache: CacheManager, + sessionStore: SessionStore, + currentUserStore: CurrentUserStore, + userSettingsStore: UserSettingsStore, ): CurrentUserViewModel { return CurrentUserViewModel( gateway = gateway, - repository = repository, - cache = cache, + sessionStore = sessionStore, + currentUserStore = currentUserStore, + userSettingsStore = userSettingsStore, ) } fun provideChannelPinsViewModel( persistentDataManager: PersistentDataManager, - repository: DiscordApiRepository + messageStore: MessageStore, ): ChannelPinsViewModel { return ChannelPinsViewModel( persistentDataManager = persistentDataManager, - repository = repository + messageStore = messageStore, ) } - viewModel { provideMainViewModel(get()) } - viewModel { provideLoginViewModel(get(), get(), get()) } - viewModel { provideChatViewModel(get(), get(), get()) } - viewModel { provideGuildsViewModel(get(), get(), get()) } - viewModel { provideChannelsViewModel(get(), get(), get()) } - viewModel { provideMembersViewModel(get(), get(), get()) } - viewModel { provideCurrentUserViewModel(get(), get(), get()) } - viewModel { provideChannelPinsViewModel(get(), get()) } + viewModelOf(::provideMainViewModel) + viewModelOf(::provideLoginViewModel) + viewModelOf(::provideChatViewModel) + viewModelOf(::provideGuildsViewModel) + viewModelOf(::provideChannelsViewModel) + viewModelOf(::provideMembersViewModel) + viewModelOf(::provideCurrentUserViewModel) + viewModelOf(::provideChannelPinsViewModel) } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/domain/manager/CacheManager.kt b/app/src/main/java/com/xinto/opencord/domain/manager/CacheManager.kt deleted file mode 100644 index 3d6eb97c..00000000 --- a/app/src/main/java/com/xinto/opencord/domain/manager/CacheManager.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.xinto.opencord.domain.manager - -import com.xinto.opencord.domain.mapper.toDomain -import com.xinto.opencord.domain.model.DomainActivity -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.dto.SessionData -import com.xinto.opencord.gateway.event.ReadyEvent -import com.xinto.opencord.gateway.event.SessionsReplaceEvent -import com.xinto.opencord.gateway.onEvent - -interface CacheManager { - fun getSessions(): List - fun getCurrentSession(): SessionData - - fun getActivities(): List -} - -class CacheManagerImpl( - val gateway: DiscordGateway, -) : CacheManager { - private var _sessions: List? = null - private var _activities: List? = null - - override fun getSessions(): List { - return _sessions - ?: throw IllegalStateException("No session data from gateway!") - } - - override fun getActivities(): List { - return _activities - ?: throw IllegalStateException("No session data from gateway!") - } - - override fun getCurrentSession(): SessionData { - val sessionId = gateway.getSessionId() - return getSessions().find { it.sessionId == sessionId } - ?: throw IllegalStateException("Current session is not cached!") - } - - private fun handleSessions(sessions: List) { - _sessions = sessions.filter { it.sessionId != "all" } - _activities = _sessions!! - .flatMap { it.activities } - .map { it.toDomain() } - } - - init { - gateway.onEvent { - handleSessions(it.data.sessions) - } - gateway.onEvent { - handleSessions(it.data) - } - } -} diff --git a/app/src/main/java/com/xinto/opencord/domain/mapper/AuthDomainMapper.kt b/app/src/main/java/com/xinto/opencord/domain/mapper/AuthApiDomainMapper.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/domain/mapper/AuthDomainMapper.kt rename to app/src/main/java/com/xinto/opencord/domain/mapper/AuthApiDomainMapper.kt diff --git a/app/src/main/java/com/xinto/opencord/domain/mapper/ApiDomainMapper.kt b/app/src/main/java/com/xinto/opencord/domain/mapper/RestApiDomainMapper.kt similarity index 95% rename from app/src/main/java/com/xinto/opencord/domain/mapper/ApiDomainMapper.kt rename to app/src/main/java/com/xinto/opencord/domain/mapper/RestApiDomainMapper.kt index 5d7fa75a..f63b9e2c 100644 --- a/app/src/main/java/com/xinto/opencord/domain/mapper/ApiDomainMapper.kt +++ b/app/src/main/java/com/xinto/opencord/domain/mapper/RestApiDomainMapper.kt @@ -41,7 +41,6 @@ fun ApiAttachment.toDomain(): DomainAttachment { } fun ApiChannel.toDomain(): DomainChannel { - val permissions = permissions.toDomain() return when (type) { 2 -> DomainChannel.VoiceChannel( id = id.value, @@ -49,14 +48,12 @@ fun ApiChannel.toDomain(): DomainChannel { name = name, position = position, parentId = parentId?.value, - permissions = permissions ) 4 -> DomainChannel.Category( id = id.value, guildId = guildId?.value, name = name, position = position, - permissions = permissions ) 5 -> DomainChannel.AnnouncementChannel( id = id.value, @@ -64,7 +61,6 @@ fun ApiChannel.toDomain(): DomainChannel { name = name, position = position, parentId = parentId?.value, - permissions = permissions, nsfw = nsfw ) else -> DomainChannel.TextChannel( @@ -73,7 +69,6 @@ fun ApiChannel.toDomain(): DomainChannel { name = name, position = position, parentId = parentId?.value, - permissions = permissions, nsfw = nsfw ) } @@ -91,7 +86,6 @@ fun ApiGuild.toDomain(): DomainGuild { name = name, iconUrl = iconUrl, bannerUrl = bannerUrl, - permissions = permissions.toDomain(), premiumTier = premiumTier, premiumSubscriptionCount = premiumSubscriptionCount ?: 0, ) @@ -121,18 +115,6 @@ fun ApiGuildMemberChunk.toDomain(): DomainGuildMemberChunk { ) } -fun ApiMeGuild.toDomain(): DomainMeGuild { - val iconUrl = icon?.let { icon -> - DiscordCdnServiceImpl.getGuildIconUrl(id.toString(), icon) - } - return DomainMeGuild( - id = id.value, - name = name, - iconUrl = iconUrl, - permissions = permissions.toDomain() - ) -} - fun ApiMessage.toDomain(): DomainMessage { val domainAuthor = author.toDomain() return when (type) { @@ -146,6 +128,7 @@ fun ApiMessage.toDomain(): DomainMessage { content = content, author = domainAuthor, timestamp = timestamp, + pinned = pinned, editedTimestamp = editedTimestamp, attachments = domainAttachments, embeds = domainEmbeds, @@ -161,9 +144,18 @@ fun ApiMessage.toDomain(): DomainMessage { content = content, channelId = channelId.value, timestamp = timestamp, + pinned = pinned, author = domainAuthor ) } + else -> DomainMessageUnknown( + id = id.value, + content = content, + channelId = channelId.value, + timestamp = timestamp, + pinned = pinned, + author = domainAuthor + ) } } @@ -272,9 +264,6 @@ fun ApiEmbedField.toDomain(): DomainEmbedField { fun ApiUserSettingsPartial.toDomain(): DomainUserSettingsPartial { val domainPartialTheme = theme.map { DomainThemeSetting.fromValue(it)!! } - val domainPartialGuildPositions = guildPositions.map { guildPositions -> - guildPositions.map { it.value } - } val domainPartialStatus = status.map { DomainUserStatus.fromValue(it)!! } val domainPartialFriendSourceFlags = friendSourceFlags.map { it.toDomain() } val domainPartialGuildFolders = guildFolders.map { guildFolders -> @@ -296,7 +285,6 @@ fun ApiUserSettingsPartial.toDomain(): DomainUserSettingsPartial { disableGamesTab = disableGamesTab, theme = domainPartialTheme, developerMode = developerMode, - guildPositions = domainPartialGuildPositions, detectPlatformAccounts = detectPlatformAccounts, status = domainPartialStatus, afkTimeout = afkTimeout, @@ -335,7 +323,6 @@ fun ApiUserSettings.toDomain(): DomainUserSettings { disableGamesTab = disableGamesTab, theme = domainTheme, developerMode = developerMode, - guildPositions = guildPositions.map { it.value }, detectPlatformAccounts = detectPlatformAccounts, status = domainStatus, afkTimeout = afkTimeout, diff --git a/app/src/main/java/com/xinto/opencord/domain/mapper/RestApiEntityMapper.kt b/app/src/main/java/com/xinto/opencord/domain/mapper/RestApiEntityMapper.kt new file mode 100644 index 00000000..fbb56bd7 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/domain/mapper/RestApiEntityMapper.kt @@ -0,0 +1,92 @@ +package com.xinto.opencord.domain.mapper + +import com.xinto.opencord.db.entity.channel.EntityChannel +import com.xinto.opencord.db.entity.guild.EntityGuild +import com.xinto.opencord.db.entity.message.EntityAttachment +import com.xinto.opencord.db.entity.message.EntityEmbed +import com.xinto.opencord.db.entity.message.EntityMessage +import com.xinto.opencord.db.entity.user.EntityUser +import com.xinto.opencord.rest.dto.* + +fun ApiMessage.toEntity(): EntityMessage { + return EntityMessage( + id = id.value, + channelId = channelId.value, + type = type.value, + timestamp = timestamp.toEpochMilliseconds(), + pinned = pinned, + content = content, + authorId = author.id.value, + editedTimestamp = editedTimestamp?.toEpochMilliseconds(), + referencedMessageId = referencedMessage?.id?.value, + mentionsEveryone = mentionEveryone, + hasAttachments = attachments.isNotEmpty(), + hasEmbeds = embeds.isNotEmpty(), + ) +} + +fun ApiAttachment.toEntity(messageId: Long): EntityAttachment { + return EntityAttachment( + id = id.value, + messageId = messageId, + fileName = filename, + size = size, + url = url, + proxyUrl = proxyUrl, + width = width, + height = height, + contentType = contentType, + ) +} + +fun ApiEmbed.toEntity(messageId: Long, embedIndex: Int): EntityEmbed { + return EntityEmbed( + embedIndex = embedIndex, + messageId = messageId, + title = title, + description = description, + url = url, + color = color?.rgbColor, + timestamp = timestamp?.toEpochMilliseconds(), + authorName = author?.name, + fields = fields, + ) +} + +fun ApiUser.toEntity(): EntityUser { + return EntityUser( + id = id.value, + username = username, + discriminator = discriminator, + avatarHash = avatar, + bot = bot, + pronouns = pronouns, + bio = bio, + bannerUrl = banner, + publicFlags = publicFlags ?: 0, + ) +} + +fun ApiChannel.toEntity(guildId: Long): EntityChannel { + return EntityChannel( + id = id.value, + guildId = this.guildId?.value ?: guildId, + name = name, + type = type, + position = position, + parentId = parentId?.value, + nsfw = nsfw, + pinsStored = false, + ) +} + +fun ApiGuild.toEntity(): EntityGuild { + return EntityGuild( + id = id.value, + name = name, + icon = icon, + banner = banner, + premiumTier = premiumTier, + premiumSubscriptionCount = premiumSubscriptionCount, + ) +} diff --git a/app/src/main/java/com/xinto/opencord/domain/mapper/DomainApiMapper.kt b/app/src/main/java/com/xinto/opencord/domain/mapper/RestDomainApiMapper.kt similarity index 93% rename from app/src/main/java/com/xinto/opencord/domain/mapper/DomainApiMapper.kt rename to app/src/main/java/com/xinto/opencord/domain/mapper/RestDomainApiMapper.kt index 313cb801..fc205e8c 100644 --- a/app/src/main/java/com/xinto/opencord/domain/mapper/DomainApiMapper.kt +++ b/app/src/main/java/com/xinto/opencord/domain/mapper/RestDomainApiMapper.kt @@ -6,9 +6,6 @@ import com.xinto.opencord.rest.dto.* fun DomainUserSettingsPartial.toApi(): ApiUserSettingsPartial { val apiPartialTheme = theme.map { it.value } - val apiPartialGuildPositions = guildPositions.map { guildPositions -> - guildPositions.map { ApiSnowflake(it) } - } val apiPartialStatus = status.map { it.value } val apiPartialFriendSourceFlags = friendSourceFlags.map { it.toApi() } val apiPartialGuildFolders = guildFolders.map { guildFolders -> @@ -30,7 +27,6 @@ fun DomainUserSettingsPartial.toApi(): ApiUserSettingsPartial { disableGamesTab = disableGamesTab, theme = apiPartialTheme, developerMode = developerMode, - guildPositions = apiPartialGuildPositions, detectPlatformAccounts = detectPlatformAccounts, status = apiPartialStatus, afkTimeout = afkTimeout, diff --git a/app/src/main/java/com/xinto/opencord/domain/mapper/RestEntityDomainMapper.kt b/app/src/main/java/com/xinto/opencord/domain/mapper/RestEntityDomainMapper.kt new file mode 100644 index 00000000..a9350d62 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/domain/mapper/RestEntityDomainMapper.kt @@ -0,0 +1,174 @@ +package com.xinto.opencord.domain.mapper + +import androidx.compose.ui.graphics.Color +import com.xinto.opencord.db.entity.channel.EntityChannel +import com.xinto.opencord.db.entity.guild.EntityGuild +import com.xinto.opencord.db.entity.message.EntityAttachment +import com.xinto.opencord.db.entity.message.EntityEmbed +import com.xinto.opencord.db.entity.message.EntityMessage +import com.xinto.opencord.db.entity.user.EntityUser +import com.xinto.opencord.domain.model.* +import com.xinto.opencord.rest.dto.ApiMessageType +import com.xinto.opencord.rest.dto.ApiPermissions +import com.xinto.opencord.rest.dto.fromValue +import com.xinto.opencord.rest.service.DiscordCdnServiceImpl +import kotlinx.datetime.Instant + +fun EntityMessage.toDomain( + author: DomainUser, + referencedMessage: DomainMessage?, + embeds: List?, + attachments: List?, +): DomainMessage { + return when (val type = ApiMessageType.fromValue(type)) { + ApiMessageType.Default, ApiMessageType.Reply -> { + DomainMessageRegular( + id = id, + channelId = channelId, + content = content, + author = author, + timestamp = Instant.fromEpochMilliseconds(timestamp), + pinned = pinned, + editedTimestamp = editedTimestamp?.let { Instant.fromEpochMilliseconds(it) }, + attachments = attachments ?: emptyList(), + embeds = embeds ?: emptyList(), + isReply = type == ApiMessageType.Reply, + referencedMessage = referencedMessage as? DomainMessageRegular, + mentionEveryone = mentionsEveryone, +// mentions = mentions.map { it.toDomain() }, + mentions = emptyList(), + ) + } + ApiMessageType.GuildMemberJoin -> { + DomainMessageMemberJoin( + id = id, + content = content, + channelId = channelId, + timestamp = Instant.fromEpochMilliseconds(timestamp), + pinned = pinned, + author = author, + ) + } + else -> DomainMessageUnknown( + id = id, + content = content, + channelId = channelId, + timestamp = Instant.fromEpochMilliseconds(timestamp), + pinned = pinned, + author = author, + ) + } +} + +fun EntityAttachment.toDomain(): DomainAttachment { + return if (contentType?.isNotEmpty() == true) { + when (contentType) { + "video/mp4" -> DomainAttachment.Video( + id = id, + filename = fileName, + size = size, + url = url, + proxyUrl = proxyUrl, + width = width ?: 100, + height = height ?: 100 + ) + else -> DomainAttachment.Picture( + id = id, + filename = fileName, + size = size, + url = url, + proxyUrl = proxyUrl, + width = width ?: 100, + height = height ?: 100 + ) + } + } else { + DomainAttachment.File( + id = id, + filename = fileName, + size = size, + url = url, + proxyUrl = proxyUrl, + ) + } +} + +fun EntityEmbed.toDomain(): DomainEmbed { + return DomainEmbed( + title = title, + description = description, + url = url, + color = color?.let { Color(it) }, + author = authorName?.let { DomainEmbedAuthor(it) }, + fields = fields?.map { it.toDomain() } + ) +} + +fun EntityUser.toDomain(): DomainUser { + val avatarUrl = avatarHash + ?.let { DiscordCdnServiceImpl.getUserAvatarUrl(id.toString(), it) } + ?: DiscordCdnServiceImpl.getDefaultAvatarUrl(discriminator.toInt().rem(5)) + + return DomainUserPublic( + id = id, + username = username, + discriminator = discriminator, + avatarUrl = avatarUrl, + bot = bot, + bio = bio, + flags = publicFlags, + pronouns = pronouns, + ) +} + +fun EntityChannel.toDomain(): DomainChannel { + return when (type) { + 2 -> DomainChannel.VoiceChannel( + id = id, + guildId = guildId, + name = name, + position = position, + parentId = parentId, + ) + 4 -> DomainChannel.Category( + id = id, + guildId = guildId, + name = name, + position = position, + ) + 5 -> DomainChannel.AnnouncementChannel( + id = id, + guildId = guildId, + name = name, + position = position, + parentId = parentId, + nsfw = nsfw + ) + else -> DomainChannel.TextChannel( + id = id, + guildId = guildId, + name = name, + position = position, + parentId = parentId, + nsfw = nsfw + ) + } +} + +fun EntityGuild.toDomain(): DomainGuild { + val iconUrl = icon?.let { icon -> + DiscordCdnServiceImpl.getGuildIconUrl(id.toString(), icon) + } + val bannerUrl = banner?.let { banner -> + DiscordCdnServiceImpl.getGuildBannerUrl(id.toString(), banner) + } + + return DomainGuild( + id = id, + name = name, + iconUrl = iconUrl, + bannerUrl = bannerUrl, + premiumTier = premiumTier, + premiumSubscriptionCount = premiumSubscriptionCount ?: 0, + ) +} diff --git a/app/src/main/java/com/xinto/opencord/domain/model/Channel.kt b/app/src/main/java/com/xinto/opencord/domain/model/Channel.kt index 6d3f3666..4fadacd9 100644 --- a/app/src/main/java/com/xinto/opencord/domain/model/Channel.kt +++ b/app/src/main/java/com/xinto/opencord/domain/model/Channel.kt @@ -6,13 +6,6 @@ sealed class DomainChannel : Comparable, Mentionable { abstract val name: String abstract val position: Int abstract val parentId: Long? - abstract val permissions: List - - val canView - get() = permissions.contains(DomainPermission.VIEW_CHANNEL) - - val canSendMessages - get() = permissions.contains(DomainPermission.SEND_MESSAGES) override val formattedMention get() = "<#$id>" @@ -23,7 +16,6 @@ sealed class DomainChannel : Comparable, Mentionable { override val name: String, override val position: Int, override val parentId: Long?, - override val permissions: List, val nsfw: Boolean, ) : DomainChannel() @@ -33,7 +25,6 @@ sealed class DomainChannel : Comparable, Mentionable { override val name: String, override val position: Int, override val parentId: Long?, - override val permissions: List, ) : DomainChannel() data class AnnouncementChannel( @@ -42,7 +33,6 @@ sealed class DomainChannel : Comparable, Mentionable { override val name: String, override val position: Int, override val parentId: Long?, - override val permissions: List, val nsfw: Boolean, ) : DomainChannel() @@ -51,7 +41,6 @@ sealed class DomainChannel : Comparable, Mentionable { override val guildId: Long?, override val name: String, override val position: Int, - override val permissions: List, ) : DomainChannel() { override val parentId: Long? = null diff --git a/app/src/main/java/com/xinto/opencord/domain/model/Guild.kt b/app/src/main/java/com/xinto/opencord/domain/model/Guild.kt index a34acfdc..ffb388ad 100644 --- a/app/src/main/java/com/xinto/opencord/domain/model/Guild.kt +++ b/app/src/main/java/com/xinto/opencord/domain/model/Guild.kt @@ -5,7 +5,6 @@ data class DomainGuild( val name: String, val iconUrl: String?, val bannerUrl: String?, - val permissions: List, val premiumTier: Int, val premiumSubscriptionCount: Int, ) { diff --git a/app/src/main/java/com/xinto/opencord/domain/model/Message.kt b/app/src/main/java/com/xinto/opencord/domain/model/Message.kt index c00d7101..a59c77cc 100644 --- a/app/src/main/java/com/xinto/opencord/domain/model/Message.kt +++ b/app/src/main/java/com/xinto/opencord/domain/model/Message.kt @@ -7,11 +7,11 @@ import kotlinx.datetime.Instant import org.koin.core.component.KoinComponent import org.koin.core.component.get -//@Partialize(parent = true) sealed class DomainMessage { abstract val id: Long abstract val channelId: Long abstract val timestamp: Instant + abstract val pinned: Boolean abstract val content: String abstract val author: DomainUser @@ -23,6 +23,7 @@ data class DomainMessageRegular( override val id: Long, override val channelId: Long, override val timestamp: Instant, + override val pinned: Boolean, override val content: String, override val author: DomainUser, val editedTimestamp: Instant?, @@ -44,6 +45,16 @@ data class DomainMessageMemberJoin( override val id: Long, override val channelId: Long, override val timestamp: Instant, + override val pinned: Boolean, override val content: String, override val author: DomainUser -): DomainMessage() \ No newline at end of file +): DomainMessage() + +data class DomainMessageUnknown( + override val id: Long, + override val channelId: Long, + override val timestamp: Instant, + override val pinned: Boolean, + override val content: String, + override val author: DomainUser +) : DomainMessage() diff --git a/app/src/main/java/com/xinto/opencord/domain/model/UserSettings.kt b/app/src/main/java/com/xinto/opencord/domain/model/UserSettings.kt index f58929de..0879c13f 100644 --- a/app/src/main/java/com/xinto/opencord/domain/model/UserSettings.kt +++ b/app/src/main/java/com/xinto/opencord/domain/model/UserSettings.kt @@ -23,7 +23,6 @@ data class DomainUserSettings( val disableGamesTab: Boolean, val theme: DomainThemeSetting, val developerMode: Boolean, - val guildPositions: List, val detectPlatformAccounts: Boolean, val status: DomainUserStatus, val afkTimeout: Int, diff --git a/app/src/main/java/com/xinto/opencord/domain/repository/DiscordApiRepository.kt b/app/src/main/java/com/xinto/opencord/domain/repository/DiscordApiRepository.kt deleted file mode 100644 index 723594bb..00000000 --- a/app/src/main/java/com/xinto/opencord/domain/repository/DiscordApiRepository.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.xinto.opencord.domain.repository - -import com.xinto.opencord.domain.mapper.toApi -import com.xinto.opencord.domain.mapper.toDomain -import com.xinto.opencord.domain.model.* -import com.xinto.opencord.rest.body.MessageBody -import com.xinto.opencord.rest.service.DiscordApiService - -interface DiscordApiRepository { - suspend fun getMeGuilds(): List - suspend fun getGuild(guildId: Long): DomainGuild - suspend fun getGuildChannels(guildId: Long): Map - - suspend fun getChannel(channelId: Long): DomainChannel - suspend fun getChannelMessages(channelId: Long): Map - suspend fun getChannelPins(channelId: Long): Map - - suspend fun postChannelMessage(channelId: Long, body: MessageBody) - - suspend fun getUserSettings(): DomainUserSettings - suspend fun updateUserSettings(settings: DomainUserSettingsPartial): DomainUserSettings - - suspend fun startTyping(channelId: Long) -} - -class DiscordApiRepositoryImpl( - private val service: DiscordApiService -) : DiscordApiRepository { - - override suspend fun getMeGuilds(): List { - return service.getMeGuilds().map { it.toDomain() } - } - - override suspend fun getGuild(guildId: Long): DomainGuild { - return service.getGuild(guildId).toDomain() - } - - override suspend fun getGuildChannels(guildId: Long): Map { - return service.getGuildChannels(guildId) - .toList().associate { - it.first.value to it.second.toDomain() - } - } - - override suspend fun getChannel(channelId: Long): DomainChannel { - return service.getChannel(channelId).toDomain() - } - - override suspend fun getChannelMessages(channelId: Long): Map { - return service.getChannelMessages(channelId) - .toList().associate { - it.first.value to it.second.toDomain() - } - } - - override suspend fun getChannelPins(channelId: Long): Map { - return service.getChannelPins(channelId) - .toList().associate { - it.first.value to it.second.toDomain() - } - } - - override suspend fun postChannelMessage(channelId: Long, body: MessageBody) { - service.postChannelMessage(channelId, body) - } - - override suspend fun getUserSettings(): DomainUserSettings { - return service.getUserSettings().toDomain() - } - - override suspend fun updateUserSettings(settings: DomainUserSettingsPartial): DomainUserSettings { - return service.updateUserSettings(settings.toApi()).toDomain() - } - - override suspend fun startTyping(channelId: Long) { - service.startTyping(channelId) - } -} diff --git a/app/src/main/java/com/xinto/opencord/domain/repository/DiscordAuthRepository.kt b/app/src/main/java/com/xinto/opencord/domain/repository/DiscordAuthRepository.kt deleted file mode 100644 index 9ed9642c..00000000 --- a/app/src/main/java/com/xinto/opencord/domain/repository/DiscordAuthRepository.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.xinto.opencord.domain.repository - -import com.xinto.opencord.domain.mapper.toDomain -import com.xinto.opencord.domain.model.DomainLogin -import com.xinto.opencord.rest.body.LoginBody -import com.xinto.opencord.rest.body.TwoFactorBody -import com.xinto.opencord.rest.service.DiscordAuthService - -interface DiscordAuthRepository { - - suspend fun login(body: LoginBody): DomainLogin - - suspend fun verifyTwoFactor(body: TwoFactorBody): DomainLogin - -} - -class DiscordAuthRepositoryImpl( - private val service: DiscordAuthService -) : DiscordAuthRepository { - - override suspend fun login(body: LoginBody): DomainLogin { - val result = service.login(body) - return result.toDomain() - } - - override suspend fun verifyTwoFactor(body: TwoFactorBody): DomainLogin { - val result = service.verifyTwoFactor(body) - return result.toDomain() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/gateway/DiscordGateway.kt b/app/src/main/java/com/xinto/opencord/gateway/DiscordGateway.kt index 98eecddf..86a87124 100644 --- a/app/src/main/java/com/xinto/opencord/gateway/DiscordGateway.kt +++ b/app/src/main/java/com/xinto/opencord/gateway/DiscordGateway.kt @@ -41,8 +41,6 @@ interface DiscordGateway : CoroutineScope { suspend fun connect() suspend fun disconnect() - fun getSessionId(): String - suspend fun requestGuildMembers(guildId: Long) suspend fun updatePresence(presence: UpdatePresence) } @@ -103,10 +101,6 @@ class DiscordGatewayImpl( _state.emit(DiscordGateway.State.Disconnected) } - override fun getSessionId(): String { - return sessionId - } - private suspend fun listenToSocket() { webSocketSession.incoming.receiveAsFlow().buffer(Channel.UNLIMITED).map { frame -> val jsonString = when (frame) { @@ -124,7 +118,7 @@ class DiscordGatewayImpl( try { json.decodeFromString(str) } catch (e: Exception) { -// e.printStackTrace() + logger.error("Gateway", "Failed to decode payload", e) null } } @@ -147,7 +141,7 @@ class DiscordGatewayImpl( _events.emit(decodedEvent) } } catch (e: Exception) { -// e.printStackTrace() + logger.error("Gateway", "Failed to decode event data", e) } } OpCode.Heartbeat -> {} diff --git a/app/src/main/java/com/xinto/opencord/gateway/dto/GuildDeleteData.kt b/app/src/main/java/com/xinto/opencord/gateway/dto/GuildDeleteData.kt new file mode 100644 index 00000000..eff194ec --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/gateway/dto/GuildDeleteData.kt @@ -0,0 +1,10 @@ +package com.xinto.opencord.gateway.dto + +import com.xinto.opencord.rest.dto.ApiSnowflake +import kotlinx.serialization.Serializable + +@Serializable +data class GuildDeleteData( + val id: ApiSnowflake, + val unavailable: Boolean, +) diff --git a/app/src/main/java/com/xinto/opencord/gateway/dto/Ready.kt b/app/src/main/java/com/xinto/opencord/gateway/dto/Ready.kt index a0892bc3..c66c1220 100644 --- a/app/src/main/java/com/xinto/opencord/gateway/dto/Ready.kt +++ b/app/src/main/java/com/xinto/opencord/gateway/dto/Ready.kt @@ -2,6 +2,7 @@ package com.xinto.opencord.gateway.dto import com.xinto.opencord.rest.dto.ApiGuild import com.xinto.opencord.rest.dto.ApiUser +import com.xinto.opencord.rest.dto.ApiUserSettings import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -10,12 +11,15 @@ data class Ready( @SerialName("user") val user: ApiUser, - @SerialName("session_id") - val sessionId: String, - @SerialName("guilds") val guilds: List, @SerialName("sessions") val sessions: List, + + @SerialName("session_id") + val sessionId: String, + + @SerialName("user_settings") + val userSettings: ApiUserSettings, ) diff --git a/app/src/main/java/com/xinto/opencord/gateway/event/Event.kt b/app/src/main/java/com/xinto/opencord/gateway/event/Event.kt index 4dc19c9b..e76c8bd2 100644 --- a/app/src/main/java/com/xinto/opencord/gateway/event/Event.kt +++ b/app/src/main/java/com/xinto/opencord/gateway/event/Event.kt @@ -1,5 +1,6 @@ package com.xinto.opencord.gateway.event +import com.xinto.opencord.gateway.dto.GuildDeleteData import com.xinto.opencord.gateway.dto.MessageDeleteData import com.xinto.opencord.gateway.dto.Ready import com.xinto.opencord.gateway.dto.SessionData @@ -48,7 +49,9 @@ class EventDeserializationStrategy( ) } EventName.GuildDelete -> { - TODO() + GuildDeleteEvent( + data = decoder.decodeSerializableValue(GuildDeleteData.serializer()) + ) } EventName.ChannelCreate -> { ChannelCreateEvent( diff --git a/app/src/main/java/com/xinto/opencord/gateway/event/Guild.kt b/app/src/main/java/com/xinto/opencord/gateway/event/Guild.kt index 8c85bae7..c6185cbf 100644 --- a/app/src/main/java/com/xinto/opencord/gateway/event/Guild.kt +++ b/app/src/main/java/com/xinto/opencord/gateway/event/Guild.kt @@ -1,5 +1,6 @@ package com.xinto.opencord.gateway.event +import com.xinto.opencord.gateway.dto.GuildDeleteData import com.xinto.opencord.rest.dto.ApiGuild data class GuildCreateEvent( @@ -10,3 +11,6 @@ data class GuildUpdateEvent( val data: ApiGuild ): Event +data class GuildDeleteEvent( + val data: GuildDeleteData +) : Event diff --git a/app/src/main/java/com/xinto/opencord/rest/dto/Channel.kt b/app/src/main/java/com/xinto/opencord/rest/dto/Channel.kt index 7bcb72ee..c7be9304 100644 --- a/app/src/main/java/com/xinto/opencord/rest/dto/Channel.kt +++ b/app/src/main/java/com/xinto/opencord/rest/dto/Channel.kt @@ -25,7 +25,4 @@ data class ApiChannel( @SerialName("nsfw") val nsfw: Boolean = false, - - @SerialName("permissions") - val permissions: ApiPermissions = ApiPermissions(0) ) diff --git a/app/src/main/java/com/xinto/opencord/rest/dto/Guild.kt b/app/src/main/java/com/xinto/opencord/rest/dto/Guild.kt index 6d51f4f6..1412726c 100644 --- a/app/src/main/java/com/xinto/opencord/rest/dto/Guild.kt +++ b/app/src/main/java/com/xinto/opencord/rest/dto/Guild.kt @@ -17,28 +17,12 @@ data class ApiGuild( @SerialName("banner") val banner: String? = null, - @SerialName("permissions") - val permissions: ApiPermissions = ApiPermissions(0), - @SerialName("premium_tier") val premiumTier: Int, @SerialName("premium_subscription_count") - val premiumSubscriptionCount: Int? = null -) - - -@Serializable -data class ApiMeGuild( - @SerialName("id") - val id: ApiSnowflake, + val premiumSubscriptionCount: Int? = null, - @SerialName("name") - val name: String, - - @SerialName("icon") - val icon: String?, - - @SerialName("permissions") - val permissions: ApiPermissions = ApiPermissions(0), -) \ No newline at end of file + @SerialName("channels") + val channels: List, +) diff --git a/app/src/main/java/com/xinto/opencord/rest/dto/Message.kt b/app/src/main/java/com/xinto/opencord/rest/dto/Message.kt index cce2433f..ea1c594f 100644 --- a/app/src/main/java/com/xinto/opencord/rest/dto/Message.kt +++ b/app/src/main/java/com/xinto/opencord/rest/dto/Message.kt @@ -24,6 +24,9 @@ data class ApiMessage( @SerialName("timestamp") val timestamp: Instant, + @SerialName("pinned") + val pinned: Boolean, + @SerialName("edited_timestamp") val editedTimestamp: Instant?, diff --git a/app/src/main/java/com/xinto/opencord/rest/dto/UserSettings.kt b/app/src/main/java/com/xinto/opencord/rest/dto/UserSettings.kt index 20b2b9ba..9de0b5ca 100644 --- a/app/src/main/java/com/xinto/opencord/rest/dto/UserSettings.kt +++ b/app/src/main/java/com/xinto/opencord/rest/dto/UserSettings.kt @@ -4,7 +4,6 @@ import com.github.materiiapps.partial.Partialize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -// TODO: check what the commented out parts are @Partialize @Serializable data class ApiUserSettings( @@ -14,11 +13,13 @@ data class ApiUserSettings( @SerialName("show_current_game") val showCurrentGame: Boolean, -// @SerialName("restricted_guilds") -// val restrictedGuilds: List + // servers with dms turned off + @SerialName("restricted_guilds") + val restrictedGuilds: List, -// @SerialName("default_guilds_restricted") -// val defaultGuildsRestricted: Boolean, + // turn off dms from servers automatically upon joining + @SerialName("default_guilds_restricted") + val defaultGuildsRestricted: Boolean, @SerialName("inline_attachment_media") val inlineAttachmentMedia: Boolean, @@ -60,9 +61,6 @@ data class ApiUserSettings( @SerialName("developer_mode") val developerMode: Boolean, - @SerialName("guild_positions") - val guildPositions: List, - @SerialName("detect_platform_accounts") val detectPlatformAccounts: Boolean, diff --git a/app/src/main/java/com/xinto/opencord/rest/service/DiscordApiService.kt b/app/src/main/java/com/xinto/opencord/rest/service/DiscordApiService.kt index 42a7e7dc..7f61710c 100644 --- a/app/src/main/java/com/xinto/opencord/rest/service/DiscordApiService.kt +++ b/app/src/main/java/com/xinto/opencord/rest/service/DiscordApiService.kt @@ -2,14 +2,9 @@ package com.xinto.opencord.rest.service import com.github.materiiapps.partial.getOrNull import com.xinto.opencord.BuildConfig -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.event.MessageCreateEvent -import com.xinto.opencord.gateway.event.MessageDeleteEvent -import com.xinto.opencord.gateway.event.MessageUpdateEvent -import com.xinto.opencord.gateway.event.UserSettingsUpdateEvent -import com.xinto.opencord.gateway.onEvent import com.xinto.opencord.rest.body.MessageBody import com.xinto.opencord.rest.dto.* +import com.xinto.opencord.util.queryParameters import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* @@ -17,99 +12,49 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext interface DiscordApiService { - suspend fun getMeGuilds(): List - suspend fun getGuild(guildId: Long): ApiGuild - suspend fun getGuildChannels(guildId: Long): Map - - suspend fun getChannel(channelId: Long): ApiChannel - suspend fun getChannelMessages(channelId: Long): Map - suspend fun getChannelPins(channelId: Long): Map + suspend fun getChannelPins(channelId: Long): List + suspend fun getChannelMessages( + channelId: Long, + limit: Long = 50, + before: Long? = null, + around: Long? = null, + after: Long? = null, + ): List suspend fun postChannelMessage(channelId: Long, body: MessageBody) - suspend fun getUserSettings(): ApiUserSettings suspend fun updateUserSettings(settings: ApiUserSettingsPartial): ApiUserSettings suspend fun startTyping(channelId: Long) } class DiscordApiServiceImpl( - gateway: DiscordGateway, - private val client: HttpClient + private val client: HttpClient, ) : DiscordApiService { - private val cachedMeGuilds = mutableListOf() - - private val cachedGuildById = mutableMapOf() - private val cachedChannelById = mutableMapOf() - - private val cachedGuildChannels = mutableMapOf>() - private val cachedChannelMessages = mutableMapOf>() - private val cachedChannelPins = mutableMapOf>() - - private var cachedUserSettings: ApiUserSettings? = null - - override suspend fun getMeGuilds(): List { - return withContext(Dispatchers.IO) { - if (cachedMeGuilds.isEmpty()) { - val url = getMeGuildsUrl() - val response: List = client.get(url).body() - cachedMeGuilds.addAll(response) - } - cachedMeGuilds - } - } - - override suspend fun getGuild(guildId: Long): ApiGuild { - return withContext(Dispatchers.IO) { - if (cachedGuildById[guildId] == null) { - val url = getGuildUrl(guildId) - val response: ApiGuild = client.get(url).body() - cachedGuildById[guildId] = response - } - cachedGuildById[guildId]!! - } - } - - override suspend fun getGuildChannels(guildId: Long): Map { + override suspend fun getChannelMessages( + channelId: Long, + limit: Long, + before: Long?, + around: Long?, + after: Long?, + ): List { return withContext(Dispatchers.IO) { - if (cachedGuildChannels[guildId] == null) { - val url = getGuildChannelsUrl(guildId) - val response: List = client.get(url).body() - cachedGuildChannels[guildId] = response.associateBy { it.id }.toMutableMap() - } - cachedGuildChannels[guildId]!! - } - } + val url = getChannelMessagesUrl( + channelId = channelId, + limit = limit, + before = before, + around = around, + after = after, + ) - override suspend fun getChannel(channelId: Long): ApiChannel { - return withContext(Dispatchers.IO) { - if (cachedChannelById[channelId] == null) { - val url = getChannelUrl(channelId) - cachedChannelById[channelId] = client.get(url).body() - } - cachedChannelById[channelId]!! + client.get(url).body() } } - override suspend fun getChannelMessages(channelId: Long): Map { + override suspend fun getChannelPins(channelId: Long): List { return withContext(Dispatchers.IO) { - if (cachedChannelMessages[channelId] == null) { - val url = getChannelMessagesUrl(channelId) - val response: List = client.get(url).body() - cachedChannelMessages[channelId] = response.associateBy { it.id }.toMutableMap() - } - cachedChannelMessages[channelId]!! - } - } - - override suspend fun getChannelPins(channelId: Long): Map { - return withContext(Dispatchers.IO) { - if (cachedChannelPins[channelId] == null) { - val url = getChannelPinsUrl(channelId) - val response: List = client.get(url).body() - cachedChannelPins[channelId] = response.associateBy { it.id }.toMutableMap() - } - cachedChannelPins[channelId]!! + val url = getChannelPinsUrl(channelId) + client.get(url).body() } } @@ -122,22 +67,11 @@ class DiscordApiServiceImpl( } } - override suspend fun getUserSettings(): ApiUserSettings { - return withContext(Dispatchers.IO) { - if (cachedUserSettings == null) { - cachedUserSettings = client.get(getUserSettingsUrl()).body() - } - cachedUserSettings!! - } - } - override suspend fun updateUserSettings(settings: ApiUserSettingsPartial): ApiUserSettings { return withContext(Dispatchers.IO) { client.patch(getUserSettingsUrl()) { setBody(settings) - }.body().also { - cachedUserSettings = it - } + }.body() } } @@ -148,59 +82,26 @@ class DiscordApiServiceImpl( } } - init { - gateway.onEvent { - val data = it.data - val channelId = data.channelId.value - cachedChannelMessages[channelId]?.put(data.id, data) - } - - gateway.onEvent { - val partialData = it.data - val id = partialData.id.getOrNull()!! - val channelId = partialData.channelId.getOrNull()!!.value - val mergedData = cachedChannelMessages[channelId]?.get(id).let { message -> - message?.merge(partialData) - } - if (mergedData != null) { - cachedChannelMessages[channelId]?.put(id, mergedData) - } - } - - gateway.onEvent { - val data = it.data - val channelId = data.channelId.value - cachedChannelMessages[channelId]?.remove(data.messageId) - } - - gateway.onEvent { - cachedUserSettings = cachedUserSettings?.merge(it.data) - } - } - private companion object { const val BASE = BuildConfig.URL_API - fun getMeGuildsUrl(): String { - return "$BASE/users/@me/guilds" - } - - fun getGuildUrl(guildId: Long): String { - return "$BASE/guilds/$guildId" - } - - fun getGuildChannelsUrl(guildId: Long): String { - val guildUrl = getGuildUrl(guildId) - return "$guildUrl/channels" - } - fun getChannelUrl(channelId: Long): String { return "$BASE/channels/$channelId" } - fun getChannelMessagesUrl(channelId: Long): String { - val channelUrl = getChannelUrl(channelId) - return "$channelUrl/messages" + fun getChannelMessagesUrl( + channelId: Long, + limit: Long? = null, + before: Long? = null, + around: Long? = null, + after: Long? = null, + ): String { + return getChannelUrl(channelId) + "/messages" + queryParameters { + before?.let { append("before", it.toString()) } + around?.let { append("around", it.toString()) } + after?.let { append("after", it.toString()) } + limit?.let { append("limit", limit.toString()) } + } } fun getChannelPinsUrl(channelId: Long): String { diff --git a/app/src/main/java/com/xinto/opencord/store/ChannelStore.kt b/app/src/main/java/com/xinto/opencord/store/ChannelStore.kt new file mode 100644 index 00000000..c6f37c0c --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/ChannelStore.kt @@ -0,0 +1,111 @@ +package com.xinto.opencord.store + +import com.xinto.opencord.db.database.CacheDatabase +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.mapper.toEntity +import com.xinto.opencord.domain.model.DomainChannel +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.event.ChannelCreateEvent +import com.xinto.opencord.gateway.event.ChannelDeleteEvent +import com.xinto.opencord.gateway.event.ChannelUpdateEvent +import com.xinto.opencord.gateway.event.ReadyEvent +import com.xinto.opencord.gateway.onEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.withContext + +interface ChannelStore { + fun observeChannel(channelId: Long): Flow> + fun observeChannels(guildId: Long): Flow> + + suspend fun fetchChannel(channelId: Long): DomainChannel? + suspend fun fetchChannels(guildId: Long): List +} + +class ChannelStoreImpl( + gateway: DiscordGateway, + private val cache: CacheDatabase, +) : ChannelStore { + private val events = MutableSharedFlow>() + + override fun observeChannel(channelId: Long): Flow> { + return events.filter { event -> + event.fold( + onAdd = { it.id == channelId }, + onUpdate = { it.id == channelId }, + onRemove = { it == channelId } + ) + } + } + + override fun observeChannels(guildId: Long): Flow> { + return events.filter { event -> + event.fold( + onAdd = { it.guildId == guildId }, + onUpdate = { it.guildId == guildId }, + onRemove = { it == guildId } + ) + } + } + + override suspend fun fetchChannel(channelId: Long): DomainChannel? { + return withContext(Dispatchers.IO) { + cache.channels().getChannel(channelId)?.toDomain() + } + } + + override suspend fun fetchChannels(guildId: Long): List { + return withContext(Dispatchers.IO) { + cache.channels().getChannels(guildId) + .map { it.toDomain() } + } + } + + init { + gateway.onEvent { event -> + val channels = event.data.guilds.flatMap { guild -> + guild.channels.map { it.toEntity(guild.id.value) } + } + + for (channel in channels) { + events.emit(Event.Add(channel.toDomain())) + } + + cache.runInTransaction { + cache.channels().apply { + clear() + insertChannels(channels) + } + } + } + + gateway.onEvent { + val guildId = it.data.guildId?.value + ?: error("no guild id on channel create event") + + events.emit(Event.Add(it.data.toDomain())) + cache.channels().insertChannels(listOf(it.data.toEntity(guildId))) + } + + gateway.onEvent { + val guildId = it.data.guildId?.value + ?: error("no guild id on channel update event") + + events.emit(Event.Update(it.data.toDomain())) + cache.channels().insertChannels(listOf(it.data.toEntity(guildId))) + } + + gateway.onEvent { + val channelId = it.data.id.value + + events.emit(Event.Remove(channelId)) + + cache.runInTransaction { + cache.channels().deleteChannel(channelId) + cache.messages().deleteByChannel(channelId) + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/CurrentUserStore.kt b/app/src/main/java/com/xinto/opencord/store/CurrentUserStore.kt new file mode 100644 index 00000000..a0c9a3dc --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/CurrentUserStore.kt @@ -0,0 +1,37 @@ +package com.xinto.opencord.store + +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.model.DomainUser +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.event.ReadyEvent +import com.xinto.opencord.gateway.onEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +// TODO: figure out how to get generic domain user with private data (private/ready event user different) +interface CurrentUserStore { + fun observeCurrentUser(): Flow + + suspend fun getCurrentUser(): DomainUser? +} + +class CurrentUserStoreImpl( + gateway: DiscordGateway, +) : CurrentUserStore { + private val events = MutableSharedFlow(replay = 1) + + // TODO: implement db caching in refactor + private var currentUser: DomainUser? = null + + override fun observeCurrentUser() = events + override suspend fun getCurrentUser() = currentUser + + init { + gateway.onEvent { event -> + event.data.user.toDomain().also { + currentUser = it + events.emit(it) + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/Event.kt b/app/src/main/java/com/xinto/opencord/store/Event.kt new file mode 100644 index 00000000..b3eaebd1 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/Event.kt @@ -0,0 +1,21 @@ +package com.xinto.opencord.store + +// TODO: Partialable once DomainMessagePartial can preserve inheritance +sealed interface Event { + data class Add(val data: T) : Event + data class Update(val data: T) : Event + data class Remove(val id: Long) : Event +} + +@Suppress("NOTHING_TO_INLINE") +inline fun Event.fold( + onAdd: (T) -> R, + onUpdate: (T) -> R, + onRemove: (Long) -> R, +): R { + return when (this) { + is Event.Add -> onAdd.invoke(data) + is Event.Update -> onUpdate.invoke(data) + is Event.Remove -> onRemove.invoke(id) + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/GuildStore.kt b/app/src/main/java/com/xinto/opencord/store/GuildStore.kt new file mode 100644 index 00000000..d973fdb7 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/GuildStore.kt @@ -0,0 +1,88 @@ +package com.xinto.opencord.store + +import com.xinto.opencord.db.database.CacheDatabase +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.mapper.toEntity +import com.xinto.opencord.domain.model.DomainGuild +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.event.GuildCreateEvent +import com.xinto.opencord.gateway.event.GuildDeleteEvent +import com.xinto.opencord.gateway.event.GuildUpdateEvent +import com.xinto.opencord.gateway.event.ReadyEvent +import com.xinto.opencord.gateway.onEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.withContext + +interface GuildStore { + fun observeGuild(guildId: Long): Flow> + fun observeGuilds(): Flow> + + suspend fun fetchGuild(guildId: Long): DomainGuild? +} + +class GuildStoreImpl( + gateway: DiscordGateway, + private val cache: CacheDatabase, +) : GuildStore { + private val events = MutableSharedFlow>() + + override fun observeGuild(guildId: Long): Flow> { + return events.filter { event -> + event.fold( + onAdd = { it.id == guildId }, + onUpdate = { it.id == guildId }, + onRemove = { it == guildId } + ) + } + } + + override fun observeGuilds() = events + + override suspend fun fetchGuild(guildId: Long): DomainGuild? { + return withContext(Dispatchers.IO) { + cache.guilds().getGuild(guildId)?.toDomain() + } + } + + init { + gateway.onEvent { event -> + val guilds = event.data.guilds.map { it.toEntity() } + + for (guild in guilds) { + events.emit(Event.Add(guild.toDomain())) + } + + cache.runInTransaction { + cache.guilds().apply { + clear() + insertGuilds(guilds) + } + } + } + + gateway.onEvent { + events.emit(Event.Add(it.data.toDomain())) + + cache.guilds().insertGuilds(listOf(it.data.toEntity())) + } + + gateway.onEvent { + events.emit(Event.Update(it.data.toDomain())) + cache.guilds().insertGuilds(listOf(it.data.toEntity())) + } + + gateway.onEvent { + val guildId = it.data.id.value + + events.emit(Event.Remove(guildId)) + + cache.runInTransaction { + cache.guilds().deleteGuild(guildId) + cache.channels().deleteChannelsByGuild(guildId) + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/MessageStore.kt b/app/src/main/java/com/xinto/opencord/store/MessageStore.kt new file mode 100644 index 00000000..0b8d0f32 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/MessageStore.kt @@ -0,0 +1,186 @@ +package com.xinto.opencord.store + +import androidx.room.withTransaction +import com.xinto.opencord.db.database.CacheDatabase +import com.xinto.opencord.db.entity.message.EntityMessage +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.mapper.toEntity +import com.xinto.opencord.domain.model.DomainMessage +import com.xinto.opencord.domain.model.DomainUser +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.event.MessageCreateEvent +import com.xinto.opencord.gateway.event.MessageDeleteEvent +import com.xinto.opencord.gateway.event.MessageUpdateEvent +import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.rest.dto.ApiMessage +import com.xinto.opencord.rest.service.DiscordApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.withContext + +interface MessageStore { + fun observeChannel(channelId: Long): Flow> + + suspend fun fetchPinnedMessages(channelId: Long): List + suspend fun fetchMessages( + channelId: Long, + after: Long? = null, + around: Long? = null, + before: Long? = null, + ): List +} + +class MessageStoreImpl( + gateway: DiscordGateway, + private val api: DiscordApiService, + private val cache: CacheDatabase, +) : MessageStore { + private val events = MutableSharedFlow>() + + override fun observeChannel(channelId: Long): Flow> { + return events.filter { event -> + event.fold( + onAdd = { it.id == channelId }, + onUpdate = { it.id == channelId }, + onRemove = { it == channelId }, + ) + } + } + + private suspend fun constructDomainMessage( + message: EntityMessage, + cachedUsers: MutableMap = mutableMapOf() + ): DomainMessage? { + return cache.withTransaction { + val attachments = if (!message.hasAttachments) null else { + cache.attachments().getAttachments(message.id) + } + + val referencedMessage = message.referencedMessageId?.let { + cache.messages().getMessage(it) + } + + val embeds = if (!message.hasEmbeds) null else { + cache.embeds().getEmbeds(message.id) + } + + val author = cachedUsers.computeIfAbsent(message.authorId) { + cache.users().getUser(message.authorId)?.toDomain() + } ?: return@withTransaction null + + message.toDomain( + author = author, + referencedMessage = referencedMessage?.let { + constructDomainMessage(it, cachedUsers) + }, + embeds = embeds?.map { it.toDomain() }, + attachments = attachments?.map { it.toDomain() }, + ) + } + } + + private suspend fun storeMessages(messages: List) { + cache.runInTransaction { + cache.users().apply { + val users = messages + .distinctBy { it.author.id } + .map { it.author.toEntity() } + + insertUsers(users) + } + + cache.messages().insertMessages(messages.map { it.toEntity() }) + + cache.attachments().insertAttachments(messages.flatMap { msg -> + msg.attachments.map { + it.toEntity(messageId = msg.id.value) + } + }) + + cache.embeds().insertEmbeds(messages.flatMap { msg -> + msg.embeds.mapIndexed { i, embed -> + embed.toEntity( + messageId = msg.id.value, + embedIndex = i, + ) + } + }) + } + } + + override suspend fun fetchPinnedMessages(channelId: Long): List { + return withContext(Dispatchers.IO) { + val pinsStored = cache.channels().isChannelPinsStored(channelId) + ?: false + + if (pinsStored) { + cache.messages().getPinnedMessages(channelId) + .mapNotNull { constructDomainMessage(it) } + } else { + val messages = api.getChannelPins(channelId) + + storeMessages(messages) + cache.channels().setChannelPinsStored(channelId, true) + messages.map { it.toDomain() } + } + } + } + + override suspend fun fetchMessages( + channelId: Long, + after: Long?, + around: Long?, + before: Long?, + ): List { + return withContext(Dispatchers.IO) { + val cachedMessages = when { + after != null -> cache.messages().getMessagesAfter(channelId, 50, after) + around != null -> cache.messages().getMessagesAround(channelId, 50, around) + before != null -> cache.messages().getMessagesBefore(channelId, 50, before) + else -> cache.messages().getMessagesLast(channelId, 50) + } + + if (cachedMessages.size >= 50) { + cachedMessages.mapNotNull { constructDomainMessage(it) } + } else { + val messages = api.getChannelMessages( + channelId = channelId, + limit = 50, + before = before, + around = around, + after = after, + ) + + storeMessages(messages) + messages.map { it.toDomain() } + } + } + } + + init { + gateway.onEvent { event -> + val message = event.data + + events.emit(Event.Add(message.toDomain())) + storeMessages(listOf(message)) + } + + gateway.onEvent { event -> + // TODO: figure out message updates + cache updating + } + + gateway.onEvent { + val messageId = it.data.messageId.value + + events.emit(Event.Remove(messageId)) + + cache.runInTransaction { + cache.messages().deleteMessage(messageId) + cache.attachments().deleteAttachments(messageId) + cache.embeds().deleteEmbeds(messageId) + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/SessionStore.kt b/app/src/main/java/com/xinto/opencord/store/SessionStore.kt new file mode 100644 index 00000000..29f54de0 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/SessionStore.kt @@ -0,0 +1,73 @@ +package com.xinto.opencord.store + +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.model.DomainActivity +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.dto.SessionData +import com.xinto.opencord.gateway.event.ReadyEvent +import com.xinto.opencord.gateway.event.SessionsReplaceEvent +import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.rest.dto.ApiActivity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +// TODO: proper session domain models in refactor +interface SessionStore { + fun observeSessions(): Flow> + fun observeCurrentSession(): Flow + fun observeActivities(): Flow> + + fun getActivities(): List? + fun getCurrentSession(): SessionData? +} + +class SessionStoreImpl( + gateway: DiscordGateway, +) : SessionStore { + private val events = MutableSharedFlow>(replay = 1) + private val activityEvents = MutableSharedFlow>() + + private var _sessionId: String? = null + private var _currentSession: SessionData? = null + private var _activities: List? = null + + override fun observeSessions() = events + + override fun observeCurrentSession(): Flow { + return events.map { event -> + event.find { it.sessionId == _sessionId } + ?: throw IllegalStateException("Active session not found in sessions list") + } + } + + override fun observeActivities() = activityEvents + override fun getActivities() = _activities + override fun getCurrentSession() = _currentSession + + private suspend fun handleSessionsUpdate(sessions: List) { + val newSessions = sessions + .filter { it.sessionId != "all" } + .also { events.emit(it) } + + newSessions + .flatMap { it.activities } + .map(ApiActivity::toDomain) + .also { _activities = it } + .also { activityEvents.emit(it) } + + _currentSession = newSessions + .find { it.sessionId == _sessionId } + } + + init { + gateway.onEvent { event -> + _sessionId = event.data.sessionId + handleSessionsUpdate(event.data.sessions) + } + + gateway.onEvent { + handleSessionsUpdate(it.data) + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/store/UserSettingsStore.kt b/app/src/main/java/com/xinto/opencord/store/UserSettingsStore.kt new file mode 100644 index 00000000..236d952e --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/UserSettingsStore.kt @@ -0,0 +1,63 @@ +package com.xinto.opencord.store + +import com.xinto.opencord.domain.mapper.toApi +import com.xinto.opencord.domain.mapper.toDomain +import com.xinto.opencord.domain.model.DomainUserSettings +import com.xinto.opencord.domain.model.DomainUserSettingsPartial +import com.xinto.opencord.domain.model.merge +import com.xinto.opencord.gateway.DiscordGateway +import com.xinto.opencord.gateway.event.ReadyEvent +import com.xinto.opencord.gateway.event.UserSettingsUpdateEvent +import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.rest.service.DiscordApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withContext + +interface UserSettingsStore { + fun observeUserSettings(): Flow + + suspend fun getUserSettings(): DomainUserSettings? + suspend fun updateUserSettings(settings: DomainUserSettingsPartial): DomainUserSettings +} + +class UserSettingsStoreImpl( + gateway: DiscordGateway, + private val api: DiscordApiService, +) : UserSettingsStore { + private val events = MutableSharedFlow(replay = 1) + + // TODO: implement db caching in refactor + private var userSettings: DomainUserSettings? = null + + override fun observeUserSettings() = events + override suspend fun getUserSettings() = userSettings + + override suspend fun updateUserSettings(settings: DomainUserSettingsPartial): DomainUserSettings { + return withContext(Dispatchers.IO) { + val newSettings = api.updateUserSettings(settings.toApi()) + + newSettings.toDomain().also { + userSettings = it + events.emit(it) + } + } + } + + init { + gateway.onEvent { event -> + event.data.userSettings.toDomain().also { + userSettings = it + events.emit(it) + } + } + + gateway.onEvent { event -> + userSettings?.merge(event.data.toDomain())?.also { + userSettings = it + events.emit(it) + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screen/Home.kt b/app/src/main/java/com/xinto/opencord/ui/screen/Home.kt index ac8a3b08..ccf4f5dd 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screen/Home.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screen/Home.kt @@ -43,7 +43,6 @@ fun HomeScreen( GuildsChannelsScreen( onGuildSelect = { channelsViewModel.load() - membersViewModel.load() }, onChannelSelect = chatViewModel::load, guildsViewModel = guildsViewModel, diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt index 8db5b0f0..297d9995 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt @@ -7,15 +7,14 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.manager.PersistentDataManager import com.xinto.opencord.domain.model.DomainMessage -import com.xinto.opencord.domain.repository.DiscordApiRepository +import com.xinto.opencord.store.MessageStore import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel import kotlinx.coroutines.launch class ChannelPinsViewModel( persistentDataManager: PersistentDataManager, - private val repository: DiscordApiRepository + private val messageStore: MessageStore, ) : BasePersistenceViewModel(persistentDataManager) { - sealed interface State { object Loading : State object Loaded : State @@ -30,14 +29,15 @@ class ChannelPinsViewModel( viewModelScope.launch { try { state = State.Loading - val pinnedMessages = repository.getChannelPins(persistentChannelId) + + val messages = messageStore.fetchPinnedMessages(persistentChannelId) pins.clear() - pins.putAll(pinnedMessages) + pins.putAll(messages.map { it.id to it }) + state = State.Loaded } catch (e: Exception) { e.printStackTrace() } } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt index 3d6a4594..4ee03bd9 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt @@ -3,23 +3,21 @@ package com.xinto.opencord.ui.viewmodel import androidx.compose.runtime.* import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.manager.PersistentDataManager -import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.DomainChannel -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.event.ChannelCreateEvent -import com.xinto.opencord.gateway.event.ChannelDeleteEvent -import com.xinto.opencord.gateway.event.ChannelUpdateEvent -import com.xinto.opencord.gateway.event.GuildUpdateEvent -import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.store.ChannelStore +import com.xinto.opencord.store.GuildStore +import com.xinto.opencord.store.fold import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel +import com.xinto.opencord.util.collectIn import com.xinto.opencord.util.getSortedChannels +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ChannelsViewModel( - gateway: DiscordGateway, persistentDataManager: PersistentDataManager, - private val repository: DiscordApiRepository + private val channelStore: ChannelStore, + private val guildStore: GuildStore, ) : BasePersistenceViewModel(persistentDataManager) { sealed interface State { @@ -45,21 +43,56 @@ class ChannelsViewModel( fun load() { viewModelScope.launch { - try { - state = State.Loading - val guildChannels = repository.getGuildChannels(persistentGuildId) - val guild = repository.getGuild(persistentGuildId) - channels.clear() - channels.putAll(guildChannels) - guildName = guild.name - guildBannerUrl = guild.bannerUrl - guildBoostLevel = guild.premiumTier - state = State.Loaded - } catch (e: Exception) { - state = State.Error - e.printStackTrace() + state = State.Loading + withContext(Dispatchers.IO) { + try { + val guild = guildStore.fetchGuild(persistentGuildId) ?: return@withContext + val guildChannels = channelStore.fetchChannels(persistentGuildId) + .associateBy { it.id } + + withContext(Dispatchers.Main) { + channels.clear() + channels.putAll(guildChannels) + guildName = guild.name + guildBannerUrl = guild.bannerUrl + guildBoostLevel = guild.premiumTier + state = State.Loaded + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + state = State.Error + } + } } } + + guildStore.observeGuild(persistentGuildId).collectIn(viewModelScope) { event -> + event.fold( + onAdd = { + guildName = it.name + guildBannerUrl = it.bannerUrl + guildBoostLevel = it.premiumTier + }, + onUpdate = { + guildName = it.name + guildBannerUrl = it.bannerUrl + guildBoostLevel = it.premiumTier + }, + onRemove = { + state = State.Unselected + }, + ) + } + + channelStore.observeChannels(persistentGuildId).collectIn(viewModelScope) { event -> + state = State.Loaded + event.fold( + onAdd = { channels[it.id] = it }, + onUpdate = { channels[it.id] = it }, + onRemove = { channels.remove(it) }, + ) + } } fun selectChannel(channelId: Long) { @@ -90,23 +123,5 @@ class ChannelsViewModel( selectedChannelId = persistentChannelId } collapsedCategories.addAll(persistentDataManager.collapsedCategories) - - gateway.onEvent({ it.data.id.value == persistentGuildId }) { - guildName = it.data.name - guildBannerUrl = it.data.banner - guildBoostLevel = it.data.premiumTier - } - - gateway.onEvent({ it.data.guildId?.value == persistentGuildId }) { - val domainData = it.data.toDomain() - channels[domainData.id] = domainData - } - gateway.onEvent({ it.data.guildId?.value == persistentGuildId }) { - val domainData = it.data.toDomain() - channels[domainData.id] = domainData - } - gateway.onEvent({ it.data.guildId?.value == persistentGuildId }) { - channels.remove(it.data.id.value) - } } } diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt index f69b5323..0e4dbd20 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt @@ -1,30 +1,27 @@ package com.xinto.opencord.ui.viewmodel import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.manager.PersistentDataManager -import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.DomainMessage -import com.xinto.opencord.domain.model.DomainMessageRegular -import com.xinto.opencord.domain.model.merge -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.event.MessageCreateEvent -import com.xinto.opencord.gateway.event.MessageDeleteEvent -import com.xinto.opencord.gateway.event.MessageUpdateEvent -import com.xinto.opencord.gateway.event.ReadyEvent -import com.xinto.opencord.gateway.onEvent import com.xinto.opencord.rest.body.MessageBody -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel +import com.xinto.opencord.rest.service.DiscordApiService +import com.xinto.opencord.store.ChannelStore +import com.xinto.opencord.store.MessageStore +import com.xinto.opencord.store.fold +import com.xinto.opencord.util.collectIn import com.xinto.opencord.util.throttle +import kotlinx.coroutines.Job import com.github.materiiapps.partial.getOrNull import kotlinx.coroutines.launch class ChatViewModel( - gateway: DiscordGateway, - persistentDataManager: PersistentDataManager, - private val repository: DiscordApiRepository -) : BasePersistenceViewModel(persistentDataManager) { + private val messageStore: MessageStore, + private val channelStore: ChannelStore, + private val api: DiscordApiService, + private val persistentDataManager: PersistentDataManager, +) : ViewModel() { sealed interface State { object Unselected : State @@ -46,25 +43,41 @@ class ChatViewModel( var currentUserId by mutableStateOf(null) private set + private var job: Job? = null + val startTyping = throttle(9500, viewModelScope) { - repository.startTyping(persistentChannelId) + api.startTyping(persistentDataManager.persistentChannelId) } fun load() { viewModelScope.launch { + state = State.Loading + try { - state = State.Loading - val channelMessages = repository.getChannelMessages(persistentChannelId) - val channel = repository.getChannel(persistentChannelId) - messages.clear() - messages.putAll(channelMessages) + val channel = channelStore.fetchChannel(persistentDataManager.persistentChannelId) + ?: return@launch + val channelMessages = + messageStore.fetchMessages(persistentDataManager.persistentChannelId) + channelName = channel.name + messages.clear() + messages.putAll(channelMessages.associateBy { it.id }) state = State.Loaded - } catch (e: Exception) { + } catch (t: Throwable) { + t.printStackTrace() state = State.Error - e.printStackTrace() } } + + job = messageStore + .observeChannel(persistentDataManager.persistentChannelId) + .collectIn(viewModelScope) { event -> + event.fold( + onAdd = { messages[it.id] = it }, + onUpdate = { messages[it.id] = it }, + onRemove = { messages.remove(it) }, + ) + } } fun sendMessage() { @@ -72,8 +85,8 @@ class ChatViewModel( sendEnabled = false val message = userMessage userMessage = "" - repository.postChannelMessage( - channelId = persistentChannelId, + api.postChannelMessage( + channelId = persistentDataManager.persistentChannelId, MessageBody( content = message ) @@ -91,41 +104,7 @@ class ChatViewModel( return messages.values.sortedByDescending { it.id } } - init { - gateway.onEvent { - currentUserId = it.data.user.id.value - } - - gateway.onEvent( - filterPredicate = { it.data.channelId.value == persistentChannelId } - ) { event -> - val domainData = event.data.toDomain() - messages[domainData.id] = domainData - } - - gateway.onEvent( - filterPredicate = { it.data.channelId.getOrNull()!!.value == persistentChannelId } - ) { event -> - val domainPartialData = event.data.toDomain() - val id = domainPartialData.id.getOrNull()!! - val mergedData = messages[id]?.let { - if (it is DomainMessageRegular) { - it.merge(domainPartialData) - } else null - } - if (mergedData != null) { - messages[id] = mergedData - } - } - - gateway.onEvent( - filterPredicate = { it.data.channelId.value == persistentChannelId } - ) { event -> - messages.remove(event.data.messageId.value) - } - - if (persistentChannelId != 0L) { - load() - } + override fun onCleared() { + job?.cancel() } } diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt index 9f57a4e8..9d6619a2 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt @@ -7,26 +7,23 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xinto.opencord.R -import com.xinto.opencord.domain.manager.CacheManager import com.xinto.opencord.domain.mapper.toApi -import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.* -import com.xinto.opencord.domain.repository.DiscordApiRepository import com.xinto.opencord.gateway.DiscordGateway import com.xinto.opencord.gateway.dto.UpdatePresence -import com.xinto.opencord.gateway.event.ReadyEvent -import com.xinto.opencord.gateway.event.SessionsReplaceEvent -import com.xinto.opencord.gateway.event.UserSettingsUpdateEvent -import com.xinto.opencord.gateway.event.UserUpdateEvent -import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.store.CurrentUserStore +import com.xinto.opencord.store.SessionStore +import com.xinto.opencord.store.UserSettingsStore +import com.xinto.opencord.util.collectIn import com.github.materiiapps.partial.Partial import kotlinx.coroutines.launch import kotlinx.datetime.Clock class CurrentUserViewModel( - val repository: DiscordApiRepository, - val gateway: DiscordGateway, - val cache: CacheManager, + private val gateway: DiscordGateway, + private val sessionStore: SessionStore, + private val currentUserStore: CurrentUserStore, + private val userSettingsStore: UserSettingsStore, ) : ViewModel() { sealed interface State { @@ -52,8 +49,6 @@ class CurrentUserViewModel( var isStreaming by mutableStateOf(false) private set - private var userSettings: DomainUserSettings? = null - fun setStatus(@DrawableRes icon: Int) { viewModelScope.launch { val status = when (icon) { @@ -69,29 +64,37 @@ class CurrentUserViewModel( status = status.value, afk = null, since = Clock.System.now().toEpochMilliseconds(), - activities = cache.getActivities().map { it.toApi() }, + activities = sessionStore.getActivities()?.map { it.toApi() } ?: return@launch, ) ) - val settings = DomainUserSettingsPartial(status = Partial.Value(status)) - repository.updateUserSettings(settings) + userSettingsStore.updateUserSettings( + DomainUserSettingsPartial( + status = Partial.Value(status) + ) + ) } } fun setCustomStatus(status: DomainCustomStatus?) { viewModelScope.launch { - val settings = DomainUserSettingsPartial( - customStatus = Partial.Value(status) + val currentStatus = sessionStore.getCurrentSession()?.status + ?: return@launch + val currentActivities = sessionStore.getActivities() + ?.filter { it !is DomainActivityCustom } + ?.toMutableList() + ?: return@launch + + userSettingsStore.updateUserSettings( + DomainUserSettingsPartial( + customStatus = Partial.Value(status) + ) ) - repository.updateUserSettings(settings) val currentMillis = Clock.System.now().toEpochMilliseconds() - val activities = cache.getActivities() - .filter { it !is DomainActivityCustom } - .toMutableList() if (status != null) { - activities += DomainActivityCustom( + currentActivities += DomainActivityCustom( name = "Custom Status", status = status.text, createdAt = currentMillis, @@ -107,50 +110,30 @@ class CurrentUserViewModel( gateway.updatePresence( UpdatePresence( - status = cache.getCurrentSession().status, + status = currentStatus, since = currentMillis, afk = null, - activities = activities.map { it.toApi() }, + activities = currentActivities.map { it.toApi() }, ) ) } } init { - gateway.onEvent { - val domainUser = it.data.user.toDomain() - avatarUrl = domainUser.avatarUrl - username = domainUser.username - discriminator = domainUser.formattedDiscriminator - } - gateway.onEvent { - val data = it.data.toDomain() as DomainUserPrivate - avatarUrl = data.avatarUrl - username = data.username - discriminator = data.formattedDiscriminator + currentUserStore.observeCurrentUser().collectIn(viewModelScope) { user -> + avatarUrl = user.avatarUrl + username = user.username + discriminator = user.discriminator + state = State.Loaded } - gateway.onEvent { - val mergedData = userSettings?.merge(it.data.toDomain()) - .also { mergedData -> userSettings = mergedData } - userStatus = mergedData?.status - userCustomStatus = mergedData?.customStatus - } - gateway.onEvent { - isStreaming = cache.getActivities() - .any { it is DomainActivityStreaming } + + userSettingsStore.observeUserSettings().collectIn(viewModelScope) { event -> + userStatus = event.status + userCustomStatus = event.customStatus } - viewModelScope.launch { - try { - val settings = repository.getUserSettings() - userSettings = settings - userStatus = settings.status - userCustomStatus = settings.customStatus - state = State.Loaded - } catch (e: Throwable) { - e.printStackTrace() - state = State.Error - } + sessionStore.observeActivities().collectIn(viewModelScope) { event -> + isStreaming = event.any { it is DomainActivityStreaming } } } } diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt index bd1c1108..241dd3ce 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt @@ -3,20 +3,15 @@ package com.xinto.opencord.ui.viewmodel import androidx.compose.runtime.* import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.manager.PersistentDataManager -import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.DomainGuild -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.event.GuildCreateEvent -import com.xinto.opencord.gateway.event.ReadyEvent -import com.xinto.opencord.gateway.onEvent +import com.xinto.opencord.store.GuildStore +import com.xinto.opencord.store.fold import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel -import kotlinx.coroutines.launch +import com.xinto.opencord.util.collectIn class GuildsViewModel( - gateway: DiscordGateway, + guildStore: GuildStore, persistentDataManager: PersistentDataManager, - private val repository: DiscordApiRepository ) : BasePersistenceViewModel(persistentDataManager) { sealed interface State { @@ -32,44 +27,23 @@ class GuildsViewModel( var selectedGuildId by mutableStateOf(0L) private set - fun load() { - viewModelScope.launch { - try { - state = State.Loading -// val meGuilds = repository.getMeGuilds() -// guilds.clear() -// guilds.addAll(meGuilds) - state = State.Loaded - } catch (e: Exception) { - state = State.Error - e.printStackTrace() - } - } - } - fun selectGuild(guildId: Long) { selectedGuildId = guildId persistentGuildId = guildId } init { - load() - - gateway.onEvent { event -> - event.data.guilds.forEach { - val domainGuild = it.toDomain() - guilds[domainGuild.id] = domainGuild - } - } - - gateway.onEvent { - val domainGuild = it.data.toDomain() - guilds[domainGuild.id] = domainGuild + guildStore.observeGuilds().collectIn(viewModelScope) { event -> + state = State.Loaded + event.fold( + onAdd = { guilds[it.id] = it }, + onUpdate = { guilds[it.id] = it }, + onRemove = { guilds.remove(it) }, + ) } if (persistentGuildId != 0L) { selectedGuildId = persistentGuildId } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt index f06884bd..6e91f662 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt @@ -7,14 +7,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.manager.AccountManager import com.xinto.opencord.domain.manager.ActivityManager +import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.DomainLogin -import com.xinto.opencord.domain.repository.DiscordAuthRepository import com.xinto.opencord.rest.body.LoginBody import com.xinto.opencord.rest.body.TwoFactorBody +import com.xinto.opencord.rest.service.DiscordAuthService import kotlinx.coroutines.launch class LoginViewModel( - private val repository: DiscordAuthRepository, + private val api: DiscordAuthService, private val activityManager: ActivityManager, private val accountManager: AccountManager ) : ViewModel() { @@ -59,13 +60,13 @@ class LoginViewModel( } try { - val response = repository.login( + val response = api.login( LoginBody( login = username, password = password, captchaKey = captchaToken ) - ) + ).toDomain() when (response) { is DomainLogin.Login -> { @@ -99,12 +100,12 @@ class LoginViewModel( try { showMfa = false - val response = repository.verifyTwoFactor( + val response = api.verifyTwoFactor( TwoFactorBody( code = code, ticket = mfaTicket ) - ) + ).toDomain() when (response) { is DomainLogin.Login -> { activityManager.startMainActivity() diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MembersViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/MembersViewModel.kt index 88035a17..6f55a4cd 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MembersViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/MembersViewModel.kt @@ -2,39 +2,11 @@ package com.xinto.opencord.ui.viewmodel import androidx.compose.runtime.mutableStateListOf import com.xinto.opencord.domain.manager.PersistentDataManager -import com.xinto.opencord.domain.mapper.toDomain import com.xinto.opencord.domain.model.DomainGuildMember -import com.xinto.opencord.domain.repository.DiscordApiRepository -import com.xinto.opencord.gateway.DiscordGateway -import com.xinto.opencord.gateway.event.GuildMemberChunkEvent -import com.xinto.opencord.gateway.onEvent -import com.xinto.opencord.gateway.scheduleOnConnection import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel class MembersViewModel( persistentDataManager: PersistentDataManager, - private val gateway: DiscordGateway, - private val repository: DiscordApiRepository, ) : BasePersistenceViewModel(persistentDataManager) { - val members = mutableStateListOf() - - fun load() { -// viewModelScope.launch { -// gateway.requestGuildMembers(persistentGuildId) -// } - } - - init { - gateway.onEvent { - val domainMembers = it.data.toDomain().guildMembers - members.addAll(domainMembers) - } - - if (persistentGuildId != 0L) { - gateway.scheduleOnConnection { - load() - } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/xinto/opencord/util/FlowUtil.kt b/app/src/main/java/com/xinto/opencord/util/FlowUtil.kt new file mode 100644 index 00000000..0555763a --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/util/FlowUtil.kt @@ -0,0 +1,11 @@ +package com.xinto.opencord.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +fun Flow.collectIn(scope: CoroutineScope, action: suspend (T) -> Unit): Job { + return this.onEach(action).launchIn(scope) +} diff --git a/app/src/main/java/com/xinto/opencord/util/KtorUtil.kt b/app/src/main/java/com/xinto/opencord/util/KtorUtil.kt new file mode 100644 index 00000000..0d70ea0e --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/util/KtorUtil.kt @@ -0,0 +1,14 @@ +package com.xinto.opencord.util + +import io.ktor.http.* + +fun queryParameters(internalSize: Int = 4, block: ParametersBuilder.() -> Unit): String { + val builder = ParametersBuilder(internalSize) + block(builder) + + return if (builder.isEmpty()) { + "" + } else { + "?${builder.build().formUrlEncode()}" + } +} diff --git a/app/src/main/java/com/xinto/opencord/util/Logger.kt b/app/src/main/java/com/xinto/opencord/util/Logger.kt index 15e3197e..9d8de5ed 100644 --- a/app/src/main/java/com/xinto/opencord/util/Logger.kt +++ b/app/src/main/java/com/xinto/opencord/util/Logger.kt @@ -33,7 +33,10 @@ class LoggerImpl : Logger { } override fun debug(tag: String, message: String) { - if (BuildConfig.DEBUG) - Log.d(tag, clean(message)) + if (!BuildConfig.DEBUG) return + + for (part in clean(message).chunked(4000)) { + Log.d(tag, part) + } } } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 65165e14..9b43d5c1 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -73,15 +73,17 @@ sealed class Dependencies { object AndroidxRoom : Dependencies() { const val version = "2.4.3" + const val roomCompiler = "androidx.room:room-compiler:$version" const val roomRuntime = "androidx.room:room-runtime:$version" const val roomKtx = "androidx.room:room-ktx:$version" - const val roomCompiler = "androidx.room:room-compiler:$version" + const val roomPaging = "androidx.room:room-paging:$version" override fun invoke(scope: DependencyHandlerScope) { scope { + ksp(roomCompiler) implementation(roomRuntime) implementation(roomKtx) - ksp(roomCompiler) + implementation(roomPaging) } } }