multiplatform: product specification (#6655)

This commit is contained in:
spaced4ndy
2026-02-26 17:54:44 +00:00
committed by GitHub
parent 3c27b5a29f
commit a09acda329
61 changed files with 9501 additions and 0 deletions
@@ -108,6 +108,7 @@ class ActiveCallState: Closeable {
}
// Spec: spec/services/calls.md#ActiveCallView
@SuppressLint("SourceLockedOrientationActivity")
@Composable
actual fun ActiveCallView() {
@@ -42,6 +42,7 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Spec: spec/client/navigation.md#AppScreen
@Composable
fun AppScreen() {
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
@@ -78,6 +79,7 @@ fun AppScreen() {
}
}
// Spec: spec/client/navigation.md#MainScreen
@Composable
fun MainScreen() {
val chatModel = ChatModel
@@ -289,6 +291,7 @@ fun AndroidWrapInCallLayout(content: @Composable () -> Unit) {
}
}
// Spec: spec/client/navigation.md#AndroidScreen
@Composable
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints {
@@ -402,6 +405,7 @@ fun EndPartOfScreen() {
ModalManager.end.showInView()
}
// Spec: spec/client/navigation.md#DesktopScreen
@Composable
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
@@ -13,6 +13,7 @@ import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
// Spec: spec/client/navigation.md#AppLock
object AppLock {
/**
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
@@ -81,6 +81,7 @@ val connectProgressManager = ConnectProgressManager
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
// Spec: spec/state.md#ChatModel
@Stable
object ChatModel {
val controller: ChatController = ChatController
@@ -334,6 +335,7 @@ object ChatModel {
}
}
// Spec: spec/state.md#ChatsContext
class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) {
val chats = mutableStateOf(SnapshotStateList<Chat>())
/** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on.
@@ -1321,6 +1323,7 @@ interface SomeChat {
val updatedAt: Instant
}
// Spec: spec/state.md#Chat
@Serializable @Stable
data class Chat(
val remoteHostId: Long?,
@@ -1362,6 +1365,7 @@ data class Chat(
true
}
// Spec: spec/state.md#ChatStats
@Serializable
data class ChatStats(
val unreadCount: Int = 0,
@@ -1382,6 +1386,7 @@ data class Chat(
}
}
// Spec: spec/state.md#ChatInfo
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
@@ -20,6 +20,7 @@ sealed class WriteFileResult {
}
* */
// Spec: spec/services/files.md#writeCryptoFile
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized")
val buffer = ByteBuffer.allocateDirect(data.size)
@@ -90,6 +90,7 @@ enum class SimplexLinkMode {
}
}
// Spec: spec/state.md#AppPreferences
class AppPreferences {
// deprecated, remove in 2024
private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
@@ -491,6 +492,7 @@ private const val MESSAGE_TIMEOUT: Int = 300_000_000
object ChatController {
private var chatCtrl: ChatCtrl? = -1
// Spec: spec/state.md#appPrefs
val appPrefs: AppPreferences by lazy { AppPreferences() }
val messagesChannel: Channel<API> = Channel()
@@ -654,6 +656,7 @@ object ChatController {
chatModel.updateChatTags(rhId)
}
// Spec: spec/api.md#startReceiver
private fun startReceiver() {
Log.d(TAG, "ChatController startReceiver")
if (receiverJob != null || chatCtrl == null) return
@@ -797,6 +800,7 @@ object ChatController {
return null
}
// Spec: spec/api.md#sendCmd
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API {
val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized")
@@ -821,6 +825,7 @@ object ChatController {
}
}
// Spec: spec/api.md#recvMsg
fun recvMsg(ctrl: ChatCtrl): API? {
val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
return if (rStr == "") {
@@ -2559,6 +2564,7 @@ object ChatController {
AlertManager.shared.showAlertMsg(title, errMsg)
}
// Spec: spec/api.md#processReceivedMsg
private suspend fun processReceivedMsg(msg: API) {
lastMsgReceivedTimestamp = System.currentTimeMillis()
val rhId = msg.rhId
@@ -3519,6 +3525,7 @@ class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
}
// ChatCommand
// Spec: spec/api.md#CC
sealed class CC {
class Console(val cmd: String): CC()
class ShowActiveUser: CC()
@@ -4150,9 +4157,11 @@ class UpdatedMessage(val msgContent: MsgContent, val mentions: Map<String, Long>
@Serializable
class ChatTagData(val emoji: String?, val text: String)
// Spec: spec/api.md#ArchiveConfig
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
// Spec: spec/database.md#DBEncryptionConfig
@Serializable
class DBEncryptionConfig(val currentKey: String, val newKey: String)
@@ -5960,6 +5969,7 @@ val yaml = Yaml(configuration = YamlConfiguration(
codePointLimit = 5500000,
))
// Spec: spec/api.md#API
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable(with = APISerializer::class)
sealed class API {
@@ -6099,6 +6109,7 @@ private fun <T> decodeObject(deserializer: DeserializationStrategy<T>, obj: Json
runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull()
// ChatResponse
// Spec: spec/api.md#CR
@Serializable
sealed class CR {
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
@@ -6958,6 +6969,7 @@ data class RemoteFile(
val fileSource: CryptoFile
)
// Spec: spec/api.md#ChatError
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
@@ -7641,6 +7653,7 @@ sealed class RCErrorType {
@Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType()
}
// Spec: spec/database.md#ArchiveError
@Serializable
sealed class ArchiveError {
val string: String get() = when (this) {
@@ -7722,6 +7735,7 @@ sealed class RemoteCtrlError {
@Serializable @SerialName("protocolError") object ProtocolError: RemoteCtrlError()
}
// Spec: spec/services/notifications.md#NotificationsMode
enum class NotificationsMode() {
OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */;
@@ -14,12 +14,14 @@ import java.io.File
import java.nio.ByteBuffer
// ghc's rts
// Spec: spec/architecture.md#initHS
external fun initHS()
// android-support
external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
// Spec: spec/architecture.md#chatMigrateInit
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
external fun chatCloseStore(ctrl: ChatCtrl): String
external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String
@@ -45,6 +47,7 @@ val appPreferences: AppPreferences
val chatController: ChatController = ChatController
// Spec: spec/architecture.md#initChatControllerOnStart
fun initChatControllerOnStart() {
withLongRunningApi {
if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) {
@@ -55,6 +58,7 @@ fun initChatControllerOnStart() {
}
}
// Spec: spec/architecture.md#initChatController
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred<Boolean> = { CompletableDeferred(true) }) {
Log.d(TAG, "initChatController")
try {
@@ -182,6 +186,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
}
// Spec: spec/architecture.md#chatInitTemporaryDatabase
fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair<DBMigrationResult, ChatCtrl?> {
val dbKey = key ?: randomDatabasePassword()
Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath")
@@ -193,6 +198,7 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation:
return res to migrated[1] as ChatCtrl
}
// Spec: spec/architecture.md#chatInitControllerRemovingDatabases
fun chatInitControllerRemovingDatabases() {
val dbPath = dbAbsolutePrefixPath
// Remove previous databases, otherwise, can be .errorNotADatabase with null controller
@@ -14,6 +14,7 @@ import java.net.URLEncoder
import java.nio.file.Files
import java.nio.file.StandardCopyOption
// Spec: spec/services/files.md#dataDir
expect val dataDir: File
expect val tmpDir: File
expect val filesDir: File
@@ -13,6 +13,7 @@ enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
}
// Spec: spec/services/notifications.md#ntfManager
lateinit var ntfManager: NtfManager
abstract class NtfManager {
@@ -22,6 +22,7 @@ import chat.simplex.res.MR
import kotlinx.serialization.Transient
import java.util.UUID
// Spec: spec/services/theme.md#DefaultTheme
enum class DefaultTheme {
LIGHT, DARK, SIMPLEX, BLACK;
@@ -47,6 +48,7 @@ enum class DefaultThemeMode {
@SerialName("dark") DARK
}
// Spec: spec/services/theme.md#AppColors
@Stable
class AppColors(
title: Color,
@@ -99,6 +101,7 @@ class AppColors(
}
}
// Spec: spec/services/theme.md#AppWallpaper
@Stable
class AppWallpaper(
background: Color? = null,
@@ -133,6 +136,7 @@ class AppWallpaper(
}
}
// Spec: spec/services/theme.md#ThemeColor
enum class ThemeColor {
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT;
@@ -174,6 +178,7 @@ enum class ThemeColor {
}
}
// Spec: spec/services/theme.md#ThemeColors
@Serializable
data class ThemeColors(
@SerialName("accent")
@@ -214,6 +219,7 @@ data class ThemeColors(
}
}
// Spec: spec/services/theme.md#ThemeWallpaper
@Serializable
data class ThemeWallpaper (
val preset: String? = null,
@@ -293,6 +299,7 @@ data class ThemesFile(
val themes: List<ThemeOverrides> = emptyList()
)
// Spec: spec/services/theme.md#ThemeOverrides
@Serializable
data class ThemeOverrides (
val themeId: String = UUID.randomUUID().toString(),
@@ -463,6 +470,7 @@ fun List<ThemeOverrides>.skipDuplicates(): List<ThemeOverrides> {
return res
}
// Spec: spec/services/theme.md#ThemeModeOverrides
@Serializable
data class ThemeModeOverrides (
val light: ThemeModeOverride? = null,
@@ -474,6 +482,7 @@ data class ThemeModeOverrides (
}
}
// Spec: spec/services/theme.md#ThemeModeOverride
@Serializable
data class ThemeModeOverride (
val mode: DefaultThemeMode = CurrentColors.value.base.mode,
@@ -714,6 +723,7 @@ val BlackColorPaletteApp = AppColors(
var systemInDarkThemeCurrently: Boolean = isInNightMode()
// Spec: spec/services/theme.md#CurrentColors
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()))
@Composable
@@ -758,6 +768,7 @@ fun reactOnDarkThemeChanges(isDark: Boolean) {
}
}
// Spec: spec/services/theme.md#SimpleXTheme
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
// TODO: Fix preview working with dark/light theme
@@ -53,6 +53,7 @@ object ThemeManager {
?: ThemeWallpaper.from(PresetWallpaper.SCHOOL.toType(CurrentColors.value.base), null, null))
}
// Spec: spec/services/theme.md#currentColors
fun currentColors(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?, appSettingsTheme: List<ThemeOverrides>): ActiveTheme {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = nonSystemThemeName()
@@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import kotlinx.coroutines.*
// Spec: spec/services/calls.md#ActiveCallView
@Composable
expect fun ActiveCallView()
@@ -22,6 +22,7 @@ import chat.simplex.common.views.usersettings.ProfilePreview
import chat.simplex.res.MR
import kotlinx.datetime.Clock
// Spec: spec/services/calls.md#IncomingCallAlertView
@Composable
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
@@ -46,6 +46,7 @@ data class Call(
get() = localMediaSources.hasVideo || peerMediaSources.hasVideo
}
// Spec: spec/services/calls.md#CallState
enum class CallState {
WaitCapabilities,
InvitationSent,
@@ -93,6 +93,7 @@ fun ConnectInProgressView(s: String) {
@Composable
// staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat
// to chat list smooth. Otherwise, chat view will become blank right before the transition starts
// Spec: spec/client/chat-view.md#ChatView
fun ChatView(
chatsCtx: ChatModel.ChatsContext,
staleChatId: State<String?>,
@@ -47,6 +47,7 @@ import kotlin.math.min
const val MAX_NUMBER_OF_MENTIONS = 3
// Spec: spec/client/compose.md#ComposePreview
@Serializable
sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@@ -92,6 +93,7 @@ object ComposeMessageSerializer : KSerializer<TextRange> {
decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) }
}
// Spec: spec/client/compose.md#ComposeState
@Serializable
data class ComposeState(
val message: ComposeMessage = ComposeMessage(),
@@ -259,6 +261,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
}
}
// Spec: spec/client/compose.md#AttachmentSelection
@Composable
expect fun AttachmentSelection(
composeState: MutableState<ComposeState>,
@@ -341,6 +344,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
}
}
// Spec: spec/client/compose.md#ComposeView
@Composable
fun ComposeView(
rhId: Long?,
@@ -31,6 +31,7 @@ import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.*
import java.net.URI
// Spec: spec/client/compose.md#SendMsgView
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
@@ -61,6 +61,7 @@ data class ChatItemReactionMenuItem (
val onClick: (() -> Unit)?
)
// Spec: spec/client/chat-view.md#ChatItemView
@Composable
fun ChatItemView(
chatsCtx: ChatModel.ChatsContext,
@@ -32,6 +32,7 @@ import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
// Spec: spec/client/chat-list.md#ChatListNavLinkView
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
@@ -122,6 +122,7 @@ fun ToggleChatListCard() {
}
}
// Spec: spec/client/chat-list.md#ChatListView
@Composable
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
@@ -35,6 +35,7 @@ import chat.simplex.common.views.chat.item.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
// Spec: spec/client/chat-list.md#ChatPreviewView
@Composable
fun ChatPreviewView(
chat: Chat,
@@ -43,6 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
// Spec: spec/client/chat-list.md#TagListView
@Composable
fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) {
val userTags = remember { chatModel.userTags }
@@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.*
private val USER_PICKER_SECTION_SPACING = 32.dp
// Spec: spec/client/chat-list.md#UserPicker
@Composable
fun UserPicker(
chatModel: ChatModel,
@@ -74,6 +74,7 @@ object DatabaseUtils {
}
}
// Spec: spec/database.md#DBMigrationResult
@Serializable
sealed class DBMigrationResult {
@Serializable @SerialName("ok") object OK: DBMigrationResult()
@@ -114,6 +114,7 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr
expect fun SetupClipboardListener()
// maximum image file size to be auto-accepted
// Spec: spec/services/files.md#MAX_IMAGE_SIZE
const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
@@ -23,6 +23,7 @@ private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
val connections = ArrayList<WebSocket>()
// Spec: spec/services/calls.md#ActiveCallView
@Composable
actual fun ActiveCallView() {
val scope = rememberCoroutineScope()