diff --git a/.gitignore b/.gitignore index e645225e9..42477be20 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ stack.yaml.lock # Temporary test files tests/tmp +logs/ diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index d0b5c8a8d..ca49f575c 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 36 - versionName "2.2" + versionCode 41 + versionName "3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index 3a0a54723..ed33745c0 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -24,8 +24,8 @@ var TransformOperation; let activeCall; const processCommand = (function () { const defaultIceServers = [ - { urls: ["stun:stun.simplex.chat:5349"] }, - { urls: ["turn:turn.simplex.chat:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, + { urls: ["stun:stun.simplex.im:5349"] }, + { urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, ]; function getCallConfig(encodedInsertableStreams, iceServers, relay) { return { diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c index e9178df5c..5be1d1fad 100644 --- a/apps/android/app/src/main/cpp/simplex-api.c +++ b/apps/android/app/src/main/cpp/simplex-api.c @@ -24,9 +24,11 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass // from simplex-chat typedef void* chat_ctrl; -extern chat_ctrl chat_init(const char * path); +extern chat_ctrl chat_init(const char *path); extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); extern char *chat_recv_msg(chat_ctrl ctrl); +extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); +extern char *chat_parse_markdown(const char *str); JNIEXPORT jlong JNICALL Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) { @@ -48,3 +50,16 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { + return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) { + const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str)); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index a724d0fda..e0039a67b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -305,7 +305,8 @@ fun MainPage( if (chatModel.showCallView.value) ActiveCallView(chatModel) else { showAdvertiseLAAlert = true - if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) }) + val stopped = chatModel.chatRunning.value == false + if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped) else ChatView(chatModel) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 73ecd073d..e0eded442 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -24,8 +24,10 @@ external fun pipeStdOutToSocket(socketName: String) : Int // SimpleX API typealias ChatCtrl = Long external fun chatInit(path: String): ChatCtrl -external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String -external fun chatRecvMsg(ctrl: ChatCtrl) : String +external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String +external fun chatRecvMsg(ctrl: ChatCtrl): String +external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String +external fun chatParseMarkdown(str: String): String class SimplexApp: Application(), LifecycleEventObserver { val chatController: ChatController by lazy { @@ -55,7 +57,6 @@ class SimplexApp: Application(), LifecycleEventObserver { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } else { chatController.startChat(user) - SimplexService.start(applicationContext) chatController.showBackgroundServiceNoticeIfNeeded() } } @@ -66,13 +67,14 @@ class SimplexApp: Application(), LifecycleEventObserver { withApi { when (event) { Lifecycle.Event.ON_STOP -> - if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext) + if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext) Lifecycle.Event.ON_START -> - SimplexService.start(applicationContext) + SimplexService.stop(applicationContext) Lifecycle.Event.ON_RESUME -> if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { chatController.showBackgroundServiceNoticeIfNeeded() } + else -> {} } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index 94ae6a737..6163e6d97 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -7,6 +7,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.* +import chat.simplex.app.model.AppPreferences import chat.simplex.app.views.helpers.withApi import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers @@ -20,6 +21,7 @@ class SimplexService: Service() { private var wakeLock: PowerManager.WakeLock? = null private var isServiceStarted = false private var isStartingService = false + private var isStoppingService = false private var notificationManager: NotificationManager? = null private var serviceNotification: Notification? = null private val chatController by lazy { (application as SimplexApp).chatController } @@ -47,7 +49,6 @@ class SimplexService: Service() { val text = getString(R.string.simplex_service_notification_text) notificationManager = createNotificationChannel() serviceNotification = createNotification(title, text) - startForeground(SIMPLEX_SERVICE_ID, serviceNotification) } @@ -71,7 +72,6 @@ class SimplexService: Service() { } else { Log.w(TAG, "Starting foreground service") chatController.startChat(user) - chatController.startReceiver() isServiceStarted = true saveServiceState(self, ServiceState.STARTED) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { @@ -88,6 +88,8 @@ class SimplexService: Service() { private fun stopService() { Log.d(TAG, "Stopping foreground service") + if (isStoppingService) return + isStoppingService = true try { wakeLock?.let { while (it.isHeld) it.release() // release all, in case acquired more than once @@ -98,7 +100,7 @@ class SimplexService: Service() { } catch (e: Exception) { Log.d(TAG, "Service stopped without being started: ${e.message}") } - + isStoppingService = false isServiceStarted = false saveServiceState(this, ServiceState.STOPPED) } @@ -121,7 +123,7 @@ class SimplexService: Service() { PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) } return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ntf_icon) + .setSmallIcon(R.drawable.ntf_service_icon) .setColor(0x88FFFF) .setContentTitle(title) .setContentText(text) @@ -213,6 +215,7 @@ class SimplexService: Service() { suspend fun stop(context: Context) = serviceAction(context, Action.STOP) private suspend fun serviceAction(context: Context, action: Action) { + if (!AppPreferences(context).runServiceInBackground.get()) { return } Log.d(TAG, "SimplexService serviceAction: ${action.name}") withContext(Dispatchers.IO) { Intent(context, SimplexService::class.java).also { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 8f27073ca..b911edc4a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -23,6 +23,8 @@ class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) val userCreated = mutableStateOf(null) + val chatRunning = mutableStateOf(null) + val chatDbChanged = mutableStateOf(false) val chats = mutableStateListOf() val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() @@ -45,8 +47,8 @@ class ChatModel(val controller: ChatController) { // current WebRTC call val callManager = CallManager(this) - val callInvitations = mutableStateMapOf() - val activeCallInvitation = mutableStateOf(null) + val callInvitations = mutableStateMapOf() + val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val callCommand = mutableStateOf(null) val showCallView = mutableStateOf(false) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index dc9ebe512..c64b1c8d2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -102,7 +102,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference } } - fun notifyCallInvitation(invitation: CallInvitation) { + fun notifyCallInvitation(invitation: RcvCallInvitation) { if (isAppOnForeground(context)) return val contactId = invitation.contact.id Log.d(TAG, "notifyCallInvitation $contactId") @@ -124,7 +124,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference .setSound(soundUri) } val text = generalGetString( - if (invitation.peerMedia == CallMediaType.Video) { + if (invitation.callType.media == CallMediaType.Video) { if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call } else { if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f1f66fbeb..a9b9ec8aa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -81,6 +81,9 @@ class AppPreferences(val context: Context) { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) + val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) + val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) + val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) private fun mkIntPreference(prefName: String, default: Int) = Preference( @@ -100,6 +103,15 @@ class AppPreferences(val context: Context) { set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() ) + private fun mkDatePreference(prefName: String, default: Instant?): Preference = + Preference( + get = { + val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString()) + pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) } + }, + set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply() + ) + companion object { private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" @@ -113,12 +125,17 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" - + private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" + private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" + private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" } } +private const val MESSAGE_TIMEOUT: Int = 15_000_000 + open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) { val chatModel = ChatModel(this) + private var receiverStarted = false init { chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get() @@ -128,12 +145,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager suspend fun startChat(user: User) { Log.d(TAG, "user: $user") try { - val chatStarted = apiStartChat() + val justStarted = apiStartChat() apiSetFilesFolder(getAppFilesDirectory(appContext)) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() val chats = apiGetChats() - if (chatStarted) { + if (justStarted) { chatModel.chats.clear() chatModel.chats.addAll(chats) } else { @@ -142,6 +159,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager chatModel.currentUser.value = user chatModel.userCreated.value = true chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) + chatModel.chatRunning.value = true + startReceiver() Log.d(TAG, "chat started") } catch (e: Error) { Log.e(TAG, "failed starting chat $e") @@ -149,8 +169,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - fun startReceiver() { + private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") + if (receiverStarted) return thread(name="receiver") { GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } } } @@ -176,18 +197,23 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - suspend fun recvMsg(): CR { + private suspend fun recvMsg(): CR? { return withContext(Dispatchers.IO) { - val json = chatRecvMsg(ctrl) - val r = APIResponse.decodeStr(json).resp - Log.d(TAG, "chatRecvMsg: ${r.responseType}") - if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - r + val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) + if (json == "") { + null + } else { + val r = APIResponse.decodeStr(json).resp + Log.d(TAG, "chatRecvMsg: ${r.responseType}") + if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") + r + } } } - suspend fun recvMspLoop() { - processReceivedMsg(recvMsg()) + private suspend fun recvMspLoop() { + val msg = recvMsg() + if (msg != null) processReceivedMsg(msg) recvMspLoop() } @@ -215,13 +241,39 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - suspend fun apiSetFilesFolder(filesFolder: String) { + suspend fun apiStopChat(): Boolean { + val r = sendCmd(CC.ApiStopChat()) + when (r) { + is CR.ChatStopped -> return true + else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") + } + } + + private suspend fun apiSetFilesFolder(filesFolder: String) { val r = sendCmd(CC.SetFilesFolder(filesFolder)) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } - suspend fun apiGetChats(): List { + suspend fun apiExportArchive(config: ArchiveConfig) { + val r = sendCmd(CC.ApiExportArchive(config)) + if (r is CR.CmdOk) return + throw Error("failed to export archive: ${r.responseType} ${r.details}") + } + + suspend fun apiImportArchive(config: ArchiveConfig) { + val r = sendCmd(CC.ApiImportArchive(config)) + if (r is CR.CmdOk) return + throw Error("failed to import archive: ${r.responseType} ${r.details}") + } + + suspend fun apiDeleteStorage() { + val r = sendCmd(CC.ApiDeleteStorage()) + if (r is CR.CmdOk) return + throw Error("failed to delete storage: ${r.responseType} ${r.details}") + } + + private suspend fun apiGetChats(): List { val r = sendCmd(CC.ApiGetChats()) if (r is CR.ApiChats ) return r.chats throw Error("failed getting the list of chats: ${r.responseType} ${r.details}") @@ -256,7 +308,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } - suspend fun getUserSMPServers(): List? { + private suspend fun getUserSMPServers(): List? { val r = sendCmd(CC.GetUserSMPServers()) if (r is CR.UserSMPServers) return r.smpServers Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}") @@ -383,7 +435,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return false } - suspend fun apiGetUserAddress(): String? { + private suspend fun apiGetUserAddress(): String? { val r = sendCmd(CC.ShowMyAddress()) if (r is CR.UserContactLink) return r.connReqContact if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore @@ -566,10 +618,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager removeFile(appContext, fileName) } } - is CR.CallInvitation -> { - val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey, r.callTs) - chatModel.callManager.reportNewIncomingCall(invitation) - } + is CR.CallInvitation -> + chatModel.callManager.reportNewIncomingCall(r.callInvitation) is CR.CallOffer -> { // TODO askConfirmation? // TODO check encryption is compatible @@ -837,7 +887,11 @@ sealed class CC { class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() class StartChat: CC() + class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() + class ApiExportArchive(val config: ArchiveConfig): CC() + class ApiImportArchive(val config: ArchiveConfig): CC() + class ApiDeleteStorage: CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() @@ -871,7 +925,11 @@ sealed class CC { is ShowActiveUser -> "/u" is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start" + is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" + is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" + is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" + is ApiDeleteStorage -> "/_db delete" is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" @@ -906,7 +964,11 @@ sealed class CC { is ShowActiveUser -> "showActiveUser" is CreateActiveUser -> "createActiveUser" is StartChat -> "startChat" + is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" + is ApiExportArchive -> "apiExportArchive" + is ApiImportArchive -> "apiImportArchive" + is ApiDeleteStorage -> "apiDeleteStorage" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" @@ -948,6 +1010,9 @@ sealed class CC { @Serializable class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) +@Serializable +class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -981,6 +1046,7 @@ sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() @Serializable @SerialName("chatStarted") class ChatStarted: CR() @Serializable @SerialName("chatRunning") class ChatRunning: CR() + @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() @@ -1022,7 +1088,7 @@ sealed class CR { @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() - @Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant): CR() + @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR() @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() @@ -1039,6 +1105,7 @@ sealed class CR { is ActiveUser -> "activeUser" is ChatStarted -> "chatStarted" is ChatRunning -> "chatRunning" + is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" is UserSMPServers -> "userSMPServers" @@ -1098,6 +1165,7 @@ sealed class CR { is ActiveUser -> json.encodeToString(user) is ChatStarted -> noDetails() is ChatRunning -> noDetails() + is ChatStopped -> noDetails() is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) is UserSMPServers -> json.encodeToString(smpServers) @@ -1139,7 +1207,7 @@ sealed class CR { is SndFileRcvCancelled -> json.encodeToString(chatItem) is SndFileStart -> json.encodeToString(chatItem) is SndGroupFileCancelled -> json.encodeToString(chatItem) - is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}" + is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}" is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}" is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 52340e2ac..46709dd1c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -17,8 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.ComposeState -import chat.simplex.app.views.chat.SendMsgView +import chat.simplex.app.views.chat.* import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding @@ -82,6 +82,10 @@ fun TerminalLayout( @Composable fun TerminalLog(terminalItems: List) { val listState = rememberLazyListState() + val keyboardState by getKeyboardState() + val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { + mutableStateOf(CIListState(false, terminalItems.count(), keyboardState)) + } val scope = rememberCoroutineScope() LazyColumn(state = listState) { items(terminalItems) { item -> @@ -101,8 +105,9 @@ fun TerminalLog(terminalItems: List) { ) } val len = terminalItems.count() - if (len > 1) { + if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { scope.launch { + ciListState.value = CIListState(true, len, keyboardState) listState.animateScrollToItem(len - 1) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 833977b98..862e47684 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -114,7 +114,6 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) { Profile(displayName, fullName, null) ) chatModel.controller.startChat(user) - SimplexService.start(chatModel.controller.appContext) chatModel.controller.showBackgroundServiceNoticeIfNeeded() chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt index 78f60ab1e..441b97d6b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -9,11 +9,10 @@ import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes class CallManager(val chatModel: ChatModel) { - fun reportNewIncomingCall(invitation: CallInvitation) { + fun reportNewIncomingCall(invitation: RcvCallInvitation) { Log.d(TAG, "CallManager.reportNewIncomingCall") with (chatModel) { callInvitations[invitation.contact.id] = invitation - if (!chatModel.controller.appPrefs.experimentalCalls.get()) return if (Clock.System.now() - invitation.callTs <= 3.minutes) { activeCallInvitation.value = invitation controller.ntfManager.notifyCallInvitation(invitation) @@ -24,7 +23,7 @@ class CallManager(val chatModel: ChatModel) { } } - fun acceptIncomingCall(invitation: CallInvitation) { + fun acceptIncomingCall(invitation: RcvCallInvitation) { ModalManager.shared.closeModals() val call = chatModel.activeCall.value if (call == null) { @@ -42,17 +41,17 @@ class CallManager(val chatModel: ChatModel) { } } - private fun justAcceptIncomingCall(invitation: CallInvitation) { + private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { with (chatModel) { activeCall.value = Call( contact = invitation.contact, callState = CallState.InvitationAccepted, - localMedia = invitation.peerMedia, + localMedia = invitation.callType.media, sharedKey = invitation.sharedKey ) showCallView.value = true val useRelay = controller.appPrefs.webrtcPolicyRelay.get() - callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey, relay = useRelay) + callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null @@ -77,7 +76,7 @@ class CallManager(val chatModel: ChatModel) { } } - fun endCall(invitation: CallInvitation) { + fun endCall(invitation: RcvCallInvitation) { with (chatModel) { callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { @@ -92,7 +91,7 @@ class CallManager(val chatModel: ChatModel) { } } - fun reportCallRemoteEnded(invitation: CallInvitation) { + fun reportCallRemoteEnded(invitation: RcvCallInvitation) { if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) { chatModel.activeCallInvitation.value = null chatModel.controller.ntfManager.cancelCallNotification() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 036228cb6..b6f529600 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.call import android.Manifest +import android.content.Context +import android.media.AudioManager import android.util.Log import android.view.ViewGroup import android.webkit.* @@ -35,6 +37,7 @@ import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.helpers.withApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -44,6 +47,8 @@ fun ActiveCallView(chatModel: ChatModel) { val call = chatModel.activeCall.value if (call != null) withApi { chatModel.callManager.endCall(call) } }) + val cxt = LocalContext.current + val scope = rememberCoroutineScope() Box(Modifier.fillMaxSize()) { WebRTCView(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCView: $apiMsg") @@ -79,6 +84,10 @@ fun ActiveCallView(chatModel: ChatModel) { } is WCallResponse.Connected -> { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) + scope.launch { + delay(2000L) + setCallSound(cxt, call) + } } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) @@ -117,21 +126,43 @@ fun ActiveCallView(chatModel: ChatModel) { @Composable private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) { + var cxt = LocalContext.current ActiveCallOverlayLayout( call = call, dismiss = { withApi { chatModel.callManager.endCall(call) } }, toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, + toggleSound = { + var call = chatModel.activeCall.value + if (call != null) { + call = call.copy(soundSpeaker = !call.soundSpeaker) + chatModel.activeCall.value = call + setCallSound(cxt, call) + } + }, flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } ) } +private fun setCallSound(cxt: Context, call: Call) { + Log.d(TAG, "setCallSound: set audio mode") + val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager + if (call.soundSpeaker) { + am.mode = AudioManager.MODE_NORMAL + am.isSpeakerphoneOn = true + } else { + am.mode = AudioManager.MODE_IN_CALL + am.isSpeakerphoneOn = false + } +} + @Composable private fun ActiveCallOverlayLayout( call: Call, dismiss: () -> Unit, toggleAudio: () -> Unit, toggleVideo: () -> Unit, + toggleSound: () -> Unit, flipCamera: () -> Unit ) { Column(Modifier.padding(16.dp)) { @@ -174,6 +205,11 @@ private fun ActiveCallOverlayLayout( Box(Modifier.padding(start = 32.dp)) { ToggleAudioButton(call, toggleAudio) } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Box(Modifier.padding(end = 32.dp)) { + ToggleSoundButton(call, toggleSound) + } + } } } } @@ -194,12 +230,21 @@ private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: In @Composable private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) { if (call.audioEnabled) { - ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_video_off, toggleAudio) + ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio) } else { ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio) } } +@Composable +private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) { + if (call.soundSpeaker) { + ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound) + } else { + ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound) + } +} + @Composable fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { @Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) = @@ -393,6 +438,7 @@ fun PreviewActiveCallOverlayVideo() { dismiss = {}, toggleAudio = {}, toggleVideo = {}, + toggleSound = {}, flipCamera = {} ) } @@ -413,6 +459,7 @@ fun PreviewActiveCallOverlayAudio() { dismiss = {}, toggleAudio = {}, toggleVideo = {}, + toggleSound = {}, flipCamera = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index ff1b424f3..283b6be68 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -112,7 +112,7 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) { } @Composable -fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) { +fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) { val cm = chatModel.callManager val cxt = LocalContext.current val scope = rememberCoroutineScope() @@ -141,7 +141,7 @@ fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel @Composable fun IncomingCallLockScreenAlertLayout( - invitation: CallInvitation, + invitation: RcvCallInvitation, callOnLockScreen: CallOnLockScreen?, rejectCall: () -> Unit, ignoreCall: () -> Unit, @@ -210,9 +210,9 @@ fun PreviewIncomingCallLockScreenAlert() { .background(MaterialTheme.colors.background) .fillMaxSize()) { IncomingCallLockScreenAlertLayout( - invitation = CallInvitation( + invitation = RcvCallInvitation( contact = Contact.sampleData, - peerMedia = CallMediaType.Audio, + callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), sharedKey = null, callTs = Clock.System.now() ), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt index c0960220c..631649211 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt @@ -24,7 +24,7 @@ import chat.simplex.app.views.usersettings.ProfilePreview import kotlinx.datetime.Clock @Composable -fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) { +fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { val cm = chatModel.callManager val cxt = LocalContext.current val scope = rememberCoroutineScope() @@ -40,7 +40,7 @@ fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) { @Composable fun IncomingCallAlertLayout( - invitation: CallInvitation, + invitation: RcvCallInvitation, rejectCall: () -> Unit, ignoreCall: () -> Unit, acceptCall: () -> Unit @@ -60,10 +60,10 @@ fun IncomingCallAlertLayout( } @Composable -fun IncomingCallInfo(invitation: CallInvitation) { +fun IncomingCallInfo(invitation: RcvCallInvitation) { @Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen) Row { - if (invitation.peerMedia == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call)) + if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call)) else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call)) Spacer(Modifier.width(4.dp)) Text(invitation.callTypeText) @@ -94,9 +94,9 @@ private fun CallButton(text: String, icon: ImageVector, color: Color, action: () fun PreviewIncomingCallAlertLayout() { SimpleXTheme { IncomingCallAlertLayout( - invitation = CallInvitation( + invitation = RcvCallInvitation( contact = Contact.sampleData, - peerMedia = CallMediaType.Audio, + callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), sharedKey = null, callTs = Clock.System.now() ), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index 2c8919f3b..bb079e800 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -18,6 +18,7 @@ data class Call( val sharedKey: String? = null, val audioEnabled: Boolean = true, val videoEnabled: Boolean = localMedia == CallMediaType.Video, + val soundSpeaker: Boolean = localMedia == CallMediaType.Video, var localCamera: VideoCamera = VideoCamera.User, val connectionInfo: ConnectionInfo? = null ) { @@ -90,12 +91,12 @@ sealed class WCallResponse { @Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) { - val callTypeText: String get() = generalGetString(when(peerMedia) { +@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) { + val callTypeText: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call }) - val callTitle: String get() = generalGetString(when(peerMedia) { + val callTitle: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> R.string.incoming_video_call CallMediaType.Audio -> R.string.incoming_audio_call }) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index b0d59c903..6d94fd8c7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -45,7 +45,6 @@ fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() - val enableCalls = chatModel.controller.appPrefs.experimentalCalls.get() val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } val attachmentOption = remember { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) @@ -86,7 +85,6 @@ fun ChatView(chatModel: ChatModel) { attachmentBottomSheetState, chatModel.chatItems, useLinkPreviews = useLinkPreviews, - enableCalls = enableCalls, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -141,7 +139,6 @@ fun ChatLayout( attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, useLinkPreviews: Boolean, - enableCalls: Boolean = false, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, @@ -169,7 +166,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( - topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> @@ -183,7 +180,7 @@ fun ChatLayout( } @Composable -fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) { +fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) { @Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) { IconButton(onClick, modifier = modifier) { Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary) @@ -200,7 +197,7 @@ fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () ) { val cInfo = chat.chatInfo toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) - if (cInfo is ChatInfo.Direct && enableCalls) { + if (cInfo is ChatInfo.Direct) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 0fc819304..db30a923c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -28,6 +29,7 @@ import kotlinx.datetime.Clock fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val showMenu = remember { mutableStateOf(false) } var showMarkRead by remember { mutableStateOf(false) } + val stopped = chatModel.chatRunning.value == false LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) { showMenu.value = false delay(500L) @@ -36,31 +38,35 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { when (chat.chatInfo) { is ChatInfo.Direct -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat) }, + chatLinkPreview = { ChatPreviewView(chat, stopped) }, click = { openOrPendingChat(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, - showMenu + showMenu, + stopped ) is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat) }, + chatLinkPreview = { ChatPreviewView(chat, stopped) }, click = { openOrPendingChat(chat.chatInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) }, - showMenu + showMenu, + stopped ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( chatLinkPreview = { ContactRequestView(chat.chatInfo) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, - showMenu + showMenu, + stopped ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) }, dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, - showMenu + showMenu, + stopped ) } } @@ -286,17 +292,12 @@ fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, dropdownMenuItems: (@Composable () -> Unit)?, - showMenu: MutableState + showMenu: MutableState, + stopped: Boolean ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = click, - onLongClick = { showMenu.value = true } - ) - .height(88.dp) - ) { + var modifier = Modifier.fillMaxWidth().height(88.dp) + if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + Surface(modifier) { Row( modifier = Modifier .fillMaxWidth() @@ -345,12 +346,14 @@ fun PreviewChatListNavLinkDirect() { ) ), chatStats = Chat.ChatStats() - ) + ), + stopped = false ) }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } @@ -378,12 +381,14 @@ fun PreviewChatListNavLinkGroup() { ) ), chatStats = Chat.ChatStats() - ) + ), + stopped = false ) }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } @@ -403,7 +408,8 @@ fun PreviewChatListNavLinkContactRequest() { }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index a5ff2008a..a3fc3ada3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.* @@ -20,6 +21,8 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight +import chat.simplex.app.views.helpers.AlertManager +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView @@ -64,7 +67,7 @@ fun scaffoldController(): ScaffoldController { } @Composable -fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { +fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val scaffoldCtrl = scaffoldController() LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() @@ -82,7 +85,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { .fillMaxSize() .background(MaterialTheme.colors.background) ) { - ChatListToolbar(scaffoldCtrl) + ChatListToolbar(scaffoldCtrl, stopped) Divider() if (chatModel.chats.isNotEmpty()) { ChatList(chatModel) @@ -103,7 +106,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } @Composable -fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { +fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -127,13 +130,24 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) ) - IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { - Icon( - Icons.Outlined.PersonAdd, - stringResource(R.string.add_contact), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) + if (!stopped) { + IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { + Icon( + Icons.Outlined.PersonAdd, + stringResource(R.string.add_contact), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } else { + IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { + Icon( + Icons.Filled.Report, + generalGetString(R.string.chat_is_stopped_indication), + tint = Color.Red, + modifier = Modifier.padding(10.dp) + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 4bbabd914..1ce4177e2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -26,7 +26,7 @@ import chat.simplex.app.views.helpers.ChatInfoImage import chat.simplex.app.views.helpers.badgeLayout @Composable -fun ChatPreviewView(chat: Chat) { +fun ChatPreviewView(chat: Chat, stopped: Boolean) { Row { val cInfo = chat.chatInfo ChatInfoImage(cInfo, size = 72.dp) @@ -80,7 +80,7 @@ fun ChatPreviewView(chat: Chat) { color = MaterialTheme.colors.onPrimary, fontSize = 11.sp, modifier = Modifier - .background(MaterialTheme.colors.primary, shape = CircleShape) + .background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape) .badgeLayout() .padding(horizontal = 3.dp) .padding(vertical = 1.dp) @@ -131,6 +131,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData) + ChatPreviewView(Chat.sampleData, stopped = false) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt new file mode 100644 index 000000000..f3c4fb5a6 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt @@ -0,0 +1,145 @@ +package chat.simplex.app.views.database + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.app.views.usersettings.SettingsSectionView +import kotlinx.datetime.* +import java.io.BufferedOutputStream +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) { + val context = LocalContext.current + val archivePath = "${getFilesDirectory(context)}/$archiveName" + val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath) + ChatArchiveLayout( + title, + archiveTime, + saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) }, + deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) } + ) +} + +@Composable +fun ChatArchiveLayout( + title: String, + archiveTime: Instant, + saveArchive: () -> Unit, + deleteArchiveAlert: () -> Unit +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + Text( + title, + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SettingsSectionView(stringResource(R.string.chat_database_section)) { + SettingsActionItem( + Icons.Outlined.IosShare, + stringResource(R.string.save_archive), + saveArchive, + textColor = MaterialTheme.colors.primary + ) + divider() + SettingsActionItem( + Icons.Outlined.Delete, + stringResource(R.string.delete_archive), + deleteArchiveAlert, + textColor = Color.Red + ) + } + val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + SettingsSectionFooter( + String.format(generalGetString(R.string.archive_created_on_ts), archiveTs) + ) + } +} + +@Composable +private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + try { + destination?.let { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(chatArchivePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } + } catch (e: Error) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e") + } + } + ) + +private fun deleteArchiveAlert(m: ChatModel, archivePath: String) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_chat_archive_question), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + val fileDeleted = File(archivePath).delete() + if (fileDeleted) { + m.controller.appPrefs.chatArchiveName.set(null) + m.controller.appPrefs.chatArchiveTime.set(null) + ModalManager.shared.closeModal() + } else { + Log.e(TAG, "deleteArchiveAlert delete() error") + } + } + ) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewChatArchiveLayout() { + SimpleXTheme { + ChatArchiveLayout( + title = "New database archive", + archiveTime = Clock.System.now(), + saveArchive = {}, + deleteArchiveAlert = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt new file mode 100644 index 000000000..e3d55c164 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -0,0 +1,471 @@ +package chat.simplex.app.views.database + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.os.FileUtils +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.* +import kotlinx.datetime.* +import java.io.* +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun DatabaseView( + m: ChatModel, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) +) { + val context = LocalContext.current + val progressIndicator = remember { mutableStateOf(false) } + val runChat = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) } + val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) } + val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } + val chatArchiveFile = remember { mutableStateOf(null) } + val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile) + val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null) { + importArchiveAlert(m, context, uri, progressIndicator) + } + } + LaunchedEffect(m.chatRunning) { + runChat.value = m.chatRunning.value ?: true + } + Box( + Modifier.fillMaxSize(), + ) { + DatabaseLayout( + progressIndicator.value, + runChat.value, + m.chatDbChanged.value, + importArchiveLauncher, + chatArchiveName, + chatArchiveTime, + chatLastStart, + startChat = { startChat(m, runChat) }, + stopChatAlert = { stopChatAlert(m, runChat) }, + exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, + deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, + showSettingsModal + ) + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } + } +} + +@Composable +fun DatabaseLayout( + progressIndicator: Boolean, + runChat: Boolean, + chatDbChanged: Boolean, + importArchiveLauncher: ManagedActivityResultLauncher, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatLastStart: MutableState, + startChat: () -> Unit, + stopChatAlert: () -> Unit, + exportArchive: () -> Unit, + deleteChatAlert: () -> Unit, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) +) { + val stopped = !runChat + val operationsDisabled = !stopped || progressIndicator || chatDbChanged + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + Text( + stringResource(R.string.your_chat_database), + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SettingsSectionView(stringResource(R.string.run_chat_section)) { + RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert) + } + Spacer(Modifier.height(30.dp)) + + SettingsSectionView(stringResource(R.string.chat_database_section)) { + SettingsActionItem( + Icons.Outlined.IosShare, + stringResource(R.string.export_database), + exportArchive, + textColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + divider() + SettingsActionItem( + Icons.Outlined.FileDownload, + stringResource(R.string.import_database), + { importArchiveLauncher.launch("application/zip") }, + textColor = Color.Red, + disabled = operationsDisabled + ) + divider() + val chatArchiveNameVal = chatArchiveName.value + val chatArchiveTimeVal = chatArchiveTime.value + val chatLastStartVal = chatLastStart.value + if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { + val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) + SettingsActionItem( + Icons.Outlined.Inventory2, + title, + click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, + disabled = operationsDisabled + ) + divider() + } + SettingsActionItem( + Icons.Outlined.DeleteForever, + stringResource(R.string.delete_database), + deleteChatAlert, + textColor = Color.Red, + disabled = operationsDisabled + ) + } + SettingsSectionFooter( + if (chatDbChanged) { + stringResource(R.string.restart_the_app_to_use_new_chat_database) + } else { + if (stopped) { + stringResource(R.string.you_must_use_the_most_recent_version_of_database) + } else { + stringResource(R.string.stop_chat_to_enable_database_actions) + } + } + ) + } +} + +@Composable +fun RunChatSetting( + runChat: Boolean, + stopped: Boolean, + chatDbChanged: Boolean, + startChat: () -> Unit, + stopChatAlert: () -> Unit +) { + SettingsItemView() { + Row(verticalAlignment = Alignment.CenterVertically) { + val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running) + Icon( + if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow, + chatRunningText, + tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + chatRunningText, + Modifier.padding(end = 24.dp), + color = if (chatDbChanged) HighOrLowlight else Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = runChat, + onCheckedChange = { runChatSwitch -> + if (runChatSwitch) { + startChat() + } else { + stopChatAlert() + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + enabled = !chatDbChanged + ) + } + } +} + +@Composable +fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { + return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive) +} + +@Composable +fun SettingsSectionFooter(text: String) { + Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp) +} + +private fun startChat(m: ChatModel, runChat: MutableState) { + withApi { + try { + m.controller.apiStartChat() + runChat.value = true + m.chatRunning.value = true + m.controller.appPrefs.chatLastStart.set(Clock.System.now()) + } catch (e: Error) { + runChat.value = false + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) + } + } +} + +private fun stopChatAlert(m: ChatModel, runChat: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.stop_chat_question), + text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database), + confirmText = generalGetString(R.string.stop_chat_confirmation), + onConfirm = { stopChat(m, runChat) }, + onDismiss = { runChat.value = true } + ) +} + +private fun stopChat(m: ChatModel, runChat: MutableState) { + withApi { + try { + m.controller.apiStopChat() + runChat.value = false + m.chatRunning.value = false + } catch (e: Error) { + runChat.value = true + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) + } + } +} + +private fun exportArchive( + context: Context, + m: ChatModel, + progressIndicator: MutableState, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatArchiveFile: MutableState, + saveArchiveLauncher: ManagedActivityResultLauncher +) { + progressIndicator.value = true + withApi { + try { + val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile) + chatArchiveFile.value = archiveFile + saveArchiveLauncher.launch(archiveFile.substringAfterLast("/")) + progressIndicator.value = false + } catch (e: Error) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString()) + progressIndicator.value = false + } + } +} + +private suspend fun exportChatArchive( + m: ChatModel, + context: Context, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatArchiveFile: MutableState +): String { + val archiveTime = Clock.System.now() + val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + val archiveName = "simplex-chat.$ts.zip" + val archivePath = "${getFilesDirectory(context)}/$archiveName" + val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) + m.controller.apiExportArchive(config) + deleteOldArchive(m, context) + m.controller.appPrefs.chatArchiveName.set(archiveName) + chatArchiveName.value = archiveName + m.controller.appPrefs.chatArchiveTime.set(archiveTime) + chatArchiveTime.value = archiveTime + chatArchiveFile.value = archivePath + return archivePath +} + +private fun deleteOldArchive(m: ChatModel, context: Context) { + val chatArchiveName = m.controller.appPrefs.chatArchiveName.get() + if (chatArchiveName != null) { + val file = File("${getFilesDirectory(context)}/$chatArchiveName") + val fileDeleted = file.delete() + if (fileDeleted) { + m.controller.appPrefs.chatArchiveName.set(null) + m.controller.appPrefs.chatArchiveTime.set(null) + } else { + Log.e(TAG, "deleteOldArchive file.delete() error") + } + } +} + +@Composable +private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + try { + destination?.let { + val filePath = chatArchiveFile.value + if (filePath != null) { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } + } + } catch (e: Error) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e") + } finally { + chatArchiveFile.value = null + } + } + ) + +private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.import_database_question), + text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), + confirmText = generalGetString(R.string.import_database_confirmation), + onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) } + ) +} + +private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { + progressIndicator.value = true + val archivePath = saveArchiveFromUri(context, importedArchiveUri) + if (archivePath != null) { + withApi { + try { + m.controller.apiDeleteStorage() + try { + val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) + m.controller.apiImportArchive(config) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database)) + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString()) + } + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString()) + } + } finally { + File(archivePath).delete() + } + } + } +} + +private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(importedArchiveUri) + val archiveName = getFileName(context, importedArchiveUri) + if (inputStream != null && archiveName != null) { + val archivePath = "${context.cacheDir}/$archiveName" + val destFile = File(archivePath) + FileUtils.copy(inputStream, FileOutputStream(destFile)) + archivePath + } else { + Log.e(TAG, "saveArchiveFromUri null inputStream") + null + } + } catch (e: Exception) { + Log.e(TAG, "saveArchiveFromUri error: ${e.message}") + null + } +} + +private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_chat_profile_question), + text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { deleteChat(m, progressIndicator) } + ) +} + +private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { + progressIndicator.value = true + withApi { + try { + m.controller.apiDeleteStorage() + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile)) + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString()) + } + } + } +} + +private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) { + m.chatDbChanged.value = true + progressIndicator.value = false + alert.invoke() +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewDatabaseLayout() { + SimpleXTheme { + DatabaseLayout( + progressIndicator = false, + runChat = true, + chatDbChanged = false, + importArchiveLauncher = rememberGetContentLauncher {}, + chatArchiveName = remember { mutableStateOf("dummy_archive") }, + chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, + chatLastStart = remember { mutableStateOf(Clock.System.now()) }, + startChat = {}, + stopChatAlert = {}, + exportArchive = {}, + deleteChatAlert = {}, + showSettingsModal = { {} } + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 505ada39a..6e42d149e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -23,12 +24,14 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.TerminalView +import chat.simplex.app.views.database.DatabaseView import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.SimpleXInfo @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value + val stopped = chatModel.chatRunning.value == false fun setRunServiceInBackground(on: Boolean) { chatModel.controller.appPrefs.runServiceInBackground.set(on) @@ -42,10 +45,10 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { if (user != null) { SettingsLayout( profile = user.profile, + stopped, runServiceInBackground = chatModel.runServiceInBackground, setRunServiceInBackground = ::setRunServiceInBackground, setPerformLA = setPerformLA, - enableCalls = remember { mutableStateOf(chatModel.controller.appPrefs.experimentalCalls.get()) }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close -> ModalView(close = close, modifier = Modifier, @@ -66,10 +69,10 @@ val simplexTeamUri = @Composable fun SettingsLayout( profile: Profile, + stopped: Boolean, runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit, - enableCalls: MutableState, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), @@ -94,47 +97,47 @@ fun SettingsLayout( Spacer(Modifier.height(30.dp)) SettingsSectionView(stringResource(R.string.settings_section_title_you)) { - SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { - ProfilePreview(profile) + SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) { + ProfilePreview(profile, stopped = stopped) } divider() - SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }) + SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) + divider() + DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { - if (enableCalls.value) { - SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }) - divider() - } - SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }) + SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped) divider() - PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground) + SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) divider() - SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }) + PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped) + divider() + SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_help)) { - SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }) + SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped) divider() SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) divider() SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() }) divider() - SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary) + SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) divider() SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_develop)) { - ChatConsoleItem(showTerminal) + ChatConsoleItem(showTerminal, stopped) divider() InstallTerminalAppItem(uriHandler) divider() - SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) }) - divider() +// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) }) +// divider() AppVersionItem() } } @@ -143,19 +146,49 @@ fun SettingsLayout( @Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) { Column { - Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, - modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp) + Text( + title, color = HighOrLowlight, style = MaterialTheme.typography.body2, + modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp + ) Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { Column(Modifier.padding(horizontal = 6.dp)) { content() } } } } +@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { + SettingsItemView(openDatabaseView) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row { + Icon( + Icons.Outlined.Archive, + contentDescription = stringResource(R.string.database_export_and_import), + tint = HighOrLowlight, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.database_export_and_import)) + } + if (stopped) { + Icon( + Icons.Filled.Report, + contentDescription = stringResource(R.string.chat_is_stopped), + tint = Color.Red, + modifier = Modifier.padding(end = 6.dp) + ) + } + } + } +} + @Composable private fun PrivateNotificationsItem( runServiceInBackground: MutableState, - setRunServiceInBackground: (Boolean) -> Unit + setRunServiceInBackground: (Boolean) -> Unit, + stopped: Boolean ) { - SettingsItemView() { + SettingsItemView(disabled = stopped) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Bolt, @@ -168,7 +201,8 @@ fun SettingsLayout( Modifier .padding(end = 24.dp) .fillMaxWidth() - .weight(1f) + .weight(1f), + color = if (stopped) HighOrLowlight else Color.Unspecified ) Switch( checked = runServiceInBackground.value, @@ -177,7 +211,8 @@ fun SettingsLayout( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), - modifier = Modifier.padding(end = 6.dp) + modifier = Modifier.padding(end = 6.dp), + enabled = !stopped ) } } @@ -211,15 +246,18 @@ fun SettingsLayout( } } -@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) { - SettingsItemView(showTerminal) { +@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) { + SettingsItemView(showTerminal, disabled = stopped) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.chat_console)) + Text( + stringResource(R.string.chat_console), + color = if (stopped) HighOrLowlight else Color.Unspecified + ) } } @@ -241,7 +279,7 @@ fun SettingsLayout( } } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) { ProfileImage(size = size, image = profileOf.image, color = color) Spacer(Modifier.padding(horizontal = 4.dp)) Column { @@ -249,19 +287,23 @@ fun SettingsLayout( profileOf.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, + color = if (stopped) HighOrLowlight else Color.Unspecified + ) + Text( + profileOf.fullName, + color = if (stopped) HighOrLowlight else Color.Unspecified ) - Text(profileOf.fullName) } } @Composable -fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) { +fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) { val modifier = Modifier .padding(start = 8.dp) .fillMaxWidth() .height(height) Row( - if (click == null) modifier else modifier.clickable(onClick = click), + if (click == null || disabled) modifier else modifier.clickable(onClick = click), verticalAlignment = Alignment.CenterVertically ) { content() @@ -269,11 +311,11 @@ fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: ( } @Composable -fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) { - SettingsItemView(click) { +fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) { + SettingsItemView(click, disabled = disabled) { Icon(icon, text, tint = HighOrLowlight) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(text, color = textColor) + Text(text, color = if (disabled) HighOrLowlight else textColor) } } @@ -299,10 +341,10 @@ fun PreviewSettingsLayout() { SimpleXTheme { SettingsLayout( profile = Profile.sampleData, + stopped = false, runServiceInBackground = remember { mutableStateOf(true) }, setRunServiceInBackground = {}, setPerformLA = {}, - enableCalls = remember { mutableStateOf(true) }, showModal = { {} }, showSettingsModal = { {} }, showCustomModal = { {} }, diff --git a/apps/android/app/src/main/res/drawable-hdpi/ntf_service_icon.png b/apps/android/app/src/main/res/drawable-hdpi/ntf_service_icon.png new file mode 100644 index 000000000..2bdab389b Binary files /dev/null and b/apps/android/app/src/main/res/drawable-hdpi/ntf_service_icon.png differ diff --git a/apps/android/app/src/main/res/drawable-mdpi/ntf_service_icon.png b/apps/android/app/src/main/res/drawable-mdpi/ntf_service_icon.png new file mode 100644 index 000000000..8d71e704d Binary files /dev/null and b/apps/android/app/src/main/res/drawable-mdpi/ntf_service_icon.png differ diff --git a/apps/android/app/src/main/res/drawable-xhdpi/ntf_service_icon.png b/apps/android/app/src/main/res/drawable-xhdpi/ntf_service_icon.png new file mode 100644 index 000000000..950a78316 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-xhdpi/ntf_service_icon.png differ diff --git a/apps/android/app/src/main/res/drawable-xxhdpi/ntf_service_icon.png b/apps/android/app/src/main/res/drawable-xxhdpi/ntf_service_icon.png new file mode 100644 index 000000000..81da12bb9 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-xxhdpi/ntf_service_icon.png differ diff --git a/apps/android/app/src/main/res/drawable-xxxhdpi/ntf_service_icon.png b/apps/android/app/src/main/res/drawable-xxxhdpi/ntf_service_icon.png new file mode 100644 index 000000000..4ba82576b Binary files /dev/null and b/apps/android/app/src/main/res/drawable-xxxhdpi/ntf_service_icon.png differ diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 16269f27a..9df9a051a 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -244,6 +244,7 @@ Настройки Ваш SimpleX адрес + Экспорт и импорт архива чата Информация о SimpleX Chat Как использовать Форматирование сообщений @@ -404,6 +405,8 @@ Включить видео Выключить звук Включить звук + Выключить спикер + Включить спикер Перевернуть камеру @@ -437,4 +440,47 @@ УСТРОЙСТВО ЧАТЫ Экспериментальные функции + + + Данные чата + ЗАПУСТИТЬ ЧАТ + Чат запущен + Чат остановлен + АРХИВ ЧАТА + Экспорт архива чата + Импорт архива чата + Новый архив чата + Старый архив чата + Удалить данные чата + Ошибка при запуске чата + Остановить чат? + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + Остановить + Ошибка при остановке чата + Ошибка при экспорте архива чата + Импортировать архив чата? + Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Импортировать + Ошибка при удалении данных чата + Ошибка при импорте архива чата + Архив чата импортирован + Перезапустите приложение, чтобы использовать импортированные данные чата. + Удалить профиль? + Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Данные чата удалены + Перезапустите приложение, чтобы создать новый профиль. + Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов. + Остановите чат, чтобы разблокировать операции с архивом чата. + Перезапустите приложение, чтобы использовать новый архив чата. + + + Чат остановлен + Вы можете запустить чат через Настройки приложения или перезапустив приложение. + + + Архив чата + Сохранить архив + Удалить архив + Дата создания %1$s + Удалить архив чата? diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 284fd2ca8..eb30fbb86 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -250,6 +250,7 @@ Your settings Your SimpleX contact address + Database export & import About SimpleX Chat How to use it Markdown help @@ -406,6 +407,8 @@ Video on Audio off Audio on + Speaker off + Speaker on Flip camera @@ -439,4 +442,47 @@ DEVICE CHATS Experimental features + + + Your chat database + RUN CHAT + Chat is running + Chat is stopped + CHAT DATABASE + Export database + Import database + New database archive + Old database archive + Delete database + Error starting chat + Stop chat? + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Stop + Error stopping chat + Error exporting chat database + Import chat database? + Your current chat database will be DELETED and REPLACED with the imported one.\nThis action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Import + Error deleting chat database + Error importing chat database + Chat database imported + Restart the app to use imported chat database. + Delete chat profile? + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Chat database deleted + Restart the app to create a new chat profile. + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + Stop chat to enable database actions. + Restart the app to use new chat database. + + + Chat is stopped + You can start chat via app Settings / Database or by restarting the app. + + + Chat archive + Save archive + Delete archive + Created on %1$s + Delete chat archive? diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 95e21a251..b06fac9ee 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -8,6 +8,7 @@ import Foundation import UIKit +import SimpleXChat class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { @@ -20,18 +21,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") let m = ChatModel.shared - m.deviceToken = token - UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS) -// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") -// if useNotifications { -// Task { -// do { -// m.tokenStatus = try await apiRegisterToken(token: token) -// } catch { -// logger.error("apiRegisterToken error: \(responseError(error))") -// } -// } -// } + let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) + m.deviceToken = deviceToken + if m.savedToken != nil { + registerToken(token: deviceToken) + } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { @@ -42,19 +36,19 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("AppDelegate: didReceiveRemoteNotification") + print("*** userInfo", userInfo) + let m = ChatModel.shared if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], - UserDefaults.standard.bool(forKey: "useNotifications") { + m.notificationMode != .off { if let verification = ntfData["verification"] as? String, let nonce = ntfData["nonce"] as? String { if let token = ChatModel.shared.deviceToken { logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") Task { - let m = ChatModel.shared do { if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed } - try await apiVerifyToken(token: token, code: verification, nonce: nonce) + try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active - try await apiIntervalNofication(token: token, interval: 20) } catch { if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { m.tokenStatus = .expired @@ -67,15 +61,12 @@ class AppDelegate: NSObject, UIApplicationDelegate { completionHandler(.noData) } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { - // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") - // TODO remove - // NtfManager.shared.notifyCheckingMessages() - receiveMessages(completionHandler) - } else if let smpQueue = ntfData["checkMessage"] as? String { - // TODO check if app in background - logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)") - receiveMessages(completionHandler) + if appStateGroupDefault.get().inactive { + receiveMessages(completionHandler) + } else { + completionHandler(.noData) + } } else { completionHandler(.noData) } @@ -84,6 +75,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } + func applicationWillTerminate(_ application: UIApplication) { + logger.debug("AppDelegate: applicationWillTerminate") + terminateChat() + } + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index e1e3b808b..914071e41 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -22,39 +22,45 @@ struct ContentView: View { ZStack { if prefPerformLA && userAuthorized != true { Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } - } else { - if let step = chatModel.onboardingStage { - if case .onboardingComplete = step, - chatModel.currentUser != nil { - ZStack(alignment: .top) { - ChatListView(showChatInfo: $showChatInfo) - .onAppear { - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) - // Local Authentication notice is to be shown on next start after onboarding is complete - if (!prefLANoticeShown && prefShowLANotice) { - prefLANoticeShown = true - alertManager.showAlert(laNoticeAlert()) - } - prefShowLANotice = true - } - if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call) - } - IncomingCallView() - } - } else { - OnboardingView(onboarding: step) - } + } else if !chatModel.v3DBMigration.startChat { + MigrateToAppGroupView() + } else if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + chatModel.currentUser != nil { + mainView() + } else { + OnboardingView(onboarding: step) } } } - .onAppear { if doAuthenticate { runAuthenticate() } } + .onAppear { + if doAuthenticate { runAuthenticate() } + } .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } + private func mainView() -> some View { + ZStack(alignment: .top) { + ChatListView(showChatInfo: $showChatInfo) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) + // Local Authentication notice is to be shown on next start after onboarding is complete + if (!prefLANoticeShown && prefShowLANotice) { + prefLANoticeShown = true + alertManager.showAlert(laNoticeAlert()) + } + prefShowLANotice = true + } + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call) + } + IncomingCallView() + } + } + private func runAuthenticate() { if !prefPerformLA { userAuthorized = true diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index b76097bc7..d7267ff8b 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -8,6 +8,7 @@ import Foundation import BackgroundTasks +import SimpleXChat private let receiveTaskId = "chat.simplex.app.receive" @@ -16,11 +17,14 @@ private let waitForMessages: TimeInterval = 6 private let bgRefreshInterval: TimeInterval = 450 +private let maxTimerCount = 9 + class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? var bgTimer: Timer? var completed = true + var timerCount = 0 func register() { logger.debug("BGManager.register") @@ -43,11 +47,16 @@ class BGManager { private func handleRefresh(_ task: BGAppRefreshTask) { logger.debug("BGManager.handleRefresh") schedule() - let completeRefresh = completionHandler { + if appStateGroupDefault.get().inactive { + let completeRefresh = completionHandler { + task.setTaskCompleted(success: true) + } + task.expirationHandler = { completeRefresh("expirationHandler") } + receiveMessages(completeRefresh) + } else { + logger.debug("BGManager.completionHandler: already active, not started") task.setTaskCompleted(success: true) } - task.expirationHandler = { completeRefresh("expirationHandler") } - receiveMessages(completeRefresh) } func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) { @@ -59,6 +68,8 @@ class BGManager { self.chatReceiver = nil self.bgTimer?.invalidate() self.bgTimer = nil + self.timerCount = 0 + suspendBgRefresh() complete() } } @@ -71,20 +82,28 @@ class BGManager { } self.completed = false DispatchQueue.main.async { - initializeChat() + do { + try initializeChat(start: true) + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } if ChatModel.shared.currentUser == nil { completeReceiving("no current user") return } logger.debug("BGManager.receiveMessages: starting chat") + activateChat(appState: .bgRefresh) let cr = ChatReceiver() self.chatReceiver = cr cr.start() RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in logger.debug("BGManager.receiveMessages: timer") self.bgTimer = timer + self.timerCount += 1 if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { completeReceiving("timer (no messages after \(waitForMessages) seconds)") + } else if self.timerCount >= maxTimerCount { + completeReceiving("timer (called \(maxTimerCount) times") } }, forMode: .default) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d4ae6ad9..f56f1423e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -10,10 +10,14 @@ import Foundation import Combine import SwiftUI import WebKit +import SimpleXChat final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? + @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? + @Published var chatRunning: Bool? + @Published var chatDbChanged = false // list of chat "previews" @Published var chats: [Chat] = [] // current chat @@ -25,10 +29,14 @@ final class ChatModel: ObservableObject { @Published var userAddress: String? @Published var userSMPServers: [String]? @Published var appOpenUrl: URL? - @Published var deviceToken: String? - @Published var tokenStatus = NtfTknStatus.new + @Published var deviceToken: DeviceToken? + @Published var savedToken: DeviceToken? + @Published var tokenRegistered = false + @Published var tokenStatus: NtfTknStatus? + @Published var notificationMode = NotificationsMode.off + @Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get() // current WebRTC call - @Published var callInvitations: Dictionary = [:] + @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false @@ -86,13 +94,27 @@ final class ChatModel: ObservableObject { func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { + let serverInfo = chats[i].serverInfo chats[i] = chat + chats[i].serverInfo = serverInfo } else { // invalid state, correcting chats.insert(chat, at: 0) } } + func updateChats(with newChats: [ChatData]) { + for c in newChats { + if let chat = getChat(c.id) { + chat.chatInfo = c.chatInfo + chat.chatItems = c.chatItems + chat.chatStats = c.chatStats + } else { + addChat(Chat(c)) + } + } + } + func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { // update previews if let i = getChatIndex(cInfo.id) { @@ -242,6 +264,7 @@ final class Chat: ObservableObject, Identifiable { @Published var chatItems: [ChatItem] @Published var chatStats: ChatStats @Published var serverInfo = ServerInfo(networkStatus: .unknown) + var created = Date.now struct ServerInfo: Decodable { var networkStatus: NetworkStatus @@ -299,4 +322,6 @@ final class Chat: ObservableObject, Identifiable { } var id: ChatId { get { chatInfo.id } } + + var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 146075ded..de78a6078 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -9,6 +9,7 @@ import Foundation import UserNotifications import UIKit +import SimpleXChat let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" @@ -135,12 +136,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification") ), - // TODO remove UNNotificationCategory( - identifier: ntfCategoryCheckingMessages, + identifier: ntfCategoryConnectionEvent, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification") + hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification") ) ]) } @@ -183,21 +183,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createMessageReceivedNtf(cInfo, cItem)) } - func notifyCallInvitation(_ invitation: CallInvitation) { + func notifyCallInvitation(_ invitation: RcvCallInvitation) { logger.debug("NtfManager.notifyCallInvitation") addNotification(createCallInvitationNtf(invitation)) } - // TODO remove - func notifyCheckingMessages() { - logger.debug("NtfManager.notifyCheckingMessages") - let content = createNotification( - categoryIdentifier: ntfCategoryCheckingMessages, - title: NSLocalizedString("Checking new messages...", comment: "notification") - ) - addNotification(content) - } - private func addNotification(_ content: UNMutableNotificationContent) { if !granted { return } let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) diff --git a/apps/ios/Shared/Model/PushEnvironment.swift b/apps/ios/Shared/Model/PushEnvironment.swift new file mode 100644 index 000000000..7fa7ef0b9 --- /dev/null +++ b/apps/ios/Shared/Model/PushEnvironment.swift @@ -0,0 +1,41 @@ +// +// PushEnvironment.swift +// SimpleX (iOS) +// +// Created by Evgeny on 27/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat + +let pushEnvironment: PushEnvironment = { + guard let provisioningProfile = try? provisioningProfile(), + let entitlements = provisioningProfile["Entitlements"] as? [String: Any], + let environment = entitlements["aps-environment"] as? String, + let env = PushEnvironment(rawValue: environment) + else { + logger.warning("pushEnvironment: unknown, assuming production") + return .production + } + logger.debug("pushEnvironment: \(env.rawValue)") + return env +}() + +private func provisioningProfile() throws -> [String: Any]? { + guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") else { + return nil + } + + let binaryString = try String(contentsOf: url, encoding: .isoLatin1) + + let scanner = Scanner(string: binaryString) + guard scanner.scanUpToString(""), + let data = (plistString + "").data(using: .isoLatin1) + else { + return nil + } + + return try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] +} diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift deleted file mode 100644 index acb14f219..000000000 --- a/apps/ios/Shared/Model/Shared/CallTypes.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// CallTypes.swift -// SimpleX (iOS) -// -// Created by Evgeny on 05/05/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import Foundation -import SwiftUI - -struct WebRTCCallOffer: Encodable { - var callType: CallType - var rtcSession: WebRTCSession -} - -struct WebRTCSession: Codable { - var rtcSession: String - var rtcIceCandidates: String -} - -struct WebRTCExtraInfo: Codable { - var rtcIceCandidates: String -} - -struct CallInvitation { - var contact: Contact - var callkitUUID: UUID? - var peerMedia: CallMediaType - var sharedKey: String? - var callTs: Date - var callTypeText: LocalizedStringKey { - get { - switch peerMedia { - case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call" - case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call" - } - } - } - - static let sampleData = CallInvitation( - contact: Contact.sampleData, - peerMedia: .audio, - callTs: .now - ) -} - -struct CallType: Codable { - var media: CallMediaType - var capabilities: CallCapabilities -} - -enum CallMediaType: String, Codable, Equatable { - case video = "video" - case audio = "audio" -} - -enum VideoCamera: String, Codable, Equatable { - case user = "user" - case environment = "environment" -} - -struct CallCapabilities: Codable, Equatable { - var encryption: Bool -} - -enum WebRTCCallStatus: String, Encodable { - case connected = "connected" - case connecting = "connecting" - case disconnected = "disconnected" - case failed = "failed" -} diff --git a/apps/ios/Shared/Model/Shared/GroupDefaults.swift b/apps/ios/Shared/Model/Shared/GroupDefaults.swift deleted file mode 100644 index ca5aaef41..000000000 --- a/apps/ios/Shared/Model/Shared/GroupDefaults.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// GroupDefaults.swift -// SimpleX (iOS) -// -// Created by Evgeny on 26/04/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import Foundation -import SwiftUI - -func getGroupDefaults() -> UserDefaults? { - UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app") -} - -func setAppState(_ phase: ScenePhase) { - if let defaults = getGroupDefaults() { - defaults.set(phase == .background, forKey: "appInBackground") - defaults.synchronize() - } -} - -func getAppState() -> ScenePhase { - if let defaults = getGroupDefaults() { - if defaults.bool(forKey: "appInBackground") { - return .background - } - } - return .active -} diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift deleted file mode 100644 index 84bb1b5ba..000000000 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Notifications.swift -// SimpleX -// -// Created by Evgeny on 28/04/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import Foundation -import UserNotifications -import SwiftUI - -let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" -let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" -let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" -let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION" -let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" -// TODO remove -let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" - -let appNotificationId = "chat.simplex.app.notification" - -func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { - createNotification( - categoryIdentifier: ntfCategoryContactRequest, - title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), - targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] - ) -} - -func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { - createNotification( - categoryIdentifier: ntfCategoryContactConnected, - title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), - targetContentIdentifier: contact.id -// userInfo: ["chatId": contact.id, "contactId": contact.apiId] - ) -} - -func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { - createNotification( - categoryIdentifier: ntfCategoryMessageReceived, - title: "\(cInfo.chatViewName):", - body: hideSecrets(cItem), - targetContentIdentifier: cInfo.id -// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] - ) -} - -func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent { - let text = invitation.peerMedia == .video - ? NSLocalizedString("Incoming video call", comment: "notification") - : NSLocalizedString("Incoming audio call", comment: "notification") - return createNotification( - categoryIdentifier: ntfCategoryCallInvitation, - title: "\(invitation.contact.chatViewName):", - body: text, - targetContentIdentifier: nil, - userInfo: ["chatId": invitation.contact.id] - ) -} - -func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, - targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { - let content = UNMutableNotificationContent() - content.categoryIdentifier = categoryIdentifier - content.title = title - if let s = subtitle { content.subtitle = s } - if let s = body { content.body = s } - content.targetContentIdentifier = targetContentIdentifier - content.userInfo = userInfo - // TODO move logic of adding sound here, so it applies to background notifications too - content.sound = .default -// content.interruptionLevel = .active -// content.relevanceScore = 0.5 // 0-1 - return content -} - -func hideSecrets(_ cItem: ChatItem) -> String { - if cItem.content.text != "" { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text - } - } - return res - } else { - return cItem.content.text - } - } else { - return cItem.file?.fileName ?? "" - } -} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 8d3b8701c..bd4384b50 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -11,6 +11,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI +import SimpleXChat private var chatController: chat_ctrl? @@ -46,7 +47,7 @@ enum TerminalItem: Identifiable { } } -private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true let endTask = { @@ -71,7 +72,7 @@ private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { let msgDelay: Double = 7.5 let maxTaskDuration: Double = 15 -private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) -> ChatResponse { +private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { let endTask = beginBGTask() DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask) let r = f() @@ -92,11 +93,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") } - if case .apiParseMarkdown = cmd {} else { - DispatchQueue.main.async { - ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) - ChatModel.shared.terminalItems.append(.resp(.now, resp)) - } + DispatchQueue.main.async { + ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.resp(.now, resp)) } return resp } @@ -107,10 +106,10 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil } } -func chatRecvMsg() async -> ChatResponse { +func chatRecvMsg() async -> ChatResponse? { await withCheckedContinuation { cont in - _ = withBGTask(bgDelay: msgDelay) { - let resp = chatResponse(chat_recv_msg(getChatCtrl())!) + _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in + let resp = recvSimpleXMsg() cont.resume(returning: resp) return resp } @@ -134,7 +133,7 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { } func apiStartChat() throws -> Bool { - let r = chatSendCmdSync(.startChat) + let r = chatSendCmdSync(.startChat(subscribe: true)) switch r { case .chatStarted: return true case .chatRunning: return false @@ -142,15 +141,47 @@ func apiStartChat() throws -> Bool { } } +func apiStopChat() async throws { + let r = await chatSendCmd(.apiStopChat) + switch r { + case .chatStopped: return + default: throw r + } +} + +func apiActivateChat() { + let r = chatSendCmdSync(.apiActivateChat) + if case .cmdOk = r { return } + logger.error("apiActivateChat error: \(String(describing: r))") +} + +func apiSuspendChat(timeoutMicroseconds: Int) { + let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .cmdOk = r { return } + logger.error("apiSuspendChat error: \(String(describing: r))") +} + func apiSetFilesFolder(filesFolder: String) throws { let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) if case .cmdOk = r { return } throw r } -func apiGetChats() throws -> [Chat] { +func apiExportArchive(config: ArchiveConfig) async throws { + try await sendCommandOkResp(.apiExportArchive(config: config)) +} + +func apiImportArchive(config: ArchiveConfig) async throws { + try await sendCommandOkResp(.apiImportArchive(config: config)) +} + +func apiDeleteStorage() async throws { + try await sendCommandOkResp(.apiDeleteStorage) +} + +func apiGetChats() throws -> [ChatData] { let r = chatSendCmdSync(.apiGetChats) - if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } } + if case let .apiChats(chats) = r { return chats } throw r } @@ -160,6 +191,18 @@ func apiGetChat(type: ChatType, id: Int64) throws -> Chat { throw r } +func loadChat(chat: Chat) { + do { + let cInfo = chat.chatInfo + let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) + let m = ChatModel.shared + m.updateChatInfo(chat.chatInfo) + m.chatItems = chat.chatItems + } catch let error { + logger.error("loadChat error: \(responseError(error))") + } +} + func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem { let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg) @@ -195,21 +238,45 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM throw r } -func apiRegisterToken(token: String) async throws -> NtfTknStatus { - let r = await chatSendCmd(.apiRegisterToken(token: token)) +func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { + let r = chatSendCmdSync(.apiGetNtfToken) + switch r { + case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) + case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) + default: + logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)") + return (nil, nil, .off) + } +} + +func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus { + let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) if case let .ntfTokenStatus(status) = r { return status } throw r } -func apiVerifyToken(token: String, code: String, nonce: String) async throws { - try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce)) +func registerToken(token: DeviceToken) { + let m = ChatModel.shared + let mode = m.notificationMode + if mode != .off && !m.tokenRegistered { + m.tokenRegistered = true + logger.debug("registerToken \(mode.rawValue)") + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { m.tokenStatus = status } + } catch let error { + logger.error("registerToken apiRegisterToken error: \(responseError(error))") + } + } + } } -func apiIntervalNofication(token: String, interval: Int) async throws { - try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval)) +func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws { + try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code)) } -func apiDeleteToken(token: String) async throws { +func apiDeleteToken(token: DeviceToken) async throws { try await sendCommandOkResp(.apiDeleteToken(token: token)) } @@ -311,12 +378,6 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } -func apiParseMarkdown(text: String) throws -> [FormattedText]? { - let r = chatSendCmdSync(.apiParseMarkdown(text: text)) - if case let .apiParsedMarkdown(formattedText) = r { return formattedText } - throw r -} - func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } @@ -418,6 +479,12 @@ func apiEndCall(_ contact: Contact) async throws { try await sendCommandOkResp(.apiEndCall(contact: contact)) } +func apiGetCallInvitations() throws -> [RcvCallInvitation] { + let r = chatSendCmdSync(.apiGetCallInvitations) + if case let .callInvitations(invs) = r { return invs } + throw r +} + func apiCallStatus(_ contact: Contact, _ status: String) async throws { if let callStatus = WebRTCCallStatus.init(rawValue: status) { try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) @@ -453,42 +520,49 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func initializeChat() { +func initializeChat(start: Bool) throws { logger.debug("initializeChat") do { let m = ChatModel.shared + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) m.currentUser = try apiGetActiveUser() if m.currentUser == nil { m.onboardingStage = .step1_SimpleXInfo + } else if start { + try startChat() } else { - startChat() + m.chatRunning = false } } catch { - fatalError("Failed to initialize chat controller or database: \(error)") + fatalError("Failed to initialize chat controller or database: \(responseError(error))") } } -func startChat() { +func startChat() throws { logger.debug("startChat") - do { - let m = ChatModel.shared - // TODO set file folder once, before chat is started - let justStarted = try apiStartChat() - if justStarted { - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - m.userAddress = try apiGetUserAddress() - m.userSMPServers = try getUserSMPServers() - m.chats = try apiGetChats() - withAnimation { - m.onboardingStage = m.chats.isEmpty - ? .step3_MakeConnection - : .onboardingComplete - } + let m = ChatModel.shared + let justStarted = try apiStartChat() + if justStarted { + m.userAddress = try apiGetUserAddress() + m.userSMPServers = try getUserSMPServers() + let chats = try apiGetChats() + m.chats = chats.map { Chat.init($0) } + try refreshCallInvitations() + (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() + if let token = m.deviceToken { + registerToken(token: token) + } + withAnimation { + m.onboardingStage = m.onboardingStage == .step2_CreateProfile + ? .step3_SetNotificationsMode + : m.chats.isEmpty + ? .step4_MakeConnection + : .onboardingComplete } - ChatReceiver.shared.start() - } catch { - fatalError("Failed to start or load chats: \(error)") } + ChatReceiver.shared.start() + m.chatRunning = true + chatLastStartGroupDefault.set(Date.now) } class ChatReceiver { @@ -509,12 +583,13 @@ class ChatReceiver { } func receiveMsgLoop() async { - let msg = await chatRecvMsg() - self._lastMsgTime = .now - processReceivedMsg(msg) + // TODO use function that has timeout + if let msg = await chatRecvMsg() { + self._lastMsgTime = .now + await processReceivedMsg(msg) + } if self.receiveMessages { - do { try await Task.sleep(nanoseconds: 7_500_000) } - catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") } + _ = try? await Task.sleep(nanoseconds: 7_500_000) await receiveMsgLoop() } } @@ -527,9 +602,9 @@ class ChatReceiver { } } -func processReceivedMsg(_ res: ChatResponse) { +func processReceivedMsg(_ res: ChatResponse) async { let m = ChatModel.shared - DispatchQueue.main.async { + await MainActor.run { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { @@ -629,19 +704,9 @@ func processReceivedMsg(_ res: ChatResponse) { let fileName = cItem.file?.filePath { removeFile(fileName) } - case let .callInvitation(contact, callType, sharedKey, callTs): - let uuid = UUID() - var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey, callTs: callTs) - m.callInvitations[contact.id] = invitation - CallController.shared.reportNewIncomingCall(invitation: invitation) { error in - if let error = error { - invitation.callkitUUID = nil - m.callInvitations[contact.id] = invitation - logger.error("reportNewIncomingCall error: \(error.localizedDescription)") - } else { - logger.debug("reportNewIncomingCall success") - } - } + case let .callInvitation(invitation): + m.callInvitations[invitation.contact.id] = invitation + activateCall(invitation) // This will be called from notification service extension // CXProvider.reportNewIncomingVoIPPushPayload([ @@ -681,6 +746,8 @@ func processReceivedMsg(_ res: ChatResponse) { m.callCommand = .end // CallController.shared.reportCallRemoteEnded(call: call) } + case .chatSuspended: + chatSuspended() default: logger.debug("unsupported event: \(res.responseType)") } @@ -723,6 +790,27 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { m.updateNetworkStatus(contact.id, .error(err)) } +func refreshCallInvitations() throws { + let m = ChatModel.shared + let callInvitations = try apiGetCallInvitations() + m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv } + if let inv = callInvitations.last { + activateCall(inv) + } +} + +func activateCall(_ callInvitation: RcvCallInvitation) { + let m = ChatModel.shared + CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in + if let error = error { + m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil + logger.error("reportNewIncomingCall error: \(error.localizedDescription)") + } else { + logger.debug("reportNewIncomingCall success") + } + } +} + private struct UserResponse: Decodable { var user: User? var error: String? diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift new file mode 100644 index 000000000..2c0261ae8 --- /dev/null +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -0,0 +1,79 @@ +// +// SuspendChat.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit +import SimpleXChat + +private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") + +let appSuspendTimeout: Int = 15 // seconds + +let bgSuspendTimeout: Int = 5 // seconds + +let terminationTimeout: Int = 3 // seconds + +private func _suspendChat(timeout: Int) { + appStateGroupDefault.set(.suspending) + apiSuspendChat(timeoutMicroseconds: timeout * 1000000) + let endTask = beginBGTask(chatSuspended) + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) +} + +func suspendChat() { + suspendLockQueue.sync { + if appStateGroupDefault.get() != .stopped { + _suspendChat(timeout: appSuspendTimeout) + } + } +} + +func suspendBgRefresh() { + suspendLockQueue.sync { + if case .bgRefresh = appStateGroupDefault.get() { + _suspendChat(timeout: bgSuspendTimeout) + } + } +} + +func terminateChat() { + suspendLockQueue.sync { + switch appStateGroupDefault.get() { + case .suspending: + // suspend instantly if already suspending + _chatSuspended() + apiSuspendChat(timeoutMicroseconds: 0) + case .stopped: () + default: + _suspendChat(timeout: terminationTimeout) + } + } +} + +func chatSuspended() { + suspendLockQueue.sync { + if case .suspending = appStateGroupDefault.get() { + _chatSuspended() + } + } +} + +private func _chatSuspended() { + logger.debug("_chatSuspended") + appStateGroupDefault.set(.suspended) + if ChatModel.shared.chatRunning == true { + ChatReceiver.shared.stop() + } +} + +func activateChat(appState: AppState = .active) { + suspendLockQueue.sync { + appStateGroupDefault.set(appState) + apiActivateChat() + } +} diff --git a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h deleted file mode 100644 index bc28b42d3..000000000 --- a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -extern void hs_init(int argc, char **argv[]); - -typedef void* chat_ctrl; - -extern chat_ctrl chat_init(char *path); -extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); -extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f6e5b23c4..5347851a7 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -7,6 +7,7 @@ import SwiftUI import OSLog +import SimpleXChat let logger = Logger() @@ -24,6 +25,7 @@ struct SimpleXApp: App { init() { hs_init(0, nil) UserDefaults.standard.register(defaults: appDefaults) + setDbContainer() BGManager.shared.register() NtfManager.shared.registerCategories() } @@ -37,19 +39,33 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { - initializeChat() + do { + chatModel.v3DBMigration = v3DBMigrationDefault.get() + try initializeChat(start: chatModel.v3DBMigration.startChat) + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } } .onChange(of: scenePhase) { phase in logger.debug("scenePhase \(String(describing: scenePhase))") - setAppState(phase) switch (phase) { case .background: + suspendChat() BGManager.shared.schedule() if userAuthorized == true { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false case .active: + if chatModel.chatRunning == true { + ChatReceiver.shared.start() + } + let appState = appStateGroupDefault.get() + activateChat() + if appState.inactive && chatModel.chatRunning == true { + updateChats() + updateCallInvitations() + } doAuthenticate = authenticationExpired() default: break @@ -58,6 +74,30 @@ struct SimpleXApp: App { } } + private func setDbContainer() { +// Uncomment and run once to open DB in app documents folder: +// dbContainerGroupDefault.set(.documents) +// v3DBMigrationDefault.set(.offer) +// to create database in app documents folder also uncomment: +// let legacyDatabase = true + let legacyDatabase = hasLegacyDatabase() + if legacyDatabase, case .documents = dbContainerGroupDefault.get() { + dbContainerGroupDefault.set(.documents) + setMigrationState(.offer) + logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db") + } else { + dbContainerGroupDefault.set(.group) + setMigrationState(.ready) + logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db") + logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present") + } + } + + private func setMigrationState(_ state: V3DBMigrationState) { + if case .migrated = v3DBMigrationDefault.get() { return } + v3DBMigrationDefault.set(state) + } + private func authenticationExpired() -> Bool { if let enteredBackground = enteredBackground { return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 @@ -65,4 +105,31 @@ struct SimpleXApp: App { return true } } + + private func updateChats() { + do { + let chats = try apiGetChats() + chatModel.updateChats(with: chats) + if let id = chatModel.chatId, + let chat = chatModel.getChat(id) { + loadChat(chat: chat) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if chatModel.chatId == chat.id { + Task { await markChatRead(chat) } + } + } + } + } catch let error { + logger.error("apiGetChats: cannot update chats \(responseError(error))") + } + } + + private func updateCallInvitations() { + do { + try refreshCallInvitations() + } + catch let error { + logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))") + } + } } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 5c4635640..7e914a866 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -8,6 +8,7 @@ import SwiftUI import WebKit +import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 3421d0d10..5c0e23360 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -9,6 +9,7 @@ import Foundation //import CallKit import AVFoundation +import SimpleXChat //class CallController: NSObject, CXProviderDelegate, ObservableObject { class CallController: NSObject, ObservableObject { @@ -17,7 +18,7 @@ class CallController: NSObject, ObservableObject { // private let provider = CXProvider(configuration: CallController.configuration) // private let controller = CXCallController() private let callManager = CallManager() - @Published var activeCallInvitation: CallInvitation? + @Published var activeCallInvitation: RcvCallInvitation? // PKPushRegistry will be used from notification service extension // let registry = PKPushRegistry(queue: nil) @@ -119,9 +120,8 @@ class CallController: NSObject, ObservableObject { // } // } - func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) { + func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall") - if !UserDefaults.standard.bool(forKey: DEFAULT_EXPERIMENTAL_CALLS) { return } // if CallController.useCallKit, let uuid = invitation.callkitUUID { // let update = CXCallUpdate() // update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName) @@ -141,7 +141,7 @@ class CallController: NSObject, ObservableObject { // } // } - func reportCallRemoteEnded(invitation: CallInvitation) { + func reportCallRemoteEnded(invitation: RcvCallInvitation) { // if CallController.useCallKit, let uuid = invitation.callkitUUID { // provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) // } else if invitation.contact.id == activeCallInvitation?.contact.id { @@ -171,7 +171,7 @@ class CallController: NSObject, ObservableObject { } } - func answerCall(invitation: CallInvitation) { + func answerCall(invitation: RcvCallInvitation) { callManager.answerIncomingCall(invitation: invitation) if invitation.contact.id == self.activeCallInvitation?.contact.id { self.activeCallInvitation = nil @@ -192,7 +192,7 @@ class CallController: NSObject, ObservableObject { // } } - func endCall(invitation: CallInvitation) { + func endCall(invitation: RcvCallInvitation) { callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 5d0f5af0c..61bd3b9b3 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -7,6 +7,7 @@ // import Foundation +import SimpleXChat class CallManager { func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID { @@ -33,7 +34,7 @@ class CallManager { return false } - func answerIncomingCall(invitation: CallInvitation) { + func answerIncomingCall(invitation: RcvCallInvitation) { let m = ChatModel.shared m.callInvitations.removeValue(forKey: invitation.contact.id) m.activeCall = Call( @@ -41,13 +42,13 @@ class CallManager { contact: invitation.contact, callkitUUID: invitation.callkitUUID, callState: .invitationAccepted, - localMedia: invitation.peerMedia, + localMedia: invitation.callType.media, sharedKey: invitation.sharedKey ) m.showCallView = true let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) logger.debug("answerIncomingCall useRelay \(useRelay)") - m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay) + m.callCommand = .start(media: invitation.callType.media, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay) } func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) { @@ -85,7 +86,7 @@ class CallManager { } } - func endCall(invitation: CallInvitation, completed: @escaping () -> Void) { + func endCall(invitation: RcvCallInvitation, completed: @escaping () -> Void) { ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id) Task { do { @@ -97,7 +98,7 @@ class CallManager { } } - private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? { + private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? { if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) { return invitation } diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 089ffea5f..22a14e4d5 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct IncomingCallView: View { @EnvironmentObject var m: ChatModel @@ -25,10 +26,10 @@ struct IncomingCallView: View { } } - private func incomingCall(_ invitation: CallInvitation) -> some View { + private func incomingCall(_ invitation: RcvCallInvitation) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { - Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green) + Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green) Text(invitation.callTypeText) } HStack { @@ -80,7 +81,7 @@ struct IncomingCallView: View { struct IncomingCallView_Previews: PreviewProvider { static var previews: some View { - CallController.shared.activeCallInvitation = CallInvitation.sampleData + CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData return IncomingCallView() } } diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 0850c0f6b..7e63f4f49 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +import SimpleXChat class Call: ObservableObject, Equatable { static func == (lhs: Call, rhs: Call) -> Bool { diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index 93a61c3f4..ab48d93a3 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -8,6 +8,7 @@ import SwiftUI import WebKit +import SimpleXChat class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate { var rtcWebView: Binding diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 774e8aa1f..6e3fcfd96 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9) private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 ) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b7bd7ef58..fc82d543b 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 6b3498900..a2204cb9b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CICallItemView: View { @EnvironmentObject var m: ChatModel diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index a7a7119c7..31b90e2ee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CIFileView: View { @Environment(\.colorScheme) var colorScheme @@ -133,7 +134,7 @@ struct CIFileView: View { struct CIFileView_Previews: PreviewProvider { static var previews: some View { - let sentFile = ChatItem( + let sentFile: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false), content: .sndMsgContent(msgContent: .file("")), diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 7b8d2efdf..9c131cc1c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CIImageView: View { @Environment(\.colorScheme) var colorScheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index a2a4ecede..4c12c7312 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CILinkView: View { @Environment(\.colorScheme) var colorScheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 390281c34..b6de98d81 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CIMetaView: View { var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index 210a4dfd2..4ac47188c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct DeletedItemView: View { var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 260e6e9fe..e45b5bd18 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct EmojiItemView: View { var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index e720ab741..ddaf6d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 3a7578921..7a4fa4682 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct IntegrityErrorItemView: View { var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index b0b9928c9..6707f8bde 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) private let linkColor = Color(uiColor: uiLinkColor) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d82685695..62edc343a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatItemView: View { var chatInfo: ChatInfo diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 12aa30a50..bafe83336 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -7,13 +7,13 @@ // import SwiftUI +import SimpleXChat private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme - @AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false @ObservedObject var chat: Chat @Binding var showChatInfo: Bool @State private var composeState = ComposeState() @@ -107,7 +107,7 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - if enableCalls, case let .direct(contact) = cInfo { + if case let .direct(contact) = cInfo { HStack { callButton(contact, .audio, imageName: "phone") callButton(contact, .video, imageName: "video") @@ -224,7 +224,7 @@ struct ChatView: View { } func markAllRead() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if chatModel.chatId == chat.id { Task { await markChatRead(chat) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift index c23de7222..3e65600ce 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ComposeImageView: View { @Environment(\.colorScheme) var colorScheme diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index 7b24a06f8..335755c00 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -8,7 +8,7 @@ import SwiftUI import LinkPresentation - +import SimpleXChat func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { logger.debug("getLinkMetadata: fetching URL preview") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 0de0ea555..c97719dc1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat enum ComposePreview { case noPreview @@ -209,9 +210,8 @@ struct ComposeView: View { allowedContentTypes: [.data], allowsMultipleSelection: false ) { result in - if case .success = result { + if case let .success(files) = result, let fileURL = files.first { do { - let fileURL: URL = try result.get().first! var fileSize: Int? = nil if fileURL.startAccessingSecurityScopedResource() { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) @@ -391,17 +391,12 @@ struct ComposeView: View { } private func parseMessage(_ msg: String) -> URL? { - do { - let parsedMsg = try apiParseMarkdown(text: msg) - let uri = parsedMsg?.first(where: { ft in - ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) - }) - if let uri = uri { return URL(string: uri.text) } - else { return nil } - } catch { - logger.error("apiParseMarkdown error: \(error.localizedDescription)") - return nil - } + let parsedMsg = parseSimpleXMarkdown(msg) + let uri = parsedMsg?.first(where: { ft in + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + }) + if let uri = uri { return URL(string: uri.text) } + else { return nil } } private func isSimplexLink(_ link: String) -> Bool { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index c1245633c..b9fec6500 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 22510808c..c5269c380 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct SendMessageView: View { @Binding var composeState: ComposeState diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index c8344980f..eedf579a1 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @@ -29,16 +30,7 @@ struct ChatListNavLink: View { private func chatView() -> some View { ChatView(chat: chat, showChatInfo: $showChatInfo) - .onAppear { - do { - let cInfo = chat.chatInfo - let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) - chatModel.updateChatInfo(chat.chatInfo) - chatModel.chatItems = chat.chatItems - } catch { - logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)") - } - } + .onAppear { loadChat(chat: chat) } } @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 33078d210..29850a6e8 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @@ -19,9 +20,10 @@ struct ChatListView: View { var body: some View { let v = NavigationView { List { - ForEach(filteredChats()) { chat in + ForEach(filteredChats(), id: \.viewId) { chat in ChatListNavLink(chat: chat, showChatInfo: $showChatInfo) .padding(.trailing, -16) + .disabled(chatModel.chatRunning != true) } } .onChange(of: chatModel.chatId) { _ in @@ -32,7 +34,7 @@ struct ChatListView: View { } .onChange(of: chatModel.chats.isEmpty) { empty in if !empty { return } - withAnimation { chatModel.onboardingStage = .step3_MakeConnection } + withAnimation { chatModel.onboardingStage = .step4_MakeConnection } } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onAppear() { connectViaUrl() } @@ -45,7 +47,11 @@ struct ChatListView: View { SettingsButton() } ToolbarItem(placement: .navigationBarTrailing) { - NewChatButton() + switch chatModel.chatRunning { + case .some(true): NewChatButton() + case .some(false): chatStoppedIcon() + case .none: EmptyView() + } } } } @@ -73,6 +79,17 @@ struct ChatListView: View { } } +func chatStoppedIcon() -> some View { + Button { + AlertManager.shared.showAlertMsg( + title: "Chat is stopped", + message: "You can start chat via app Settings / Database or by restarting the app" + ) + } label: { + Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) + } +} + struct ChatListView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index eec99991d..d1490eaf0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatPreviewView: View { @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index 5243188c6..7f9388fc5 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ContactConnectionView: View { var contactConnection: PendingContactConnection diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index ac873fc8f..2edb1ad0e 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ContactRequestView: View { var contactRequest: UserContactRequest diff --git a/apps/ios/Shared/Views/Database/ChatArchiveView.swift b/apps/ios/Shared/Views/Database/ChatArchiveView.swift new file mode 100644 index 000000000..65913343d --- /dev/null +++ b/apps/ios/Shared/Views/Database/ChatArchiveView.swift @@ -0,0 +1,65 @@ +// +// ChatArchiveView.swift +// SimpleXChat +// +// Created by Evgeny on 23/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChatArchiveView: View { + var archiveName: String + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + @State private var showDeleteAlert = false + + var body: some View { + let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName) + let fileTs = chatArchiveTimeDefault.get() + List { + Section { + settingsRow("square.and.arrow.up") { + Button { + showShareSheet(items: [fileUrl]) + } label: { + Text("Save archive") + } + } + settingsRow("trash") { + Button { + showDeleteAlert = true + } label: { + Text("Delete archive").foregroundColor(.red) + } + } + } header: { + Text("Chat archive") + } footer: { + Text("Created on \(fileTs)") + } + } + .alert(isPresented: $showDeleteAlert) { + Alert( + title: Text("Delete chat archive?"), + primaryButton: .destructive(Text("Delete")) { + do { + try FileManager.default.removeItem(atPath: fileUrl.path) + chatArchiveName = nil + chatArchiveTime = 0 + } catch let error { + logger.error("removeItem error \(String(describing: error))") + } + }, + secondaryButton: .cancel() + ) + } + } +} + +struct ChatArchiveView_Previews: PreviewProvider { + static var previews: some View { + ChatArchiveView(archiveName: "") + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift new file mode 100644 index 000000000..d904cd05c --- /dev/null +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -0,0 +1,332 @@ +// +// DatabaseView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 19/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum DatabaseAlert: Identifiable { + case stopChat + case importArchive + case archiveImported + case deleteChat + case chatDeleted + case deleteLegacyDatabase + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case .stopChat: return "stopChat" + case .importArchive: return "importArchive" + case .archiveImported: return "archiveImported" + case .deleteChat: return "deleteChat" + case .chatDeleted: return "chatDeleted" + case .deleteLegacyDatabase: return "deleteLegacyDatabase" + case let .error(title, _): return "error \(title)" + } + } +} + +struct DatabaseView: View { + @EnvironmentObject var m: ChatModel + @Binding var showSettings: Bool + @State private var runChat = false + @State private var alert: DatabaseAlert? = nil + @State private var showFileImporter = false + @State private var importedArchivePath: URL? + @State private var progressIndicator = false + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + @State private var dbContainer = dbContainerGroupDefault.get() + @State private var legacyDatabase = hasLegacyDatabase() + + var body: some View { + ZStack { + chatDatabaseView() + if progressIndicator { + ProgressView().scaleEffect(2) + } + } + } + + private func chatDatabaseView() -> some View { + List { + let stopped = m.chatRunning == false + Section { + settingsRow( + stopped ? "exclamationmark.octagon.fill" : "play.fill", + color: stopped ? .red : .green + ) { + Toggle( + stopped ? "Chat is stopped" : "Chat is running", + isOn: $runChat + ) + .onChange(of: runChat) { _ in + if (runChat) { + startChat() + } else { + alert = .stopChat + } + } + } + } header: { + Text("Run chat") + } footer: { + if case .documents = dbContainer { + Text("Database will be migrated when the app restarts") + } + } + + Section { + settingsRow("square.and.arrow.up") { + Button { + exportArchive() + } label: { + Text("Export database") + } + } + settingsRow("square.and.arrow.down") { + Button(role: .destructive) { + showFileImporter = true + } label: { + Text("Import database") + } + } + if let archiveName = chatArchiveName { + let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get() + ? "Old database archive" + : "New database archive" + settingsRow("archivebox") { + NavigationLink { + ChatArchiveView(archiveName: archiveName) + .navigationTitle(title) + } label: { + Text(title) + } + } + } + settingsRow("trash.slash") { + Button(role: .destructive) { + alert = .deleteChat + } label: { + Text("Delete database") + } + } + } header: { + Text("Chat database") + } footer: { + Text( + stopped + ? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." + : "Stop chat to enable database actions" + ) + } + .disabled(!stopped) + + if case .group = dbContainer, legacyDatabase { + Section("Old database") { + settingsRow("trash") { + Button { + alert = .deleteLegacyDatabase + } label: { + Text("Delete old database") + } + } + } + } + } + .onAppear { runChat = m.chatRunning ?? true } + .alert(item: $alert) { item in databaseAlert(item) } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.zip], + allowsMultipleSelection: false + ) { result in + if case let .success(files) = result, let fileURL = files.first { + importedArchivePath = fileURL + alert = .importArchive + } + } + } + + private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert { + switch alertItem { + case .stopChat: + return Alert( + title: Text("Stop chat?"), + message: Text("Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped."), + primaryButton: .destructive(Text("Stop")) { + stopChat() + }, + secondaryButton: .cancel { + withAnimation { runChat = true } + } + ) + case .importArchive: + if let fileURL = importedArchivePath { + return Alert( + title: Text("Import chat database?"), + message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), + primaryButton: .destructive(Text("Import")) { + importArchive(fileURL) + }, + secondaryButton: .cancel() + ) + } else { + return Alert(title: Text("Error: no database file")) + } + case .archiveImported: + return Alert( + title: Text("Chat database imported"), + message: Text("Restart the app to use imported chat database") + ) + + case .deleteChat: + return Alert( + title: Text("Delete chat profile?"), + message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), + primaryButton: .destructive(Text("Delete")) { + deleteChat() + }, + secondaryButton: .cancel() + ) + case .chatDeleted: + return Alert( + title: Text("Chat database deleted"), + message: Text("Restart the app to create a new chat profile") + ) + case .deleteLegacyDatabase: + return Alert( + title: Text("Delete old database?"), + message: Text("The old database was not removed during the migration, it can be deleted."), + primaryButton: .destructive(Text("Delete")) { + deleteLegacyDatabase() + }, + secondaryButton: .cancel() + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text("\(error)")) + } + } + + private func stopChat() { + Task { + do { + try await apiStopChat() + ChatReceiver.shared.stop() + await MainActor.run { m.chatRunning = false } + appStateGroupDefault.set(.stopped) + } catch let error { + await MainActor.run { + runChat = true + alert = .error(title: "Error stopping chat", error: responseError(error)) + } + } + } + } + + private func exportArchive() { + progressIndicator = true + Task { + do { + let archivePath = try await exportChatArchive() + showShareSheet(items: [archivePath]) + await MainActor.run { progressIndicator = false } + } catch let error { + await MainActor.run { + alert = .error(title: "Error exporting chat database", error: responseError(error)) + progressIndicator = false + } + } + } + } + + private func importArchive(_ archivePath: URL) { + if archivePath.startAccessingSecurityScopedResource() { + progressIndicator = true + Task { + do { + try await apiDeleteStorage() + do { + let config = ArchiveConfig(archivePath: archivePath.path) + try await apiImportArchive(config: config) + await operationEnded(.archiveImported) + } catch let error { + await operationEnded(.error(title: "Error importing chat database", error: responseError(error))) + } + } catch let error { + await operationEnded(.error(title: "Error deleting chat database", error: responseError(error))) + } + archivePath.stopAccessingSecurityScopedResource() + } + } else { + alert = .error(title: "Error accessing database file") + } + } + + private func deleteChat() { + progressIndicator = true + Task { + do { + try await apiDeleteStorage() + await operationEnded(.chatDeleted) + } catch let error { + await operationEnded(.error(title: "Error deleting database", error: responseError(error))) + } + } + } + + private func deleteLegacyDatabase() { + if removeLegacyDatabaseAndFiles() { + legacyDatabase = false + } else { + alert = .error(title: "Error deleting old database") + } + } + + private func operationEnded(_ dbAlert: DatabaseAlert) async { + await MainActor.run { + m.chatDbChanged = true + progressIndicator = false + alert = dbAlert + } + } + + private func startChat() { + if m.chatDbChanged { + showSettings = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resetChatCtrl() + do { + try initializeChat(start: true) + m.chatDbChanged = false + appStateGroupDefault.set(.active) + } catch let error { + fatalError("Error starting chat \(responseError(error))") + } + } + } else { + do { + _ = try apiStartChat() + runChat = true + m.chatRunning = true + ChatReceiver.shared.start() + chatLastStartGroupDefault.set(Date.now) + appStateGroupDefault.set(.active) + } catch let error { + runChat = false + alert = .error(title: "Error starting chat", error: responseError(error)) + } + } + } +} + +struct DatabaseView_Previews: PreviewProvider { + static var previews: some View { + DatabaseView(showSettings: Binding.constant(false)) + } +} diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift new file mode 100644 index 000000000..de99d315e --- /dev/null +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -0,0 +1,251 @@ +// +// MigrateToGroupView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 20/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum V3DBMigrationState: String { + case offer + case postponed + case exporting + case export_error + case exported + case migrating + case migration_error + case migrated + case ready + + var startChat: Bool { + switch self { + case .postponed: return true + case .ready: return true + default: return false + } + } +} + +let v3DBMigrationDefault = EnumDefault( + defaults: UserDefaults.standard, + forKey: DEFAULT_CHAT_V3_DB_MIGRATION, + withDefault: .offer +) + +struct MigrateToAppGroupView: View { + @EnvironmentObject var chatModel: ChatModel + @State private var migrationError = "" + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + + var body: some View { + ZStack(alignment: .topLeading) { + Text("Push notifications").font(.largeTitle) + + switch chatModel.v3DBMigration { + case .offer: + VStack(alignment: .leading, spacing: 16) { + Text("To support instant push notifications the chat database has to be migrated.") + Text("If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app).") + } + .padding(.top, 56) + center { + Button { + migrateDatabaseToV3() + } label: { + Text("Start migration") + .font(.title) + .frame(maxWidth: .infinity) + } + } + skipMigration() + case .exporting: + center { + ProgressView(value: 0.33) + Text("Exporting database archive...") + } + migrationProgress() + case .export_error: + migrationFailed().padding(.top, 56) + center { + Text("Export error:").font(.headline) + Text(migrationError) + } + skipMigration() + case .exported: + center { + Text("Exported database archive.") + } + case .migrating: + center { + ProgressView(value: 0.67) + Text("Migrating database archive...") + } + migrationProgress() + case .migration_error: + VStack(alignment: .leading, spacing: 16) { + migrationFailed() + Text("The created archive is available via app Settings / Database / Old database archive.") + } + .padding(.top, 56) + center { + Text("Migration error:").font(.headline) + Text(migrationError) + } + skipMigration() + case .migrated: + center { + ProgressView(value: 1.0) + Text("Migration is completed") + } + VStack { + Spacer() + Spacer() + Spacer() + Button { + do { + resetChatCtrl() + try initializeChat(start: true) + chatModel.onboardingStage = .step3_SetNotificationsMode + setV3DBMigration(.ready) + } catch let error { + dbContainerGroupDefault.set(.documents) + setV3DBMigration(.migration_error) + migrationError = "Error starting chat: \(responseError(error))" + } + deleteOldArchive() + } label: { + Text("Start chat") + .font(.title) + .frame(maxWidth: .infinity) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + default: + Spacer() + Text("Unexpected migration state") + Text("\(chatModel.v3DBMigration.rawValue)") + Spacer() + skipMigration() + } + } + .padding() + } + + private func center(@ViewBuilder c: @escaping () -> Content) -> some View where Content: View { + VStack(alignment: .leading, spacing: 8) { c() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private func migrationProgress() -> some View { + VStack { + Spacer() + ProgressView().scaleEffect(2) + Spacer() + Spacer() + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func migrationFailed() -> some View { + Text("Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat).") + } + + private func skipMigration() -> some View { + ZStack { + Button { + setV3DBMigration(.postponed) + do { + try startChat() + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } + } label: { + Text("Do it later") + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } + + private func setV3DBMigration(_ state: V3DBMigrationState) { + chatModel.v3DBMigration = state + v3DBMigrationDefault.set(state) + } + + func migrateDatabaseToV3() { + setV3DBMigration(.exporting) + let archiveTime = Date.now + let archiveName = "simplex-chat.\(archiveTime.ISO8601Format()).zip" + chatArchiveTime = archiveTime.timeIntervalSince1970 + chatArchiveName = archiveName + let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path) + Task { + do { + try await apiExportArchive(config: config) + await MainActor.run { setV3DBMigration(.exported) } + } catch let error { + await MainActor.run { + setV3DBMigration(.export_error) + migrationError = responseError(error) + } + return + } + + do { + await MainActor.run { setV3DBMigration(.migrating) } + dbContainerGroupDefault.set(.group) + resetChatCtrl() + try await MainActor.run { try initializeChat(start: false) } + try await apiImportArchive(config: config) + await MainActor.run { setV3DBMigration(.migrated) } + } catch let error { + dbContainerGroupDefault.set(.documents) + await MainActor.run { + setV3DBMigration(.migration_error) + migrationError = responseError(error) + } + } + } + } +} + +func exportChatArchive() async throws -> URL { + let archiveTime = Date.now + let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) + let archiveName = "simplex-chat.\(ts).zip" + let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName) + let config = ArchiveConfig(archivePath: archivePath.path) + try await apiExportArchive(config: config) + deleteOldArchive() + UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + chatArchiveTimeDefault.set(archiveTime) + return archivePath +} + +func deleteOldArchive() { + let d = UserDefaults.standard + if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) { + do { + try FileManager.default.removeItem(atPath: getDocumentsDirectory().appendingPathComponent(archiveName).path) + d.set(nil, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + d.set(0, forKey: DEFAULT_CHAT_ARCHIVE_TIME) + } catch let error { + logger.error("removeItem error \(String(describing: error))") + } + } +} + +struct MigrateToGroupView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.v3DBMigration = .migrated + return MigrateToAppGroupView() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index c9a2bdf16..1b344148c 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ChatInfoImage: View { @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index f8f75d74d..cc4f09ae3 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct ProfileImage: View { var imageStr: String? = nil diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 99f62e65c..a965d72db 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -12,25 +12,27 @@ import CoreImage.CIFilterBuiltins struct AddContactView: View { var connReqInvitation: String var body: some View { - VStack(alignment: .leading) { - Text("One-time invitation link") - .font(.title) - .padding(.vertical) - Text("Your contact can scan it from the app") - .padding(.bottom) - QRCode(uri: connReqInvitation) - .padding(.bottom) - Text("If you can't meet in person, **show QR code in the video call**, or share the link.") - .padding(.bottom) - Button { - showShareSheet(items: [connReqInvitation]) - } label: { - Label("Share invitation link", systemImage: "square.and.arrow.up") + ScrollView { + VStack(alignment: .leading) { + Text("One-time invitation link") + .font(.title) + .padding(.vertical) + Text("Your contact can scan it from the app") + .padding(.bottom) + QRCode(uri: connReqInvitation) + .padding(.bottom) + Text("If you can't meet in person, **show QR code in the video call**, or share the link.") + .padding(.bottom) + Button { + showShareSheet(items: [connReqInvitation]) + } label: { + Label("Share invitation link", systemImage: "square.and.arrow.up") + } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) + .padding() + .frame(maxHeight: .infinity, alignment: .top) } - .padding() - .frame(maxHeight: .infinity, alignment: .top) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index ddd693c8f..3adf3d4c2 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat enum NewChatAction: Identifiable { case createLink diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 639a161ad..8b2fcd627 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel @@ -94,11 +95,11 @@ struct CreateProfile: View { ) do { m.currentUser = try apiCreateActiveUser(profile) - startChat() - withAnimation { m.onboardingStage = .step3_MakeConnection } + try startChat() + withAnimation { m.onboardingStage = .step3_SetNotificationsMode } } catch { - fatalError("Failed to create user: \(error)") + fatalError("Failed to create user or start chat: \(responseError(error))") } } diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift index 3251b1653..92ae376b9 100644 --- a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct MakeConnection: View { @EnvironmentObject var m: ChatModel @@ -15,7 +16,14 @@ struct MakeConnection: View { var body: some View { VStack(alignment: .leading) { - SettingsButton().padding(.bottom, 1) + HStack { + SettingsButton() + if m.chatRunning == false { + Spacer() + chatStoppedIcon() + } + } + .padding(.bottom, 1) if let user = m.currentUser { Text("Welcome \(user.displayName)!") @@ -65,6 +73,7 @@ struct MakeConnection: View { } } } + .disabled(m.chatRunning != true) } Spacer() diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 04fcbf42e..0b0189383 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -15,7 +15,8 @@ struct OnboardingView: View { switch onboarding { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) case .step2_CreateProfile: CreateProfile() - case .step3_MakeConnection: MakeConnection() + case .step3_SetNotificationsMode: SetNotificationsMode() + case .step4_MakeConnection: MakeConnection() case .onboardingComplete: EmptyView() } } @@ -24,7 +25,8 @@ struct OnboardingView: View { enum OnboardingStage { case step1_SimpleXInfo case step2_CreateProfile - case step3_MakeConnection + case step3_SetNotificationsMode + case step4_MakeConnection case onboardingComplete } diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift new file mode 100644 index 000000000..87803512b --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -0,0 +1,112 @@ +// +// NotificationsModeView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 03/07/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SetNotificationsMode: View { + @EnvironmentObject var m: ChatModel + @State private var notificationMode = NotificationsMode.instant + @State private var showAlert: NotificationAlert? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Push notifications").font(.largeTitle) + + Text("Send notifications:") + ForEach(NotificationsMode.values) { mode in + NtfModeSelector(mode: mode, selection: $notificationMode) + } + + Spacer() + + Button { + if let token = m.deviceToken { + setNotificationsMode(token, notificationMode) + } else { + AlertManager.shared.showAlertMsg(title: "No device token!") + } + m.onboardingStage = m.chats.isEmpty + ? .step4_MakeConnection + : .onboardingComplete + } label: { + if case .off = notificationMode { + Text("Use chat") + } else { + Text("Enable notifications") + } + } + .font(.title) + .frame(maxWidth: .infinity) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + + private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { + switch mode { + case .off: + m.tokenStatus = .new + m.notificationMode = .off + default: + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status + m.notificationMode = mode + } + } catch let error { + AlertManager.shared.showAlertMsg( + title: "Error enabling notifications", + message: "\(responseError(error))" + ) + } + } + } + } +} + +struct NtfModeSelector: View { + var mode: NotificationsMode + @Binding var selection: NotificationsMode + @State private var tapped = false + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 4) { + Text(mode.label) + .font(.headline) + .foregroundColor(selection == mode ? .accentColor : .secondary) + Text(ntfModeDescription(mode)) + .lineLimit(10) + .font(.subheadline) + } + .padding(12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: tapped ? .secondarySystemFill : .systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(selection == mode ? Color.accentColor : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + ._onButtonGesture { down in + tapped = down + if down { selection = mode } + } perform: {} + } +} + +struct NotificationsModeView_Previews: PreviewProvider { + static var previews: some View { + SetNotificationsMode() + } +} diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 2d0585264..b53c871c6 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -77,7 +77,7 @@ struct OnboardingActionButton: View { if m.currentUser == nil { actionButton("Create your profile", onboarding: .step2_CreateProfile) } else { - actionButton("Make a private connection", onboarding: .step3_MakeConnection) + actionButton("Make a private connection", onboarding: .step4_MakeConnection) } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 75198c097..6aa9e8804 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat private let terminalFont = Font.custom("Menlo", size: 16) diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 516b23753..7bc79dd0b 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -21,10 +21,9 @@ struct CallSettings: View { Section("Limitations") { VStack(alignment: .leading, spacing: 8) { textListItem("1.", "Do NOT use SimpleX for emergency calls.") - textListItem("2.", "Pre-arrange the calls, as notifications arrive with a delay (we are improving it).") - textListItem("3.", "The microphone does not work when the app is in the background.") - textListItem("4.", "To prevent the call interruption, enable Do Not Disturb mode.") - textListItem("5.", "If the video fails to connect, flip the camera to resolve it.") + textListItem("2.", "The microphone does not work when the app is in the background.") + textListItem("3.", "To prevent the call interruption, enable Do Not Disturb mode.") + textListItem("4.", "If the video fails to connect, flip the camera to resolve it.") } .font(.callout) .padding(.vertical, 8) diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift new file mode 100644 index 000000000..4e4001b24 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -0,0 +1,222 @@ +// +// NotificationsView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct NotificationsView: View { + @EnvironmentObject var m: ChatModel + @State private var notificationMode: NotificationsMode? + @State private var showAlert: NotificationAlert? + @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents + + var body: some View { + List { + Section { + NavigationLink { + List { + Section { + SelectionListView(list: NotificationsMode.values, selection: $notificationMode) { mode in + showAlert = .setMode(mode: mode) + } + } footer: { + VStack(alignment: .leading) { + if let mode = notificationMode { + Text(ntfModeDescription(mode)) + } + } + .font(.callout) + .padding(.top, 1) + } + } + .navigationTitle("Send notifications") + .navigationBarTitleDisplayMode(.inline) + .alert(item: $showAlert) { alert in + if let token = m.deviceToken { + return notificationAlert(alert, token) + } else { + return Alert(title: Text("No device token!")) + } + } + .onAppear { notificationMode = m.notificationMode } + } label: { + HStack { + Text("Send notifications") + Spacer() + Text(m.notificationMode.label) + } + } + + NavigationLink { + List { + Section { + SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in + ntfPreviewModeGroupDefault.set(previewMode) + m.notificationPreview = previewMode + } + } footer: { + VStack(alignment: .leading, spacing: 1) { + Text("You can set lock screen notification preview via settings.") + Button("Open Settings") { + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) + } + } + } + } + } + .navigationTitle("Show preview") + .navigationBarTitleDisplayMode(.inline) + } label: { + HStack { + Text("Show preview") + Spacer() + Text(m.notificationPreview?.label ?? "") + } + } + } header: { + Text("Push notifications") + } footer: { + if legacyDatabase { + Text("Please restart the app and migrate the database to enable push notifications.") + .font(.callout) + .padding(.top, 1) + } + } + .disabled(legacyDatabase) + } + } + + private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert { + switch alert { + case let .setMode(mode): + return Alert( + title: Text(ntfModeAlertTitle(mode)), + message: Text(ntfModeDescription(mode)), + primaryButton: .default(Text(mode == .off ? "Turn off" : "Enable")) { + setNotificationsMode(token, mode) + }, + secondaryButton: .cancel() { + notificationMode = m.notificationMode + } + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } + + private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey { + switch mode { + case .off: return "Turn off notifications?" + case .periodic: return "Enable periodic notifications?" + case .instant: return "Enable instant notifications?" + } + } + + private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { + Task { + switch mode { + case .off: + do { + try await apiDeleteToken(token: token) + await MainActor.run { + m.tokenStatus = .new + notificationMode = .off + m.notificationMode = .off + } + } catch let error { + await MainActor.run { + let err = responseError(error) + logger.error("apiDeleteToken error: \(err)") + showAlert = .error(title: "Error deleting token", error: err) + } + } + default: + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status + notificationMode = mode + m.notificationMode = mode + } + } catch let error { + await MainActor.run { + let err = responseError(error) + logger.error("apiRegisterToken error: \(err)") + showAlert = .error(title: "Error enabling notifications", error: err) + } + } + } + } + } +} + +func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey { + switch mode { + case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." + case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." + case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." + } +} + +struct SelectionListView: View { + var list: [Item] + @Binding var selection: Item? + var onSelection: ((Item) -> Void)? + @State private var tapped: Item? = nil + + var body: some View { + ForEach(list) { item in + HStack { + Text(item.label) + Spacer() + if selection == item { + Image(systemName: "checkmark") + .resizable().scaledToFit().frame(width: 16) + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + .listRowBackground(Color(uiColor: tapped == item ? .secondarySystemFill : .systemBackground)) + .onTapGesture { + if selection == item { return } + if let f = onSelection { + f(item) + } else { + selection = item + } + } + ._onButtonGesture { down in + if down { + tapped = item + } else { + tapped = nil + } + } perform: {} + } + .environment(\.editMode, .constant(.active)) + } +} + +enum NotificationAlert: Identifiable { + case setMode(mode: NotificationsMode) + case error(title: LocalizedStringKey, error: String) + + var id: String { + switch self { + case let .setMode(mode): return "enable \(mode.rawValue)" + case let .error(title, error): return "error \(title): \(error)" + } + } +} + +struct NotificationsView_Previews: PreviewProvider { + static var previews: some View { + NotificationsView() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift index 35909320d..07913c59a 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServers.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat private let serversFont = Font.custom("Menlo", size: 14) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f531634c2..ab2b8836f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")! @@ -17,41 +18,41 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice" let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown" let DEFAULT_PERFORM_LA = "performLocalAuthentication" -let DEFAULT_USE_NOTIFICATIONS = "useNotifications" let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" +let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" +let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" +let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_PERFORM_LA: false, - DEFAULT_USE_NOTIFICATIONS: false, DEFAULT_PENDING_CONNECTIONS: true, DEFAULT_WEBRTC_POLICY_RELAY: true, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true, - DEFAULT_EXPERIMENTAL_CALLS: false + DEFAULT_EXPERIMENTAL_CALLS: false, + DEFAULT_CHAT_V3_DB_MIGRATION: "offer" ] private var indent: CGFloat = 36 +let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME) + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true - @AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false - @State var showNotificationsAlert: Bool = false - @State var whichNotificationsAlert = NotificationAlert.enable var body: some View { let user: User = chatModel.currentUser! - return NavigationView { + NavigationView { List { Section("You") { NavigationLink { @@ -61,23 +62,48 @@ struct SettingsView: View { ProfilePreview(profileOf: user) .padding(.leading, -8) } + .disabled(chatModel.chatRunning != true) + NavigationLink { UserAddress() .navigationTitle("Your chat address") } label: { settingsRow("qrcode") { Text("Your SimpleX contact address") } } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + DatabaseView(showSettings: $showSettings) + .navigationTitle("Your chat database") + } label: { + settingsRow("internaldrive") { + HStack { + Text("Database export & import") + Spacer() + if chatModel.chatRunning == false { + Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) + } + } + } + } } Section("Settings") { - if enableCalls { - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - } label: { - settingsRow("video") { Text("Audio & video calls") } + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + } label: { + HStack { + notificationsIcon() + Text("Notifications") } } + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + } label: { + settingsRow("video") { Text("Audio & video calls") } + } NavigationLink { PrivacySettings() .navigationTitle("Your privacy") @@ -94,6 +120,7 @@ struct SettingsView: View { settingsRow("server.rack") { Text("SMP servers") } } } + .disabled(chatModel.chatRunning != true) Section("Help") { NavigationLink { @@ -127,6 +154,7 @@ struct SettingsView: View { Text("Chat with the developers") } } + .disabled(chatModel.chatRunning != true) settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") } } @@ -136,6 +164,7 @@ struct SettingsView: View { } label: { settingsRow("terminal") { Text("Chat console") } } + .disabled(chatModel.chatRunning != true) ZStack(alignment: .leading) { Image(colorScheme == .dark ? "github_light" : "github") .resizable() @@ -144,17 +173,11 @@ struct SettingsView: View { Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") .padding(.leading, indent) } - NavigationLink { - ExperimentalFeaturesView() - .navigationTitle("Experimental features") - } label: { - settingsRow("gauge") { Text("Experimental features") } - } -// if let token = chatModel.deviceToken { -// HStack { -// notificationsIcon() -// notificationsToggle(token) -// } +// NavigationLink { +// ExperimentalFeaturesView() +// .navigationTitle("Experimental features") +// } label: { +// settingsRow("gauge") { Text("Experimental features") } // } Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } @@ -174,13 +197,13 @@ struct SettingsView: View { switch (chatModel.tokenStatus) { case .new: icon = "bolt" - color = .primary + color = .secondary case .registered: icon = "bolt.fill" - color = .primary + color = .secondary case .invalid: icon = "bolt.slash" - color = .primary + color = .secondary case .confirmed: icon = "bolt.fill" color = .yellow @@ -189,80 +212,20 @@ struct SettingsView: View { color = .green case .expired: icon = "bolt.slash.fill" - color = .primary + color = .secondary + case .none: + icon = "bolt" + color = .secondary } return Image(systemName: icon) .padding(.trailing, 9) .foregroundColor(color) } - - private func notificationsToggle(_ token: String) -> some View { - Toggle("Check messages", isOn: $useNotifications) - .onChange(of: useNotifications) { enable in - if enable { - showNotificationsAlert = true - whichNotificationsAlert = .enable - } else { - Task { - do { - try await apiDeleteToken(token: token) - chatModel.tokenStatus = .new - } - catch { - DispatchQueue.main.async { - if let cr = error as? ChatResponse { - let err = String(describing: cr) - logger.error("apiDeleteToken error: \(err)") - showNotificationsAlert = true - whichNotificationsAlert = .error("Error deleting token", err) - } else { - logger.error("apiDeleteToken unknown error: \(error.localizedDescription)") - } - } - } - } - } - } - .alert(isPresented: $showNotificationsAlert) { - switch (whichNotificationsAlert) { - case .enable: return enableNotificationsAlert(token) - case let .error(title, err): return Alert(title: Text(title), message: Text(err)) - } - } - } - - private func enableNotificationsAlert(_ token: String) -> Alert { - Alert( - title: Text("Enable notifications? (BETA)"), - message: Text("The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server."), - primaryButton: .destructive(Text("Confirm")) { - Task { - do { - chatModel.tokenStatus = try await apiRegisterToken(token: token) - } catch { - DispatchQueue.main.async { - useNotifications = false - if let cr = error as? ChatResponse { - let err = String(describing: cr) - logger.error("apiRegisterToken error: \(err)") - showNotificationsAlert = true - whichNotificationsAlert = .error("Error registering token", err) - } else { - logger.error("apiRegisterToken unknown error: \(error.localizedDescription)") - } - } - } - } - }, secondaryButton: .cancel() { - withAnimation() { useNotifications = false } - } - ) - } } -func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { +func settingsRow(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View { ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary) + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color) content().padding(.leading, indent) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index c1ff70823..16e4994d3 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -13,61 +13,63 @@ struct UserAddress: View { @State private var deleteAddressAlert = false var body: some View { - VStack (alignment: .leading) { - Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.") - .padding(.bottom) - if let userAdress = chatModel.userAddress { - QRCode(uri: userAdress) - HStack { - Button { - showShareSheet(items: [userAdress]) - } label: { - Label("Share link", systemImage: "square.and.arrow.up") - } - .padding() + ScrollView { + VStack (alignment: .leading) { + Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.") + .padding(.bottom) + if let userAdress = chatModel.userAddress { + QRCode(uri: userAdress) + HStack { + Button { + showShareSheet(items: [userAdress]) + } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + .padding() - Button(role: .destructive) { deleteAddressAlert = true } label: { - Label("Delete address", systemImage: "trash") - } - .padding() - .alert(isPresented: $deleteAddressAlert) { - Alert( - title: Text("Delete address?"), - message: Text("All your contacts will remain connected"), - primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteUserAddress() - DispatchQueue.main.async { - chatModel.userAddress = nil + Button(role: .destructive) { deleteAddressAlert = true } label: { + Label("Delete address", systemImage: "trash") + } + .padding() + .alert(isPresented: $deleteAddressAlert) { + Alert( + title: Text("Delete address?"), + message: Text("All your contacts will remain connected"), + primaryButton: .destructive(Text("Delete")) { + Task { + do { + try await apiDeleteUserAddress() + DispatchQueue.main.async { + chatModel.userAddress = nil + } + } catch let error { + logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)") } - } catch let error { - logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)") } - } - }, secondaryButton: .cancel() - ) - } - } - .frame(maxWidth: .infinity) - } else { - Button { - Task { - do { - let userAddress = try await apiCreateUserAddress() - DispatchQueue.main.async { - chatModel.userAddress = userAddress - } - } catch let error { - logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)") + }, secondaryButton: .cancel() + ) } } - } label: { Label("Create address", systemImage: "qrcode") } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity) + } else { + Button { + Task { + do { + let userAddress = try await apiCreateUserAddress() + DispatchQueue.main.async { + chatModel.userAddress = userAddress + } + } catch let error { + logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)") + } + } + } label: { Label("Create address", systemImage: "qrcode") } + .frame(maxWidth: .infinity) + } } + .padding() + .frame(maxHeight: .infinity, alignment: .top) } - .padding() - .frame(maxHeight: .infinity, alignment: .top) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 13238971b..52137f476 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat struct UserProfile: View { @EnvironmentObject var chatModel: ChatModel diff --git a/apps/ios/Shared/dummy.m b/apps/ios/Shared/dummy.m deleted file mode 100644 index 301c29909..000000000 --- a/apps/ios/Shared/dummy.m +++ /dev/null @@ -1,23 +0,0 @@ -// -// dummy.m -// SimpleX -// -// Created by Evgeny Poberezkin on 22/01/2022. -// - -#import - -#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR - -#import - -int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry, - struct dirent **restrict result) { - return readdir_r(dirp, entry, result); -} - -DIR *opendir$INODE64(const char *name) { - return opendir(name); -} - -#endif diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 5f1e5c140..06630b751 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2,7 +2,7 @@
- +
@@ -30,6 +30,11 @@ #secret# No comment provided by engineer. + + %@ + %@ + No comment provided by engineer. + %@ / %@ %@ / %@ @@ -80,11 +85,26 @@ **Create link / QR code** for your contact to use. No comment provided by engineer. + + **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have. + **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have. + No comment provided by engineer. + + + **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app). + **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app). + No comment provided by engineer. + **Paste received link** or open it in the browser and tap **Open in mobile app**. **Paste received link** or open it in the browser and tap **Open in mobile app**. No comment provided by engineer. + + **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. + **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. + No comment provided by engineer. + **Scan QR code**: to connect to your contact in person or via video call. **Scan QR code**: to connect to your contact in person or via video call. @@ -120,6 +140,11 @@ : No comment provided by engineer. + + A new contact + A new contact + notification title + About SimpleX About SimpleX @@ -206,11 +231,41 @@ Cancel No comment provided by engineer. + + Chat archive + Chat archive + No comment provided by engineer. + Chat console Chat console No comment provided by engineer. + + Chat database + Chat database + No comment provided by engineer. + + + Chat database deleted + Chat database deleted + No comment provided by engineer. + + + Chat database imported + Chat database imported + No comment provided by engineer. + + + Chat is running + Chat is running + No comment provided by engineer. + + + Chat is stopped + Chat is stopped + No comment provided by engineer. + Chat with the developers Chat with the developers @@ -221,16 +276,6 @@ Chats back button to return to chats list - - Check messages - Check messages - No comment provided by engineer. - - - Checking new messages... - Checking new messages... - notification - Choose file Choose file @@ -346,6 +391,11 @@ Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. + + Contact hidden: + Contact hidden: + notification + Contact is connected Contact is connected @@ -356,6 +406,11 @@ Contact is not connected yet! No comment provided by engineer. + + Contact name + Contact name + No comment provided by engineer. + Copy Copy @@ -386,11 +441,26 @@ Create your profile No comment provided by engineer. + + Created on %@ + Created on %@ + No comment provided by engineer. + Currently maximum supported file size is %@. Currently maximum supported file size is %@. No comment provided by engineer. + + Database export & import + Database export & import + No comment provided by engineer. + + + Database will be migrated when the app restarts + Database will be migrated when the app restarts + No comment provided by engineer. + Decentralized Decentralized @@ -416,6 +486,21 @@ Delete address? No comment provided by engineer. + + Delete archive + Delete archive + No comment provided by engineer. + + + Delete chat archive? + Delete chat archive? + No comment provided by engineer. + + + Delete chat profile? + Delete chat profile? + No comment provided by engineer. + Delete contact Delete contact @@ -426,6 +511,11 @@ Delete contact? No comment provided by engineer. + + Delete database + Delete database + No comment provided by engineer. + Delete for everyone Delete for everyone @@ -446,6 +536,16 @@ Delete message? No comment provided by engineer. + + Delete old database + Delete old database + No comment provided by engineer. + + + Delete old database? + Delete old database? + No comment provided by engineer. + Delete pending connection Delete pending connection @@ -491,19 +591,59 @@ Do NOT use SimpleX for emergency calls. No comment provided by engineer. + + Do it later + Do it later + No comment provided by engineer. + Edit Edit No comment provided by engineer. + + Enable + Enable + No comment provided by engineer. + Enable SimpleX Lock Enable SimpleX Lock authentication reason - - Enable notifications? (BETA) - Enable notifications? (BETA) + + Enable instant notifications? + Enable instant notifications? + No comment provided by engineer. + + + Enable notifications + Enable notifications + No comment provided by engineer. + + + Enable periodic notifications? + Enable periodic notifications? + No comment provided by engineer. + + + Error accessing database file + Error accessing database file + No comment provided by engineer. + + + Error deleting chat database + Error deleting chat database + No comment provided by engineer. + + + Error deleting database + Error deleting database + No comment provided by engineer. + + + Error deleting old database + Error deleting old database No comment provided by engineer. @@ -511,9 +651,19 @@ Error deleting token No comment provided by engineer. - - Error registering token - Error registering token + + Error enabling notifications + Error enabling notifications + No comment provided by engineer. + + + Error exporting chat database + Error exporting chat database + No comment provided by engineer. + + + Error importing chat database + Error importing chat database No comment provided by engineer. @@ -521,6 +671,16 @@ Error saving SMP servers No comment provided by engineer. + + Error starting chat + Error starting chat + No comment provided by engineer. + + + Error stopping chat + Error stopping chat + No comment provided by engineer. + Error: %@ Error: %@ @@ -531,9 +691,29 @@ Error: URL is invalid No comment provided by engineer. - - Experimental features - Experimental features + + Error: no database file + Error: no database file + No comment provided by engineer. + + + Export database + Export database + No comment provided by engineer. + + + Export error: + Export error: + No comment provided by engineer. + + + Exported database archive. + Exported database archive. + No comment provided by engineer. + + + Exporting database archive... + Exporting database archive... No comment provided by engineer. @@ -551,11 +731,21 @@ Group deletion is not supported No comment provided by engineer. + + Group message: + Group message: + notification + Help Help No comment provided by engineer. + + Hidden + Hidden + No comment provided by engineer. + How SimpleX works How SimpleX works @@ -596,6 +786,11 @@ If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. No comment provided by engineer. + + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + No comment provided by engineer. + Ignore Ignore @@ -611,6 +806,21 @@ Immune to spam and abuse No comment provided by engineer. + + Import + Import + No comment provided by engineer. + + + Import chat database? + Import chat database? + No comment provided by engineer. + + + Import database + Import database + No comment provided by engineer. + In person or via a video call – the most secure way to connect. In person or via a video call – the most secure way to connect. @@ -636,6 +846,11 @@ Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. + + Instantly + Instantly + No comment provided by engineer. + Invalid connection link Invalid connection link @@ -701,6 +916,31 @@ We will be adding server redundancy to prevent lost messages. Message delivery error No comment provided by engineer. + + Message text + Message text + No comment provided by engineer. + + + Migrating database archive... + Migrating database archive... + No comment provided by engineer. + + + Migration error: + Migration error: + No comment provided by engineer. + + + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + No comment provided by engineer. + + + Migration is completed + Migration is completed + No comment provided by engineer. + Most likely this contact has deleted the connection with you. Most likely this contact has deleted the connection with you. @@ -711,16 +951,51 @@ We will be adding server redundancy to prevent lost messages. New contact request notification + + New contact: + New contact: + notification + + + New database archive + New database archive + No comment provided by engineer. + New message New message notification + + No device token! + No device token! + No comment provided by engineer. + + + Notifications + Notifications + No comment provided by engineer. + Notifications are disabled! Notifications are disabled! No comment provided by engineer. + + Off (Local) + Off (Local) + No comment provided by engineer. + + + Old database + Old database + No comment provided by engineer. + + + Old database archive + Old database archive + No comment provided by engineer. + One-time invitation link One-time invitation link @@ -776,6 +1051,11 @@ We will be adding server redundancy to prevent lost messages. People can connect to you only via the links you share. No comment provided by engineer. + + Periodically + Periodically + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Please check that you used the correct link or ask your contact to send you another one. @@ -786,9 +1066,9 @@ We will be adding server redundancy to prevent lost messages. Please check your network connection and try again. No comment provided by engineer. - - Pre-arrange the calls, as notifications arrive with a delay (we are improving it). - Pre-arrange the calls, as notifications arrive with a delay (we are improving it). + + Please restart the app and migrate the database to enable push notifications. + Please restart the app and migrate the database to enable push notifications. No comment provided by engineer. @@ -806,6 +1086,11 @@ We will be adding server redundancy to prevent lost messages. Profile image No comment provided by engineer. + + Push notifications + Push notifications + No comment provided by engineer. + Read Read @@ -821,6 +1106,11 @@ We will be adding server redundancy to prevent lost messages. Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. + + Received file event + Received file event + notification + Reject Reject @@ -841,9 +1131,19 @@ We will be adding server redundancy to prevent lost messages. Reply No comment provided by engineer. - - Retry - Retry + + Restart the app to create a new chat profile + Restart the app to create a new chat profile + No comment provided by engineer. + + + Restart the app to use imported chat database + Restart the app to use imported chat database + No comment provided by engineer. + + + Run chat + Run chat No comment provided by engineer. @@ -866,6 +1166,11 @@ We will be adding server redundancy to prevent lost messages. Save (and notify contacts) No comment provided by engineer. + + Save archive + Save archive + No comment provided by engineer. + Saved SMP servers will be removed Saved SMP servers will be removed @@ -886,6 +1191,21 @@ We will be adding server redundancy to prevent lost messages. Send link previews No comment provided by engineer. + + Send notifications + Send notifications + No comment provided by engineer. + + + Send notifications: + Send notifications: + No comment provided by engineer. + + + Sent file event + Sent file event + notification + Server connected Server connected @@ -916,6 +1236,11 @@ We will be adding server redundancy to prevent lost messages. Show pending connections No comment provided by engineer. + + Show preview + Show preview + No comment provided by engineer. + SimpleX Lock SimpleX Lock @@ -926,11 +1251,51 @@ We will be adding server redundancy to prevent lost messages. SimpleX Lock turned on No comment provided by engineer. + + SimpleX encrypted message or connection event + SimpleX encrypted message or connection event + notification + Skipped messages Skipped messages No comment provided by engineer. + + Somebody + Somebody + notification title + + + Start chat + Start chat + No comment provided by engineer. + + + Start migration + Start migration + No comment provided by engineer. + + + Stop + Stop + No comment provided by engineer. + + + Stop chat to enable database actions + Stop chat to enable database actions + No comment provided by engineer. + + + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + No comment provided by engineer. + + + Stop chat? + Stop chat? + No comment provided by engineer. + Take picture Take picture @@ -956,13 +1321,6 @@ We will be adding server redundancy to prevent lost messages. The app can notify you when you receive messages or contact requests - please open settings to enable. No comment provided by engineer. - - The app can receive background notifications every 20 minutes to check the new messages. -*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. - The app can receive background notifications every 20 minutes to check the new messages. -*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. - No comment provided by engineer. - The connection you accepted will be cancelled! The connection you accepted will be cancelled! @@ -973,6 +1331,11 @@ We will be adding server redundancy to prevent lost messages. The contact you shared this link with will NOT be able to connect! No comment provided by engineer. + + The created archive is available via app Settings / Database / Old database archive. + The created archive is available via app Settings / Database / Old database archive. + No comment provided by engineer. + The microphone does not work when the app is in the background. The microphone does not work when the app is in the background. @@ -983,6 +1346,11 @@ We will be adding server redundancy to prevent lost messages. The next generation of private messaging No comment provided by engineer. + + The old database was not removed during the migration, it can be deleted. + The old database was not removed during the migration, it can be deleted. + No comment provided by engineer. + The profile is only shared with your contacts. The profile is only shared with your contacts. @@ -993,6 +1361,11 @@ We will be adding server redundancy to prevent lost messages. The sender will NOT be notified No comment provided by engineer. + + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + No comment provided by engineer. + To ask any questions and to receive SimpleX Chat updates. To ask any questions and to receive SimpleX Chat updates. @@ -1030,6 +1403,11 @@ You will be prompted to complete authentication before this feature is enabled.< You will be prompted to complete authentication before this feature is enabled. No comment provided by engineer. + + To support instant push notifications the chat database has to be migrated. + To support instant push notifications the chat database has to be migrated. + No comment provided by engineer. + Trying to connect to the server used to receive messages from this contact (error: %@). Trying to connect to the server used to receive messages from this contact (error: %@). @@ -1040,6 +1418,16 @@ You will be prompted to complete authentication before this feature is enabled.< Trying to connect to the server used to receive messages from this contact. No comment provided by engineer. + + Turn off + Turn off + No comment provided by engineer. + + + Turn off notifications? + Turn off notifications? + No comment provided by engineer. + Turn on Turn on @@ -1050,6 +1438,11 @@ You will be prompted to complete authentication before this feature is enabled.< Unexpected error: %@ No comment provided by engineer. + + Unexpected migration state + Unexpected migration state + No comment provided by engineer. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. @@ -1067,6 +1460,11 @@ To connect, please ask your contact to create another connection link and check Use SimpleX Chat servers? No comment provided by engineer. + + Use chat + Use chat + No comment provided by engineer. + Using SimpleX Chat servers. Using SimpleX Chat servers. @@ -1117,11 +1515,21 @@ To connect, please ask your contact to create another connection link and check You can now send messages to %@ notification body + + You can set lock screen notification preview via settings. + You can set lock screen notification preview via settings. + No comment provided by engineer. + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. No comment provided by engineer. + + You can start chat via app Settings / Database or by restarting the app + You can start chat via app Settings / Database or by restarting the app + No comment provided by engineer. + You can use markdown to format messages: You can use markdown to format messages: @@ -1142,6 +1550,11 @@ To connect, please ask your contact to create another connection link and check You invited your contact No comment provided by engineer. + + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! You will be connected when your connection request is accepted, please wait or check later! @@ -1177,6 +1590,11 @@ To connect, please ask your contact to create another connection link and check Your chat address No comment provided by engineer. + + Your chat database + Your chat database + No comment provided by engineer. + Your chat profile Your chat profile @@ -1209,6 +1627,11 @@ You can cancel this connection and remove the contact (and try later with a new Your contact sent a file that is larger than currently supported maximum size (%@). No comment provided by engineer. + + Your current chat database will be DELETED and REPLACED with the imported one. + Your current chat database will be DELETED and REPLACED with the imported one. + No comment provided by engineer. + Your privacy Your privacy @@ -1376,11 +1799,21 @@ SimpleX servers cannot see your profile. italic No comment provided by engineer. + + message received + message received + notification + missed call missed call call status + + new message + new message + notification + no e2e encryption no e2e encryption @@ -1426,6 +1859,11 @@ SimpleX servers cannot see your profile. strike No comment provided by engineer. + + this contact + this contact + notification title + unknown unknown @@ -1485,7 +1923,7 @@ SimpleX servers cannot see your profile.
- +
@@ -1517,7 +1955,7 @@ SimpleX servers cannot see your profile.
- +
@@ -1537,161 +1975,4 @@ SimpleX servers cannot see your profile.
- -
- -
- - - %@ is connected! - %@ is connected! - notification title - - - %@ wants to connect! - %@ wants to connect! - notification title - - - %d skipped message(s) - %d skipped message(s) - integrity error chat item - - - **e2e encrypted** audio call - **e2e encrypted** audio call - No comment provided by engineer. - - - **e2e encrypted** video call - **e2e encrypted** video call - No comment provided by engineer. - - - Accept contact request from %@? - Accept contact request from %@? - notification body - - - Incoming audio call - Incoming audio call - notification - - - Incoming video call - Incoming video call - notification - - - You can now send messages to %@ - You can now send messages to %@ - notification body - - - accepted call - accepted call - call status - - - audio call (not e2e encrypted) - audio call (not e2e encrypted) - No comment provided by engineer. - - - bad message ID - bad message ID - integrity error chat item - - - bad message hash - bad message hash - integrity error chat item - - - call error - call error - call status - - - call in progress - call in progress - call status - - - calling… - calling… - call status - - - connecting call… - connecting call… - call status - - - connecting… - connecting… - chat list item title - - - connection established - connection established - chat list item title (it should not be shown - - - connection:%@ - connection:%@ - connection information - - - deleted - deleted - deleted chat item - - - duplicate message - duplicate message - integrity error chat item - - - ended call %@ - ended call %@ - call status - - - invited to connect - invited to connect - chat list item title - - - missed call - missed call - call status - - - rejected call - rejected call - call status - - - via contact address link - via contact address link - chat list item description - - - via one-time link - via one-time link - chat list item description - - - video call (not e2e encrypted) - video call (not e2e encrypted) - No comment provided by engineer. - - - you shared one-time link - you shared one-time link - chat list item description - - -
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 34577cd8e..000000000 Binary files a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings and /dev/null differ diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json index 3e0830ec0..4429d3639 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "en", "toolInfo" : { - "toolBuildNumber" : "13E113", + "toolBuildNumber" : "13F100", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "13.3" + "toolVersion" : "13.4.1" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 4dbb579d1..7f9beb6e7 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2,7 +2,7 @@
- +
@@ -30,6 +30,11 @@ #секрет# No comment provided by engineer. + + %@ + %@ + No comment provided by engineer. + %@ / %@ %@ / %@ @@ -80,11 +85,26 @@ **Создать ссылку / QR код** для вашего контакта. No comment provided by engineer. + + **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have. + **Более конфиденциально**: проверять новые сообщения каждые 20 минут. Токен устройства будет отправлен на сервер уведомлений SimpleX Chat, но у сервера не будет информации о количестве контактов и сообщений. + No comment provided by engineer. + + + **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app). + **Самый конфиденциальный**: не использовать сервер уведомлений SimpleX Chat, проверять сообщения периодически в фоновом режиме (зависит от того насколько часто вы используете приложение). + No comment provided by engineer. + **Paste received link** or open it in the browser and tap **Open in mobile app**. **Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**. No comment provided by engineer. + + **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. + **Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они. + No comment provided by engineer. + **Scan QR code**: to connect to your contact in person or via video call. **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. @@ -120,6 +140,11 @@ : No comment provided by engineer. + + A new contact + Новый контакт + notification title + About SimpleX О SimpleX @@ -206,11 +231,41 @@ Отменить No comment provided by engineer. + + Chat archive + Архив чата + No comment provided by engineer. + Chat console Консоль No comment provided by engineer. + + Chat database + Архив чата + No comment provided by engineer. + + + Chat database deleted + Данные чата удалены + No comment provided by engineer. + + + Chat database imported + Архив чата импортирован + No comment provided by engineer. + + + Chat is running + Чат запущен + No comment provided by engineer. + + + Chat is stopped + Чат остановлен + No comment provided by engineer. + Chat with the developers Соединиться с разработчиками @@ -221,16 +276,6 @@ Чаты back button to return to chats list - - Check messages - Проверять сообщения - No comment provided by engineer. - - - Checking new messages... - Проверяются новые сообщения... - notification - Choose file Выбрать файл @@ -346,6 +391,11 @@ Контакт и все сообщения будут удалены - это действие нельзя отменить! No comment provided by engineer. + + Contact hidden: + Контакт скрыт: + notification + Contact is connected Соединение с контактом установлено @@ -356,6 +406,11 @@ Соединение еще не установлено! No comment provided by engineer. + + Contact name + Имена контактов + No comment provided by engineer. + Copy Скопировать @@ -386,11 +441,26 @@ Создать профиль No comment provided by engineer. + + Created on %@ + Дата создания %@ + No comment provided by engineer. + Currently maximum supported file size is %@. Максимальный размер файла - %@. No comment provided by engineer. + + Database export & import + Экспорт и импорт архива чата + No comment provided by engineer. + + + Database will be migrated when the app restarts + Данные чата будут мигрированы при перезапуске + No comment provided by engineer. + Decentralized Децентрализованный @@ -416,6 +486,21 @@ Удалить адрес? No comment provided by engineer. + + Delete archive + Удалить архив + No comment provided by engineer. + + + Delete chat archive? + Удалить архив чата? + No comment provided by engineer. + + + Delete chat profile? + Удалить профиль? + No comment provided by engineer. + Delete contact Удалить контакт @@ -426,6 +511,11 @@ Удалить контакт? No comment provided by engineer. + + Delete database + Удалить данные чата + No comment provided by engineer. + Delete for everyone Удалить для всех @@ -446,6 +536,16 @@ Удалить сообщение? No comment provided by engineer. + + Delete old database + Удалить предыдущую версию данных + No comment provided by engineer. + + + Delete old database? + Удалить предыдущую версию данных? + No comment provided by engineer. + Delete pending connection Удалить соединение @@ -491,19 +591,59 @@ Не используйте SimpleX для экстренных звонков No comment provided by engineer. + + Do it later + Отложить + No comment provided by engineer. + Edit Редактировать No comment provided by engineer. + + Enable + Включить + No comment provided by engineer. + Enable SimpleX Lock Включить блокировку SimpleX authentication reason - - Enable notifications? (BETA) - Включить уведомления? (БЕТА) + + Enable instant notifications? + Включить мгновенные уведомления? + No comment provided by engineer. + + + Enable notifications + Включить уведомления + No comment provided by engineer. + + + Enable periodic notifications? + Включить периодические уведомления? + No comment provided by engineer. + + + Error accessing database file + Ошибка при доступе к данным чата + No comment provided by engineer. + + + Error deleting chat database + Ошибка при удалении данных чата + No comment provided by engineer. + + + Error deleting database + Ошибка при удалении данных чата + No comment provided by engineer. + + + Error deleting old database + Ошибка при удалении предыдущей версии данных чата No comment provided by engineer. @@ -511,9 +651,19 @@ Ошибка удаления токена No comment provided by engineer. - - Error registering token - Ошибка регистрации токена + + Error enabling notifications + Ошибка при включении уведомлений + No comment provided by engineer. + + + Error exporting chat database + Ошибка при экспорте архива чата + No comment provided by engineer. + + + Error importing chat database + Ошибка при импорте архива чата No comment provided by engineer. @@ -521,6 +671,16 @@ Ошибка при сохранении SMP серверов No comment provided by engineer. + + Error starting chat + Ошибка при запуске чата + No comment provided by engineer. + + + Error stopping chat + Ошибка при остановке чата + No comment provided by engineer. + Error: %@ Ошибка: %@ @@ -531,9 +691,29 @@ Ошибка: неверная ссылка No comment provided by engineer. - - Experimental features - Экспериментальные функции + + Error: no database file + Ошибка: данные чата не найдены + No comment provided by engineer. + + + Export database + Экспорт архива чата + No comment provided by engineer. + + + Export error: + Ошибка при экспорте: + No comment provided by engineer. + + + Exported database archive. + Архив чата экспортирован. + No comment provided by engineer. + + + Exporting database archive... + Архив чата экспортируется... No comment provided by engineer. @@ -551,11 +731,21 @@ Удаление групп не поддерживается No comment provided by engineer. + + Group message: + Групповое сообщение: + notification + Help Помощь No comment provided by engineer. + + Hidden + Скрытое + No comment provided by engineer. + How SimpleX works Как SimpleX работает @@ -596,6 +786,11 @@ Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку. No comment provided by engineer. + + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + Если сейчас вам нужно использовать чат, нажмите **Отложить** внизу (вы сможете мигрировать данные чата при следующем запуске приложения). + No comment provided by engineer. + Ignore Не отвечать @@ -611,6 +806,21 @@ Защищен от спама No comment provided by engineer. + + Import + Импортировать + No comment provided by engineer. + + + Import chat database? + Импортировать архив чата? + No comment provided by engineer. + + + Import database + Импорт архива чата + No comment provided by engineer. + In person or via a video call – the most secure way to connect. При встрече или в видеозвонке – самый безопасный способ установить соединение @@ -636,6 +846,11 @@ [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. + + Instantly + Мгновенно + No comment provided by engineer. + Invalid connection link Ошибка в ссылке контакта @@ -701,6 +916,31 @@ We will be adding server redundancy to prevent lost messages. Ошибка доставки сообщения No comment provided by engineer. + + Message text + Текст сообщения + No comment provided by engineer. + + + Migrating database archive... + Данные чата перемещаются... + No comment provided by engineer. + + + Migration error: + Ошибка при перемещении данных: + No comment provided by engineer. + + + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + Ошибка при перемещении данных. Нажмите **Отложить** внизу чтобы продолжить использовать предыдущую версию данных. Пожалуйста, сообщите об этой ошибке разработчикам через чат или email [chat@simplex.chat](mailto:chat@simplex.chat). + No comment provided by engineer. + + + Migration is completed + Перемещение данных завершено + No comment provided by engineer. + Most likely this contact has deleted the connection with you. Скорее всего, этот контакт удалил соединение с вами. @@ -711,16 +951,51 @@ We will be adding server redundancy to prevent lost messages. Новый запрос на соединение notification + + New contact: + Новый контакт: + notification + + + New database archive + Новый архив чата + No comment provided by engineer. + New message Новое сообщение notification + + No device token! + Отсутствует токен устройства! + No comment provided by engineer. + + + Notifications + Уведомления + No comment provided by engineer. + Notifications are disabled! Уведомления выключены No comment provided by engineer. + + Off (Local) + Выключить (Локальные) + No comment provided by engineer. + + + Old database + Предыдущая версия данных чата + No comment provided by engineer. + + + Old database archive + Старый архив чата + No comment provided by engineer. + One-time invitation link Одноразовая ссылка @@ -776,6 +1051,11 @@ We will be adding server redundancy to prevent lost messages. С вами можно соединиться только через созданные вами ссылки. No comment provided by engineer. + + Periodically + Периодически + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку. @@ -786,9 +1066,9 @@ We will be adding server redundancy to prevent lost messages. Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. No comment provided by engineer. - - Pre-arrange the calls, as notifications arrive with a delay (we are improving it). - Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это) + + Please restart the app and migrate the database to enable push notifications. + Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений. No comment provided by engineer. @@ -806,6 +1086,11 @@ We will be adding server redundancy to prevent lost messages. Аватар No comment provided by engineer. + + Push notifications + Доставка уведомлений + No comment provided by engineer. + Read Прочитано @@ -821,6 +1106,11 @@ We will be adding server redundancy to prevent lost messages. Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme). No comment provided by engineer. + + Received file event + Загрузка файла + notification + Reject Отклонить @@ -841,9 +1131,19 @@ We will be adding server redundancy to prevent lost messages. Ответить No comment provided by engineer. - - Retry - Повторить + + Restart the app to create a new chat profile + Перезапустите приложение, чтобы создать новый профиль. + No comment provided by engineer. + + + Restart the app to use imported chat database + Перезапустите приложение, чтобы использовать импортированные данные чата. + No comment provided by engineer. + + + Run chat + Запустить chat No comment provided by engineer. @@ -866,6 +1166,11 @@ We will be adding server redundancy to prevent lost messages. Сохранить (и уведомить контакты) No comment provided by engineer. + + Save archive + Сохранить архив + No comment provided by engineer. + Saved SMP servers will be removed Сохраненные SMP серверы будут удалены @@ -886,6 +1191,21 @@ We will be adding server redundancy to prevent lost messages. Отправлять картинки ссылок No comment provided by engineer. + + Send notifications + Отправлять уведомления + No comment provided by engineer. + + + Send notifications: + Отправлять уведомления: + No comment provided by engineer. + + + Sent file event + Отправка файла + notification + Server connected Установлено соединение с сервером @@ -916,6 +1236,11 @@ We will be adding server redundancy to prevent lost messages. Показать ожидаемые соединения No comment provided by engineer. + + Show preview + Показывать уведомления + No comment provided by engineer. + SimpleX Lock Блокировка SimpleX @@ -926,11 +1251,51 @@ We will be adding server redundancy to prevent lost messages. Блокировка SimpleX включена No comment provided by engineer. + + SimpleX encrypted message or connection event + SimpleX: зашифрованное сообщение или соединение контакта + notification + Skipped messages Пропущенные сообщения No comment provided by engineer. + + Somebody + Контакт + notification title + + + Start chat + Запустить чат + No comment provided by engineer. + + + Start migration + Запустить перемещение данных + No comment provided by engineer. + + + Stop + Остановить + No comment provided by engineer. + + + Stop chat to enable database actions + Остановите чат, чтобы разблокировать операции с архивом чата + No comment provided by engineer. + + + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + No comment provided by engineer. + + + Stop chat? + Остановить чат? + No comment provided by engineer. + Take picture Сделать фото @@ -956,13 +1321,6 @@ We will be adding server redundancy to prevent lost messages. Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках. No comment provided by engineer. - - The app can receive background notifications every 20 minutes to check the new messages. -*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. - Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения. -*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat. - No comment provided by engineer. - The connection you accepted will be cancelled! Подтвержденное соединение будет отменено! @@ -973,6 +1331,11 @@ We will be adding server redundancy to prevent lost messages. Контакт, которому вы отправили эту ссылку, не сможет соединиться! No comment provided by engineer. + + The created archive is available via app Settings / Database / Old database archive. + Созданный архив доступен через Настройки приложения. + No comment provided by engineer. + The microphone does not work when the app is in the background. Микрофон не работает, когда приложение в фоновом режиме. @@ -983,6 +1346,11 @@ We will be adding server redundancy to prevent lost messages. Новое поколение приватных сообщений No comment provided by engineer. + + The old database was not removed during the migration, it can be deleted. + Предыдущая версия данных чата не удалена при перемещении, её можно удалить. + No comment provided by engineer. + The profile is only shared with your contacts. Профиль отправляется только вашим контактам. @@ -993,6 +1361,11 @@ We will be adding server redundancy to prevent lost messages. Отправитель не будет уведомлён No comment provided by engineer. + + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + No comment provided by engineer. + To ask any questions and to receive SimpleX Chat updates. Чтобы задать вопросы и получать уведомления о SimpleX Chat. @@ -1030,6 +1403,11 @@ You will be prompted to complete authentication before this feature is enabled.< Вам будет нужно пройти аутентификацию для включения блокировки. No comment provided by engineer. + + To support instant push notifications the chat database has to be migrated. + Для поддержки мгновенный доставки уведомлений данные чата должны быть перемещены. + No comment provided by engineer. + Trying to connect to the server used to receive messages from this contact (error: %@). Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@). @@ -1040,6 +1418,16 @@ You will be prompted to complete authentication before this feature is enabled.< Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта. No comment provided by engineer. + + Turn off + Выключить + No comment provided by engineer. + + + Turn off notifications? + Выключить уведомления? + No comment provided by engineer. + Turn on Включить @@ -1050,6 +1438,11 @@ You will be prompted to complete authentication before this feature is enabled.< Неожиданная ошибка: %@ No comment provided by engineer. + + Unexpected migration state + Неожиданная ошибка при перемещении данных чата + No comment provided by engineer. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. @@ -1067,6 +1460,11 @@ To connect, please ask your contact to create another connection link and check Использовать серверы предосталенные SimpleX Chat? No comment provided by engineer. + + Use chat + Использовать чат + No comment provided by engineer. + Using SimpleX Chat servers. Используются серверы, предоставленные SimpleX Chat. @@ -1117,11 +1515,21 @@ To connect, please ask your contact to create another connection link and check Вы теперь можете отправлять сообщения %@ notification body + + You can set lock screen notification preview via settings. + Вы можете установить просмотр уведомлений на экране блокировки в настройках. + No comment provided by engineer. + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились. No comment provided by engineer. + + You can start chat via app Settings / Database or by restarting the app + Вы можете запустить чат через Настройки приложения или перезапустив приложение. + No comment provided by engineer. + You can use markdown to format messages: Вы можете форматировать сообщения: @@ -1142,6 +1550,11 @@ To connect, please ask your contact to create another connection link and check Вы пригласили ваш контакт No comment provided by engineer. + + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + Вы должны всегда использовать самую новую версию данных чата, ТОЛЬКО на одном устройстве, инача вы можете перестать получать сообщения от каких то контактов. + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! @@ -1177,6 +1590,11 @@ To connect, please ask your contact to create another connection link and check Ваш SimpleX адрес No comment provided by engineer. + + Your chat database + Данные чата + No comment provided by engineer. + Your chat profile Ваш профиль @@ -1209,6 +1627,11 @@ You can cancel this connection and remove the contact (and try later with a new Ваш контакт отправил файл, размер которого превышает максимальный размер (%@). No comment provided by engineer. + + Your current chat database will be DELETED and REPLACED with the imported one. + Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными. + No comment provided by engineer. + Your privacy Конфиденциальность @@ -1376,11 +1799,21 @@ SimpleX серверы не могут получить доступ к ваше курсив No comment provided by engineer. + + message received + получено сообщение + notification + missed call пропущенный звонок call status + + new message + новое сообщение + notification + no e2e encryption нет e2e шифрования @@ -1426,6 +1859,11 @@ SimpleX серверы не могут получить доступ к ваше зачеркнуть No comment provided by engineer. + + this contact + этот контакт + notification title + unknown неизвестно @@ -1485,7 +1923,7 @@ SimpleX серверы не могут получить доступ к ваше
- +
@@ -1517,7 +1955,7 @@ SimpleX серверы не могут получить доступ к ваше
- +
@@ -1537,161 +1975,4 @@ SimpleX серверы не могут получить доступ к ваше
- -
- -
- - - %@ is connected! - соединение с %@ установлено! - notification title - - - %@ wants to connect! - %@ хочет соединиться! - notification title - - - %d skipped message(s) - %d пропущенных сообщений - integrity error chat item - - - **e2e encrypted** audio call - **e2e зашифрованный** аудиозвонок - No comment provided by engineer. - - - **e2e encrypted** video call - **e2e зашифрованный** видеозвонок - No comment provided by engineer. - - - Accept contact request from %@? - Принять запрос на соединение от %@? - notification body - - - Incoming audio call - Входящий аудиозвонок - notification - - - Incoming video call - Входящий видеозвонок - notification - - - You can now send messages to %@ - Вы можете отправлять сообщения %@ - notification body - - - accepted call - принятный звонок - call status - - - audio call (not e2e encrypted) - аудиозвонок (не e2e зашифрованный) - No comment provided by engineer. - - - bad message ID - ошибка ID сообщения - integrity error chat item - - - bad message hash - ошибка хэш сообщения - integrity error chat item - - - call error - ошибка звонка - call status - - - call in progress - активный звонок - call status - - - calling… - входящий звонок… - call status - - - connecting call… - звонок соединяется… - call status - - - connecting… - соединяется… - chat list item title - - - connection established - соединение установлено - chat list item title (it should not be shown - - - connection:%@ - connection:%@ - connection information - - - deleted - удалено - deleted chat item - - - duplicate message - повторное сообщение - integrity error chat item - - - ended call %@ - завершённый звонок %@ - call status - - - invited to connect - приглашение соединиться - chat list item title - - - missed call - пропущенный звонок - call status - - - rejected call - отклонённый звонок - call status - - - via contact address link - через ссылку-контакт - chat list item description - - - via one-time link - через одноразовую ссылку - chat list item description - - - video call (not e2e encrypted) - видеозвонок (не e2e зашифрованный) - No comment provided by engineer. - - - you shared one-time link - вы создали одноразовую ссылку - chat list item description - - -
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings deleted file mode 100644 index 34577cd8e..000000000 Binary files a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings and /dev/null differ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json index 50ec87db4..ab6a47f15 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ru", "toolInfo" : { - "toolBuildNumber" : "13E113", + "toolBuildNumber" : "13F100", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "13.3" + "toolVersion" : "13.4.1" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index a615ac5b7..0f93abd8b 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -8,46 +8,83 @@ import UserNotifications import OSLog +import SimpleXChat let logger = Logger() -class NotificationService: UNNotificationServiceExtension { +let suspendingDelay: UInt64 = 2_000_000_000 +class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? + var bestAttemptContent: UNNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") - if getAppState() != .background { + bestAttemptContent = request.content + self.contentHandler = contentHandler + let appState = appStateGroupDefault.get() + switch appState { + case .suspended: + logger.debug("NotificationService: app is suspended") + receiveNtfMessages(request, contentHandler) + case .suspending: + logger.debug("NotificationService: app is suspending") + Task { + var state = appState + for _ in 1...5 { + _ = try await Task.sleep(nanoseconds: suspendingDelay) + state = appStateGroupDefault.get() + if state == .suspended || state != .suspending { break } + } + logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") + if state.inactive { + receiveNtfMessages(request, contentHandler) + } else { + contentHandler(request.content) + } + } + default: + logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") + contentHandler(request.content) + } + } + + func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + logger.debug("NotificationService: receiveNtfMessages") + if case .documents = dbContainerGroupDefault.get() { contentHandler(request.content) return } - logger.debug("NotificationService: app is in the background") - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - if let _ = startChat() { - let content = receiveMessages() - contentHandler (content) - return - } - - if let bestAttemptContent = bestAttemptContent { - // Modify the notification content here... - bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" - - contentHandler(bestAttemptContent) + let userInfo = request.content.userInfo + if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], + let nonce = ntfData["nonce"] as? String, + let encNtfInfo = ntfData["message"] as? String, + let _ = startChat() { + logger.debug("NotificationService: receiveNtfMessages: chat is started") + if let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") + if let connEntity = ntfMsgInfo.connEntity { + bestAttemptContent = createConnectionEventNtf(connEntity) + } + if let content = receiveMessageForNotification() { + logger.debug("NotificationService: receiveMessageForNotification: has message") + contentHandler(content) + } else if let content = bestAttemptContent { + logger.debug("NotificationService: receiveMessageForNotification: no message") + contentHandler(content) + } + } } } - + override func serviceExtensionTimeWillExpire() { logger.debug("NotificationService.serviceExtensionTimeWillExpire") // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - contentHandler(bestAttemptContent) + if let contentHandler = self.contentHandler, let content = bestAttemptContent { + contentHandler(content) } } - } func startChat() -> User? { @@ -57,9 +94,10 @@ func startChat() -> User? { do { try apiStartChat() try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + chatLastStartGroupDefault.set(Date.now) return user } catch { - logger.error("NotificationService startChat error: \(responseError(error))") + logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { logger.debug("no active user") @@ -67,40 +105,43 @@ func startChat() -> User? { return nil } -func receiveMessages() -> UNNotificationContent { +func receiveMessageForNotification() -> UNNotificationContent? { logger.debug("NotificationService receiveMessages started") while true { - let res = chatResponse(chat_recv_msg(getChatCtrl())!) - logger.debug("NotificationService receiveMessages: \(res.responseType)") - switch res { -// case let .newContactConnection(connection): -// case let .contactConnectionDeleted(connection): - case let .contactConnected(contact): - return createContactConnectedNtf(contact) -// case let .contactConnecting(contact): -// TODO profile update - case let .receivedContactRequest(contactRequest): - return createContactRequestNtf(contactRequest) -// case let .contactUpdated(toContact): -// TODO profile updated - case let .newChatItem(aChatItem): - let cInfo = aChatItem.chatInfo - let cItem = aChatItem.chatItem - return createMessageReceivedNtf(cInfo, cItem) -// case let .chatItemUpdated(aChatItem): -// TODO message updated -// let cInfo = aChatItem.chatInfo -// let cItem = aChatItem.chatItem -// NtfManager.shared.notifyMessageReceived(cInfo, cItem) -// case let .chatItemDeleted(_, toChatItem): -// TODO message updated -// case let .rcvFileComplete(aChatItem): -// TODO file received? -// let cInfo = aChatItem.chatInfo -// let cItem = aChatItem.chatItem -// NtfManager.shared.notifyMessageReceived(cInfo, cItem) - default: - logger.debug("NotificationService ignored event: \(res.responseType)") + if let res = recvSimpleXMsg() { + logger.debug("NotificationService receiveMessages: \(res.responseType)") + switch res { + // case let .newContactConnection(connection): + // case let .contactConnectionDeleted(connection): + case let .contactConnected(contact): + return createContactConnectedNtf(contact) + // case let .contactConnecting(contact): + // TODO profile update + case let .receivedContactRequest(contactRequest): + return createContactRequestNtf(contactRequest) + // case let .contactUpdated(toContact): + // TODO profile updated + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + return createMessageReceivedNtf(cInfo, cItem) + // case let .chatItemUpdated(aChatItem): + // TODO message updated + // let cInfo = aChatItem.chatInfo + // let cItem = aChatItem.chatItem + // NtfManager.shared.notifyMessageReceived(cInfo, cItem) + // case let .chatItemDeleted(_, toChatItem): + // TODO message updated + // case let .rcvFileComplete(aChatItem): + // TODO file received? + // let cInfo = aChatItem.chatInfo + // let cItem = aChatItem.chatItem + // NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("NotificationService ignored event: \(res.responseType)") + } + } else { + return nil } } } @@ -119,9 +160,12 @@ func apiGetActiveUser() -> User? { } func apiStartChat() throws { - let r = sendSimpleXCmd(.startChat) - if case .chatStarted = r { return } - throw r + let r = sendSimpleXCmd(.startChat(subscribe: false)) + switch r { + case .chatStarted: return + case .chatRunning: return + default: throw r + } } func apiSetFilesFolder(filesFolder: String) throws { @@ -130,3 +174,17 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } +func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { + let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { + return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + } + logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") + return nil +} + +struct NtfMessages { + var connEntity: ConnectionEntity? + var msgTs: Date? + var ntfMessages: [NtfMsgInfo] +} diff --git a/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h deleted file mode 100644 index bc28b42d3..000000000 --- a/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -extern void hs_init(int argc, char **argv[]); - -typedef void* chat_ctrl; - -extern chat_ctrl chat_init(char *path); -extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); -extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 1287772e1..01f16a5a1 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -23,8 +23,10 @@ UIBackgroundModes + audio fetch remote-notification + voip diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 27c9e9c79..c5004171b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -18,6 +18,11 @@ 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; + 5C14247E286DAC1D0004E3EE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C142479286DAC1D0004E3EE /* libffi.a */; }; + 5C14247F286DAC1D0004E3EE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C14247A286DAC1D0004E3EE /* libgmpxx.a */; }; + 5C142480286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C14247B286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a */; }; + 5C142481286DAC1D0004E3EE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C14247C286DAC1D0004E3EE /* libgmp.a */; }; + 5C142482286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C14247D286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; @@ -31,6 +36,7 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */; }; + 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -38,23 +44,18 @@ 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; - 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; - 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; - 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; - 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; - 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; - 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; }; @@ -68,6 +69,9 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA952827143500B3292C /* MakeConnection.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; + 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; + 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; + 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; @@ -81,25 +85,30 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; - 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; - 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; - 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5728187C7500503DA2 /* dummy.m */; }; - 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; - 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; - 5CDCAD682818876500503DA2 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; - 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; - 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; - 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; - 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; - 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; - 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; - 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; - 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; - 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; + 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; + 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */; }; + 5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */; }; + 5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CE2BA8C284533A300EC33A6 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* AppGroup.swift */; }; + 5CE2BA8D284533A300EC33A6 /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; + 5CE2BA8E284533A300EC33A6 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CE2BA8F284533A300EC33A6 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; + 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; + 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; + 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; + 5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CE2BA8A2845332200EC33A6 /* SimpleX.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA96284537A800EC33A6 /* dummy.m */; }; + 5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; platformFilter = ios; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; + 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; @@ -109,19 +118,8 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; - 64A6908128376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */; }; - 64A6908228376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */; }; - 64A6908328376BBA0076573F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907D28376BB90076573F /* libffi.a */; }; - 64A6908428376BBA0076573F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907D28376BB90076573F /* libffi.a */; }; - 64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907E28376BB90076573F /* libgmpxx.a */; }; - 64A6908628376BBA0076573F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907E28376BB90076573F /* libgmpxx.a */; }; - 64A6908728376BBA0076573F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907F28376BB90076573F /* libgmp.a */; }; - 64A6908828376BBA0076573F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907F28376BB90076573F /* libgmp.a */; }; - 64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */; }; - 64A6908A28376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64DAE1512809D9F5000DA960 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -132,8 +130,54 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; + 5CE2BA6E2845308900EC33A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CE2BA672845308900EC33A6; + remoteInfo = SimpleXChat; + }; + 5CE2BA9E284555F500EC33A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CDCAD442818589900503DA2; + remoteInfo = "SimpleX NSE"; + }; + 5CE2BAA82845617C00EC33A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CE2BA672845308900EC33A6; + remoteInfo = SimpleXChat; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 5CE2BA722845308900EC33A6 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 5CE2BAA0284555F500EC33A6 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; @@ -147,6 +191,11 @@ 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 5C142479286DAC1D0004E3EE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C14247A286DAC1D0004E3EE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C14247B286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a"; sourceTree = ""; }; + 5C14247C286DAC1D0004E3EE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C14247D286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a"; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; @@ -161,6 +210,7 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; + 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -175,13 +225,10 @@ 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; - 5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; - 5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; - 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = ""; }; - 5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; + 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; @@ -199,6 +246,9 @@ 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; 5CB0BA952827143500B3292C /* MakeConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeConnection.swift; sourceTree = ""; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; + 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; + 5CB346E62868D76D001FD2EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; + 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnvironment.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -215,19 +265,24 @@ 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX NSE.entitlements"; sourceTree = ""; }; - 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDefaults.swift; sourceTree = ""; }; - 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX NSE-Bridging-Header.h"; sourceTree = ""; }; - 5CDCAD5728187C7500503DA2 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; + 5CDCAD5228186F9500503DA2 /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; 5CDCAD6028187D7900503DA2 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypes.swift; sourceTree = ""; }; 5CDCAD7428188D2900503DA2 /* APITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITypes.swift; sourceTree = ""; }; 5CDCAD7D2818941F00503DA2 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 5CDCAD80281A7E2700503DA2 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; + 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SimpleXChat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SimpleXChat.h; sourceTree = ""; }; + 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = SimpleXChat.docc; sourceTree = ""; }; + 5CE2BA8A2845332200EC33A6 /* SimpleX.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SimpleX.h; sourceTree = ""; }; + 5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; + 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; + 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; @@ -237,11 +292,6 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; - 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a"; sourceTree = ""; }; - 64A6907D28376BB90076573F /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64A6907E28376BB90076573F /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 64A6907F28376BB90076573F /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a"; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; @@ -252,15 +302,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */, + 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */, 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */, - 64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */, - 64A6908728376BBA0076573F /* libgmp.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 64A6908328376BBA0076573F /* libffi.a in Frameworks */, - 64A6908128376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -275,13 +319,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */, - 64A6908628376BBA0076573F /* libgmpxx.a in Frameworks */, - 64A6908A28376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */, - 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, - 64A6908228376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */, - 64A6908828376BBA0076573F /* libgmp.a in Frameworks */, - 64A6908428376BBA0076573F /* libffi.a in Frameworks */, + 5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CE2BA652845308900EC33A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C14247F286DAC1D0004E3EE /* libgmpxx.a in Frameworks */, + 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5C142480286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a in Frameworks */, + 5C14247E286DAC1D0004E3EE /* libffi.a in Frameworks */, + 5C142482286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a in Frameworks */, + 5C142481286DAC1D0004E3EE /* libgmp.a in Frameworks */, + 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -311,6 +363,7 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, 5CB924DD27A8622200ACCCDD /* NewChat */, + 5CFA59C22860B04D00863A68 /* Database */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -334,11 +387,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 64A6907D28376BB90076573F /* libffi.a */, - 64A6907F28376BB90076573F /* libgmp.a */, - 64A6907E28376BB90076573F /* libgmpxx.a */, - 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */, - 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */, + 5C142479286DAC1D0004E3EE /* libffi.a */, + 5C14247C286DAC1D0004E3EE /* libgmp.a */, + 5C14247A286DAC1D0004E3EE /* libgmpxx.a */, + 5C14247B286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj-ghc8.10.7.a */, + 5C14247D286DAC1D0004E3EE /* libHSsimplex-chat-2.2.0-B9XlcSXdcwBJ5TIVTDfRQj.a */, ); path = Libraries; sourceTree = ""; @@ -349,8 +402,6 @@ 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */, 5CDCAD6028187D7900503DA2 /* libz.tbd */, 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, - 5C764E7C279C71DB000C6508 /* libz.tbd */, - 5C764E7B279C71D4000C6508 /* libiconv.tbd */, ); name = Frameworks; sourceTree = ""; @@ -358,11 +409,12 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( - 5CDCAD7128188CEB00503DA2 /* Shared */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, + 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, ); path = Model; sourceTree = ""; @@ -394,6 +446,7 @@ 5CA059C2279559F40002BEB4 /* Shared */, 5CDCAD462818589900503DA2 /* SimpleX NSE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, + 5CE2BA692845308900EC33A6 /* SimpleXChat */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, ); @@ -405,12 +458,9 @@ 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, 5C36027227F47AD5009F19D9 /* AppDelegate.swift */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, - 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C764E87279CBC8E000C6508 /* Model */, 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, - 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, - 5C764E7F279C7276000C6508 /* dummy.m */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); path = Shared; @@ -422,6 +472,7 @@ 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, + 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */, ); name = Products; sourceTree = ""; @@ -440,9 +491,10 @@ children = ( 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, - 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, - 5CB0BA952827143500B3292C /* MakeConnection.swift */, 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, + 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, + 5CB0BA952827143500B3292C /* MakeConnection.swift */, ); path = Onboarding; sourceTree = ""; @@ -465,6 +517,7 @@ children = ( 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, + 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, @@ -497,24 +550,27 @@ 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA892826CB3A00B3292C /* Localizable.strings */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, - 5CDCAD5728187C7500503DA2 /* dummy.m */, - 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */, ); path = "SimpleX NSE"; sourceTree = ""; }; - 5CDCAD7128188CEB00503DA2 /* Shared */ = { + 5CE2BA692845308900EC33A6 /* SimpleXChat */ = { isa = PBXGroup; children = ( - 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */, + 5CDCAD5228186F9500503DA2 /* AppGroup.swift */, 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, 5CDCAD7428188D2900503DA2 /* APITypes.swift */, 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, + 64DAE1502809D9F5000DA960 /* FileUtils.swift */, + 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, + 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, + 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, + 5CE2BA96284537A800EC33A6 /* dummy.m */, ); - path = Shared; + path = SimpleXChat; sourceTree = ""; }; 5CE4407427ADB657007B033A /* ChatItem */ = { @@ -547,8 +603,30 @@ path = ComposeMessage; sourceTree = ""; }; + 5CFA59C22860B04D00863A68 /* Database */ = { + isa = PBXGroup; + children = ( + 5C4B3B09285FB130003915F2 /* DatabaseView.swift */, + 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */, + 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */, + ); + path = Database; + sourceTree = ""; + }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + 5CE2BA632845308900EC33A6 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */, + 5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */ = { isa = PBXNativeTarget; @@ -557,10 +635,14 @@ 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, + 5CE2BA722845308900EC33A6 /* Embed Frameworks */, + 5CE2BAA0284555F500EC33A6 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 5CE2BA6F2845308900EC33A6 /* PBXTargetDependency */, + 5CE2BA9F284555F500EC33A6 /* PBXTargetDependency */, ); name = "SimpleX (iOS)"; packageProductDependencies = ( @@ -599,12 +681,31 @@ buildRules = ( ); dependencies = ( + 5CE2BAA92845617C00EC33A6 /* PBXTargetDependency */, ); name = "SimpleX NSE"; productName = "SimpleX NSE"; productReference = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; productType = "com.apple.product-type.app-extension"; }; + 5CE2BA672845308900EC33A6 /* SimpleXChat */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CE2BA752845308900EC33A6 /* Build configuration list for PBXNativeTarget "SimpleXChat" */; + buildPhases = ( + 5CE2BA632845308900EC33A6 /* Headers */, + 5CE2BA642845308900EC33A6 /* Sources */, + 5CE2BA652845308900EC33A6 /* Frameworks */, + 5CE2BA662845308900EC33A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SimpleXChat; + productName = SimpleXChat; + productReference = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -613,7 +714,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1330; + LastUpgradeCheck = 1340; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { 5CA059C9279559F40002BEB4 = { @@ -628,6 +729,10 @@ CreatedOnToolsVersion = 13.3; LastSwiftMigration = 1330; }; + 5CE2BA672845308900EC33A6 = { + CreatedOnToolsVersion = 13.3; + LastSwiftMigration = 1330; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -637,6 +742,7 @@ knownRegions = ( en, ru, + Base, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -649,6 +755,7 @@ 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, 5CDCAD442818589900503DA2 /* SimpleX NSE */, + 5CE2BA672845308900EC33A6 /* SimpleXChat */, ); }; /* End PBXProject section */ @@ -682,6 +789,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CE2BA662845308900EC33A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -691,25 +805,22 @@ files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, - 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, - 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, - 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, - 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, - 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, @@ -719,10 +830,9 @@ 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, - 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, - 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, @@ -735,6 +845,7 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, + 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -746,12 +857,14 @@ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, + 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, + 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, - 64DAE1512809D9F5000DA960 /* FileUtils.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, @@ -761,7 +874,6 @@ 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, - 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, @@ -784,17 +896,25 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */, - 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */, - 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */, 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, - 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */, - 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */, - 5CDCAD682818876500503DA2 /* JSON.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, - 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */, - 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */, - 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CE2BA642845308900EC33A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, + 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, + 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */, + 5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */, + 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, + 5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */, + 5CE2BA8F284533A300EC33A6 /* APITypes.swift in Sources */, + 5CE2BA8C284533A300EC33A6 /* AppGroup.swift in Sources */, + 5CE2BA8D284533A300EC33A6 /* CallTypes.swift in Sources */, + 5CE2BA8E284533A300EC33A6 /* API.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -806,6 +926,22 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; + 5CE2BA6F2845308900EC33A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CE2BA672845308900EC33A6 /* SimpleXChat */; + targetProxy = 5CE2BA6E2845308900EC33A6 /* PBXContainerItemProxy */; + }; + 5CE2BA9F284555F500EC33A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CDCAD442818589900503DA2 /* SimpleX NSE */; + targetProxy = 5CE2BA9E284555F500EC33A6 /* PBXContainerItemProxy */; + }; + 5CE2BAA92845617C00EC33A6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 5CE2BA672845308900EC33A6 /* SimpleXChat */; + targetProxy = 5CE2BAA82845617C00EC33A6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -849,6 +985,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -909,6 +1046,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -961,12 +1099,13 @@ 5CA059F4279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 57; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -987,15 +1126,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; - "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1004,12 +1139,13 @@ 5CA059F5279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 57; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1030,14 +1166,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; - "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; @@ -1091,7 +1224,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 57; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1104,22 +1237,12 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries/ios", - ); - "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries/sim", - ); - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1131,7 +1254,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 57; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1144,6 +1267,40 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 3.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5CE2BA732845308900EC33A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( "$(inherited)", "$(PROJECT_DIR)/Libraries/ios", @@ -1152,16 +1309,66 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 2.2.1; - PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; - PRODUCT_NAME = "$(TARGET_NAME)"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_INCLUDE_PATHS = ""; + SWIFT_OBJC_BRIDGING_HEADER = ./SimpleXChat/SimpleX.h; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 5CE2BA742845308900EC33A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = ""; + SWIFT_OBJC_BRIDGING_HEADER = ./SimpleXChat/SimpleX.h; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; }; name = Release; }; @@ -1204,6 +1411,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5CE2BA752845308900EC33A6 /* Build configuration list for PBXNativeTarget "SimpleXChat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CE2BA732845308900EC33A6 /* Debug */, + 5CE2BA742845308900EC33A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index ecdec033c..6a1d4192e 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -1,6 +1,6 @@ chat_ctrl { +public func getChatCtrl() -> chat_ctrl { if let controller = chatController { return controller } - let dataDir = getDocumentsDirectory().path + "/mobile_v1" - logger.debug("documents directory \(dataDir)") - var cstr = dataDir.cString(using: .utf8)! + let dbPath = getAppDatabasePath().path + logger.debug("getChatCtrl DB path: \(dbPath)") + var cstr = dbPath.cString(using: .utf8)! chatController = chat_init(&cstr) logger.debug("getChatCtrl: chat_init") return chatController! } -func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - return chatResponse(chat_send_cmd(getChatCtrl(), &c)) +public func resetChatCtrl() { + chatController = nil } -func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { - let s = String.init(cString: cjson) +public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + let cjson = chat_send_cmd(getChatCtrl(), &c)! + return chatResponse(fromCString(cjson)) +} + +// in microseconds +let MESSAGE_TIMEOUT: Int32 = 15_000_000 + +public func recvSimpleXMsg() -> ChatResponse? { + if let cjson = chat_recv_msg_wait(getChatCtrl(), MESSAGE_TIMEOUT) { + let s = fromCString(cjson) + return s == "" ? nil : chatResponse(s) + } + return nil +} + +public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + var c = s.cString(using: .utf8)! + if let cjson = chat_parse_markdown(&c) { + if let d = fromCString(cjson).data(using: .utf8) { + do { + let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) + return r.formattedText + } catch { + logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") + } + } + } + return nil +} + +struct ParsedMarkdown: Decodable { + var formattedText: [FormattedText]? +} + +private func fromCString(_ c: UnsafeMutablePointer) -> String { + let s = String.init(cString: c) + free(c) + return s +} + +public func chatResponse(_ s: String) -> ChatResponse { let d = s.data(using: .utf8)! // TODO is there a way to do it without copying the data? e.g: // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) @@ -46,7 +86,6 @@ func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { } json = prettyJSON(j) } - free(cjson) return ChatResponse.response(type: type ?? "invalid", json: json ?? s) } @@ -57,7 +96,7 @@ func prettyJSON(_ obj: NSDictionary) -> String? { return nil } -func responseError(_ err: Error) -> String { +public func responseError(_ err: Error) -> String { if let r = err as? ChatResponse { return String(describing: r) } else { diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift similarity index 78% rename from apps/ios/Shared/Model/Shared/APITypes.swift rename to apps/ios/SimpleXChat/APITypes.swift index c4e5f5eab..b2622a539 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -7,24 +7,32 @@ // import Foundation +import SwiftUI let jsonDecoder = getJSONDecoder() let jsonEncoder = getJSONEncoder() -enum ChatCommand { +public enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) - case startChat + case startChat(subscribe: Bool) + case apiStopChat + case apiActivateChat + case apiSuspendChat(timeoutMicroseconds: Int) case setFilesFolder(filesFolder: String) + case apiExportArchive(config: ArchiveConfig) + case apiImportArchive(config: ArchiveConfig) + case apiDeleteStorage case apiGetChats case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) - case apiRegisterToken(token: String) - case apiVerifyToken(token: String, code: String, nonce: String) - case apiIntervalNofication(token: String, interval: Int) - case apiDeleteToken(token: String) + case apiGetNtfToken + case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) + case apiVerifyToken(token: DeviceToken, nonce: String, code: String) + case apiDeleteToken(token: DeviceToken) + case apiGetNtfMessage(nonce: String, encNtfInfo: String) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -32,7 +40,6 @@ enum ChatCommand { case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) case apiUpdateProfile(profile: Profile) - case apiParseMarkdown(text: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -45,18 +52,25 @@ enum ChatCommand { case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case apiEndCall(contact: Contact) + case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case receiveFile(fileId: Int64) case string(String) - var cmdString: String { + public var cmdString: String { get { switch self { case .showActiveUser: return "/u" case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" - case .startChat: return "/_start" + case let .startChat(subscribe): return "/_start subscribe=\(subscribe ? "on" : "off")" + case .apiStopChat: return "/_stop" + case .apiActivateChat: return "/_app activate" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" + case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" + case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" + case .apiDeleteStorage: return "/_db delete" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, file, quotedItemId, mc): @@ -64,10 +78,11 @@ enum ChatCommand { return "/_send \(ref(type, id)) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" - case let .apiRegisterToken(token): return "/_ntf register apns \(token)" - case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" - case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" - case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" + case .apiGetNtfToken: return "/_ntf get " + case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" + case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" + case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" + case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -75,7 +90,6 @@ enum ChatCommand { case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" - case let .apiParseMarkdown(text): return "/_parse \(text)" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -87,6 +101,7 @@ enum ChatCommand { case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .receiveFile(fileId): return "/freceive \(fileId)" @@ -95,22 +110,29 @@ enum ChatCommand { } } - var cmdType: String { + public var cmdType: String { get { switch self { case .showActiveUser: return "showActiveUser" case .createActiveUser: return "createActiveUser" case .startChat: return "startChat" + case .apiStopChat: return "apiStopChat" + case .apiActivateChat: return "apiActivateChat" + case .apiSuspendChat: return "apiSuspendChat" case .setFilesFolder: return "setFilesFolder" + case .apiExportArchive: return "apiExportArchive" + case .apiImportArchive: return "apiImportArchive" + case .apiDeleteStorage: return "apiDeleteStorage" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" - case .apiIntervalNofication: return "apiIntervalNofication" case .apiDeleteToken: return "apiDeleteToken" + case .apiGetNtfMessage: return "apiGetNtfMessage" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -118,7 +140,6 @@ enum ChatCommand { case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" case .apiUpdateProfile: return "apiUpdateProfile" - case .apiParseMarkdown: return "apiParseMarkdown" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -130,6 +151,7 @@ enum ChatCommand { case .apiSendCallAnswer: return "apiSendCallAnswer" case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" case .apiEndCall: return "apiEndCall" + case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" case .apiChatRead: return "apiChatRead" case .receiveFile: return "receiveFile" @@ -151,11 +173,13 @@ struct APIResponse: Decodable { var resp: ChatResponse } -enum ChatResponse: Decodable, Error { +public enum ChatResponse: Decodable, Error { case response(type: String, json: String) case activeUser(user: User) case chatStarted case chatRunning + case chatStopped + case chatSuspended case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) @@ -167,7 +191,6 @@ enum ChatResponse: Decodable, Error { case chatCleared(chatInfo: ChatInfo) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case apiParsedMarkdown(formattedText: [FormattedText]?) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -199,25 +222,30 @@ enum ChatResponse: Decodable, Error { case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case callInvitation(contact: Contact, callType: CallType, sharedKey: String?, callTs: Date) + case callInvitation(callInvitation: RcvCallInvitation) case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) case callAnswer(contact: Contact, answer: WebRTCSession) case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case callEnded(contact: Contact) + case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) + case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) + case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(connection: PendingContactConnection) case contactConnectionDeleted(connection: PendingContactConnection) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) - var responseType: String { + public var responseType: String { get { switch self { case let .response(type, _): return "* \(type)" case .activeUser: return "activeUser" case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" + case .chatStopped: return "chatStopped" + case .chatSuspended: return "chatSuspended" case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .userSMPServers: return "userSMPServers" @@ -229,7 +257,6 @@ enum ChatResponse: Decodable, Error { case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" - case .apiParsedMarkdown: return "apiParsedMarkdown" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -264,7 +291,10 @@ enum ChatResponse: Decodable, Error { case .callAnswer: return "callAnswer" case .callExtraInfo: return "callExtraInfo" case .callEnded: return "callEnded" + case .callInvitations: return "callInvitations" case .ntfTokenStatus: return "ntfTokenStatus" + case .ntfToken: return "ntfToken" + case .ntfMessages: return "ntfMessages" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .cmdOk: return "cmdOk" @@ -274,13 +304,15 @@ enum ChatResponse: Decodable, Error { } } - var details: String { + public var details: String { get { switch self { case let .response(_, json): return json case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails + case .chatStopped: return noDetails + case .chatSuspended: return noDetails case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) @@ -292,7 +324,6 @@ enum ChatResponse: Decodable, Error { case let .chatCleared(chatInfo): return String(describing: chatInfo) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails @@ -322,12 +353,15 @@ enum ChatResponse: Decodable, Error { case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) - case let .callInvitation(contact, callType, sharedKey, _): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")" + case let .callInvitation(inv): return String(describing: inv) case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" case let .callEnded(contact): return "contact: \(contact.id)" + case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) + case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" + case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" case let .newContactConnection(connection): return String(describing: connection) case let .contactConnectionDeleted(connection): return String(describing: connection) case .cmdOk: return noDetails @@ -346,7 +380,89 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } -func decodeJSON(_ json: String) -> T? { +public struct ArchiveConfig: Encodable { + var archivePath: String + var disableCompression: Bool? + + public init(archivePath: String, disableCompression: Bool? = nil) { + self.archivePath = archivePath + self.disableCompression = disableCompression + } +} + +public protocol SelectableItem: Hashable, Identifiable { + var label: LocalizedStringKey { get } + static var values: [Self] { get } +} + +public struct DeviceToken: Decodable { + var pushProvider: PushProvider + var token: String + + public init(pushProvider: PushProvider, token: String) { + self.pushProvider = pushProvider + self.token = token + } + + public var cmdString: String { + "\(pushProvider) \(token)" + } +} + +public enum PushEnvironment: String { + case development + case production +} + +public enum PushProvider: String, Decodable { + case apns_dev + case apns_prod + + public init(env: PushEnvironment) { + switch env { + case .development: self = .apns_dev + case .production: self = .apns_prod + } + } +} + +public enum NotificationsMode: String, Decodable, SelectableItem { + case off = "OFF" + case periodic = "PERIODIC" + case instant = "INSTANT" + + public var label: LocalizedStringKey { + switch self { + case .off: return "Off (Local)" + case .periodic: return "Periodically" + case .instant: return "Instantly" + } + } + + public var id: String { self.rawValue } + + public static var values: [NotificationsMode] = [.instant, .periodic, .off] +} + +public enum NotificationPreviewMode: String, SelectableItem { + case hidden + case contact + case message + + public var label: LocalizedStringKey { + switch self { + case .hidden: return "Hidden" + case .contact: return "Contact name" + case .message: return "Message text" + } + } + + public var id: String { self.rawValue } + + public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] +} + +public func decodeJSON(_ json: String) -> T? { if let data = json.data(using: .utf8) { return try? jsonDecoder.decode(T.self, from: data) } @@ -366,7 +482,7 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { return try? JSONSerialization.jsonObject(with: d) as? NSDictionary } -func encodeJSON(_ value: T) -> String { +public func encodeJSON(_ value: T) -> String { let data = try! jsonEncoder.encode(value) return String(decoding: data, as: UTF8.self) } @@ -375,13 +491,13 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } -enum ChatError: Decodable { +public enum ChatError: Decodable { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) } -enum ChatErrorType: Decodable { +public enum ChatErrorType: Decodable { case noActiveUser case activeUserExists case chatNotStarted @@ -415,7 +531,7 @@ enum ChatErrorType: Decodable { case commandError(message: String) } -enum StoreError: Decodable { +public enum StoreError: Decodable { case duplicateName case contactNotFound(contactId: Int64) case contactNotFoundByName(contactName: ContactName) @@ -448,7 +564,7 @@ enum StoreError: Decodable { case chatItemNotFoundByFileId(fileId: Int64) } -enum AgentErrorType: Decodable { +public enum AgentErrorType: Decodable { case CMD(cmdErr: CommandErrorType) case CONN(connErr: ConnectionErrorType) case SMP(smpErr: ProtocolErrorType) @@ -458,7 +574,7 @@ enum AgentErrorType: Decodable { case INTERNAL(internalErr: String) } -enum CommandErrorType: Decodable { +public enum CommandErrorType: Decodable { case PROHIBITED case SYNTAX case NO_CONN @@ -466,7 +582,7 @@ enum CommandErrorType: Decodable { case LARGE } -enum ConnectionErrorType: Decodable { +public enum ConnectionErrorType: Decodable { case NOT_FOUND case DUPLICATE case SIMPLEX @@ -474,7 +590,7 @@ enum ConnectionErrorType: Decodable { case NOT_AVAILABLE } -enum BrokerErrorType: Decodable { +public enum BrokerErrorType: Decodable { case RESPONSE(smpErr: ProtocolErrorType) case UNEXPECTED case NETWORK @@ -482,7 +598,7 @@ enum BrokerErrorType: Decodable { case TIMEOUT } -enum ProtocolErrorType: Decodable { +public enum ProtocolErrorType: Decodable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) @@ -493,7 +609,7 @@ enum ProtocolErrorType: Decodable { case INTERNAL } -enum ProtocolCommandError: Decodable { +public enum ProtocolCommandError: Decodable { case UNKNOWN case SYNTAX case NO_AUTH @@ -501,20 +617,20 @@ enum ProtocolCommandError: Decodable { case NO_ENTITY } -enum ProtocolTransportError: Decodable { +public enum ProtocolTransportError: Decodable { case badBlock case largeMsg case badSession case handshake(handshakeErr: SMPHandshakeError) } -enum SMPHandshakeError: Decodable { +public enum SMPHandshakeError: Decodable { case PARSE case VERSION case IDENTITY } -enum SMPAgentError: Decodable { +public enum SMPAgentError: Decodable { case A_MESSAGE case A_PROHIBITED case A_VERSION diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift new file mode 100644 index 000000000..708907bd3 --- /dev/null +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -0,0 +1,105 @@ +// +// GroupDefaults.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +let GROUP_DEFAULT_APP_STATE = "appState" +let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" +public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" +let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" + +let APP_GROUP_NAME = "group.chat.simplex.app" + +public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! + +public enum AppState: String { + case active + case bgRefresh + case suspending + case suspended + case stopped + + public var inactive: Bool { + switch self { + case .suspending: return true + case .suspended: return true + default: return false + } + } +} + +public enum DBContainer: String { + case documents + case group +} + +public let appStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_APP_STATE, + withDefault: .active +) + +public let dbContainerGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_DB_CONTAINER, + withDefault: .documents +) + +public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START) + +public let ntfPreviewModeGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NTF_PREVIEW_MODE, + withDefault: .message +) + +public class DateDefault { + var defaults: UserDefaults + var key: String + + public init(defaults: UserDefaults = UserDefaults.standard, forKey: String) { + self.defaults = defaults + self.key = forKey + } + + public func get() -> Date { + let ts = defaults.double(forKey: key) + return Date(timeIntervalSince1970: ts) + } + + public func set(_ ts: Date) { + defaults.set(ts.timeIntervalSince1970, forKey: key) + defaults.synchronize() + } +} + +public class EnumDefault where T.RawValue == String { + var defaults: UserDefaults + var key: String + var defaultValue: T + + public init(defaults: UserDefaults = UserDefaults.standard, forKey: String, withDefault: T) { + self.defaults = defaults + self.key = forKey + self.defaultValue = withDefault + } + + public func get() -> T { + if let rawValue = defaults.string(forKey: key), + let value = T(rawValue: rawValue) { + return value + } + return defaultValue + } + + public func set(_ value: T) { + defaults.set(value.rawValue, forKey: key) + defaults.synchronize() + } +} diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift new file mode 100644 index 000000000..1d5fa360c --- /dev/null +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -0,0 +1,91 @@ +// +// CallTypes.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +public struct WebRTCCallOffer: Encodable { + public init(callType: CallType, rtcSession: WebRTCSession) { + self.callType = callType + self.rtcSession = rtcSession + } + + public var callType: CallType + public var rtcSession: WebRTCSession +} + +public struct WebRTCSession: Codable { + public init(rtcSession: String, rtcIceCandidates: String) { + self.rtcSession = rtcSession + self.rtcIceCandidates = rtcIceCandidates + } + + public var rtcSession: String + public var rtcIceCandidates: String +} + +public struct WebRTCExtraInfo: Codable { + public init(rtcIceCandidates: String) { + self.rtcIceCandidates = rtcIceCandidates + } + + public var rtcIceCandidates: String +} + +public struct RcvCallInvitation: Decodable { + public var contact: Contact + public var callkitUUID: UUID? = UUID() + public var callType: CallType + public var sharedKey: String? + public var callTs: Date + public var callTypeText: LocalizedStringKey { + get { + switch callType.media { + case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call" + case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call" + } + } + } + + public static let sampleData = RcvCallInvitation( + contact: Contact.sampleData, + callType: CallType(media: .audio, capabilities: CallCapabilities(encryption: false)), + callTs: .now + ) +} + +public struct CallType: Codable { + public init(media: CallMediaType, capabilities: CallCapabilities) { + self.media = media + self.capabilities = capabilities + } + + public var media: CallMediaType + public var capabilities: CallCapabilities +} + +public enum CallMediaType: String, Codable, Equatable { + case video = "video" + case audio = "audio" +} + +public enum VideoCamera: String, Codable, Equatable { + case user = "user" + case environment = "environment" +} + +public struct CallCapabilities: Codable, Equatable { + public var encryption: Bool +} + +public enum WebRTCCallStatus: String, Encodable { + case connected = "connected" + case connecting = "connecting" + case disconnected = "disconnected" + case failed = "failed" +} diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift similarity index 69% rename from apps/ios/Shared/Model/Shared/ChatTypes.swift rename to apps/ios/SimpleXChat/ChatTypes.swift index 69966d8a5..04bf5a50d 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,18 +9,18 @@ import Foundation import SwiftUI -struct User: Decodable, NamedChat { +public struct User: Decodable, NamedChat { var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName - var profile: Profile + public var profile: Profile var activeUser: Bool - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } + public var displayName: String { get { profile.displayName } } + public var fullName: String { get { profile.fullName } } + public var image: String? { get { profile.image } } - static let sampleData = User( + public static let sampleData = User( userId: 1, userContactId: 1, localDisplayName: "alice", @@ -29,14 +29,20 @@ struct User: Decodable, NamedChat { ) } -typealias ContactName = String +public typealias ContactName = String -typealias GroupName = String +public typealias GroupName = String -struct Profile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? +public struct Profile: Codable, NamedChat { + public init(displayName: String, fullName: String, image: String? = nil) { + self.displayName = displayName + self.fullName = fullName + self.image = image + } + + public var displayName: String + public var fullName: String + public var image: String? static let sampleData = Profile( displayName: "alice", @@ -44,34 +50,34 @@ struct Profile: Codable, NamedChat { ) } -enum ChatType: String { +public enum ChatType: String { case direct = "@" case group = "#" case contactRequest = "<@" case contactConnection = ":" } -protocol NamedChat { +public protocol NamedChat { var displayName: String { get } var fullName: String { get } var image: String? { get } } extension NamedChat { - var chatViewName: String { + public var chatViewName: String { get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } } } -typealias ChatId = String +public typealias ChatId = String -enum ChatInfo: Identifiable, Decodable, NamedChat { +public enum ChatInfo: Identifiable, Decodable, NamedChat { case direct(contact: Contact) case group(groupInfo: GroupInfo) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) - var localDisplayName: String { + public var localDisplayName: String { get { switch self { case let .direct(contact): return contact.localDisplayName @@ -82,7 +88,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var displayName: String { + public var displayName: String { get { switch self { case let .direct(contact): return contact.displayName @@ -93,7 +99,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var fullName: String { + public var fullName: String { get { switch self { case let .direct(contact): return contact.fullName @@ -104,7 +110,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var image: String? { + public var image: String? { get { switch self { case let .direct(contact): return contact.image @@ -115,7 +121,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var id: ChatId { + public var id: ChatId { get { switch self { case let .direct(contact): return contact.id @@ -126,7 +132,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var chatType: ChatType { + public var chatType: ChatType { get { switch self { case .direct: return .direct @@ -137,7 +143,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var apiId: Int64 { + public var apiId: Int64 { get { switch self { case let .direct(contact): return contact.apiId @@ -148,7 +154,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var ready: Bool { + public var ready: Bool { get { switch self { case let .direct(contact): return contact.ready @@ -168,7 +174,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - var updatedAt: Date { + public var updatedAt: Date { switch self { case let .direct(contact): return contact.updatedAt case let .group(groupInfo): return groupInfo.updatedAt @@ -177,49 +183,54 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } - struct SampleData { - var direct: ChatInfo - var group: ChatInfo - var contactRequest: ChatInfo + public struct SampleData { + public var direct: ChatInfo + public var group: ChatInfo + public var contactRequest: ChatInfo } - static var sampleData: ChatInfo.SampleData = SampleData( + public static var sampleData: ChatInfo.SampleData = SampleData( direct: ChatInfo.direct(contact: Contact.sampleData), group: ChatInfo.group(groupInfo: GroupInfo.sampleData), contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) ) } -struct ChatData: Decodable, Identifiable { - var chatInfo: ChatInfo - var chatItems: [ChatItem] - var chatStats: ChatStats +public struct ChatData: Decodable, Identifiable { + public var chatInfo: ChatInfo + public var chatItems: [ChatItem] + public var chatStats: ChatStats - var id: ChatId { get { chatInfo.id } } + public var id: ChatId { get { chatInfo.id } } } -struct ChatStats: Decodable { - var unreadCount: Int = 0 - var minUnreadItemId: Int64 = 0 +public struct ChatStats: Decodable { + public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0) { + self.unreadCount = unreadCount + self.minUnreadItemId = minUnreadItemId + } + + public var unreadCount: Int = 0 + public var minUnreadItemId: Int64 = 0 } -struct Contact: Identifiable, Decodable, NamedChat { +public struct Contact: Identifiable, Decodable, NamedChat { var contactId: Int64 var localDisplayName: ContactName - var profile: Profile - var activeConn: Connection + public var profile: Profile + public var activeConn: Connection var viaGroup: Int64? var createdAt: Date var updatedAt: Date - var id: ChatId { get { "@\(contactId)" } } - var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == .ready } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } + public var id: ChatId { get { "@\(contactId)" } } + public var apiId: Int64 { get { contactId } } + public var ready: Bool { get { activeConn.connStatus == .ready } } + public var displayName: String { get { profile.displayName } } + public var fullName: String { get { profile.fullName } } + public var image: String? { get { profile.image } } - static let sampleData = Contact( + public static let sampleData = Contact( contactId: 1, localDisplayName: "alice", profile: Profile.sampleData, @@ -229,23 +240,23 @@ struct Contact: Identifiable, Decodable, NamedChat { ) } -struct ContactRef: Decodable, Equatable { +public struct ContactRef: Decodable, Equatable { var contactId: Int64 var localDisplayName: ContactName - var id: ChatId { get { "@\(contactId)" } } + public var id: ChatId { get { "@\(contactId)" } } } -struct ContactSubStatus: Decodable { - var contact: Contact - var contactError: ChatError? +public struct ContactSubStatus: Decodable { + public var contact: Contact + public var contactError: ChatError? } -struct Connection: Decodable { +public struct Connection: Decodable { var connId: Int64 var connStatus: ConnStatus - var id: ChatId { get { ":\(connId)" } } + public var id: ChatId { get { ":\(connId)" } } static let sampleData = Connection( connId: 1, @@ -253,21 +264,24 @@ struct Connection: Decodable { ) } -struct UserContactRequest: Decodable, NamedChat { +public struct UserContact: Decodable { +} + +public struct UserContactRequest: Decodable, NamedChat { var contactRequestId: Int64 var localDisplayName: ContactName var profile: Profile var createdAt: Date - var updatedAt: Date + public var updatedAt: Date - var id: ChatId { get { "<@\(contactRequestId)" } } - var apiId: Int64 { get { contactRequestId } } + public var id: ChatId { get { "<@\(contactRequestId)" } } + public var apiId: Int64 { get { contactRequestId } } var ready: Bool { get { true } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } + public var displayName: String { get { profile.displayName } } + public var fullName: String { get { profile.fullName } } + public var image: String? { get { profile.image } } - static let sampleData = UserContactRequest( + public static let sampleData = UserContactRequest( contactRequestId: 1, localDisplayName: "alice", profile: Profile.sampleData, @@ -276,21 +290,21 @@ struct UserContactRequest: Decodable, NamedChat { ) } -struct PendingContactConnection: Decodable, NamedChat { +public struct PendingContactConnection: Decodable, NamedChat { var pccConnId: Int64 var pccAgentConnId: String var pccConnStatus: ConnStatus - var viaContactUri: Bool + public var viaContactUri: Bool var createdAt: Date - var updatedAt: Date + public var updatedAt: Date - var id: ChatId { get { ":\(pccConnId)" } } - var apiId: Int64 { get { pccConnId } } + public var id: ChatId { get { ":\(pccConnId)" } } + public var apiId: Int64 { get { pccConnId } } var ready: Bool { get { false } } var localDisplayName: String { get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } } - var displayName: String { + public var displayName: String { get { if let initiated = pccConnStatus.initiated { return initiated && !viaContactUri @@ -302,11 +316,11 @@ struct PendingContactConnection: Decodable, NamedChat { } } } - var fullName: String { get { "" } } - var image: String? { get { nil } } - var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + public var fullName: String { get { "" } } + public var image: String? { get { nil } } + public var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } - var description: String { + public var description: String { get { if let initiated = pccConnStatus.initiated { return initiated && !viaContactUri @@ -320,7 +334,7 @@ struct PendingContactConnection: Decodable, NamedChat { } } - static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + public static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { PendingContactConnection( pccConnId: 1, pccAgentConnId: "abcd", @@ -332,7 +346,7 @@ struct PendingContactConnection: Decodable, NamedChat { } } -enum ConnStatus: String, Decodable { +public enum ConnStatus: String, Decodable { case new = "new" case joined = "joined" case requested = "requested" @@ -356,19 +370,19 @@ enum ConnStatus: String, Decodable { } } -struct GroupInfo: Identifiable, Decodable, NamedChat { +public struct GroupInfo: Identifiable, Decodable, NamedChat { var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile var createdAt: Date var updatedAt: Date - var id: ChatId { get { "#\(groupId)" } } + public var id: ChatId { get { "#\(groupId)" } } var apiId: Int64 { get { groupId } } - var ready: Bool { get { true } } - var displayName: String { get { groupProfile.displayName } } - var fullName: String { get { groupProfile.fullName } } - var image: String? { get { groupProfile.image } } + public var ready: Bool { get { true } } + public var displayName: String { get { groupProfile.displayName } } + public var fullName: String { get { groupProfile.fullName } } + public var image: String? { get { groupProfile.image } } static let sampleData = GroupInfo( groupId: 1, @@ -379,10 +393,10 @@ struct GroupInfo: Identifiable, Decodable, NamedChat { ) } -struct GroupProfile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? +public struct GroupProfile: Codable, NamedChat { + public var displayName: String + public var fullName: String + public var image: String? static let sampleData = GroupProfile( displayName: "team", @@ -390,15 +404,15 @@ struct GroupProfile: Codable, NamedChat { ) } -struct GroupMember: Decodable { - var groupMemberId: Int64 +public struct GroupMember: Decodable { + public var groupMemberId: Int64 var memberId: String // var memberRole: GroupMemberRole // var memberCategory: GroupMemberCategory // var memberStatus: GroupMemberStatus // var invitedBy: InvitedBy var localDisplayName: ContactName - var memberProfile: Profile + public var memberProfile: Profile var memberContactId: Int64? // var activeConn: Connection? @@ -412,7 +426,14 @@ struct GroupMember: Decodable { } } - static let sampleData = GroupMember( + public var chatViewName: String { + get { + let p = memberProfile + return p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + } + } + + public static let sampleData = GroupMember( groupMemberId: 1, memberId: "abcd", localDisplayName: "alice", @@ -421,29 +442,50 @@ struct GroupMember: Decodable { ) } -struct MemberSubError: Decodable { +public struct MemberSubError: Decodable { var member: GroupMember var memberError: ChatError } -struct AChatItem: Decodable { - var chatInfo: ChatInfo - var chatItem: ChatItem +public enum ConnectionEntity: Decodable { + case rcvDirectMsgConnection(entityConnection: Connection, contact: Contact?) + case rcvGroupMsgConnection(entityConnection: Connection, groupInfo: GroupInfo, groupMember: GroupMember) + case sndFileConnection(entityConnection: Connection, sndFileTransfer: SndFileTransfer) + case rcvFileConnection(entityConnection: Connection, rcvFileTransfer: RcvFileTransfer) + case userContactConnection(entityConnection: Connection, userContact: UserContact) } -struct ChatItem: Identifiable, Decodable { - var chatDir: CIDirection - var meta: CIMeta - var content: CIContent - var formattedText: [FormattedText]? - var quotedItem: CIQuote? - var file: CIFile? +public struct NtfMsgInfo: Decodable { - var id: Int64 { get { meta.itemId } } +} - var timestampText: Text { get { meta.timestampText } } +public struct AChatItem: Decodable { + public var chatInfo: ChatInfo + public var chatItem: ChatItem +} - var text: String { +public struct ChatItem: Identifiable, Decodable { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, file: CIFile? = nil) { + self.chatDir = chatDir + self.meta = meta + self.content = content + self.formattedText = formattedText + self.quotedItem = quotedItem + self.file = file + } + + public var chatDir: CIDirection + public var meta: CIMeta + public var content: CIContent + public var formattedText: [FormattedText]? + public var quotedItem: CIQuote? + public var file: CIFile? + + public var id: Int64 { get { meta.itemId } } + + public var timestampText: Text { get { meta.timestampText } } + + public var text: String { get { switch (content.text, file) { case let ("", .some(file)): return file.fileName @@ -452,12 +494,12 @@ struct ChatItem: Identifiable, Decodable { } } - func isRcvNew() -> Bool { + public func isRcvNew() -> Bool { if case .rcvNew = meta.itemStatus { return true } return false } - func isMsgContent() -> Bool { + public func isMsgContent() -> Bool { switch content { case .sndMsgContent: return true case .rcvMsgContent: return true @@ -465,7 +507,7 @@ struct ChatItem: Identifiable, Decodable { } } - func isDeletedContent() -> Bool { + public func isDeletedContent() -> Bool { switch content { case .sndDeleted: return true case .rcvDeleted: return true @@ -473,7 +515,7 @@ struct ChatItem: Identifiable, Decodable { } } - func isCall() -> Bool { + public func isCall() -> Bool { switch content { case .sndCall: return true case .rcvCall: return true @@ -481,7 +523,7 @@ struct ChatItem: Identifiable, Decodable { } } - var memberDisplayName: String? { + public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { return groupMember.memberProfile.displayName @@ -491,7 +533,7 @@ struct ChatItem: Identifiable, Decodable { } } - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), @@ -501,7 +543,7 @@ struct ChatItem: Identifiable, Decodable { ) } - static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem { + public static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem { ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false), @@ -511,7 +553,7 @@ struct ChatItem: Identifiable, Decodable { ) } - static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { + public static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { ChatItem( chatDir: dir, meta: CIMeta.getSample(id, ts, text, status, false, false, false), @@ -521,7 +563,7 @@ struct ChatItem: Identifiable, Decodable { ) } - static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem { + public static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem { ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", status, false, false, false), @@ -532,13 +574,13 @@ struct ChatItem: Identifiable, Decodable { } } -enum CIDirection: Decodable { +public enum CIDirection: Decodable { case directSnd case directRcv case groupSnd case groupRcv(groupMember: GroupMember) - var sent: Bool { + public var sent: Bool { get { switch self { case .directSnd: return true @@ -550,19 +592,19 @@ enum CIDirection: Decodable { } } -struct CIMeta: Decodable { +public struct CIMeta: Decodable { var itemId: Int64 var itemTs: Date var itemText: String - var itemStatus: CIStatus + public var itemStatus: CIStatus var createdAt: Date - var itemDeleted: Bool - var itemEdited: Bool - var editable: Bool + public var itemDeleted: Bool + public var itemEdited: Bool + public var editable: Bool var timestampText: Text { get { formatTimestampText(itemTs) } } - static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { + public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { CIMeta( itemId: id, itemTs: ts, @@ -579,14 +621,14 @@ struct CIMeta: Decodable { let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) -func formatTimestampText(_ date: Date) -> Text { +public func formatTimestampText(_ date: Date) -> Text { let now = Calendar.current.dateComponents([.day, .hour], from: .now) let dc = Calendar.current.dateComponents([.day, .hour], from: date) let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) return Text(date, format: recent ? msgTimeFormat : msgDateFormat) } -enum CIStatus: Decodable { +public enum CIStatus: Decodable { case sndNew case sndSent case sndErrorAuth @@ -595,7 +637,7 @@ enum CIStatus: Decodable { case rcvRead } -enum CIDeleteMode: String, Decodable { +public enum CIDeleteMode: String, Decodable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" } @@ -604,7 +646,7 @@ protocol ItemContent { var text: String { get } } -enum CIContent: Decodable, ItemContent { +public enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) case sndDeleted(deleteMode: CIDeleteMode) @@ -613,7 +655,7 @@ enum CIContent: Decodable, ItemContent { case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) - var text: String { + public var text: String { get { switch self { case let .sndMsgContent(mc): return mc.text @@ -627,7 +669,7 @@ enum CIContent: Decodable, ItemContent { } } - var msgContent: MsgContent? { + public var msgContent: MsgContent? { get { switch self { case let .sndMsgContent(mc): return mc @@ -638,17 +680,17 @@ enum CIContent: Decodable, ItemContent { } } -struct CIQuote: Decodable, ItemContent { +public struct CIQuote: Decodable, ItemContent { var chatDir: CIDirection? var itemId: Int64? var sharedMsgId: String? = nil var sentAt: Date - var content: MsgContent - var formattedText: [FormattedText]? + public var content: MsgContent + public var formattedText: [FormattedText]? - var text: String { get { content.text } } + public var text: String { get { content.text } } - func getSender(_ currentUser: User?) -> String? { + public func getSender(_ currentUser: User?) -> String? { switch (chatDir) { case .directSnd: return "you" case .directRcv: return nil @@ -658,7 +700,7 @@ struct CIQuote: Decodable, ItemContent { } } - static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { + public static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { let mc: MsgContent if let image = image { mc = .image(text: text, image: image) @@ -669,14 +711,14 @@ struct CIQuote: Decodable, ItemContent { } } -struct CIFile: Decodable { - var fileId: Int64 - var fileName: String - var fileSize: Int64 - var filePath: String? - var fileStatus: CIFileStatus +public struct CIFile: Decodable { + public var fileId: Int64 + public var fileName: String + public var fileSize: Int64 + public var filePath: String? + public var fileStatus: CIFileStatus - static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { + public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) } @@ -697,7 +739,7 @@ struct CIFile: Decodable { } } -enum CIFileStatus: String, Decodable { +public enum CIFileStatus: String, Decodable { case sndStored = "snd_stored" case sndTransfer = "snd_transfer" case sndComplete = "snd_complete" @@ -709,7 +751,7 @@ enum CIFileStatus: String, Decodable { case rcvCancelled = "rcv_cancelled" } -enum MsgContent { +public enum MsgContent { case text(String) case link(text: String, preview: LinkPreview) case image(text: String, image: String) @@ -738,7 +780,7 @@ enum MsgContent { } } - func isFile() -> Bool { + public func isFile() -> Bool { switch self { case .file: return true default: return false @@ -754,7 +796,7 @@ enum MsgContent { } extension MsgContent: Decodable { - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { do { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: CodingKeys.type) @@ -784,7 +826,7 @@ extension MsgContent: Decodable { } extension MsgContent: Encodable { - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case let .text(text): @@ -809,12 +851,12 @@ extension MsgContent: Encodable { } } -struct FormattedText: Decodable { - var text: String - var format: Format? +public struct FormattedText: Decodable { + public var text: String + public var format: Format? } -enum Format: Decodable, Equatable { +public enum Format: Decodable, Equatable { case bold case italic case strikeThrough @@ -826,7 +868,7 @@ enum Format: Decodable, Equatable { case phone } -enum FormatColor: String, Decodable { +public enum FormatColor: String, Decodable { case red = "red" case green = "green" case blue = "blue" @@ -836,7 +878,7 @@ enum FormatColor: String, Decodable { case black = "black" case white = "white" - var uiColor: Color { + public var uiColor: Color { get { switch (self) { case .red: return .red @@ -853,15 +895,22 @@ enum FormatColor: String, Decodable { } // Struct to use with simplex API -struct LinkPreview: Codable { - var uri: URL - var title: String +public struct LinkPreview: Codable { + public init(uri: URL, title: String, description: String = "", image: String) { + self.uri = uri + self.title = title + self.description = description + self.image = image + } + + public var uri: URL + public var title: String // TODO remove once optional in haskell - var description: String = "" - var image: String + public var description: String = "" + public var image: String } -enum NtfTknStatus: String, Decodable { +public enum NtfTknStatus: String, Decodable { case new = "NEW" case registered = "REGISTERED" case invalid = "INVALID" @@ -870,15 +919,19 @@ enum NtfTknStatus: String, Decodable { case expired = "EXPIRED" } -struct SndFileTransfer: Decodable { +public struct SndFileTransfer: Decodable { } -struct FileTransferMeta: Decodable { +public struct RcvFileTransfer: Decodable { + +} + +public struct FileTransferMeta: Decodable { } -enum CICallStatus: String, Decodable { +public enum CICallStatus: String, Decodable { case pending case missed case rejected @@ -901,12 +954,12 @@ enum CICallStatus: String, Decodable { } } - static func durationText(_ sec: Int) -> String { + public static func durationText(_ sec: Int) -> String { String(format: "%02d:%02d", sec / 60, sec % 60) } } -enum MsgErrorType: Decodable { +public enum MsgErrorType: Decodable { case msgSkipped(fromMsgId: Int64, toMsgId: Int64) case msgBadId(msgId: Int64) case msgBadHash diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift similarity index 68% rename from apps/ios/Shared/FileUtils.swift rename to apps/ios/SimpleXChat/FileUtils.swift index abb150965..b23671291 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -8,25 +8,71 @@ import Foundation import SwiftUI +import OSLog + +let logger = Logger() // maximum image file size to be auto-accepted -let maxImageSize: Int64 = 236700 +public let maxImageSize: Int64 = 236700 -let maxFileSize: Int64 = 8000000 +public let maxFileSize: Int64 = 8000000 -func getDocumentsDirectory() -> URL { +public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } -func getAppFilesDirectory() -> URL { - getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) +func getGroupContainerDirectory() -> URL { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! +} + +func getAppDirectory() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory() + : getDocumentsDirectory() +// getDocumentsDirectory() +} + +let DB_FILE_PREFIX = "simplex_v1" + +func getLegacyDatabasePath() -> URL { + getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false) +} + +public func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) + : getLegacyDatabasePath() +// getLegacyDatabasePath() +} + +public func hasLegacyDatabase() -> Bool { + let dbPath = getLegacyDatabasePath() + let fm = FileManager.default + return fm.isReadableFile(atPath: dbPath.path + "_agent.db") && + fm.isReadableFile(atPath: dbPath.path + "_chat.db") +} + +public func removeLegacyDatabaseAndFiles() -> Bool { + let dbPath = getLegacyDatabasePath() + let appFiles = getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) + let fm = FileManager.default + let r1 = nil != (try? fm.removeItem(atPath: dbPath.path + "_agent.db")) + let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + "_chat.db")) + try? fm.removeItem(atPath: dbPath.path + "_agent.db.bak") + try? fm.removeItem(atPath: dbPath.path + "_chat.db.bak") + try? fm.removeItem(at: appFiles) + return r1 && r2 +} + +public func getAppFilesDirectory() -> URL { + getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } -func getLoadedFilePath(_ file: CIFile?) -> String? { +public func getLoadedFilePath(_ file: CIFile?) -> String? { if let file = file, file.loaded, let savedFile = file.filePath { @@ -35,14 +81,14 @@ func getLoadedFilePath(_ file: CIFile?) -> String? { return nil } -func getLoadedImage(_ file: CIFile?) -> UIImage? { +public func getLoadedImage(_ file: CIFile?) -> UIImage? { if let filePath = getLoadedFilePath(file) { return UIImage(contentsOfFile: filePath) } return nil } -func saveFileFromURL(_ url: URL) -> String? { +public func saveFileFromURL(_ url: URL) -> String? { let savedFile: String? if url.startAccessingSecurityScopedResource() { do { @@ -61,7 +107,7 @@ func saveFileFromURL(_ url: URL) -> String? { return savedFile } -func saveImage(_ uiImage: UIImage) -> String? { +public func saveImage(_ uiImage: UIImage) -> String? { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") let fileName = uniqueCombine("IMG_\(timestamp).jpg") @@ -92,8 +138,9 @@ private func saveFile(_ data: Data, _ fileName: String) -> String? { private func uniqueCombine(_ fileName: String) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String { - let name = fileName.deletingPathExtension - let ext = fileName.pathExtension + let ns = fileName as NSString + let name = ns.deletingPathExtension + let ext = ns.pathExtension let suffix = (n == 0) ? "" : "_\(n)" let f = "\(name)\(suffix).\(ext)" return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f @@ -101,19 +148,7 @@ private func uniqueCombine(_ fileName: String) -> String { return tryCombine(fileName, 0) } -private extension String { - var ns: NSString { - return self as NSString - } - var pathExtension: String { - return ns.pathExtension - } - var deletingPathExtension: String { - return ns.deletingPathExtension - } -} - -func removeFile(_ fileName: String) { +public func removeFile(_ fileName: String) { do { try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path) } catch { @@ -123,7 +158,7 @@ func removeFile(_ fileName: String) { // image utils -func dropImagePrefix(_ s: String) -> String { +public func dropImagePrefix(_ s: String) -> String { dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") } @@ -131,7 +166,7 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String { s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s } -func cropToSquare(_ image: UIImage) -> UIImage { +public func cropToSquare(_ image: UIImage) -> UIImage { let size = image.size let side = min(size.width, size.height) let newSize = CGSize(width: side, height: side) @@ -159,7 +194,7 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { return data } -func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { +public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { var img = image var str = compressImageStr(img) var dataSize = str?.count ?? 0 diff --git a/apps/ios/Shared/Model/Shared/JSON.swift b/apps/ios/SimpleXChat/JSON.swift similarity index 92% rename from apps/ios/Shared/Model/Shared/JSON.swift rename to apps/ios/SimpleXChat/JSON.swift index 2123c5481..e9dfdef44 100644 --- a/apps/ios/Shared/Model/Shared/JSON.swift +++ b/apps/ios/SimpleXChat/JSON.swift @@ -8,7 +8,7 @@ import Foundation -func getJSONDecoder() -> JSONDecoder { +public func getJSONDecoder() -> JSONDecoder { let jd = JSONDecoder() let fracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ") let noFracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ") @@ -23,7 +23,7 @@ func getJSONDecoder() -> JSONDecoder { return jd } -func getJSONEncoder() -> JSONEncoder { +public func getJSONEncoder() -> JSONEncoder { let je = JSONEncoder() je.dateEncodingStrategy = .iso8601 return je diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift new file mode 100644 index 000000000..8f2706324 --- /dev/null +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -0,0 +1,162 @@ +// +// Notifications.swift +// SimpleX +// +// Created by Evgeny on 28/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UserNotifications +import SwiftUI + +public let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" +public let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" +public let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +public let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION" +public let ntfCategoryConnectionEvent = "NTF_CAT_CONNECTION_EVENT" +public let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" + +public let appNotificationId = "chat.simplex.app.notification" + +let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") + +public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { + let hideContent = ntfPreviewModeGroupDefault.get() == .hidden + return createNotification( + categoryIdentifier: ntfCategoryContactRequest, + title: String.localizedStringWithFormat( + NSLocalizedString("%@ wants to connect!", comment: "notification title"), + hideContent ? NSLocalizedString("Somebody", comment: "notification title") : contactRequest.displayName + ), + body: String.localizedStringWithFormat( + NSLocalizedString("Accept contact request from %@?", comment: "notification body"), + hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName + ), + targetContentIdentifier: nil, + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + ) +} + +public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { + let hideContent = ntfPreviewModeGroupDefault.get() == .hidden + return createNotification( + categoryIdentifier: ntfCategoryContactConnected, + title: String.localizedStringWithFormat( + NSLocalizedString("%@ is connected!", comment: "notification title"), + hideContent ? NSLocalizedString("A new contact", comment: "notification title") : contact.displayName + ), + body: String.localizedStringWithFormat( + NSLocalizedString("You can now send messages to %@", comment: "notification body"), + hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName + ), + targetContentIdentifier: contact.id +// userInfo: ["chatId": contact.id, "contactId": contact.apiId] + ) +} + +public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { + let previewMode = ntfPreviewModeGroupDefault.get() + var title: String + if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { + title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: previewMode == .hidden) + } else { + title = previewMode == .hidden ? contactHidden : "\(cInfo.chatViewName):" + } + return createNotification( + categoryIdentifier: ntfCategoryMessageReceived, + title: title, + body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), + targetContentIdentifier: cInfo.id +// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] + ) +} + +public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutableNotificationContent { + let text = invitation.callType.media == .video + ? NSLocalizedString("Incoming video call", comment: "notification") + : NSLocalizedString("Incoming audio call", comment: "notification") + let hideContent = ntfPreviewModeGroupDefault.get() == .hidden + return createNotification( + categoryIdentifier: ntfCategoryCallInvitation, + title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):", + body: text, + targetContentIdentifier: nil, + userInfo: ["chatId": invitation.contact.id] + ) +} + +public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutableNotificationContent { + let hideContent = ntfPreviewModeGroupDefault.get() == .hidden + var title: String + var body: String? = nil + var targetContentIdentifier: String? = nil + switch connEntity { + case let .rcvDirectMsgConnection(_, contact): + if let contact = contact { + title = hideContent ? contactHidden : "\(contact.chatViewName):" + targetContentIdentifier = contact.id + } else { + title = NSLocalizedString("New contact:", comment: "notification") + } + body = NSLocalizedString("message received", comment: "notification") + case let .rcvGroupMsgConnection(_, groupInfo, groupMember): + title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: hideContent) + body = NSLocalizedString("message received", comment: "notification") + targetContentIdentifier = groupInfo.id + case .sndFileConnection: + title = NSLocalizedString("Sent file event", comment: "notification") + case .rcvFileConnection: + title = NSLocalizedString("Received file event", comment: "notification") + case .userContactConnection: + title = NSLocalizedString("New contact request", comment: "notification") + } + return createNotification( + categoryIdentifier: ntfCategoryCallInvitation, + title: title, + body: body, + targetContentIdentifier: targetContentIdentifier + ) +} + +private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String { + hideContent + ? NSLocalizedString("Group message:", comment: "notification") + : "#\(groupInfo.displayName) \(groupMember.chatViewName):" +} + +public func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, + targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.categoryIdentifier = categoryIdentifier + content.title = title + if let s = subtitle { content.subtitle = s } + if let s = body { content.body = s } + content.targetContentIdentifier = targetContentIdentifier + content.userInfo = userInfo + // TODO move logic of adding sound here, so it applies to background notifications too + content.sound = .default +// content.interruptionLevel = .active +// content.relevanceScore = 0.5 // 0-1 + return content +} + +func hideSecrets(_ cItem: ChatItem) -> String { + if cItem.content.text != "" { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } + } + return res + } else { + return cItem.content.text + } + } else { + return cItem.file?.fileName ?? "" + } +} diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h new file mode 100644 index 000000000..e848ad5cd --- /dev/null +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -0,0 +1,22 @@ +// +// SimpleX.h +// SimpleX +// +// Created by Evgeny on 30/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#ifndef SimpleX_h +#define SimpleX_h + +#endif /* SimpleX_h */ + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); +extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); +extern char *chat_parse_markdown(char *str); diff --git a/apps/ios/SimpleXChat/SimpleXChat.docc/SimpleXChat.md b/apps/ios/SimpleXChat/SimpleXChat.docc/SimpleXChat.md new file mode 100755 index 000000000..2ab4ef5f0 --- /dev/null +++ b/apps/ios/SimpleXChat/SimpleXChat.docc/SimpleXChat.md @@ -0,0 +1,13 @@ +# ``SimpleXChat`` + +Summary + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` \ No newline at end of file diff --git a/apps/ios/SimpleXChat/SimpleXChat.h b/apps/ios/SimpleXChat/SimpleXChat.h new file mode 100644 index 000000000..c109fc941 --- /dev/null +++ b/apps/ios/SimpleXChat/SimpleXChat.h @@ -0,0 +1,17 @@ +// +// SimpleXChat.h +// SimpleXChat +// +// Created by Evgeny on 30/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#import + +//! Project version number for SimpleXChat. +FOUNDATION_EXPORT double SimpleXChatVersionNumber; + +//! Project version string for SimpleXChat. +FOUNDATION_EXPORT const unsigned char SimpleXChatVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import diff --git a/apps/ios/SimpleX NSE/dummy.m b/apps/ios/SimpleXChat/dummy.m similarity index 89% rename from apps/ios/SimpleX NSE/dummy.m rename to apps/ios/SimpleXChat/dummy.m index adef54036..64fbc32dd 100644 --- a/apps/ios/SimpleX NSE/dummy.m +++ b/apps/ios/SimpleXChat/dummy.m @@ -1,8 +1,8 @@ // // dummy.m -// SimpleX NSE +// SimpleXChat // -// Created by Evgeny on 26/04/2022. +// Created by Evgeny on 30/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index ec3d6440d..b44ef5fb8 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -40,9 +40,18 @@ /* No comment provided by engineer. */ "**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок"; +/* No comment provided by engineer. */ +"**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." = "**Более конфиденциально**: проверять новые сообщения каждые 20 минут. Токен устройства будет отправлен на сервер уведомлений SimpleX Chat, но у сервера не будет информации о количестве контактов и сообщений."; + +/* No comment provided by engineer. */ +"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**Самый конфиденциальный**: не использовать сервер уведомлений SimpleX Chat, проверять сообщения периодически в фоновом режиме (зависит от того насколько часто вы используете приложение)."; + /* No comment provided by engineer. */ "**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; +/* No comment provided by engineer. */ +"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они."; + /* No comment provided by engineer. */ "**Scan QR code**: to connect to your contact in person or via video call." = "**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка."; @@ -52,6 +61,9 @@ /* No comment provided by engineer. */ "#secret#" = "#секрет#"; +/* No comment provided by engineer. */ +"%@" = "%@"; + /* No comment provided by engineer. */ "%@ / %@" = "%@ / %@"; @@ -79,6 +91,9 @@ /* No comment provided by engineer. */ "6" = "6"; +/* notification title */ +"A new contact" = "Новый контакт"; + /* No comment provided by engineer. */ "About SimpleX" = "О SimpleX"; @@ -158,21 +173,33 @@ /* No comment provided by engineer. */ "Cancel" = "Отменить"; +/* No comment provided by engineer. */ +"Chat archive" = "Архив чата"; + /* No comment provided by engineer. */ "Chat console" = "Консоль"; +/* No comment provided by engineer. */ +"Chat database" = "Архив чата"; + +/* No comment provided by engineer. */ +"Chat database deleted" = "Данные чата удалены"; + +/* No comment provided by engineer. */ +"Chat database imported" = "Архив чата импортирован"; + +/* No comment provided by engineer. */ +"Chat is running" = "Чат запущен"; + +/* No comment provided by engineer. */ +"Chat is stopped" = "Чат остановлен"; + /* No comment provided by engineer. */ "Chat with the developers" = "Соединиться с разработчиками"; /* back button to return to chats list */ "Chats" = "Чаты"; -/* No comment provided by engineer. */ -"Check messages" = "Проверять сообщения"; - -/* notification */ -"Checking new messages..." = "Проверяются новые сообщения..."; - /* No comment provided by engineer. */ "Choose file" = "Выбрать файл"; @@ -269,12 +296,18 @@ /* No comment provided by engineer. */ "contact has no e2e encryption" = "у контакта нет e2e шифрования"; +/* notification */ +"Contact hidden:" = "Контакт скрыт:"; + /* notification */ "Contact is connected" = "Соединение с контактом установлено"; /* No comment provided by engineer. */ "Contact is not connected yet!" = "Соединение еще не установлено!"; +/* No comment provided by engineer. */ +"Contact name" = "Имена контактов"; + /* No comment provided by engineer. */ "Copy" = "Скопировать"; @@ -293,9 +326,18 @@ /* No comment provided by engineer. */ "Create your profile" = "Создать профиль"; +/* No comment provided by engineer. */ +"Created on %@" = "Дата создания %@"; + /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; +/* No comment provided by engineer. */ +"Database export & import" = "Экспорт и импорт архива чата"; + +/* No comment provided by engineer. */ +"Database will be migrated when the app restarts" = "Данные чата будут мигрированы при перезапуске"; + /* No comment provided by engineer. */ "Decentralized" = "Децентрализованный"; @@ -308,6 +350,15 @@ /* No comment provided by engineer. */ "Delete address?" = "Удалить адрес?"; +/* No comment provided by engineer. */ +"Delete archive" = "Удалить архив"; + +/* No comment provided by engineer. */ +"Delete chat archive?" = "Удалить архив чата?"; + +/* No comment provided by engineer. */ +"Delete chat profile?" = "Удалить профиль?"; + /* No comment provided by engineer. */ "Delete contact" = "Удалить контакт"; @@ -317,6 +368,9 @@ /* No comment provided by engineer. */ "Delete contact?" = "Удалить контакт?"; +/* No comment provided by engineer. */ +"Delete database" = "Удалить данные чата"; + /* No comment provided by engineer. */ "Delete for everyone" = "Удалить для всех"; @@ -329,6 +383,12 @@ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; +/* No comment provided by engineer. */ +"Delete old database" = "Удалить предыдущую версию данных"; + +/* No comment provided by engineer. */ +"Delete old database?" = "Удалить предыдущую версию данных?"; + /* No comment provided by engineer. */ "Delete pending connection" = "Удалить соединение"; @@ -356,6 +416,9 @@ /* No comment provided by engineer. */ "Display name" = "Имя профиля"; +/* No comment provided by engineer. */ +"Do it later" = "Отложить"; + /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "Не используйте SimpleX для экстренных звонков"; @@ -369,7 +432,16 @@ "Edit" = "Редактировать"; /* No comment provided by engineer. */ -"Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)"; +"Enable" = "Включить"; + +/* No comment provided by engineer. */ +"Enable instant notifications?" = "Включить мгновенные уведомления?"; + +/* No comment provided by engineer. */ +"Enable notifications" = "Включить уведомления"; + +/* No comment provided by engineer. */ +"Enable periodic notifications?" = "Включить периодические уведомления?"; /* authentication reason */ "Enable SimpleX Lock" = "Включить блокировку SimpleX"; @@ -381,23 +453,59 @@ "ended call %@" = "завершённый звонок %@"; /* No comment provided by engineer. */ -"Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:"; +"Error accessing database file" = "Ошибка при доступе к данным чата"; + +/* No comment provided by engineer. */ +"Error deleting chat database" = "Ошибка при удалении данных чата"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Ошибка при удалении данных чата"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Ошибка при удалении предыдущей версии данных чата"; /* No comment provided by engineer. */ "Error deleting token" = "Ошибка удаления токена"; /* No comment provided by engineer. */ -"Error registering token" = "Ошибка регистрации токена"; +"Error enabling notifications" = "Ошибка при включении уведомлений"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Ошибка при экспорте архива чата"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Ошибка при импорте архива чата"; /* No comment provided by engineer. */ "Error saving SMP servers" = "Ошибка при сохранении SMP серверов"; +/* No comment provided by engineer. */ +"Error starting chat" = "Ошибка при запуске чата"; + +/* No comment provided by engineer. */ +"Error stopping chat" = "Ошибка при остановке чата"; + /* No comment provided by engineer. */ "Error: %@" = "Ошибка: %@"; +/* No comment provided by engineer. */ +"Error: no database file" = "Ошибка: данные чата не найдены"; + /* No comment provided by engineer. */ "Error: URL is invalid" = "Ошибка: неверная ссылка"; +/* No comment provided by engineer. */ +"Export database" = "Экспорт архива чата"; + +/* No comment provided by engineer. */ +"Export error:" = "Ошибка при экспорте:"; + +/* No comment provided by engineer. */ +"Exported database archive." = "Архив чата экспортирован."; + +/* No comment provided by engineer. */ +"Exporting database archive..." = "Архив чата экспортируется..."; + /* No comment provided by engineer. */ "File will be received when your contact is online, please wait or check later!" = "Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!"; @@ -407,9 +515,15 @@ /* No comment provided by engineer. */ "Group deletion is not supported" = "Удаление групп не поддерживается"; +/* notification */ +"Group message:" = "Групповое сообщение:"; + /* No comment provided by engineer. */ "Help" = "Помощь"; +/* No comment provided by engineer. */ +"Hidden" = "Скрытое"; + /* No comment provided by engineer. */ "How it works" = "Как это работает"; @@ -434,6 +548,9 @@ /* No comment provided by engineer. */ "If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку."; +/* No comment provided by engineer. */ +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Если сейчас вам нужно использовать чат, нажмите **Отложить** внизу (вы сможете мигрировать данные чата при следующем запуске приложения)."; + /* No comment provided by engineer. */ "Ignore" = "Не отвечать"; @@ -443,6 +560,15 @@ /* No comment provided by engineer. */ "Immune to spam and abuse" = "Защищен от спама"; +/* No comment provided by engineer. */ +"Import" = "Импортировать"; + +/* No comment provided by engineer. */ +"Import chat database?" = "Импортировать архив чата?"; + +/* No comment provided by engineer. */ +"Import database" = "Импорт архива чата"; + /* No comment provided by engineer. */ "In person or via a video call – the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение"; @@ -458,6 +584,9 @@ /* No comment provided by engineer. */ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"Instantly" = "Мгновенно"; + /* No comment provided by engineer. */ "Invalid connection link" = "Ошибка в ссылке контакта"; @@ -497,6 +626,24 @@ /* No comment provided by engineer. */ "Message delivery error" = "Ошибка доставки сообщения"; +/* notification */ +"message received" = "получено сообщение"; + +/* No comment provided by engineer. */ +"Message text" = "Текст сообщения"; + +/* No comment provided by engineer. */ +"Migrating database archive..." = "Данные чата перемещаются..."; + +/* No comment provided by engineer. */ +"Migration error:" = "Ошибка при перемещении данных:"; + +/* No comment provided by engineer. */ +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Ошибка при перемещении данных. Нажмите **Отложить** внизу чтобы продолжить использовать предыдущую версию данных. Пожалуйста, сообщите об этой ошибке разработчикам через чат или email [chat@simplex.chat](mailto:chat@simplex.chat)."; + +/* No comment provided by engineer. */ +"Migration is completed" = "Перемещение данных завершено"; + /* call status */ "missed call" = "пропущенный звонок"; @@ -506,15 +653,39 @@ /* notification */ "New contact request" = "Новый запрос на соединение"; +/* notification */ +"New contact:" = "Новый контакт:"; + +/* No comment provided by engineer. */ +"New database archive" = "Новый архив чата"; + +/* notification */ +"new message" = "новое сообщение"; + /* notification */ "New message" = "Новое сообщение"; +/* No comment provided by engineer. */ +"No device token!" = "Отсутствует токен устройства!"; + /* No comment provided by engineer. */ "no e2e encryption" = "нет e2e шифрования"; +/* No comment provided by engineer. */ +"Notifications" = "Уведомления"; + /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; +/* No comment provided by engineer. */ +"Off (Local)" = "Выключить (Локальные)"; + +/* No comment provided by engineer. */ +"Old database" = "Предыдущая версия данных чата"; + +/* No comment provided by engineer. */ +"Old database archive" = "Старый архив чата"; + /* No comment provided by engineer. */ "One-time invitation link" = "Одноразовая ссылка"; @@ -536,6 +707,9 @@ /* No comment provided by engineer. */ "Paste" = "Вставить"; +/* No comment provided by engineer. */ +"Paste image" = "Вставить изображение"; + /* No comment provided by engineer. */ "Paste received link" = "Вставить полученную ссылку"; @@ -551,6 +725,9 @@ /* No comment provided by engineer. */ "People can connect to you only via the links you share." = "С вами можно соединиться только через созданные вами ссылки."; +/* No comment provided by engineer. */ +"Periodically" = "Периодически"; + /* No comment provided by engineer. */ "Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку."; @@ -558,7 +735,7 @@ "Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; /* No comment provided by engineer. */ -"Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." = "Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это)"; +"Please restart the app and migrate the database to enable push notifications." = "Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений."; /* No comment provided by engineer. */ "Privacy & security" = "Конфиденциальность"; @@ -569,6 +746,9 @@ /* No comment provided by engineer. */ "Profile image" = "Аватар"; +/* No comment provided by engineer. */ +"Push notifications" = "Доставка уведомлений"; + /* No comment provided by engineer. */ "Read" = "Прочитано"; @@ -584,6 +764,9 @@ /* No comment provided by engineer. */ "received confirmation…" = "получено подтверждение…"; +/* notification */ +"Received file event" = "Загрузка файла"; + /* reject incoming call via notification */ "Reject" = "Отклонить"; @@ -600,7 +783,13 @@ "Reply" = "Ответить"; /* No comment provided by engineer. */ -"Retry" = "Повторить"; +"Restart the app to create a new chat profile" = "Перезапустите приложение, чтобы создать новый профиль."; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Перезапустите приложение, чтобы использовать импортированные данные чата."; + +/* No comment provided by engineer. */ +"Run chat" = "Запустить chat"; /* No comment provided by engineer. */ "Save" = "Сохранить"; @@ -608,6 +797,9 @@ /* No comment provided by engineer. */ "Save (and notify contacts)" = "Сохранить (и уведомить контакты)"; +/* No comment provided by engineer. */ +"Save archive" = "Сохранить архив"; + /* No comment provided by engineer. */ "Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены"; @@ -623,6 +815,15 @@ /* No comment provided by engineer. */ "Send link previews" = "Отправлять картинки ссылок"; +/* No comment provided by engineer. */ +"Send notifications" = "Отправлять уведомления"; + +/* No comment provided by engineer. */ +"Send notifications:" = "Отправлять уведомления:"; + +/* notification */ +"Sent file event" = "Отправка файла"; + /* No comment provided by engineer. */ "Server connected" = "Установлено соединение с сервером"; @@ -641,6 +842,12 @@ /* No comment provided by engineer. */ "Show pending connections" = "Показать ожидаемые соединения"; +/* No comment provided by engineer. */ +"Show preview" = "Показывать уведомления"; + +/* notification */ +"SimpleX encrypted message or connection event" = "SimpleX: зашифрованное сообщение или соединение контакта"; + /* No comment provided by engineer. */ "SimpleX Lock" = "Блокировка SimpleX"; @@ -653,9 +860,33 @@ /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; +/* No comment provided by engineer. */ +"SMP servers (one per line)" = "SMP серверы (один на строке)"; + +/* notification title */ +"Somebody" = "Контакт"; + +/* No comment provided by engineer. */ +"Start chat" = "Запустить чат"; + +/* No comment provided by engineer. */ +"Start migration" = "Запустить перемещение данных"; + /* No comment provided by engineer. */ "starting…" = "инициализация…"; +/* No comment provided by engineer. */ +"Stop" = "Остановить"; + +/* No comment provided by engineer. */ +"Stop chat to enable database actions" = "Остановите чат, чтобы разблокировать операции с архивом чата"; + +/* No comment provided by engineer. */ +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен."; + +/* No comment provided by engineer. */ +"Stop chat?" = "Остановить чат?"; + /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -674,27 +905,36 @@ /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; -/* No comment provided by engineer. */ -"The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server." = "Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения.\n*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat."; - /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Подтвержденное соединение будет отменено!"; /* No comment provided by engineer. */ "The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!"; +/* No comment provided by engineer. */ +"The created archive is available via app Settings / Database / Old database archive." = "Созданный архив доступен через Настройки приложения."; + /* No comment provided by engineer. */ "The microphone does not work when the app is in the background." = "Микрофон не работает, когда приложение в фоновом режиме."; /* No comment provided by engineer. */ "The next generation of private messaging" = "Новое поколение приватных сообщений"; +/* No comment provided by engineer. */ +"The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; + /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Профиль отправляется только вашим контактам."; /* No comment provided by engineer. */ "The sender will NOT be notified" = "Отправитель не будет уведомлён"; +/* No comment provided by engineer. */ +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; + +/* notification title */ +"this contact" = "этот контакт"; + /* No comment provided by engineer. */ "To ask any questions and to receive SimpleX Chat updates." = "Чтобы задать вопросы и получать уведомления о SimpleX Chat."; @@ -716,18 +956,30 @@ /* No comment provided by engineer. */ "To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки."; +/* No comment provided by engineer. */ +"To support instant push notifications the chat database has to be migrated." = "Для поддержки мгновенный доставки уведомлений данные чата должны быть перемещены."; + /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@)."; /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта."; +/* No comment provided by engineer. */ +"Turn off" = "Выключить"; + +/* No comment provided by engineer. */ +"Turn off notifications?" = "Выключить уведомления?"; + /* No comment provided by engineer. */ "Turn on" = "Включить"; /* No comment provided by engineer. */ "Unexpected error: %@" = "Неожиданная ошибка: %@"; +/* No comment provided by engineer. */ +"Unexpected migration state" = "Неожиданная ошибка при перемещении данных чата"; + /* connection info */ "unknown" = "неизвестно"; @@ -737,6 +989,9 @@ /* authentication reason */ "Unlock" = "Разблокировать"; +/* No comment provided by engineer. */ +"Use chat" = "Использовать чат"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; @@ -794,9 +1049,15 @@ /* notification body */ "You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; +/* No comment provided by engineer. */ +"You can set lock screen notification preview via settings." = "Вы можете установить просмотр уведомлений на экране блокировки в настройках."; + /* No comment provided by engineer. */ "You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." = "Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились."; +/* No comment provided by engineer. */ +"You can start chat via app Settings / Database or by restarting the app" = "Вы можете запустить чат через Настройки приложения или перезапустив приложение."; + /* No comment provided by engineer. */ "You can use markdown to format messages:" = "Вы можете форматировать сообщения:"; @@ -809,6 +1070,9 @@ /* No comment provided by engineer. */ "You invited your contact" = "Вы пригласили ваш контакт"; +/* No comment provided by engineer. */ +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Вы должны всегда использовать самую новую версию данных чата, ТОЛЬКО на одном устройстве, инача вы можете перестать получать сообщения от каких то контактов."; + /* chat list item description */ "you shared one-time link" = "вы создали ссылку"; @@ -827,6 +1091,9 @@ /* No comment provided by engineer. */ "Your chat address" = "Ваш SimpleX адрес"; +/* No comment provided by engineer. */ +"Your chat database" = "Данные чата"; + /* No comment provided by engineer. */ "Your chat profile" = "Ваш профиль"; @@ -845,6 +1112,9 @@ /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт отправил файл, размер которого превышает максимальный размер (%@)."; +/* No comment provided by engineer. */ +"Your current chat database will be DELETED and REPLACED with the imported one." = "Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными."; + /* No comment provided by engineer. */ "Your privacy" = "Конфиденциальность"; diff --git a/cabal.project b/cabal.project index d07117a66..2f4de58dd 100644 --- a/cabal.project +++ b/cabal.project @@ -1,9 +1,11 @@ packages: . +constraints: zip +disable-bzip2 +disable-zstd + source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 964daf5442e1069634762450bc28cfd69a2968a1 + tag: e75846aa38dd26fa70e3faa38ec780edf245e022 source-repository-package type: git diff --git a/docs/rfcs/2022-06-03-portable-archive.md b/docs/rfcs/2022-06-03-portable-archive.md new file mode 100644 index 000000000..7f83df170 --- /dev/null +++ b/docs/rfcs/2022-06-03-portable-archive.md @@ -0,0 +1,25 @@ +# Portable archive file format + +## Problems + +- database migration for notifications support +- export and import of the database + +The first problem could have been solved in an ad hoc way, but it may cause data loss, so the proposal is to have migration performed via export/import steps. + +Out of scope of this doc - what will be the UX for database migration. It may be fully automatic, via code, with zero user interactions, or it could be via step by step wizard - irrespective of this choice it would include export and import steps. + +# Proposal + +Implement creating archive file and restoring from the archive in Haskell, application would only provide a source and target folders, respectively + +Archive files structure: + +- simplex_v1_chat.db +- simplex_v1_agent.db +- simplex_v1_files + - ... + +Archive file name (includes UTC time): + +simplex-chat.YYYY-MM-DDTHH:MM:SSZ.zip diff --git a/install.sh b/install.sh index 1e53c00bf..4518967e5 100755 --- a/install.sh +++ b/install.sh @@ -77,7 +77,7 @@ fi chmod +x $BIN_PATH -echo "$APP_NAME installed sucesfully!" +echo "$APP_NAME installed successfully!" if [ -z "$(command -v $APP_NAME)" ]; then if [ -n "$($SHELL -c 'echo $ZSH_VERSION')" ]; then diff --git a/package.yaml b/package.yaml index 9e097678f..6eb0b0574 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 2.2.0 +version: 3.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -31,7 +31,7 @@ dependencies: - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - simple-logger == 0.1.* - - simplexmq >= 1.1 && < 3.0 + - simplexmq >= 3.0 - sqlite-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* @@ -39,6 +39,7 @@ dependencies: - time == 1.9.* - unliftio == 0.2.* - unliftio-core == 0.2.* + - zip == 1.7.* library: source-dirs: src @@ -77,6 +78,7 @@ tests: dependencies: - simplex-chat - async == 2.2.* + - deepseq == 1.4.* - hspec == 2.7.* - network == 3.1.* - stm == 2.5.* diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index 057d2f709..880a7eaa3 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/webrtc", - "version": "0.0.5", + "version": "0.1.0", "description": "WebRTC call in browser and webview for SimpleX Chat clients", "main": "dist/call.js", "types": "dist/call.d.ts", diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index d9a4bcccf..37ddc02b6 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -217,8 +217,8 @@ const processCommand = (function () { } const defaultIceServers: RTCIceServer[] = [ - {urls: ["stun:stun.simplex.chat:5349"]}, - {urls: ["turn:turn.simplex.chat:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z"}, + {urls: ["stun:stun.simplex.im:5349"]}, + {urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z"}, ] function getCallConfig(encodedInsertableStreams: boolean, iceServers?: RTCIceServer[], relay?: boolean): CallConfig { diff --git a/scripts/nix/README.md b/scripts/nix/README.md new file mode 100644 index 000000000..511703271 --- /dev/null +++ b/scripts/nix/README.md @@ -0,0 +1,15 @@ +# Updating nix package config + +1. Install `nix`, `gawk` and `jq`. + +2. Start nix-shell from repo root: + +```sh +nix-shell -p nix-prefetch-git +``` + +3. Run in nix shell: + +```sh +gawk -f ./scripts/nix/update-sha256.awk cabal.project > ./scripts/nix/sha256map.nix +``` diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 984e8afc7..45bf4a4a8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."964daf5442e1069634762450bc28cfd69a2968a1" = "1vsbiawqlvi6v48ws2rmg5cmp5qphnry3ymg6458p2w8wdm2gsng"; + "https://github.com/simplex-chat/simplexmq.git"."e75846aa38dd26fa70e3faa38ec780edf245e022" = "1mjr5bpjnz6pw9w4qy2r548xlgw89rxbmj36zb4vwq4jghj3gmcz"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 588beb8ca..292e4dffd 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 2.2.0 +version: 3.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -20,6 +20,7 @@ extra-source-files: library exposed-modules: Simplex.Chat + Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Call Simplex.Chat.Controller @@ -38,6 +39,8 @@ library Simplex.Chat.Migrations.M20220321_chat_item_edited Simplex.Chat.Migrations.M20220404_files_status_fields Simplex.Chat.Migrations.M20220514_profiles_user_id + Simplex.Chat.Migrations.M20220626_auto_reply + Simplex.Chat.Migrations.M20220702_calls Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol @@ -75,7 +78,7 @@ library , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , simple-logger ==0.1.* - , simplexmq >=1.1 && <3.0 + , simplexmq >=3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -83,6 +86,7 @@ library , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , zip ==1.7.* default-language: Haskell2010 executable simplex-bot @@ -113,7 +117,7 @@ executable simplex-bot , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=1.1 && <3.0 + , simplexmq >=3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -121,6 +125,7 @@ executable simplex-bot , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , zip ==1.7.* default-language: Haskell2010 executable simplex-bot-advanced @@ -151,7 +156,7 @@ executable simplex-bot-advanced , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=1.1 && <3.0 + , simplexmq >=3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -159,6 +164,7 @@ executable simplex-bot-advanced , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , zip ==1.7.* default-language: Haskell2010 executable simplex-chat @@ -191,7 +197,7 @@ executable simplex-chat , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=1.1 && <3.0 + , simplexmq >=3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -200,6 +206,7 @@ executable simplex-chat , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* + , zip ==1.7.* default-language: Haskell2010 test-suite simplex-chat-test @@ -227,6 +234,7 @@ test-suite simplex-chat-test , composition ==1.0.* , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , deepseq ==1.4.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -239,7 +247,7 @@ test-suite simplex-chat-test , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=1.1 && <3.0 + , simplexmq >=3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -247,4 +255,5 @@ test-suite simplex-chat-test , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , zip ==1.7.* default-language: Haskell2010 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e91a4c418..7a9f901c8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5,6 +5,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -27,6 +28,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) +import Data.Either (fromRight) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) @@ -39,8 +41,11 @@ import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) +import Data.Time.Clock.System (SystemTime, systemToUTCTime) import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime) import Data.Word (Word32) +import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Markdown @@ -49,20 +54,18 @@ import Simplex.Chat.Options (ChatOpts (..), smpServersP) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Types -import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM, whenM) +import Simplex.Chat.Util (safeDecodeUtf8, uncurry3) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), defaultAgentConfig) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Notifications.Client (NtfServer) -import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider (..)) import Simplex.Messaging.Parsers (base64P, parseAll) -import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) +import Simplex.Messaging.Protocol (ErrorType (..), MsgBody, MsgFlags (..), NtfServer) import qualified Simplex.Messaging.Protocol as SMP import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (tryError, (<$?>)) +import Simplex.Messaging.Util (ifM, liftEitherError, tryError, unlessM, whenM, (<$?>)) import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) @@ -81,10 +84,8 @@ defaultChatConfig = defaultAgentConfig { tcpPort = undefined, -- agent does not listen to TCP dbFile = "simplex_v1", - dbPoolSize = 1, yesToMigrations = False }, - dbPoolSize = 1, yesToMigrations = False, defaultServers = InitialAgentServers {smp = _defaultSMPServers, ntf = _defaultNtfServers}, tbqSize = 64, @@ -103,7 +104,7 @@ _defaultSMPServers = ] _defaultNtfServers :: [NtfServer] -_defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] +_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"] maxImageSize :: Integer maxImageSize = 236700 @@ -134,34 +135,46 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty filesFolder <- newTVarIO Nothing - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} + chatStoreChanged <- newTVarIO False + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} where resolveServers :: InitialAgentServers -> IO InitialAgentServers resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of Just smpServers' -> pure ss {smp = smpServers'} _ -> case user of Just usr -> do - userSmpServers <- getSMPServers chatStore usr + userSmpServers <- withTransaction chatStore (`getSMPServers` usr) pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers} _ -> pure ss -runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -runChatController = race_ notificationSubscriber . agentSubscriber - -startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m (Async ()) -startChatController user = do +startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m (Async ()) +startChatController user subConns = do + asks smpAgent >>= resumeAgentClient + restoreCalls user s <- asks agentAsync - readTVarIO s >>= maybe (start s) pure + readTVarIO s >>= maybe (start s) (pure . fst) where start s = do - a <- async $ runChatController user - atomically . writeTVar s $ Just a - pure a + a1 <- async $ race_ notificationSubscriber agentSubscriber + a2 <- + if subConns + then Just <$> async (subscribeUserConnections subscribeConnection user) + else pure Nothing + atomically . writeTVar s $ Just (a1, a2) + pure a1 + +restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +restoreCalls user = do + savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) + let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls + calls <- asks currentCalls + atomically $ writeTVar calls callsMap stopChatController :: MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s} = do disconnectAgentClient smpAgent - readTVarIO s >>= mapM_ uninterruptibleCancel >> atomically (writeTVar s Nothing) + readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) + atomically (writeTVar s Nothing) withLock :: MonadUnliftIO m => TMVar () -> m a -> m a withLock lock = @@ -188,29 +201,43 @@ processChatCommand = \case CreateActiveUser p -> do u <- asks currentUser whenM (isJust <$> readTVarIO u) $ throwChatError CEActiveUserExists - user <- withStore $ \st -> createUser st p True + user <- withStore $ \db -> createUser db p True atomically . writeTVar u $ Just user pure $ CRActiveUser user - StartChat -> withUser' $ \user -> + StartChat subConns -> withUser' $ \user -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> startChatController user $> CRChatStarted + _ -> + ifM + (asks chatStoreChanged >>= readTVarIO) + (throwChatError CEChatStoreChanged) + (startChatController user subConns $> CRChatStarted) + APIStopChat -> do + ask >>= stopChatController + pure CRChatStopped + APIActivateChat -> do + withUser $ \user -> restoreCalls user + withAgent activateAgent $> CRCmdOk + APISuspendChat t -> withAgent (`suspendAgent` t) $> CRCmdOk ResubscribeAllConnections -> withUser (subscribeUserConnections resubscribeConnection) $> CRCmdOk - SetFilesFolder filesFolder' -> withUser $ \_ -> do + SetFilesFolder filesFolder' -> do createDirectoryIfMissing True filesFolder' ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' pure CRCmdOk - APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore $ \st -> getChatPreviews st user withPCC) + APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk + APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk + APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk + APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore' $ \db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination -> withUser $ \user -> case cType of - CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) - CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) + CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination) + CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId + ct@Contact {localDisplayName = c} <- withStore $ \db -> getContact db userId chatId (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ msg <- sendDirectContactMessage ct (XMsgNew msgContainer) @@ -226,7 +253,7 @@ processChatCommand = \case (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} - fileId <- withStore $ \st -> createSndFileTransfer st userId ct file fileInvitation agentConnId chSize + fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} pure $ Just (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) @@ -234,7 +261,7 @@ processChatCommand = \case Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- - withStore $ \st -> getDirectChatItem st userId chatId quotedItemId + withStore $ \db -> getDirectChatItem db userId chatId quotedItemId (origQmc, qd, sent) <- quoteData ciContent let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent origQmc file @@ -246,7 +273,7 @@ processChatCommand = \case quoteData (CIRcvMsgContent qmc) = pure (qmc, CIQDirectRcv, False) quoteData _ = throwChatError CEInvalidQuote CTGroup -> do - Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \st -> getGroup st user chatId + Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer gInfo (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership @@ -262,7 +289,7 @@ processChatCommand = \case (fileSize, chSize) <- checkSndFile file let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo file fileInvitation chSize + fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} pure $ Just (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) @@ -270,7 +297,7 @@ processChatCommand = \case Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- - withStore $ \st -> getGroupChatItem st user chatId quotedItemId + withStore $ \db -> getGroupChatItem db user chatId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} qmc = quoteContent origQmc file @@ -307,27 +334,27 @@ processChatCommand = \case unzipMaybe t = (fst <$> t, snd <$> t) APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId + (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId case ci of CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do case (ciContent, itemSharedMsgId) of (CISndMsgContent _, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc) - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) $ Just msgId + updCi <- withStore $ \db -> updateDirectChatItem db userId contactId itemId (CISndMsgContent mc) $ Just msgId setActive $ ActiveC c pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do - Group gInfo@GroupInfo {groupId, localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId + Group gInfo@GroupInfo {groupId, localDisplayName = gName, membership} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - ci <- withStore $ \st -> getGroupChatItem st user chatId itemId + ci <- withStore $ \db -> getGroupChatItem db user chatId itemId case ci of CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do case (ciContent, itemSharedMsgId) of (CISndMsgContent _, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgUpdate itemSharedMId mc) - updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CISndMsgContent mc) msgId + updCi <- withStore $ \db -> updateGroupChatItem db user groupId itemId (CISndMsgContent mc) msgId setActive $ ActiveG gName pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) updCi _ -> throwChatError CEInvalidChatItemUpdate @@ -336,33 +363,33 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId + (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do deleteCIFile user file - toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal + toCi <- withStore $ \db -> deleteDirectChatItemLocal db userId ct itemId CIDMInternal pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do void $ sendDirectContactMessage ct (XMsgDel itemSharedMId) deleteCIFile user file - toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMBroadcast + toCi <- withStore $ \db -> deleteDirectChatItemLocal db userId ct itemId CIDMBroadcast setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete -- TODO for group integrity and pending messages, group items and messages are set to "deleted"; maybe a different workaround is needed CTGroup -> do - Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId + Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId + CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \db -> getGroupChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do deleteCIFile user file - toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId + toCi <- withStore $ \db -> deleteGroupChatItemInternal db user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) deleteCIFile user file - toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId + toCi <- withStore $ \db -> deleteGroupChatItemSndBroadcast db user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete @@ -376,81 +403,84 @@ processChatCommand = \case cancelFile user fileInfo withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of - CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk - CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk + CTDirect -> withStore' (\db -> updateDirectChatItemsRead db chatId fromToIds) $> CRCmdOk + CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk CTContactRequest -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported" APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId - withStore (\st -> getContactGroupNames st userId ct) >>= \case + ct@Contact {localDisplayName} <- withStore $ \db -> getContact db userId chatId + withStore' (\db -> getContactGroupNames db userId ct) >>= \case [] -> do - filesInfo <- withStore $ \st -> getContactFileInfo st userId ct - conns <- withStore $ \st -> getContactConnections st userId ct + filesInfo <- withStore' $ \db -> getContactFileInfo db userId ct + conns <- withStore $ \db -> getContactConnections db userId ct withChatLock . procCmd $ do forM_ filesInfo $ \fileInfo -> do cancelFile user fileInfo withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteContact st userId ct + -- two functions below are called in separate transactions to prevent crashes on android + -- (possibly, race condition on integrity check?) + withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct + withStore' $ \db -> deleteContact db userId ct unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs CTContactConnection -> withChatLock . procCmd $ do - conn <- withStore $ \st -> getPendingContactConnection st userId chatId + conn <- withStore $ \db -> getPendingContactConnection db userId chatId withAgent $ \a -> deleteConnection a $ aConnId' conn - withStore $ \st -> deletePendingContactConnection st userId chatId + withStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted conn CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withStore $ \st -> getContact st userId chatId - ciIdsAndFileInfo <- withStore $ \st -> getContactChatItemIdsAndFileInfo st user chatId + ct <- withStore $ \db -> getContact db userId chatId + ciIdsAndFileInfo <- withStore' $ \db -> getContactChatItemIdsAndFileInfo db user chatId forM_ ciIdsAndFileInfo $ \(itemId, _, fileInfo_) -> do forM_ fileInfo_ $ \fileInfo -> do cancelFile user fileInfo withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo - void $ withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal + void $ withStore $ \db -> deleteDirectChatItemLocal db userId ct itemId CIDMInternal ct' <- case ciIdsAndFileInfo of [] -> pure ct _ -> do let (_, lastItemTs, _) = last ciIdsAndFileInfo - withStore (\st -> updateContactTs st user ct lastItemTs) + withStore' $ \db -> updateContactTs db user ct lastItemTs pure (ct :: Contact) {updatedAt = lastItemTs} pure $ CRChatCleared (AChatInfo SCTDirect (DirectChat ct')) CTGroup -> do - gInfo <- withStore $ \st -> getGroupInfo st user chatId - ciIdsAndFileInfo <- withStore $ \st -> getGroupChatItemIdsAndFileInfo st user chatId + gInfo <- withStore $ \db -> getGroupInfo db user chatId + ciIdsAndFileInfo <- withStore' $ \db -> getGroupChatItemIdsAndFileInfo db user chatId forM_ ciIdsAndFileInfo $ \(itemId, _, itemDeleted, fileInfo_) -> unless itemDeleted $ do forM_ fileInfo_ $ \fileInfo -> do cancelFile user fileInfo withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo - void $ withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId + void $ withStore $ \db -> deleteGroupChatItemInternal db user gInfo itemId gInfo' <- case ciIdsAndFileInfo of [] -> pure gInfo _ -> do let (_, lastItemTs, _, _) = last ciIdsAndFileInfo - withStore (\st -> updateGroupTs st user gInfo lastItemTs) + withStore' $ \db -> updateGroupTs db user gInfo lastItemTs pure (gInfo :: GroupInfo) {updatedAt = lastItemTs} pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo')) CTContactConnection -> pure $ chatCmdError "not supported" CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do - cReq <- withStore $ \st -> getContactRequest st userId connReqId + cReq <- withStore $ \db -> getContactRequest db userId connReqId procCmd $ CRAcceptingContactRequest <$> acceptContactRequest user cReq APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- - withStore $ \st -> - getContactRequest st userId connReqId - `E.finally` deleteContactRequest st userId connReqId + withStore $ \db -> + getContactRequest db userId connReqId + `E.finally` liftIO (deleteContactRequest db userId connReqId) withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do -- party initiating call - ct <- withStore $ \st -> getContact st userId contactId + ct <- withStore $ \db -> getContact db userId contactId calls <- asks currentCalls withChatLock $ do callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) @@ -459,20 +489,20 @@ processChatCommand = \case callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} msg <- sendDirectContactMessage ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing - let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci pure CRCmdOk SendCallInvitation cName callType -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName + contactId <- withStore $ \db -> getContactIdByName db userId cName processChatCommand $ APISendCallInvitation contactId callType APIRejectCall contactId -> -- party accepting call withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of CallInvitationReceived {} -> do let aciContent = ACIContent SMDRcv $ CIRcvCall CISCallRejected 0 - withStore $ \st -> updateDirectChatItemsRead st contactId $ Just (chatItemId, chatItemId) + withStore' $ \db -> updateDirectChatItemsRead db contactId $ Just (chatItemId, chatItemId) updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> @@ -484,7 +514,7 @@ processChatCommand = \case callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer) - withStore $ \st -> updateDirectChatItemsRead st contactId $ Just (chatItemId, chatItemId) + withStore' $ \db -> updateDirectChatItemsRead db contactId $ Just (chatItemId, chatItemId) updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState @@ -518,18 +548,35 @@ processChatCommand = \case SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId) updateCallItemStatus userId ct call WCSDisconnected $ Just msgId pure Nothing + APIGetCallInvitations -> withUser $ \User {userId} -> do + calls <- asks currentCalls >>= readTVarIO + let invs = mapMaybe callInvitation $ M.elems calls + CRCallInvitations <$> mapM (rcvCallInvitation userId) invs + where + callInvitation Call {contactId, callState, callTs} = case callState of + CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey) + _ -> Nothing + rcvCallInvitation userId (contactId, callTs, peerCallType, sharedKey) = do + contact <- withStore (\db -> getContact db userId contactId) + pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs} APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \userId ct call -> updateCallItemStatus userId ct call receivedStatus Nothing $> Just call APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text - APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) - APIVerifyToken token code nonce -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token code nonce) $> CRCmdOk - APIIntervalNofication token interval -> withUser $ \_ -> withAgent (\a -> enableNtfCron a token interval) $> CRCmdOk + APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken + APIRegisterToken token mode -> CRNtfTokenStatus <$> withUser (\_ -> withAgent $ \a -> registerNtfToken a token mode) + APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk - GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) + APIGetNtfMessage nonce encNtfInfo -> withUser $ \user -> do + (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo + let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs + msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta + connEntity <- withStore (\db -> Just <$> getConnectionEntity db user ntfConnId) `catchError` \_ -> pure Nothing + pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages} + GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do - withStore $ \st -> overwriteSMPServers st user smpServers + withStore $ \db -> overwriteSMPServers db user smpServers ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) pure CRCmdOk @@ -537,12 +584,12 @@ processChatCommand = \case Welcome -> withUser $ pure . CRWelcome AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) - conn <- withStore $ \st -> createDirectConnection st userId connId ConnNew + conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew toView $ CRNewContactConnection conn pure $ CRInvitation cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile - conn <- withStore $ \st -> createDirectConnection st userId connId ConnJoined + conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined toView $ CRNewContactConnection conn pure CRSentConfirmation Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> @@ -551,39 +598,39 @@ processChatCommand = \case ConnectSimplex -> withUser $ \User {userId, profile} -> connectViaContact userId adminContactReq profile DeleteContact cName -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName + contactId <- withStore $ \db -> getContactIdByName db userId cName processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) ClearContact cName -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName + contactId <- withStore $ \db -> getContactIdByName db userId cName processChatCommand $ APIClearChat (ChatRef CTDirect contactId) - ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user) + ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user) CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) - withStore $ \st -> createUserContactLink st userId connId cReq + withStore $ \db -> createUserContactLink db userId connId cReq pure $ CRUserContactLinkCreated cReq DeleteMyAddress -> withUser $ \User {userId} -> withChatLock $ do - conns <- withStore $ \st -> getUserContactLinkConnections st userId + conns <- withStore (`getUserContactLinkConnections` userId) procCmd $ do withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteUserContactLink st userId + withStore' (`deleteUserContactLink` userId) pure CRUserContactLinkDeleted ShowMyAddress -> withUser $ \User {userId} -> - uncurry CRUserContactLink <$> withStore (`getUserContactLink` userId) - AddressAutoAccept onOff -> withUser $ \User {userId} -> do - uncurry CRUserContactLinkUpdated <$> withStore (\st -> updateUserContactLinkAutoAccept st userId onOff) + uncurry3 CRUserContactLink <$> withStore (`getUserContactLink` userId) + AddressAutoAccept onOff msgContent -> withUser $ \User {userId} -> do + uncurry3 CRUserContactLinkUpdated <$> withStore (\db -> updateUserContactLinkAutoAccept db userId onOff msgContent) AcceptContact cName -> withUser $ \User {userId} -> do - connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIAcceptContact connReqId RejectContact cName -> withUser $ \User {userId} -> do - connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIRejectContact connReqId SendMessage chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg processChatCommand . APISendMessage chatRef $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do - contacts <- withStore (`getUserContacts` user) + contacts <- withStore' (`getUserContacts` user) withChatLock . procCmd $ do let mc = MCText $ safeDecodeUtf8 msg cts = filter isReady contacts @@ -596,8 +643,8 @@ processChatCommand = \case `catchError` (toView . CRChatError) CRBroadcastSent mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName - quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) + contactId <- withStore $ \db -> getContactIdByName db userId cName + quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand . APISendMessage (ChatRef CTDirect contactId) $ ComposedMessage Nothing (Just quotedItemId) mc DeleteMessage chatName deletedMsg -> withUser $ \user -> do @@ -611,10 +658,10 @@ processChatCommand = \case processChatCommand $ APIUpdateChatItem chatRef editedItemId mc NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg - CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) + CRGroupCreated <$> withStore (\db -> createNewGroup db gVar user gProfile) AddMember gName cName memRole -> withUser $ \user@User {userId} -> withChatLock $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withStore $ \st -> (,) <$> getGroupByName st user gName <*> getContactByName st userId cName + (group, contact) <- withStore $ \db -> (,) <$> getGroupByName db user gName <*> getContactByName db userId cName let Group gInfo@GroupInfo {groupId, groupProfile, membership} members = group GroupMember {memberRole = userRole, memberId = userMemberId} = membership when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole @@ -629,26 +676,26 @@ processChatCommand = \case Nothing -> do gVar <- asks idsDrg (agentConnId, cReq) <- withAgent (`createConnection` SCMInvitation) - GroupMember {memberId} <- withStore $ \st -> createContactMember st gVar user groupId contact memRole agentConnId cReq + GroupMember {memberId} <- withStore $ \db -> createContactMember db gVar user groupId contact memRole agentConnId cReq sendInvitation memberId cReq Just GroupMember {groupMemberId, memberId, memberStatus} | memberStatus == GSMemInvited -> - withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case + withStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName JoinGroup gName -> withUser $ \user@User {userId} -> do - ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName + ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \db -> getGroupInvitation db user gName withChatLock . procCmd $ do agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember) - withStore $ \st -> do - createMemberConnection st userId fromMember agentConnId - updateGroupMemberStatus st userId fromMember GSMemAccepted - updateGroupMemberStatus st userId (membership g) GSMemAccepted + withStore' $ \db -> do + createMemberConnection db userId fromMember agentConnId + updateGroupMemberStatus db userId fromMember GSMemAccepted + updateGroupMemberStatus db userId (membership g) GSMemAccepted pure $ CRUserAcceptedGroupSent g MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported" RemoveMember gName cName -> withUser $ \user@User {userId} -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroupByName db user gName case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of Nothing -> throwChatError $ CEGroupMemberNotFound cName Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do @@ -657,17 +704,17 @@ processChatCommand = \case withChatLock . procCmd $ do when (mStatus /= GSMemInvited) . void . sendGroupMessage gInfo members $ XGrpMemDel mId deleteMemberConnection m - withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved + withStore' $ \db -> updateGroupMemberStatus db userId m GSMemRemoved pure $ CRUserDeletedMember gInfo m LeaveGroup gName -> withUser $ \user@User {userId} -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroupByName db user gName withChatLock . procCmd $ do void $ sendGroupMessage gInfo members XGrpLeave mapM_ deleteMemberConnection members - withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft + withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser gInfo DeleteGroup gName -> withUser $ \user -> do - g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroupByName st user gName + g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \db -> getGroupByName db user gName let s = memberStatus membership canDelete = memberRole (membership :: GroupMember) == GROwner @@ -676,23 +723,23 @@ processChatCommand = \case withChatLock . procCmd $ do when (memberActive membership) . void $ sendGroupMessage gInfo members XGrpDel mapM_ deleteMemberConnection members - withStore $ \st -> deleteGroup st user g + withStore' $ \db -> deleteGroup db user g pure $ CRGroupDeletedUser gInfo ClearGroup gName -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName + groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIClearChat (ChatRef CTGroup groupId) - ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName)) - ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user)) + ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\db -> getGroupByName db user gName)) + ListGroups -> CRGroupsList <$> withUser (\user -> withStore' (`getUserGroupDetails` user)) SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) + groupId <- withStore $ \db -> getGroupIdByName db user gName + quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc LastMessages (Just chatName) count -> withUser $ \user -> do chatRef <- getChatRef user chatName CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) - LastMessages Nothing count -> withUser $ \user -> withStore $ \st -> - CRLastMessages <$> getAllChatItems st user (CPLast count) + LastMessages Nothing count -> withUser $ \user -> withStore $ \db -> + CRLastMessages <$> getAllChatItems db user (CPLast count) SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "") @@ -707,7 +754,7 @@ processChatCommand = \case ForwardImage chatName fileId -> forwardFile chatName fileId SendImage ReceiveFile fileId filePath_ -> withUser $ \user -> withChatLock . procCmd $ do - ft <- withStore $ \st -> getRcvFileTransfer st user fileId + ft <- withStore $ \db -> getRcvFileTransfer db user fileId (CRRcvFileAccepted <$> acceptFileReceive user ft filePath_) `catchError` processError ft where processError ft = \case @@ -717,27 +764,27 @@ processChatCommand = \case e -> throwError e CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock . procCmd $ - withStore (\st -> getFileTransfer st user fileId) >>= \case + withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {cancelled} fts -> do unless cancelled $ do cancelSndFile user ftm fts - sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId void $ - withStore (\st -> getChatRefByFileId st user fileId) >>= \case + withStore (\db -> getChatRefByFileId db user fileId) >>= \case ChatRef CTDirect contactId -> do - contact <- withStore $ \st -> getContact st userId contactId + contact <- withStore $ \db -> getContact db userId contactId sendDirectContactMessage contact $ XFileCancel sharedMsgId ChatRef CTGroup groupId -> do - Group gInfo ms <- withStore $ \st -> getGroup st user groupId + Group gInfo ms <- withStore $ \db -> getGroup db user groupId sendGroupMessage gInfo ms $ XFileCancel sharedMsgId _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withStore $ \st -> getChatItemByFileId st user fileId + ci <- withStore $ \db -> getChatItemByFileId db user fileId pure $ CRSndGroupFileCancelled ci ftm fts FTRcv ftr@RcvFileTransfer {cancelled} -> do unless cancelled $ cancelRcvFileTransfer user ftr pure $ CRRcvFileCancelled ftr FileStatus fileId -> - CRFileTransferStatus <$> withUser (\user -> withStore $ \st -> getFileTransferProgress st user fileId) + CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (profile :: Profile) {displayName = displayName, fullName = fullName} @@ -767,24 +814,28 @@ processChatCommand = \case getChatRef :: User -> ChatName -> m ChatRef getChatRef user@User {userId} (ChatName cType name) = ChatRef cType <$> case cType of - CTDirect -> withStore $ \st -> getContactIdByName st userId name - CTGroup -> withStore $ \st -> getGroupIdByName st user name + CTDirect -> withStore $ \db -> getContactIdByName db userId name + CTGroup -> withStore $ \db -> getGroupIdByName db user name _ -> throwChatError $ CECommandError "not supported" + checkChatStopped :: m ChatResponse -> m ChatResponse + checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) + setStoreChanged :: m () + setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of - CTDirect -> withStore $ \st -> getDirectChatItemIdByText st userId cId SMDSnd (safeDecodeUtf8 msg) - CTGroup -> withStore $ \st -> getGroupChatItemIdByText st user cId (Just localDisplayName) (safeDecodeUtf8 msg) + CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg) + CTGroup -> withStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) (safeDecodeUtf8 msg) _ -> throwChatError $ CECommandError "not supported" connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse connectViaContact userId cReq profile = withChatLock $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore (\st -> getConnReqContactXContactId st userId cReqHash) >>= \case + withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists contact (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) - conn <- withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId toView $ CRNewContactConnection conn pure CRSentInvitation contactMember :: Contact -> [GroupMember] -> Maybe GroupMember @@ -800,10 +851,10 @@ processChatCommand = \case updateProfile user@User {profile = p} p'@Profile {displayName} | p' == p = pure CRUserProfileNoChange | otherwise = do - withStore $ \st -> updateUserProfile st user p' + withStore $ \db -> updateUserProfile db user p' let user' = (user :: User) {localDisplayName = displayName, profile = p'} asks currentUser >>= atomically . (`writeTVar` Just user') - contacts <- filter isReady <$> withStore (`getUserContacts` user) + contacts <- filter isReady <$> withStore' (`getUserContacts` user) withChatLock . procCmd $ do forM_ contacts $ \ct -> void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError) @@ -826,14 +877,14 @@ processChatCommand = \case unless (ciFileEnded status) $ case dir of SMDSnd -> do - (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\st -> getSndFileTransfer st user fileId) + (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) unless cancelled $ cancelSndFile user ftm fts SMDRcv -> do - ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ cancelRcvFileTransfer user ft withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse - withCurrentCall ctId action = withUser $ \User {userId} -> do - ct <- withStore $ \st -> getContact st userId ctId + withCurrentCall ctId action = withUser $ \user@User {userId} -> do + ct <- withStore $ \db -> getContact db userId ctId calls <- asks currentCalls withChatLock $ atomically (TM.lookup ctId calls) >>= \case @@ -841,14 +892,18 @@ processChatCommand = \case Just call@Call {contactId} | ctId == contactId -> do call_ <- action userId ct call - atomically $ case call_ of - Just call' -> TM.insert ctId call' calls - _ -> TM.delete ctId calls + case call_ of + Just call' -> do + unless (isRcvInvitation call') $ withStore' $ \db -> deleteCalls db user ctId + atomically $ TM.insert ctId call' calls + _ -> do + withStore' $ \db -> deleteCalls db user ctId + atomically $ TM.delete ctId calls pure CRCmdOk | otherwise -> throwChatError $ CECallContact contactId forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do - withStore (\st -> getFileTransfer st user fileId) >>= \case + withStore (\db -> getFileTransfer db user fileId) >>= \case FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath _ -> throwChatError CEFileNotReceived {fileId} @@ -862,13 +917,13 @@ updateCallItemStatus userId ct Call {chatItemId} receivedStatus msgId_ = do updateDirectChatItemView :: ChatMonad m => UserId -> Contact -> ChatItemId -> ACIContent -> Maybe MessageId -> m () updateDirectChatItemView userId ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) msgId_ = do - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId_ + updCi <- withStore $ \db -> updateDirectChatItem db userId contactId chatItemId ciContent msgId_ toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi callStatusItemContent :: ChatMonad m => UserId -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do CChatItem msgDir ChatItem {meta = CIMeta {updatedAt}, content} <- - withStore $ \st -> getDirectChatItem st userId contactId chatItemId + withStore $ \db -> getDirectChatItem db userId contactId chatItemId ts <- liftIO getCurrentTime let callDuration :: Int = nominalDiffTimeToSeconds (ts `diffUTCTime` updatedAt) `div'` 1 callStatus = case content of @@ -912,20 +967,20 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath filePath_ fName - withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId ConnJoined filePath + withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnJoined filePath Left e -> throwError e -- group file protocol Nothing -> case grpMemberId of Nothing -> throwChatError $ CEFileInternal "group member not found for file transfer" Just memId -> do - (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId + (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \db -> getGroupAndMember db user memId case activeConn of Just conn -> do - sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) filePath <- getRcvFilePath filePath_ fName - ci <- withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId ConnNew filePath + ci <- withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnNew filePath void $ sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) pure ci _ -> throwChatError $ CEFileInternal "member connection not active" @@ -969,15 +1024,14 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact -acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} = do +acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} = do connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId + withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId -agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -agentSubscriber user = do +agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () +agentSubscriber = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections subscribeConnection user forever $ do (_, connId, msg) <- atomically $ readTBQueue q u <- readTVarIO =<< asks currentUser @@ -1001,13 +1055,13 @@ subscribeUserConnections agentSubscribe user@User {userId} = do where catchErr a = a `catchError` \_ -> pure () subscribeContacts n ce = do - contacts <- withStore (`getUserContacts` user) + contacts <- withStore' (`getUserContacts` user) toView . CRContactSubSummary =<< pooledForConcurrentlyN n contacts (\ct -> ContactSubStatus ct <$> subscribeContact ce ct) subscribeContact ce ct = (subscribe (contactConnId ct) $> Nothing) `catchError` (\e -> when ce (toView $ CRContactSubError ct e) $> Just e) subscribeGroups n ce = do - groups <- withStore (`getUserGroups` user) + groups <- withStore' (`getUserGroups` user) toView . CRMemberSubErrors . mconcat =<< forM groups (subscribeGroup n ce) subscribeGroup n ce (Group g@GroupInfo {membership} members) = do let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members @@ -1028,9 +1082,9 @@ subscribeUserConnections agentSubscribe user@User {userId} = do toView $ CRGroupSubscribed g pure $ mapMaybe (\(m, e) -> (Just . MemberSubError m) =<< e) ms subscribeFiles n = do - sndFileTransfers <- withStore (`getLiveSndFileTransfers` user) + sndFileTransfers <- withStore' (`getLiveSndFileTransfers` user) pooledForConcurrentlyN_ n sndFileTransfers $ \sft -> subscribeSndFile sft - rcvFileTransfers <- withStore (`getLiveRcvFileTransfers` user) + rcvFileTransfers <- withStore' (`getLiveRcvFileTransfers` user) pooledForConcurrentlyN_ n rcvFileTransfers $ \rft -> subscribeRcvFile rft where subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId cId} = do @@ -1051,7 +1105,7 @@ subscribeUserConnections agentSubscribe user@User {userId} = do resume RcvFileInfo {agentConnId = AgentConnId cId} = subscribe cId `catchError` (toView . CRRcvFileSubError ft) subscribePendingConnections n = do - cs <- withStore (`getPendingConnections` user) + cs <- withStore' (`getPendingConnections` user) summary <- pooledForConcurrentlyN n cs $ \Connection {agentConnId = acId@(AgentConnId cId)} -> PendingSubStatus acId <$> ((subscribe cId $> Nothing) `catchError` (pure . Just)) toView $ CRPendingSubSummary summary @@ -1069,14 +1123,15 @@ processAgentMessage Nothing _ _ = throwChatError CENoActiveUser processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + SUSPENDED -> toView CRChatSuspended _ -> pure () where - serverEvent srv@SMP.ProtocolServer {host, port} conns event str = do - cs <- withStore $ \st -> getConnectionsContacts st userId conns + serverEvent srv@(SMPServer host port _) conns event str = do + cs <- withStore' $ \db -> getConnectionsContacts db userId conns toView $ event srv cs showToast ("server " <> str) (safeDecodeUtf8 . strEncode $ SrvLoc host port) processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage = - (withStore (\st -> getConnectionEntity st user agentConnId) >>= updateConnStatus) >>= \case + (withStore (\db -> getConnectionEntity db user agentConnId) >>= updateConnStatus) >>= \case RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage conn contact_ RcvGroupMsgConnection conn gInfo m -> @@ -1092,7 +1147,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage updateConnStatus acEntity = case agentMsgConnStatus agentMessage of Just connStatus -> do let conn = (entityConnection acEntity) {connStatus} - withStore $ \st -> updateConnectionStatus st conn connStatus + withStore' $ \db -> updateConnectionStatus db conn connStatus pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity @@ -1108,27 +1163,26 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> Nothing processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg conn@Connection {connId} = \case + processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink} = \case Nothing -> case agentMsg of CONF confId connInfo -> do saveConnInfo conn connInfo allowAgentConnection conn confId $ XInfo profile INFO connInfo -> saveConnInfo conn connInfo - MSG meta msgBody -> do + MSG meta _msgFlags msgBody -> do _ <- saveRcvMSG conn (ConnectionId connId) meta msgBody withAckMessage agentConnId meta $ pure () ackMsgDeliveryEvent conn meta SENT msgId -> -- ? updateDirectChatItemStatus sentMsgDeliveryEvent conn msgId - -- TODO print errors - MERR _ _ -> pure () -- ? updateDirectChatItemStatus - ERR _ -> pure () + MERR _ err -> toView . CRChatError $ ChatErrorAgent err -- ? updateDirectChatItemStatus + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of - MSG msgMeta msgBody -> do + MSG msgMeta _msgFlags msgBody -> do msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody withAckMessage agentConnId msgMeta $ case chatMsgEvent of @@ -1172,20 +1226,27 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" CON -> - withStore (\st -> getViaGroupMember st user ct) >>= \case + withStore' (\db -> getViaGroupMember db user ct) >>= \case Nothing -> do toView $ CRContactConnected ct setActive $ ActiveC c showToast (c <> "> ") "connected" + forM_ viaUserContactLink $ \userContactLinkId -> do + withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case + Just (_, True, Just mc) -> do + msg <- sendDirectContactMessage ct (XMsgNew $ MCSimple (ExtMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) Nothing Nothing + toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + _ -> pure () Just (gInfo, m@GroupMember {activeConn}) -> do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct SENT msgId -> do sentMsgDeliveryEvent conn msgId - withStore (\st -> getDirectChatItemByAgentMsgId st userId contactId connId msgId) >>= \case + withStore' (\db -> getDirectChatItemByAgentMsgId db userId contactId connId msgId) >>= \case Just (CChatItem SMDSnd ci) -> do - chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId (chatItemId' ci) CISSndSent + chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId (chatItemId' ci) CISSndSent toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () END -> do @@ -1194,13 +1255,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage unsetActive $ ActiveC c -- TODO print errors MERR msgId err -> do - chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId + chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId case chatItemId_ of Nothing -> pure () Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId (agentErrToItemStatus err) + chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err) toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) - ERR _ -> pure () + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () @@ -1213,7 +1274,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatMsgEvent of XGrpAcpt memId | sameMemberId memId m -> do - withStore $ \st -> updateGroupMemberStatus st userId m GSMemAccepted + withStore' $ \db -> updateGroupMemberStatus db userId m GSMemAccepted allowAgentConnection conn confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" _ -> messageError "CONF from invited member must have x.grp.acpt" @@ -1237,11 +1298,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> messageError "INFO from member must have x.grp.mem.info" pure () CON -> do - members <- withStore $ \st -> getGroupMembers st user gInfo - withStore $ \st -> do - updateGroupMemberStatus st userId m GSMemConnected + members <- withStore' $ \db -> getGroupMembers db user gInfo + withStore' $ \db -> do + updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ - updateGroupMemberStatus st userId membership GSMemConnected + updateGroupMemberStatus db userId membership GSMemConnected sendPendingGroupMessages m conn case memberCategory m of GCHostMember -> do @@ -1252,15 +1313,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRJoinedGroupMember gInfo m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" - intros <- withStore $ \st -> createIntroductions st members m + intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro@GroupMemberIntro {introId} -> do void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId) - withStore $ \st -> updateIntroStatus st introId GMIntroSent + withStore' $ \db -> updateIntroStatus db introId GMIntroSent _ -> do -- TODO send probe and decide whether to use existing contact connection or the new contact connection -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table - withStore (\st -> getViaGroupContact st user m) >>= \case + withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do notifyMemberConnected gInfo m messageError "implementation error: connected member does not have contact" @@ -1268,7 +1329,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct - MSG msgMeta msgBody -> do + MSG msgMeta _msgFlags msgBody -> do msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody withAckMessage agentConnId msgMeta $ case chatMsgEvent of @@ -1290,9 +1351,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ackMsgDeliveryEvent conn msgMeta SENT msgId -> sentMsgDeliveryEvent conn msgId - -- TODO print errors - MERR _ _ -> pure () - ERR _ -> pure () + MERR _ err -> toView . CRChatError $ ChatErrorAgent err + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () @@ -1307,30 +1367,29 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- TODO save XFileAcpt message XFileAcpt name | name == fileName -> do - withStore $ \st -> updateSndFileStatus st ft FSAccepted + withStore' $ \db -> updateSndFileStatus db ft FSAccepted allowAgentConnection conn confId XOk | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do - ci <- withStore $ \st -> do - updateSndFileStatus st ft FSConnected - updateDirectCIFileStatus st user fileId CIFSSndTransfer + ci <- withStore $ \db -> do + liftIO $ updateSndFileStatus db ft FSConnected + updateDirectCIFileStatus db user fileId CIFSSndTransfer toView $ CRSndFileStart ci ft sendFileChunk user ft SENT msgId -> do - withStore $ \st -> updateSndFileChunkSent st ft msgId + withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do cancelSndFileTransfer ft case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do - ci <- withStore $ \st -> getChatItemByFileId st user fileId + ci <- withStore $ \db -> getChatItemByFileId db user fileId toView $ CRSndFileRcvCancelled ci ft _ -> throwChatError $ CEFileSend fileId err - MSG meta _ -> + MSG meta _ _ -> withAckMessage agentConnId meta $ pure () - -- TODO print errors - ERR _ -> pure () + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () @@ -1346,12 +1405,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XOk -> allowAgentConnection conn confId XOk _ -> pure () CON -> do - ci <- withStore $ \st -> do - updateRcvFileStatus st ft FSConnected - updateCIFileStatus st user fileId CIFSRcvTransfer - getChatItemByFileId st user fileId + ci <- withStore $ \db -> do + liftIO $ updateRcvFileStatus db ft FSConnected + liftIO $ updateCIFileStatus db user fileId CIFSRcvTransfer + getChatItemByFileId db user fileId toView $ CRRcvFileStart ci - MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do + MSG meta@MsgMeta {recipient = (msgId, _), integrity} _ msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case FileChunkCancel -> unless cancelled $ do @@ -1363,7 +1422,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage MsgError MsgDuplicate -> pure () -- TODO remove once agent removes duplicates MsgError e -> badRcvFileChunk ft $ "invalid file chunk number " <> show chunkNo <> ": " <> show e - withStore (\st -> createRcvFileChunk st ft chunkNo msgId) >>= \case + withStore' (\db -> createRcvFileChunk db ft chunkNo msgId) >>= \case RcvChunkOk -> if B.length chunk /= fromInteger chunkSize then badRcvFileChunk ft "incorrect chunk size" @@ -1373,19 +1432,19 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage then badRcvFileChunk ft "incorrect chunk size" else do appendFileChunk ft chunkNo chunk - ci <- withStore $ \st -> do - updateRcvFileStatus st ft FSComplete - updateCIFileStatus st user fileId CIFSRcvComplete - deleteRcvFileChunks st ft - getChatItemByFileId st user fileId + ci <- withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db ft FSComplete + updateCIFileStatus db user fileId CIFSRcvComplete + deleteRcvFileChunks db ft + getChatItemByFileId db user fileId toView $ CRRcvFileComplete ci closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo - -- TODO print errors - MERR _ _ -> pure () - ERR _ -> pure () + MERR _ err -> toView . CRChatError $ ChatErrorAgent err + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () @@ -1398,18 +1457,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XInfo p -> profileContactRequest invId p Nothing -- TODO show/log error, other events in contact request _ -> pure () - -- TODO print errors - MERR _ _ -> pure () - ERR _ -> pure () + MERR _ err -> toView . CRChatError $ ChatErrorAgent err + ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () where profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () profileContactRequest invId p xContactId_ = do - withStore (\st -> createOrUpdateContactRequest st userId userContactLinkId invId p xContactId_) >>= \case + withStore (\db -> createOrUpdateContactRequest db userId userContactLinkId invId p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact CORRequest cReq@UserContactRequest {localDisplayName} -> do - (_, autoAccept) <- withStore $ \st -> getUserContactLink st userId + (_, autoAccept, _) <- withStore $ \db -> getUserContactLink db userId if autoAccept then acceptContactRequest user cReq >>= toView . CRAcceptingContactRequest else do @@ -1422,11 +1480,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ackMsgDeliveryEvent :: Connection -> MsgMeta -> m () ackMsgDeliveryEvent Connection {connId} MsgMeta {recipient = (msgId, _)} = - withStore $ \st -> createRcvMsgDeliveryEvent st connId msgId MDSRcvAcknowledged + withStore $ \db -> createRcvMsgDeliveryEvent db connId msgId MDSRcvAcknowledged sentMsgDeliveryEvent :: Connection -> AgentMsgId -> m () sentMsgDeliveryEvent Connection {connId} msgId = - withStore $ \st -> createSndMsgDeliveryEvent st connId msgId MDSSndSent + withStore $ \db -> createSndMsgDeliveryEvent db connId msgId MDSSndSent agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd agentErrToItemStatus (SMP AUTH) = CISSndErrorAuth @@ -1448,16 +1506,16 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage probeMatchingContacts :: Contact -> m () probeMatchingContacts ct = do gVar <- asks idsDrg - (probe, probeId) <- withStore $ \st -> createSentProbe st gVar userId ct + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct void . sendDirectContactMessage ct $ XInfoProbe probe - cs <- withStore (\st -> getMatchingContacts st userId ct) + cs <- withStore' $ \db -> getMatchingContacts db userId ct let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) where sendProbeHash :: Contact -> ProbeHash -> Int64 -> m () sendProbeHash c probeHash probeId = do void . sendDirectContactMessage c $ XInfoProbeCheck probeHash - withStore $ \st -> createSentProbeHash st userId probeId c + withStore' $ \db -> createSentProbeHash db userId probeId c messageWarning :: Text -> m () messageWarning = toView . CRMessageError "warning" @@ -1470,7 +1528,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ - \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize + \fi chSize -> withStore' $ \db -> createRcvFileTransfer db userId ct fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci showMsgToast (c <> "> ") content formattedText @@ -1501,7 +1559,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> throwError e where updateRcvChatItem = do - CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId + CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \db -> getDirectChatItemBySharedMsgId db userId contactId sharedMsgId case msgDir of SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" @@ -1515,10 +1573,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> throwError e where deleteRcvChatItem = do - CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId + CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \db -> getDirectChatItemBySharedMsgId db userId contactId sharedMsgId case msgDir of SMDRcv -> do - toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId + toCi <- withStore $ \db -> deleteDirectChatItemRcvBroadcast db userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" @@ -1526,7 +1584,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ - \fi chSize -> withStore $ \st -> createRcvGroupFileTransfer st userId m fi chSize + \fi chSize -> withStore' $ \db -> createRcvGroupFileTransfer db userId m fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo @@ -1535,24 +1593,24 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> m () groupMessageUpdate gInfo@GroupInfo {groupId} GroupMember {memberId} sharedMsgId mc RcvMessage {msgId} = do - CChatItem msgDir ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId + CChatItem msgDir ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> if sameMemberId memberId m then do - updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CIRcvMsgContent mc) msgId + updCi <- withStore $ \db -> updateGroupChatItem db user groupId itemId (CIRcvMsgContent mc) msgId toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) updCi else messageError "x.msg.update: group member attempted to update a message of another member" (SMDSnd, _) -> messageError "x.msg.update: group member attempted invalid message update" groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> RcvMessage -> m () groupMessageDelete gInfo@GroupInfo {groupId} GroupMember {memberId} sharedMsgId RcvMessage {msgId} = do - CChatItem msgDir deletedItem@ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId + CChatItem msgDir deletedItem@ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> if sameMemberId memberId m then do - toCi <- withStore $ \st -> deleteGroupChatItemRcvBroadcast st user gInfo itemId msgId + toCi <- withStore $ \db -> deleteGroupChatItemRcvBroadcast db user gInfo itemId msgId toView $ CRChatItemDeleted (AChatItem SCTGroup SMDRcv (GroupChat gInfo) deletedItem) toCi else messageError "x.msg.del: group member attempted to delete a message of another member" (SMDSnd, _) -> messageError "x.msg.del: group member attempted invalid message delete" @@ -1562,7 +1620,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta chSize <- asks $ fileChunkSize . config - RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize + RcvFileTransfer {fileId} <- withStore' $ \db -> createRcvFileTransfer db userId ct fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci @@ -1573,7 +1631,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do chSize <- asks $ fileChunkSize . config - RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize + RcvFileTransfer {fileId} <- withStore' $ \db -> createRcvGroupFileTransfer db userId m fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile groupMsgToView gInfo m ci msgMeta @@ -1584,8 +1642,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xFileCancel :: Contact -> SharedMsgId -> MsgMeta -> m () xFileCancel ct@Contact {contactId} sharedMsgId msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId - ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId + ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft toView $ CRRcvFileSndCancelled ft @@ -1593,13 +1651,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () xFileCancelGroup g@GroupInfo {groupId} mem@GroupMember {memberId} sharedMsgId msgMeta = do checkIntegrityCreateItem (CDGroupRcv g mem) msgMeta - fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId - CChatItem msgDir ChatItem {chatDir} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId + fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> do if sameMemberId memberId m then do - ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft toView $ CRRcvFileSndCancelled ft @@ -1609,14 +1667,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInvGroup g@GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do checkIntegrityCreateItem (CDGroupRcv g m) msgMeta - fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId - (FileTransferMeta {fileName, cancelled}, _) <- withStore (\st -> getSndFileTransfer st user fileId) + fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId + (FileTransferMeta {fileName, cancelled}, _) <- withStore (\db -> getSndFileTransfer db user fileId) unless cancelled $ if fName == fileName then tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case Right acId -> - withStore $ \st -> createSndGroupFileTransferConnection st userId fileId acId m + withStore' $ \db -> createSndGroupFileTransferConnection db userId fileId acId m Left e -> throwError e else messageError "x.file.acpt.inv: fileName is different from expected" @@ -1629,7 +1687,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId - gInfo@GroupInfo {localDisplayName = gName} <- withStore $ \st -> createGroupInvitation st user ct inv + gInfo@GroupInfo {localDisplayName = gName} <- withStore $ \db -> createGroupInvitation db user ct inv toView $ CRReceivedGroupInvitation gInfo ct memRole showToast ("#" <> gName <> " " <> c <> "> ") "invited you to join the group" @@ -1643,23 +1701,23 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage createIntegrityErrorItem e = do createdAt <- liftIO getCurrentTime let content = CIRcvIntegrityError e - ciId <- withStore $ \st -> createNewChatItemNoMsg st user cd content brokerTs createdAt + ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content brokerTs createdAt ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing brokerTs createdAt toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (p == p') $ do - c' <- withStore $ \st -> updateContactProfile st userId c p' + c' <- withStore $ \db -> updateContactProfile db userId c p' toView $ CRContactUpdated c c' xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = do - r <- withStore $ \st -> matchReceivedProbe st userId c2 probe + r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe forM_ r $ \c1 -> probeMatch c1 c2 probe xInfoProbeCheck :: Contact -> ProbeHash -> m () xInfoProbeCheck c1 probeHash = do - r <- withStore $ \st -> matchReceivedProbeHash st userId c1 probeHash + r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash forM_ r . uncurry $ probeMatch c1 probeMatch :: Contact -> Contact -> Probe -> m () @@ -1670,7 +1728,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xInfoProbeOk :: Contact -> Probe -> m () xInfoProbeOk c1 probe = do - r <- withStore $ \st -> matchSentProbe st userId c1 probe + r <- withStore' $ \db -> matchSentProbe db userId c1 probe forM_ r $ \c2 -> mergeContacts c1 c2 -- to party accepting call @@ -1681,14 +1739,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls - -- theoretically, the new call invitation for the current contant can mark the in-progress call as ended + -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended -- (and replace it in ChatController) -- practically, this should not happen + withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing - toView . CRCallInvitation ct callType sharedKey $ chatItemTs' ci + toView . CRCallInvitation $ RcvCallInvitation {contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing @@ -1757,9 +1816,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage | contactId /= ctId' || callId /= callId' -> messageError $ eventName <> ": wrong contact or callId" | otherwise -> do (call_, aciContent_) <- action call - atomically $ case call_ of - Just call' -> TM.insert ctId' call' calls - _ -> TM.delete ctId' calls + case call_ of + Just call' -> do + unless (isRcvInvitation call') $ withStore' $ \db -> deleteCalls db user ctId' + atomically $ TM.insert ctId' call' calls + _ -> do + withStore' $ \db -> deleteCalls db user ctId' + atomically $ TM.delete ctId' calls forM_ aciContent_ $ \aciContent -> updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId @@ -1769,7 +1832,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do - withStore $ \st -> mergeContactRecords st userId to from + withStore' $ \db -> mergeContactRecords db userId to from toView $ CRContactsMerged to from saveConnInfo :: Connection -> ConnInfo -> m () @@ -1777,72 +1840,72 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of XInfo p -> do - ct <- withStore $ \st -> createDirectContact st userId activeConn p + ct <- withStore $ \db -> createDirectContact db userId activeConn p toView $ CRContactConnecting ct -- TODO show/log error, other events in SMP confirmation _ -> pure () xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemNew gInfo m memInfo@(MemberInfo memId _ _) = do - members <- withStore $ \st -> getGroupMembers st user gInfo + members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ if isMember memId gInfo members then messageError "x.grp.mem.new error: member already exists" else do - newMember <- withStore $ \st -> createNewGroupMember st user gInfo memInfo GCPostMember GSMemAnnounced + newMember <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced toView $ CRJoinedGroupMemberConnecting gInfo m newMember xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro conn gInfo@GroupInfo {groupId} m memInfo@(MemberInfo memId _ _) = do case memberCategory m of GCHostMember -> do - members <- withStore $ \st -> getGroupMembers st user gInfo + members <- withStore' $ \db -> getGroupMembers db user gInfo if isMember memId gInfo members then messageWarning "x.grp.mem.intro ignored: member already exists" else do (groupConnId, groupConnReq) <- withAgent (`createConnection` SCMInvitation) (directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation) - newMember <- withStore $ \st -> createIntroReMember st user gInfo m memInfo groupConnId directConnId + newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq} void $ sendDirectMessage conn msg (GroupId groupId) - withStore $ \st -> updateGroupMemberStatus st userId newMember GSMemIntroInvited + withStore' $ \db -> updateGroupMemberStatus db userId newMember GSMemIntroInvited _ -> messageError "x.grp.mem.intro can be only sent by host member" xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () xGrpMemInv gInfo m memId introInv = do case memberCategory m of GCInviteeMember -> do - members <- withStore $ \st -> getGroupMembers st user gInfo + members <- withStore' $ \db -> getGroupMembers db user gInfo case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist" Just reMember -> do - GroupMemberIntro {introId} <- withStore $ \st -> saveIntroInvitation st reMember m introInv + GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv void $ sendXGrpMemInv gInfo reMember (XGrpMemFwd (memberInfo m) introInv) introId _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () xGrpMemFwd gInfo@GroupInfo {membership} m memInfo@(MemberInfo memId _ _) introInv@IntroInvitation {groupConnReq, directConnReq} = do - members <- withStore $ \st -> getGroupMembers st user gInfo + members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \st -> createNewGroupMember st user gInfo memInfo GCPostMember GSMemAnnounced + Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced Just m' -> pure m' - withStore $ \st -> saveMemberInvitation st toMember introInv + withStore' $ \db -> saveMemberInvitation db toMember introInv let msg = XGrpMemInfo (memberId (membership :: GroupMember)) profile groupConnId <- withAgent $ \a -> joinConnection a groupConnReq $ directMessage msg directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg - withStore $ \st -> createIntroToMemberContact st userId m toMember groupConnId directConnId + withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> m () xGrpMemDel gInfo@GroupInfo {membership} m memId = do - members <- withStore $ \st -> getGroupMembers st user gInfo + members <- withStore' $ \db -> getGroupMembers db user gInfo if memberId (membership :: GroupMember) == memId then do mapM_ deleteMemberConnection members - withStore $ \st -> updateGroupMemberStatus st userId membership GSMemRemoved + withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved toView $ CRDeletedMemberUser gInfo m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" @@ -1852,7 +1915,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage then messageError "x.grp.mem.del with insufficient member permissions" else do deleteMemberConnection member - withStore $ \st -> updateGroupMemberStatus st userId member GSMemRemoved + withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved toView $ CRDeletedMember gInfo m member sameMemberId :: MemberId -> GroupMember -> Bool @@ -1861,15 +1924,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage xGrpLeave :: GroupInfo -> GroupMember -> m () xGrpLeave gInfo m = do deleteMemberConnection m - withStore $ \st -> updateGroupMemberStatus st userId m GSMemLeft + withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft toView $ CRLeftMember gInfo m xGrpDel :: GroupInfo -> GroupMember -> m () xGrpDel gInfo m@GroupMember {memberRole} = do when (memberRole /= GROwner) $ throwChatError CEGroupUserRole - ms <- withStore $ \st -> do - members <- getGroupMembers st user gInfo - updateGroupMemberStatus st userId (membership gInfo) GSMemGroupDeleted + ms <- withStore' $ \db -> do + members <- getGroupMembers db user gInfo + updateGroupMemberStatus db userId (membership gInfo) GSMemGroupDeleted pure members mapM_ deleteMemberConnection ms toView $ CRGroupDeleted gInfo m @@ -1880,13 +1943,13 @@ parseChatMessage = first (ChatError . CEInvalidChatMessage) . strDecode sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ - withStore (`createSndFileChunk` ft) >>= \case + withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do - ci <- withStore $ \st -> do - updateSndFileStatus st ft FSComplete - deleteSndFileChunks st ft - updateDirectCIFileStatus st user fileId CIFSSndComplete + ci <- withStore $ \db -> do + liftIO $ updateSndFileStatus db ft FSComplete + liftIO $ deleteSndFileChunks db ft + updateDirectCIFileStatus db user fileId CIFSSndComplete toView $ CRSndFileComplete ci ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` acId) @@ -1894,8 +1957,8 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do chunkBytes <- readFileChunk ft chunkNo - msgId <- withAgent $ \a -> sendMessage a acId $ smpEncode FileChunk {chunkNo, chunkBytes} - withStore $ \st -> updateSndFileChunkMsg st ft chunkNo msgId + msgId <- withAgent $ \a -> sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} + withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do @@ -1943,7 +2006,7 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus} chunkNo chunk = h <- getFileHandle fileId fsFilePath rcvFiles AppendMode E.try (liftIO $ B.hPut h chunk >> hFlush h) >>= \case Left (e :: E.SomeException) -> throwChatError . CEFileWrite fsFilePath $ show e - Right () -> withStore $ \st -> updatedRcvFileChunkStored st ft chunkNo + Right () -> withStore' $ \db -> updatedRcvFileChunkStored db ft chunkNo getFileHandle :: ChatMonad m => Int64 -> FilePath -> (ChatController -> TVar (Map Int64 Handle)) -> IOMode -> m Handle getFileHandle fileId filePath files ioMode = do @@ -1965,10 +2028,10 @@ isFileActive fileId files = do cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m () cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus} = do closeFileHandle fileId rcvFiles - withStore $ \st -> do - updateFileCancelled st user fileId CIFSRcvCancelled - updateRcvFileStatus st ft FSCancelled - deleteRcvFileChunks st ft + withStore' $ \db -> do + updateFileCancelled db user fileId CIFSRcvCancelled + updateRcvFileStatus db ft FSCancelled + deleteRcvFileChunks db ft case fileStatus of RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`deleteConnection` acId) @@ -1978,17 +2041,17 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus} = do cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> m () cancelSndFile user FileTransferMeta {fileId} fts = do - withStore $ \st -> updateFileCancelled st user fileId CIFSSndCancelled + withStore' $ \db -> updateFileCancelled db user fileId CIFSSndCancelled forM_ fts $ \ft' -> cancelSndFileTransfer ft' cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do - withStore $ \st -> do - updateSndFileStatus st ft FSCancelled - deleteSndFileChunks st ft + withStore' $ \db -> do + updateSndFileStatus db ft FSCancelled + deleteSndFileChunks db ft withAgent $ \a -> do - void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () + void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel) `catchError` \_ -> pure () deleteConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () @@ -2004,8 +2067,8 @@ deleteMemberConnection :: ChatMonad m => GroupMember -> m () deleteMemberConnection m@GroupMember {activeConn} = do -- User {userId} <- asks currentUser withAgent (forM_ (memberConnId m) . deleteConnection) `catchError` const (pure ()) - -- withStore $ \st -> deleteGroupMemberConnection st userId m - forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted + -- withStore $ \db -> deleteGroupMemberConnection db userId m + forM_ activeConn $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m SndMessage sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent = do @@ -2016,24 +2079,25 @@ sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connS sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m SndMessage sendDirectMessage conn chatMsgEvent connOrGroupId = do msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId - deliverMessage conn msgBody msgId + deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId pure msg createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do gVar <- asks idsDrg - withStore $ \st -> createNewSndMessage st gVar connOrGroupId $ \sharedMsgId -> + withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> let msgBody = strEncode ChatMessage {msgId = Just sharedMsgId, chatMsgEvent} in NewMessage {chatMsgEvent, msgBody} directMessage :: ChatMsgEvent -> ByteString directMessage chatMsgEvent = strEncode ChatMessage {msgId = Nothing, chatMsgEvent} -deliverMessage :: ChatMonad m => Connection -> MsgBody -> MessageId -> m () -deliverMessage conn@Connection {connId} msgBody msgId = do - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgBody +deliverMessage :: ChatMonad m => Connection -> CMEventTag -> MsgBody -> MessageId -> m () +deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do + let msgFlags = MsgFlags {notification = hasNotification cmEventTag} + agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId + withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m SndMessage sendGroupMessage GroupInfo {groupId} members chatMsgEvent = @@ -2042,7 +2106,7 @@ sendGroupMessage GroupInfo {groupId} members chatMsgEvent = sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m SndMessage sendXGrpMemInv GroupInfo {groupId} reMember chatMsgEvent introId = sendGroupMessage' [reMember] chatMsgEvent groupId (Just introId) $ - withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m SndMessage sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do @@ -2050,23 +2114,25 @@ sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do -- TODO collect failed deliveries into a single error forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> case memberConn m of - Nothing -> withStore $ \st -> createPendingGroupMessage st groupMemberId msgId introId_ - Just conn@Connection {connStatus} -> - if not (connStatus == ConnSndReady || connStatus == ConnReady) - then unless (connStatus == ConnDeleted) $ withStore (\st -> createPendingGroupMessage st groupMemberId msgId introId_) - else (deliverMessage conn msgBody msgId >> postDeliver) `catchError` const (pure ()) + Nothing -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + Just conn@Connection {connStatus} + | connStatus == ConnSndReady || connStatus == ConnReady -> do + let tag = toCMEventTag chatMsgEvent + (deliverMessage conn tag msgBody msgId >> postDeliver) `catchError` const (pure ()) + | connStatus == ConnDeleted -> pure () + | otherwise -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ pure msg sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m () sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do - pendingMessages <- withStore $ \st -> getPendingGroupMessages st groupMemberId + pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId -- TODO ensure order - pending messages interleave with user input messages forM_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do - deliverMessage conn msgBody msgId - withStore (\st -> deletePendingGroupMessage st groupMemberId msgId) + deliverMessage conn cmEventTag msgBody msgId + withStore' $ \db -> deletePendingGroupMessage db groupMemberId msgId when (cmEventTag == XGrpMemFwd_) $ case introId_ of Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName - Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + Just introId -> withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> m RcvMessage saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do @@ -2074,13 +2140,13 @@ saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} - withStore $ \st -> createNewMessageAndRcvMsgDelivery st connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery + withStore' $ \db -> createNewMessageAndRcvMsgDelivery db connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) saveSndChatItem user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem = do createdAt <- liftIO getCurrentTime - ciId <- withStore $ \st -> createNewSndChatItem st user cd msg content quotedItem createdAt - forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + ciId <- withStore' $ \db -> createNewSndChatItem db user cd msg content quotedItem createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore' $ \db -> updateFileTransferChatItemId db fileId ciId liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) createdAt createdAt saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) @@ -2089,8 +2155,8 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} = saveRcvChatItem' user cd saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} content ciFile = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg sharedMsgId_ content brokerTs createdAt - forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + (ciId, quotedItem) <- withStore' $ \db -> createNewRcvChatItem db user cd msg sharedMsgId_ content brokerTs createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore' $ \db -> updateFileTransferChatItemId db fileId ciId liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) @@ -2103,12 +2169,12 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTs currentTs = do allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do withAgent $ \a -> allowConnection a (aConnId conn) confId $ directMessage msg - withStore $ \st -> updateConnectionStatus st conn ConnAccepted + withStore' $ \db -> updateConnectionStatus db conn ConnAccepted getCreateActiveUser :: SQLiteStore -> IO User getCreateActiveUser st = do user <- - getUsers st >>= \case + withTransaction st getUsers >>= \case [] -> newUser users -> maybe (selectUser users) pure (find activeUser users) putStrLn $ "Current user: " <> userStr user @@ -2126,7 +2192,7 @@ getCreateActiveUser st = do loop = do displayName <- getContactName fullName <- T.pack <$> getWithPrompt "full name (optional)" - liftIO (runExceptT $ createUser st Profile {displayName, fullName, image = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUser db Profile {displayName, fullName, image = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -2134,7 +2200,7 @@ getCreateActiveUser st = do Right user -> pure user selectUser :: [User] -> IO User selectUser [user] = do - liftIO $ setActiveUser st (userId user) + withTransaction st (`setActiveUser` userId user) pure user selectUser users = do putStrLn "Select user profile:" @@ -2149,7 +2215,7 @@ getCreateActiveUser st = do | n <= 0 || n > length users -> putStrLn "invalid user number" >> loop | otherwise -> do let user = users !! (n - 1) - liftIO $ setActiveUser st (userId user) + withTransaction st (`setActiveUser` userId user) pure user userStr :: User -> String userStr User {localDisplayName, profile = Profile {fullName}} = @@ -2196,24 +2262,35 @@ withAgent action = >>= runExceptT . action >>= liftEither . first ChatErrorAgent +withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a +withStore' action = withStore $ liftIO . action + withStore :: ChatMonad m => - (forall m'. (MonadUnliftIO m', MonadError StoreError m') => SQLiteStore -> m' a) -> + (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore action = - asks chatStore - >>= runExceptT . action - -- use this line instead of above to log query errors - -- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) - >>= liftEither . first ChatErrorStore +withStore action = do + st <- asks chatStore + liftEitherError ChatErrorStore $ + withTransaction st (runExceptT . action) `E.catch` handleInternal + where + handleInternal :: E.SomeException -> IO (Either StoreError a) + handleInternal = pure . Left . SEInternalError . show chatCommandP :: Parser ChatCommand chatCommandP = ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) <|> ("/user" <|> "/u") $> ShowActiveUser - <|> "/_start" $> StartChat + <|> "/_start subscribe=" *> (StartChat <$> ("on" $> True <|> "off" $> False)) + <|> "/_start" $> StartChat True + <|> "/_stop" $> APIStopChat + <|> "/_app activate" $> APIActivateChat + <|> "/_app suspend " *> (APISuspendChat <$> A.decimal) <|> "/_resubscribe all" $> ResubscribeAllConnections <|> "/_files_folder " *> (SetFilesFolder <$> filePath) + <|> "/_db export " *> (APIExportArchive <$> jsonP) + <|> "/_db import " *> (APIImportArchive <$> jsonP) + <|> "/_db delete" $> APIDeleteStorage <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) @@ -2233,12 +2310,14 @@ chatCommandP = <|> "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP) <|> "/_call end @" *> (APIEndCall <$> A.decimal) <|> "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP) + <|> "/_call get" $> APIGetCallInvitations <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) - <|> "/_ntf register " *> (APIRegisterToken <$> tokenP) - <|> "/_ntf verify " *> (APIVerifyToken <$> tokenP <* A.space <*> strP <* A.space <*> strP) - <|> "/_ntf interval " *> (APIIntervalNofication <$> tokenP <* A.space <*> A.decimal) - <|> "/_ntf delete " *> (APIDeleteToken <$> tokenP) + <|> "/_ntf get" $> APIGetNtfToken + <|> "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP) + <|> "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP) + <|> "/_ntf delete " *> (APIDeleteToken <$> strP) + <|> "/_ntf message " *> (APIGetNtfMessage <$> strP <* A.space <*> strP) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -2281,7 +2360,7 @@ chatCommandP = <|> ("/address" <|> "/ad") $> CreateMyAddress <|> ("/delete_address" <|> "/da") $> DeleteMyAddress <|> ("/show_address" <|> "/sa") $> ShowMyAddress - <|> "/auto_accept " *> (AddressAutoAccept <$> onOffP) + <|> "/auto_accept " *> (AddressAutoAccept <$> onOffP <*> optional (A.space *> msgContentP)) <|> ("/accept @" <|> "/accept " <|> "/ac @" <|> "/ac ") *> (AcceptContact <$> displayName) <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown @@ -2303,10 +2382,6 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - tokenP = "apns " *> (DeviceToken PPApns <$> hexStringP) - hexStringP = - A.takeWhile (\c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) >>= \s -> - if even (B.length s) then pure s else fail "odd number of hex characters" displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs new file mode 100644 index 000000000..8f7a92e67 --- /dev/null +++ b/src/Simplex/Chat/Archive.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Archive where + +import qualified Codec.Archive.Zip as Z +import Control.Monad.Reader +import Simplex.Chat.Controller +import Simplex.Messaging.Agent.Client (agentDbPath) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..)) +import Simplex.Messaging.Util (whenM) +import System.FilePath +import UnliftIO.Directory +import UnliftIO.STM +import UnliftIO.Temporary + +archiveAgentDbFile :: String +archiveAgentDbFile = "simplex_v1_agent.db" + +archiveChatDbFile :: String +archiveChatDbFile = "simplex_v1_chat.db" + +archiveFilesFolder :: String +archiveFilesFolder = "simplex_v1_files" + +exportArchive :: ChatMonad m => ArchiveConfig -> m () +exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = + withTempDir cfg "simplex-chat." $ \dir -> do + StorageFiles {chatDb, agentDb, filesPath} <- storageFiles + copyFile chatDb $ dir archiveChatDbFile + copyFile agentDb $ dir archiveAgentDbFile + forM_ filesPath $ \fp -> + copyDirectoryFiles fp $ dir archiveFilesFolder + let method = if disableCompression == Just True then Z.Store else Z.Deflate + Z.createArchive archivePath $ Z.packDirRecur method Z.mkEntrySelector dir + +importArchive :: ChatMonad m => ArchiveConfig -> m () +importArchive cfg@ArchiveConfig {archivePath} = + withTempDir cfg "simplex-chat." $ \dir -> do + Z.withArchive archivePath $ Z.unpackInto dir + StorageFiles {chatDb, agentDb, filesPath} <- storageFiles + backup chatDb + backup agentDb + copyFile (dir archiveChatDbFile) chatDb + copyFile (dir archiveAgentDbFile) agentDb + let filesDir = dir archiveFilesFolder + forM_ filesPath $ \fp -> + whenM (doesDirectoryExist filesDir) $ + copyDirectoryFiles filesDir fp + where + backup f = whenM (doesFileExist f) $ copyFile f $ f <> ".bak" + +withTempDir :: ChatMonad m => ArchiveConfig -> (String -> (FilePath -> m ()) -> m ()) +withTempDir cfg = case parentTempDirectory cfg of + Just tmpDir -> withTempDirectory tmpDir + _ -> withSystemTempDirectory + +copyDirectoryFiles :: MonadIO m => FilePath -> FilePath -> m () +copyDirectoryFiles fromDir toDir = do + createDirectoryIfMissing False toDir + fs <- listDirectory fromDir + forM_ fs $ \f -> do + let fn = takeFileName f + f' = fromDir fn + whenM (doesFileExist f') $ copyFile f' $ toDir fn + +deleteStorage :: ChatMonad m => m () +deleteStorage = do + StorageFiles {chatDb, agentDb, filesPath} <- storageFiles + removeFile chatDb + removeFile agentDb + mapM_ removePathForcibly filesPath + +data StorageFiles = StorageFiles + { chatDb :: FilePath, + agentDb :: FilePath, + filesPath :: Maybe FilePath + } + +storageFiles :: ChatMonad m => m StorageFiles +storageFiles = do + ChatController {chatStore, filesFolder, smpAgent} <- ask + let SQLiteStore {dbFilePath = chatDb} = chatStore + agentDb = agentDbPath smpAgent + filesPath <- readTVarIO filesFolder + pure StorageFiles {chatDb, agentDb, filesPath} diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 83a8be168..ef055143a 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -38,7 +38,7 @@ chatBotRepl welcome answer _user cc = do initializeBotAddress :: ChatController -> IO () initializeBotAddress cc = do sendChatCmd cc "/show_address" >>= \case - CRUserContactLink uri _ -> showBotAddress uri + CRUserContactLink uri _ _ -> showBotAddress uri CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do putStrLn $ "No bot address, creating..." sendChatCmd cc "/address" >>= \case diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index c40351aef..de4336493 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -12,19 +12,33 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime) +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) +import Simplex.Chat.Types (Contact, ContactId) +import Simplex.Chat.Util (safeDecodeUtf8) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call - { contactId :: Int64, + { contactId :: ContactId, callId :: CallId, chatItemId :: Int64, - callState :: CallState + callState :: CallState, + callTs :: UTCTime } + deriving (Show) + +isRcvInvitation :: Call -> Bool +isRcvInvitation Call {callState} = case callState of + CallInvitationReceived {} -> True + _ -> False data CallStateTag = CSTCallInvitationSent @@ -75,6 +89,21 @@ data CallState peerCallSession :: WebRTCSession, sharedKey :: Maybe C.Key } + deriving (Show, Generic) + +-- database representation +instance FromJSON CallState where + parseJSON = J.genericParseJSON $ singleFieldJSON fstToLower + +instance ToJSON CallState where + toJSON = J.genericToJSON $ singleFieldJSON fstToLower + toEncoding = J.genericToEncoding $ singleFieldJSON fstToLower + +instance ToField CallState where + toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + +instance FromField CallState where + fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 newtype CallId = CallId ByteString deriving (Eq, Show) @@ -91,6 +120,22 @@ instance ToJSON CallId where toJSON = strToJSON toEncoding = strToJEncoding +instance FromField CallId where fromField f = CallId <$> fromField f + +instance ToField CallId where toField (CallId m) = toField m + +data RcvCallInvitation = RcvCallInvitation + { contact :: Contact, + callType :: CallType, + sharedKey :: Maybe C.Key, + callTs :: UTCTime + } + deriving (Show, Generic) + +instance ToJSON RcvCallInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + data CallType = CallType { media :: CallMedia, capabilities :: CallCapabilities diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9abaa4cdd..f0cee565d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -4,6 +4,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Controller where @@ -23,7 +24,6 @@ import Data.Text (Text) import Data.Time (ZonedTime) import Data.Time.Clock (UTCTime) import Data.Version (showVersion) -import Data.Word (Word16) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -40,7 +40,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) -import Simplex.Messaging.Protocol (CorrId) +import Simplex.Messaging.Protocol (CorrId, MsgFlags) import Simplex.Messaging.TMap (TMap) import System.IO (Handle) import UnliftIO.STM @@ -56,7 +56,6 @@ updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-c data ChatConfig = ChatConfig { agentConfig :: AgentConfig, - dbPoolSize :: Int, yesToMigrations :: Bool, defaultServers :: InitialAgentServers, tbqSize :: Natural, @@ -74,8 +73,9 @@ data ChatController = ChatController activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, - agentAsync :: TVar (Maybe (Async ())), + agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), chatStore :: SQLiteStore, + chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), @@ -99,9 +99,15 @@ instance ToJSON HelpSection where data ChatCommand = ShowActiveUser | CreateActiveUser Profile - | StartChat + | StartChat {subscribeConnections :: Bool} + | APIStopChat + | APIActivateChat + | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections | SetFilesFolder FilePath + | APIExportArchive ArchiveConfig + | APIImportArchive ArchiveConfig + | APIDeleteStorage | APIGetChats {pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination | APIGetChatItems Int @@ -120,13 +126,15 @@ data ChatCommand | APISendCallAnswer ContactId WebRTCSession | APISendCallExtraInfo ContactId WebRTCExtraInfo | APIEndCall ContactId + | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile Profile | APIParseMarkdown Text - | APIRegisterToken DeviceToken - | APIVerifyToken DeviceToken ByteString C.CbNonce - | APIIntervalNofication DeviceToken Word16 + | APIGetNtfToken + | APIRegisterToken DeviceToken NotificationsMode + | APIVerifyToken DeviceToken C.CbNonce ByteString | APIDeleteToken DeviceToken + | APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString} | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection @@ -140,7 +148,7 @@ data ChatCommand | CreateMyAddress | DeleteMyAddress | ShowMyAddress - | AddressAutoAccept Bool + | AddressAutoAccept Bool (Maybe MsgContent) | AcceptContact ContactName | RejectContact ContactName | SendMessage ChatName ByteString @@ -178,6 +186,8 @@ data ChatResponse = CRActiveUser {user :: User} | CRChatStarted | CRChatRunning + | CRChatStopped + | CRChatSuspended | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} | CRLastMessages {chatItems :: [AChatItem]} @@ -197,8 +207,8 @@ data ChatResponse | CRGroupCreated {groupInfo :: GroupInfo} | CRGroupMembers {group :: Group} | CRContactsList {contacts :: [Contact]} - | CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool} - | CRUserContactLinkUpdated {connReqContact :: ConnReqContact, autoAccept :: Bool} + | CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent} + | CRUserContactLinkUpdated {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent} | CRContactRequestRejected {contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {groupInfo :: GroupInfo} | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} @@ -260,14 +270,17 @@ data ChatResponse | CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, callTs :: UTCTime} + | CRCallInvitation {callInvitation :: RcvCallInvitation} | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} | CRCallEnded {contact :: Contact} + | CRCallInvitations {callInvitations :: [RcvCallInvitation]} | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} | CRNtfTokenStatus {status :: NtfTknStatus} + | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} + | CRNtfMessages {connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {connection :: PendingContactConnection} | CRContactConnectionDeleted {connection :: PendingContactConnection} | CRMessageError {severity :: Text, errorMessage :: Text} @@ -279,6 +292,9 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} + deriving (Show, Generic, FromJSON) + data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError @@ -315,6 +331,14 @@ data ComposedMessage = ComposedMessage } deriving (Show, Generic, FromJSON) +data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} + deriving (Show, Generic) + +instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions + +crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse +crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} + data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType} @@ -329,6 +353,8 @@ data ChatErrorType = CENoActiveUser | CEActiveUserExists | CEChatNotStarted + | CEChatNotStopped + | CEChatStoreChanged | CEInvalidConnReq | CEInvalidChatMessage {message :: String} | CEContactNotReady {contact :: Contact} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index a4faaf876..b58f4857d 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -15,7 +15,7 @@ import Simplex.Chat.Types import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {dbPoolSize, yesToMigrations} opts sendToast chat +simplexChatCore cfg@ChatConfig {yesToMigrations} opts sendToast chat | logAgent opts = do setLogLevel LogInfo -- LogError withGlobalLogging logCfg initRun @@ -23,16 +23,18 @@ simplexChatCore cfg@ChatConfig {dbPoolSize, yesToMigrations} opts sendToast chat where initRun = do let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f dbPoolSize yesToMigrations + st <- createStore f yesToMigrations u <- getCreateActiveUser st cc <- newChatController st (Just u) cfg opts sendToast - runSimplexChat u cc chat + runSimplexChat opts u cc chat -runSimplexChat :: User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChat u cc chat = do - a1 <- async $ chat u cc - a2 <- runReaderT (startChatController u) cc - waitEither_ a1 a2 +runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () +runSimplexChat ChatOpts {maintenance} u cc chat + | maintenance = wait =<< async (chat u cc) + | otherwise = do + a1 <- async $ chat u cc + a2 <- runReaderT (startChatController u True) cc + waitEither_ a1 a2 sendChatCmd :: ChatController -> String -> IO ChatResponse sendChatCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 8242d4651..1708659f4 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -89,6 +89,13 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] +data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} + deriving (Generic) + +instance ToJSON ParsedMarkdown where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + unmarked :: Text -> Markdown unmarked = Markdown Nothing @@ -183,6 +190,6 @@ markdownP = mconcat <$> A.many' fragmentP | isUri s = markdown Uri s | isEmail s = markdown Email s | otherwise = unmarked s - isUri s = "http://" `T.isPrefixOf` s || "https://" `T.isPrefixOf` s || "simplex:/" `T.isPrefixOf` s + isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 48c1cb337..e3bad5659 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -32,12 +32,12 @@ import GHC.Generics (Generic) import Simplex.Chat.Markdown import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) +import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgErrorType (..), MsgMeta (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) -import Simplex.Messaging.Util ((<$?>)) +import Simplex.Messaging.Util (eitherToMaybe, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Show, Generic) diff --git a/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs b/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs new file mode 100644 index 000000000..6ac72ac80 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220626_auto_reply where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220626_auto_reply :: Query +m20220626_auto_reply = + [sql| +ALTER TABLE user_contact_links ADD COLUMN auto_reply_msg_content TEXT DEFAULT NULL; + +ALTER TABLE connections ADD COLUMN via_user_contact_link INTEGER DEFAULT NULL + REFERENCES user_contact_links (user_contact_link_id) ON DELETE SET NULL; +|] diff --git a/src/Simplex/Chat/Migrations/M20220702_calls.hs b/src/Simplex/Chat/Migrations/M20220702_calls.hs new file mode 100644 index 000000000..4cbf3dbad --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220702_calls.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220702_calls where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220702_calls :: Query +m20220702_calls = + [sql| +CREATE TABLE calls ( -- stores call invitations state for communicating state between NSE and app when call notification comes + call_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + shared_call_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + call_state BLOB NOT NULL, + call_ts TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index f3fa1f3b4..b99291d10 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -1,130 +1,167 @@ -CREATE TABLE migrations ( - name TEXT NOT NULL, - ts TEXT NOT NULL, - PRIMARY KEY (name) - ); -CREATE TABLE contact_profiles ( -- remote user profile +CREATE TABLE migrations( + name TEXT NOT NULL, + ts TEXT NOT NULL, + PRIMARY KEY(name) +); +CREATE TABLE contact_profiles( + -- remote user profile contact_profile_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces + display_name TEXT NOT NULL, -- contact name set by remote user(not unique), this name must not contain spaces full_name TEXT NOT NULL, properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties -, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT, user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE); -CREATE INDEX contact_profiles_index ON contact_profiles (display_name, full_name); -CREATE TABLE users ( + , + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + image TEXT, + user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE +); +CREATE INDEX contact_profiles_index ON contact_profiles( + display_name, + full_name +); +CREATE TABLE users( user_id INTEGER PRIMARY KEY, contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, + DEFERRABLE INITIALLY DEFERRED, local_display_name TEXT NOT NULL UNIQUE, - active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 1 for active user - FOREIGN KEY (user_id, local_display_name) - REFERENCES display_names (user_id, local_display_name) - ON DELETE CASCADE - ON UPDATE CASCADE - DEFERRABLE INITIALLY DEFERRED + active_user INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- 1 for active user + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED ); -CREATE TABLE display_names ( +CREATE TABLE display_names( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, ldn_base TEXT NOT NULL, - ldn_suffix INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - PRIMARY KEY (user_id, local_display_name) ON CONFLICT FAIL, - UNIQUE (user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL + ldn_suffix INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + PRIMARY KEY(user_id, local_display_name) ON CONFLICT FAIL, + UNIQUE(user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL ) WITHOUT ROWID; -CREATE TABLE contacts ( +CREATE TABLE contacts( contact_id INTEGER PRIMARY KEY, contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- NULL if it's an incognito profile - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - local_display_name TEXT NOT NULL, - is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user - via_group INTEGER REFERENCES groups (group_id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, - FOREIGN KEY (user_id, local_display_name) - REFERENCES display_names (user_id, local_display_name) - ON DELETE CASCADE - ON UPDATE CASCADE, - UNIQUE (user_id, local_display_name), - UNIQUE (user_id, contact_profile_id) +user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, +local_display_name TEXT NOT NULL, +is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user + via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT CHECK(updated_at NOT NULL), + xcontact_id BLOB, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, contact_profile_id) ); -CREATE TABLE sent_probes ( +CREATE TABLE sent_probes( sent_probe_id INTEGER PRIMARY KEY, contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE, probe BLOB NOT NULL, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - UNIQUE (user_id, probe) + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + UNIQUE(user_id, probe) ); -CREATE TABLE sent_probe_hashes ( +CREATE TABLE sent_probe_hashes( sent_probe_hash_id INTEGER PRIMARY KEY, sent_probe_id INTEGER NOT NULL REFERENCES sent_probes ON DELETE CASCADE, contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - UNIQUE (sent_probe_id, contact_id) + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + UNIQUE(sent_probe_id, contact_id) ); -CREATE TABLE received_probes ( +CREATE TABLE received_probes( received_probe_id INTEGER PRIMARY KEY, contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, probe BLOB, probe_hash BLOB NOT NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE -, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); + , + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL) +); CREATE TABLE known_servers( server_id INTEGER PRIMARY KEY, host TEXT NOT NULL, port TEXT NOT NULL, key_hash BLOB, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - UNIQUE (user_id, host, port) + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + UNIQUE(user_id, host, port) ) WITHOUT ROWID; -CREATE TABLE group_profiles ( -- shared group profiles +CREATE TABLE group_profiles( + -- shared group profiles group_profile_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, -- this name must not contain spaces full_name TEXT NOT NULL, properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile -, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT, user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE); -CREATE TABLE groups ( + , + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + image TEXT, + user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE +); +CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, -- local group name without spaces group_profile_id INTEGER REFERENCES group_profiles ON DELETE SET NULL, -- shared group profile - inv_queue_info BLOB, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- received - FOREIGN KEY (user_id, local_display_name) - REFERENCES display_names (user_id, local_display_name) - ON DELETE CASCADE - ON UPDATE CASCADE, - UNIQUE (user_id, local_display_name), - UNIQUE (user_id, group_profile_id) + inv_queue_info BLOB, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- received + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, group_profile_id) ); -CREATE INDEX idx_groups_inv_queue_info ON groups (inv_queue_info); -CREATE TABLE group_members ( -- group members, excluding the local user +CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); +CREATE TABLE group_members( + -- group members, excluding the local user group_member_id INTEGER PRIMARY KEY, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, member_id BLOB NOT NULL, -- shared member ID, unique per group member_role TEXT NOT NULL, -- owner, admin, member member_category TEXT NOT NULL, -- see GroupMemberCategory member_status TEXT NOT NULL, -- see GroupMemberStatus - invited_by INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, -- NULL for the members who joined before the current user and for the group creator + invited_by INTEGER REFERENCES contacts(contact_id) ON DELETE SET NULL, -- NULL for the members who joined before the current user and for the group creator sent_inv_queue_info BLOB, -- sent group_queue_info BLOB, -- received direct_queue_info BLOB, -- received user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, -- should be the same as contact contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles ON DELETE CASCADE, - contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - FOREIGN KEY (user_id, local_display_name) - REFERENCES display_names (user_id, local_display_name) - ON DELETE CASCADE - ON UPDATE CASCADE, - UNIQUE (group_id, member_id) + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(group_id, member_id) ); -CREATE TABLE group_member_intros ( +CREATE TABLE group_member_intros( group_member_intro_id INTEGER PRIMARY KEY, - re_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, - to_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + re_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + to_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, group_queue_info BLOB, direct_queue_info BLOB, - intro_status TEXT NOT NULL, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- see GroupMemberIntroStatus - UNIQUE (re_group_member_id, to_group_member_id) + intro_status TEXT NOT NULL, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- see GroupMemberIntroStatus + UNIQUE(re_group_member_id, to_group_member_id) ); -CREATE TABLE files ( +CREATE TABLE files( file_id INTEGER PRIMARY KEY, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, @@ -132,152 +169,215 @@ CREATE TABLE files ( file_path TEXT, file_size INTEGER NOT NULL, chunk_size INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT(datetime('now')), user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE -, chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), cancelled INTEGER, ci_file_status TEXT); -CREATE TABLE snd_files ( + , + chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, + updated_at TEXT CHECK(updated_at NOT NULL), + cancelled INTEGER, + ci_file_status TEXT +); +CREATE TABLE snd_files( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, file_status TEXT NOT NULL, -- new, accepted, connected, completed - group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), - PRIMARY KEY (file_id, connection_id) + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + PRIMARY KEY(file_id, connection_id) ) WITHOUT ROWID; -CREATE TABLE rcv_files ( +CREATE TABLE rcv_files( file_id INTEGER PRIMARY KEY REFERENCES files ON DELETE CASCADE, file_status TEXT NOT NULL, -- new, accepted, connected, completed group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, file_queue_info BLOB -, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); -CREATE TABLE snd_file_chunks ( + , + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL) +); +CREATE TABLE snd_file_chunks( file_id INTEGER NOT NULL, connection_id INTEGER NOT NULL, chunk_number INTEGER NOT NULL, chunk_agent_msg_id INTEGER, - chunk_sent INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (sent to agent), 1 (sent to server) - FOREIGN KEY (file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, - PRIMARY KEY (file_id, connection_id, chunk_number) + chunk_sent INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- 0(sent to agent), 1(sent to server) + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) ) WITHOUT ROWID; -CREATE TABLE rcv_file_chunks ( +CREATE TABLE rcv_file_chunks( file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE, chunk_number INTEGER NOT NULL, chunk_agent_msg_id INTEGER NOT NULL, - chunk_stored INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (received), 1 (appended to file) - PRIMARY KEY (file_id, chunk_number) + chunk_stored INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- 0(received), 1(appended to file) + PRIMARY KEY(file_id, chunk_number) ) WITHOUT ROWID; -CREATE TABLE connections ( -- all SMP agent connections +CREATE TABLE connections( + -- all SMP agent connections connection_id INTEGER PRIMARY KEY, agent_conn_id BLOB NOT NULL UNIQUE, conn_level INTEGER NOT NULL DEFAULT 0, - via_contact INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, + via_contact INTEGER REFERENCES contacts(contact_id) ON DELETE SET NULL, conn_status TEXT NOT NULL, conn_type TEXT NOT NULL, -- contact, member, rcv_file, snd_file user_contact_link_id INTEGER REFERENCES user_contact_links ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, snd_file_id INTEGER, - rcv_file_id INTEGER REFERENCES rcv_files (file_id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), via_contact_uri_hash BLOB, xcontact_id BLOB, - FOREIGN KEY (snd_file_id, connection_id) - REFERENCES snd_files (file_id, connection_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED + rcv_file_id INTEGER REFERENCES rcv_files(file_id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TEXT CHECK(updated_at NOT NULL), + via_contact_uri_hash BLOB, + xcontact_id BLOB, + via_user_contact_link INTEGER DEFAULT NULL + REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL, + FOREIGN KEY(snd_file_id, connection_id) + REFERENCES snd_files(file_id, connection_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED ); -CREATE TABLE user_contact_links ( +CREATE TABLE user_contact_links( user_contact_link_id INTEGER PRIMARY KEY, conn_req_contact BLOB NOT NULL, local_display_name TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), auto_accept INTEGER DEFAULT 0, - UNIQUE (user_id, local_display_name) + created_at TEXT NOT NULL DEFAULT(datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TEXT CHECK(updated_at NOT NULL), + auto_accept INTEGER DEFAULT 0, + auto_reply_msg_content TEXT DEFAULT NULL, + UNIQUE(user_id, local_display_name) ); -CREATE TABLE contact_requests ( +CREATE TABLE contact_requests( contact_request_id INTEGER PRIMARY KEY, user_contact_link_id INTEGER NOT NULL REFERENCES user_contact_links - ON UPDATE CASCADE ON DELETE CASCADE, - agent_invitation_id BLOB NOT NULL, + ON UPDATE CASCADE ON DELETE CASCADE, + agent_invitation_id BLOB NOT NULL, contact_profile_id INTEGER REFERENCES contact_profiles - ON DELETE SET NULL -- NULL if it's an incognito profile - DEFERRABLE INITIALLY DEFERRED, - local_display_name TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, - FOREIGN KEY (user_id, local_display_name) - REFERENCES display_names (user_id, local_display_name) - ON UPDATE CASCADE - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - UNIQUE (user_id, local_display_name), - UNIQUE (user_id, contact_profile_id) + ON DELETE SET NULL -- NULL if it's an incognito profile +DEFERRABLE INITIALLY DEFERRED, +local_display_name TEXT NOT NULL, +created_at TEXT NOT NULL DEFAULT(datetime('now')), +user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, +FOREIGN KEY(user_id, local_display_name) +REFERENCES display_names(user_id, local_display_name) +ON UPDATE CASCADE +ON DELETE CASCADE +DEFERRABLE INITIALLY DEFERRED, +UNIQUE(user_id, local_display_name), +UNIQUE(user_id, contact_profile_id) ); -CREATE TABLE messages ( +CREATE TABLE messages( message_id INTEGER PRIMARY KEY, msg_sent INTEGER NOT NULL, -- 0 for received, 1 for sent - chat_msg_event TEXT NOT NULL, -- message event tag (the constructor of CMEventTag) + chat_msg_event TEXT NOT NULL, -- message event tag(the constructor of CMEventTag) msg_body BLOB, -- agent message body as received or sent - created_at TEXT NOT NULL DEFAULT (datetime('now')) -, updated_at TEXT CHECK (updated_at NOT NULL), connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, shared_msg_id BLOB, shared_msg_id_user INTEGER); -CREATE TABLE msg_deliveries ( + created_at TEXT NOT NULL DEFAULT(datetime('now')) + , + updated_at TEXT CHECK(updated_at NOT NULL), + connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, + group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, + shared_msg_id BLOB, + shared_msg_id_user INTEGER +); +CREATE TABLE msg_deliveries( msg_delivery_id INTEGER PRIMARY KEY, message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- non UNIQUE for group messages connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, - agent_msg_id INTEGER, -- internal agent message ID (NULL while pending) + agent_msg_id INTEGER, -- internal agent message ID(NULL while pending) agent_msg_meta TEXT, -- JSON with timestamps etc. sent in MSG, NULL for sent - chat_ts TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- broker_ts for received, created_at for sent - UNIQUE (connection_id, agent_msg_id) + chat_ts TEXT NOT NULL DEFAULT(datetime('now')), + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), -- broker_ts for received, created_at for sent + UNIQUE(connection_id, agent_msg_id) ); -CREATE TABLE msg_delivery_events ( +CREATE TABLE msg_delivery_events( msg_delivery_event_id INTEGER PRIMARY KEY, msg_delivery_id INTEGER NOT NULL REFERENCES msg_deliveries ON DELETE CASCADE, -- non UNIQUE for multiple events per msg delivery delivery_status TEXT NOT NULL, -- see MsgDeliveryStatus for allowed values - created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), - UNIQUE (msg_delivery_id, delivery_status) + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT CHECK(updated_at NOT NULL), + UNIQUE(msg_delivery_id, delivery_status) ); -CREATE TABLE pending_group_messages ( +CREATE TABLE pending_group_messages( pending_group_message_id INTEGER PRIMARY KEY, group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, group_member_intro_id INTEGER REFERENCES group_member_intros ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); -CREATE TABLE chat_items ( +CREATE TABLE chat_items( chat_item_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- NULL for sent even if group_id is not chat_msg_id INTEGER, -- sent as part of the message that created the item - created_by_msg_id INTEGER UNIQUE REFERENCES messages (message_id) ON DELETE SET NULL, + created_by_msg_id INTEGER UNIQUE REFERENCES messages(message_id) ON DELETE SET NULL, item_sent INTEGER NOT NULL, -- 0 for received, 1 for sent item_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent item_deleted INTEGER NOT NULL DEFAULT 0, -- 1 for deleted, item_content TEXT NOT NULL, -- JSON item_text TEXT NOT NULL, -- textual representation - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -, item_status TEXT CHECK (item_status NOT NULL), shared_msg_id BLOB, quoted_shared_msg_id BLOB, quoted_sent_at TEXT, quoted_content TEXT, quoted_sent INTEGER, quoted_member_id BLOB, item_edited INTEGER); -CREATE TABLE chat_item_messages ( + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + item_status TEXT CHECK(item_status NOT NULL), + shared_msg_id BLOB, + quoted_shared_msg_id BLOB, + quoted_sent_at TEXT, + quoted_content TEXT, + quoted_sent INTEGER, + quoted_member_id BLOB, + item_edited INTEGER +); +CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (chat_item_id, message_id) + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(chat_item_id, message_id) ); -CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash); -CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id); -CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id); -CREATE TABLE smp_servers ( +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + via_contact_uri_hash +); +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); +CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); +CREATE TABLE smp_servers( smp_server_id INTEGER PRIMARY KEY, host TEXT NOT NULL, port TEXT NOT NULL, key_hash BLOB NOT NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (host, port) + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(host, port) +); +CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); +CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages( + connection_id, + shared_msg_id_user, + shared_msg_id +); +CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages( + group_id, + shared_msg_id_user, + shared_msg_id +); +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); +CREATE TABLE calls( + -- stores call invitations state for communicating state between NSE and app when call notification comes + call_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + shared_call_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + call_state BLOB NOT NULL, + call_ts TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); -CREATE INDEX idx_messages_shared_msg_id ON messages (shared_msg_id); -CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages (connection_id, shared_msg_id_user, shared_msg_id); -CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages (group_id, shared_msg_id_user, shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items (shared_msg_id); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index a83fc67f0..4cd85330d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,16 +12,21 @@ import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (find) +import Data.Maybe (fromMaybe) import Foreign.C.String +import Foreign.C.Types (CInt (..)) import Foreign.StablePtr import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller +import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Types +import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations)) import Simplex.Messaging.Protocol (CorrId (..)) +import System.Timeout (timeout) foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController) @@ -29,6 +34,10 @@ foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString +foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString + +foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString + -- | initialize chat controller -- The active user has to be created and the chat has to be started before most commands can be used. cChatInit :: CString -> IO (StablePtr ChatController) @@ -45,6 +54,14 @@ cChatSendCmd cPtr cCmd = do cChatRecvMsg :: StablePtr ChatController -> IO CJSONString cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString +-- | receive message from chat (blocking up to `t` microseconds (1/10^6 sec), returns empty string if times out) +cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString +cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t) >>= newCAString + +-- | parse markdown - returns ParsedMarkdown type JSON +cChatParseMarkdown :: CString -> IO CJSONString +cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s + mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts @@ -54,7 +71,8 @@ mobileChatOpts = logAgent = False, chatCmd = "", chatCmdDelay = 3, - chatServerPort = Nothing + chatServerPort = Nothing, + maintenance = True } defaultMobileConfig :: ChatConfig @@ -67,12 +85,12 @@ defaultMobileConfig = type CJSONString = CString getActiveUser_ :: SQLiteStore -> IO (Maybe User) -getActiveUser_ st = find activeUser <$> getUsers st +getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatInit :: String -> IO ChatController chatInit dbFilePrefix = do let f = chatStoreFile dbFilePrefix - chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations (defaultMobileConfig :: ChatConfig)) + chatStore <- createStore f (yesToMigrations (defaultMobileConfig :: ChatConfig)) user_ <- getActiveUser_ chatStore newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} Nothing @@ -84,6 +102,12 @@ chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp} +chatRecvMsgWait :: ChatController -> Int -> IO JSONString +chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) + +chatParseMarkdown :: String -> JSONString +chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack + data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index ad7cb97e1..72211bc01 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -25,7 +25,8 @@ data ChatOpts = ChatOpts logAgent :: Bool, chatCmd :: String, chatCmdDelay :: Int, - chatServerPort :: Maybe String + chatServerPort :: Maybe String, + maintenance :: Bool } chatOpts :: FilePath -> FilePath -> Parser ChatOpts @@ -88,7 +89,13 @@ chatOpts appDir defaultDbFileName = do <> help "Run chat server on specified port" <> value Nothing ) - pure ChatOpts {dbFilePrefix, smpServers, logConnections, logAgent, chatCmd, chatCmdDelay, chatServerPort} + maintenance <- + switch + ( long "maintenance" + <> short 'm' + <> help "Run in maintenance mode (/_start to start chat)" + ) + pure ChatOpts {dbFilePrefix, smpServers, logConnections, logAgent, chatCmd, chatCmdDelay, chatServerPort, maintenance} where defaultDbFilePath = combine appDir defaultDbFileName diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 83b0d8c20..43194387a 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -31,10 +31,10 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Types -import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) +import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (fromTextField_) -import Simplex.Messaging.Util ((<$?>)) +import Simplex.Messaging.Parsers (fromTextField_, fstToLower, sumTypeJSON) +import Simplex.Messaging.Util (eitherToMaybe, (<$?>)) data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} @@ -42,7 +42,11 @@ data ConnectionEntity | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} - deriving (Eq, Show) + deriving (Eq, Show, Generic) + +instance ToJSON ConnectionEntity where + toJSON = J.genericToJSON $ sumTypeJSON fstToLower + toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of @@ -437,6 +441,16 @@ instance FromField CMEventTag where fromField = fromTextField_ cmEventTagT instance ToField CMEventTag where toField = toField . serializeCMEventTag +hasNotification :: CMEventTag -> Bool +hasNotification = \case + XMsgNew_ -> True + XFile_ -> True + XContact_ -> True + XGrpInv_ -> True + XGrpDel_ -> True + XCallInv_ -> True + _ -> False + appToChatMessage :: AppMessage -> Either String ChatMessage appToChatMessage AppMessage {msgId, event, params} = do eventTag <- strDecode $ encodeUtf8 event diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index aadac15c9..3f79794cb 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Store getConnReqContactXContactId, createDirectContact, getContactGroupNames, + deleteContactConnectionsAndFiles, deleteContact, getContactByName, getContact, @@ -41,6 +42,7 @@ module Simplex.Chat.Store getUserContactLinkConnections, deleteUserContactLink, getUserContactLink, + getUserContactLinkById, updateUserContactLinkAutoAccept, createOrUpdateContactRequest, getContactRequest, @@ -158,8 +160,12 @@ module Simplex.Chat.Store updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, + createCall, + deleteCalls, + getCalls, getPendingContactConnection, deletePendingContactConnection, + withTransaction, ) where @@ -168,14 +174,13 @@ import Control.Concurrent.STM (stateTVar) import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except -import Control.Monad.IO.Unlift import Crypto.Random (ChaChaDRG, randomBytesGenerate) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import Data.Bifunctor (first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) -import Data.Either (isRight, rights) +import Data.Either (rights) import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) @@ -191,6 +196,7 @@ import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) import GHC.Generics (Generic) +import Simplex.Chat.Call import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Migrations.M20220101_initial @@ -204,17 +210,18 @@ import Simplex.Chat.Migrations.M20220304_msg_quotes import Simplex.Chat.Migrations.M20220321_chat_item_edited import Simplex.Chat.Migrations.M20220404_files_status_fields import Simplex.Chat.Migrations.M20220514_profiles_user_id +import Simplex.Chat.Migrations.M20220626_auto_reply +import Simplex.Chat.Migrations.M20220702_calls import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Chat.Util (eitherToMaybe) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, firstRow', maybeFirstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (strEncode)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) -import Simplex.Messaging.Util (liftIOEither, (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM schemaMigrations :: [(String, Query)] @@ -229,7 +236,9 @@ schemaMigrations = ("20220304_msg_quotes", m20220304_msg_quotes), ("20220321_chat_item_edited", m20220321_chat_item_edited), ("20220404_files_status_fields", m20220404_files_status_fields), - ("20220514_profiles_user_id", m20220514_profiles_user_id) + ("20220514_profiles_user_id", m20220514_profiles_user_id), + ("20220626_auto_reply", m20220626_auto_reply), + ("20220702_calls", m20220702_calls) ] -- | The list of migrations in ascending order by date @@ -238,14 +247,14 @@ migrations = sortBy (compare `on` name) $ map migration schemaMigrations where migration (name, query) = Migration {name = name, up = fromQuery query} -createStore :: FilePath -> Int -> Bool -> IO SQLiteStore -createStore dbFilePath poolSize = createSQLiteStore dbFilePath poolSize migrations +createStore :: FilePath -> Bool -> IO SQLiteStore +createStore dbFilePath = createSQLiteStore dbFilePath migrations chatStoreFile :: FilePath -> FilePath chatStoreFile = (<> "_chat.db") -checkConstraint :: StoreError -> IO (Either StoreError a) -> IO (Either StoreError a) -checkConstraint err action = action `E.catch` (pure . Left . handleSQLError err) +checkConstraint :: StoreError -> ExceptT StoreError IO a -> ExceptT StoreError IO a +checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left . handleSQLError err) handleSQLError :: StoreError -> SQLError -> StoreError handleSQLError err e @@ -255,11 +264,9 @@ handleSQLError err e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" -type StoreMonad m = (MonadUnliftIO m, MonadError StoreError m) - -createUser :: StoreMonad m => SQLiteStore -> Profile -> Bool -> m User -createUser st Profile {displayName, fullName, image} activeUser = - liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do +createUser :: DB.Connection -> Profile -> Bool -> ExceptT StoreError IO User +createUser db Profile {displayName, fullName, image} activeUser = + checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime DB.execute db @@ -281,67 +288,63 @@ createUser st Profile {displayName, fullName, image} activeUser = (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName, image) + pure $ toUser (userId, contactId, activeUser, displayName, fullName, image) -getUsers :: SQLiteStore -> IO [User] -getUsers st = - withTransaction st $ \db -> - map toUser - <$> DB.query_ - db - [sql| - SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image - FROM users u - JOIN contacts c ON u.contact_id = c.contact_id - JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - |] +getUsers :: DB.Connection -> IO [User] +getUsers db = + map toUser + <$> DB.query_ + db + [sql| + SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image + FROM users u + JOIN contacts c ON u.contact_id = c.contact_id + JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id + |] toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ImageData) -> User toUser (userId, userContactId, activeUser, displayName, fullName, image) = let profile = Profile {displayName, fullName, image} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} -setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m () -setActiveUser st userId = do - liftIO . withTransaction st $ \db -> do - DB.execute_ db "UPDATE users SET active_user = 0" - DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) +setActiveUser :: DB.Connection -> UserId -> IO () +setActiveUser db userId = do + DB.execute_ db "UPDATE users SET active_user = 0" + DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) -createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m PendingContactConnection -createConnReqConnection st userId acId cReqHash xContactId = do - liftIO . withTransaction st $ \db -> do - createdAt <- getCurrentTime - let pccConnStatus = ConnJoined - DB.execute - db - [sql| - INSERT INTO connections ( - user_id, agent_conn_id, conn_status, conn_type, - created_at, updated_at, via_contact_uri_hash, xcontact_id - ) VALUES (?,?,?,?,?,?,?,?) - |] - (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) - pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt, updatedAt = createdAt} +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId = do + createdAt <- getCurrentTime + let pccConnStatus = ConnJoined + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, + created_at, updated_at, via_contact_uri_hash, xcontact_id + ) VALUES (?,?,?,?,?,?,?,?) + |] + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt, updatedAt = createdAt} -getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId) -getConnReqContactXContactId st userId cReqHash = do - liftIO . withTransaction st $ \db -> - getContact' db >>= \case - c@(Just _) -> pure (c, Nothing) - Nothing -> (Nothing,) <$> getXContactId db +getConnReqContactXContactId :: DB.Connection -> UserId -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) +getConnReqContactXContactId db userId cReqHash = do + getContact' >>= \case + c@(Just _) -> pure (c, Nothing) + Nothing -> (Nothing,) <$> getXContactId where - getContact' :: DB.Connection -> IO (Maybe Contact) - getContact' db = - fmap toContact . listToMaybe - <$> DB.query + getContact' :: IO (Maybe Contact) + getContact' = + maybeFirstRow toContact $ + DB.query db [sql| SELECT -- Contact ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -351,59 +354,57 @@ getConnReqContactXContactId st userId cReqHash = do LIMIT 1 |] (userId, cReqHash) - getXContactId :: DB.Connection -> IO (Maybe XContactId) - getXContactId db = - fmap fromOnly . listToMaybe - <$> DB.query + getXContactId :: IO (Maybe XContactId) + getXContactId = + maybeFirstRow fromOnly $ + DB.query db "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnStatus -> m PendingContactConnection -createDirectConnection st userId acId pccConnStatus = - liftIO . withTransaction st $ \db -> do - createdAt <- getCurrentTime - DB.execute - db - [sql| - INSERT INTO connections - (user_id, agent_conn_id, conn_status, conn_type, created_at, updated_at) VALUES (?,?,?,?,?,?) - |] - (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) - pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, createdAt, updatedAt = createdAt} +createDirectConnection :: DB.Connection -> UserId -> ConnId -> ConnStatus -> IO PendingContactConnection +createDirectConnection db userId acId pccConnStatus = do + createdAt <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections + (user_id, agent_conn_id, conn_status, conn_type, created_at, updated_at) VALUES (?,?,?,?,?,?) + |] + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, createdAt, updatedAt = createdAt} -createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing +createMemberContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createMemberContactConnection_ db userId agentConnId viaContact = createConnection_ db userId ConnContact Nothing agentConnId viaContact Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId viaContact connLevel currentTs = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createConnection_ db userId connType entityId acId viaContact viaUserContactLink connLevel currentTs = do DB.execute db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_level, via_contact, conn_status, conn_type, + user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, viaContact, ConnNew, connType) + ( (userId, acId, connLevel, viaContact, viaUserContactLink, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, connLevel, connStatus = ConnNew, createdAt = currentTs} + pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, connLevel, connStatus = ConnNew, createdAt = currentTs} where ent ct = if connType == ct then entityId else Nothing -createDirectContact :: StoreMonad m => SQLiteStore -> UserId -> Connection -> Profile -> m Contact -createDirectContact st userId activeConn@Connection {connId} profile = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - createdAt <- liftIO getCurrentTime - (localDisplayName, contactId, _) <- ExceptT $ createContact_ db userId connId profile Nothing createdAt - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt, updatedAt = createdAt} +createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db userId activeConn@Connection {connId} profile = do + createdAt <- liftIO getCurrentTime + (localDisplayName, contactId, _) <- createContact_ db userId connId profile Nothing createdAt + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt, updatedAt = createdAt} -createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64)) +createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, Int64, Int64) createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs = - withLocalDisplayName db userId displayName $ \ldn -> do + ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db "INSERT INTO contact_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" @@ -417,41 +418,40 @@ createContact_ db userId connId Profile {displayName, fullName, image} viaGroup DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) pure (ldn, contactId, profileId) -getContactGroupNames :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [GroupName] -getContactGroupNames st userId Contact {contactId} = - liftIO . withTransaction st $ \db -> do - map fromOnly - <$> DB.query - db - [sql| - SELECT DISTINCT g.local_display_name - FROM groups g - JOIN group_members m ON m.group_id = g.group_id - WHERE g.user_id = ? AND m.contact_id = ? - |] - (userId, contactId) - -deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () -deleteContact st userId Contact {contactId, localDisplayName} = do - liftIO . withTransaction st $ \db -> do - DB.execute +getContactGroupNames :: DB.Connection -> UserId -> Contact -> IO [GroupName] +getContactGroupNames db userId Contact {contactId} = + map fromOnly + <$> DB.query db [sql| - DELETE FROM connections WHERE connection_id IN ( - SELECT connection_id - FROM connections c - JOIN contacts ct ON ct.contact_id = c.contact_id - WHERE ct.user_id = ? AND ct.contact_id = ? - ) + SELECT DISTINCT g.local_display_name + FROM groups g + JOIN group_members m ON m.group_id = g.group_id + WHERE g.user_id = ? AND m.contact_id = ? |] (userId, contactId) - DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) - -- in separate transaction to prevent crashes on android (race condition on integrity check?) - liftIO . withTransaction st $ \db -> do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - deleteContactProfile_ db userId contactId - DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + +deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () +deleteContactConnectionsAndFiles db userId Contact {contactId} = do + DB.execute + db + [sql| + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + ) + |] + (userId, contactId) + DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) + +deleteContact :: DB.Connection -> UserId -> Contact -> IO () +deleteContact db userId Contact {contactId, localDisplayName} = do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + deleteContactProfile_ db userId contactId + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO () deleteContactProfile_ db userId contactId = @@ -467,13 +467,12 @@ deleteContactProfile_ db userId contactId = |] (userId, contactId) -updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m () -updateUserProfile st User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} +updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () +updateUserProfile db User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} | displayName == newName = - liftIO . withTransaction st $ \db -> - updateContactProfile_ db userId userContactId p' + liftIO $ updateContactProfile_ db userId userContactId p' | otherwise = - liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do + checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) DB.execute @@ -482,20 +481,17 @@ updateUserProfile st User {userId, userContactId, localDisplayName, profile = Pr (newName, newName, userId, currentTs, currentTs) updateContactProfile_' db userId userContactId p' currentTs updateContact_ db userId userContactId localDisplayName newName currentTs - pure $ Right () -updateContactProfile :: StoreMonad m => SQLiteStore -> UserId -> Contact -> Profile -> m Contact -updateContactProfile st userId c@Contact {contactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} +updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} | displayName == newName = - liftIO . withTransaction st $ \db -> - updateContactProfile_ db userId contactId p' $> (c :: Contact) {profile = p'} + liftIO $ updateContactProfile_ db userId contactId p' $> (c :: Contact) {profile = p'} | otherwise = - liftIOEither . withTransaction st $ \db -> - withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId contactId p' currentTs - updateContact_ db userId contactId localDisplayName ldn currentTs - pure $ (c :: Contact) {localDisplayName = ldn, profile = p'} + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId contactId p' currentTs + updateContact_ db userId contactId localDisplayName ldn currentTs + pure $ (c :: Contact) {localDisplayName = ldn, profile = p'} updateContactProfile_ :: DB.Connection -> UserId -> Int64 -> Profile -> IO () updateContactProfile_ db userId contactId profile = do @@ -557,37 +553,36 @@ toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getContactByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Contact -getContactByName st userId localDisplayName = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - cId <- ExceptT $ getContactIdByName_ db userId localDisplayName - ExceptT $ getContact_ db userId cId +getContactByName :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Contact +getContactByName db userId localDisplayName = do + cId <- getContactIdByName db userId localDisplayName + getContact db userId cId -getUserContacts :: MonadUnliftIO m => SQLiteStore -> User -> m [Contact] -getUserContacts st User {userId} = - liftIO . withTransaction st $ \db -> do - contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId) - rights <$> mapM (getContact_ db userId) contactIds +getUserContacts :: DB.Connection -> User -> IO [Contact] +getUserContacts db User {userId} = do + contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getContact db userId) contactIds -createUserContactLink :: StoreMonad m => SQLiteStore -> UserId -> ConnId -> ConnReqContact -> m () -createUserContactLink st userId agentConnId cReq = - liftIOEither . checkConstraint SEDuplicateContactLink . withTransaction st $ \db -> do +createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO () +createUserContactLink db userId agentConnId cReq = + checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute db "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - Right () <$ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing 0 currentTs -getUserContactLinkConnections :: StoreMonad m => SQLiteStore -> UserId -> m [Connection] -getUserContactLinkConnections st userId = - liftIOEither . withTransaction st $ \db -> - connections - <$> DB.queryNamed +getUserContactLinkConnections :: DB.Connection -> UserId -> ExceptT StoreError IO [Connection] +getUserContactLinkConnections db userId = + connections =<< liftIO getConnections + where + getConnections = + DB.queryNamed db [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -596,110 +591,110 @@ getUserContactLinkConnections st userId = AND uc.local_display_name = '' |] [":user_id" := userId] - where - connections [] = Left SEUserContactLinkNotFound - connections rows = Right $ map toConnection rows + connections [] = throwError SEUserContactLinkNotFound + connections rows = pure $ map toConnection rows -deleteUserContactLink :: MonadUnliftIO m => SQLiteStore -> UserId -> m () -deleteUserContactLink st userId = - liftIO . withTransaction st $ \db -> do - DB.execute - db - [sql| - DELETE FROM connections WHERE connection_id IN ( - SELECT connection_id - FROM connections c - JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = ? AND uc.local_display_name = '' - ) - |] - (Only userId) - DB.executeNamed - db - [sql| - DELETE FROM display_names - WHERE user_id = :user_id - AND local_display_name in ( - SELECT cr.local_display_name - FROM contact_requests cr - JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = :user_id - AND uc.local_display_name = '' - ) - |] - [":user_id" := userId] - DB.executeNamed - db - [sql| - DELETE FROM contact_profiles - WHERE contact_profile_id in ( - SELECT cr.contact_profile_id +deleteUserContactLink :: DB.Connection -> UserId -> IO () +deleteUserContactLink db userId = do + DB.execute + db + [sql| + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' + ) + |] + (Only userId) + DB.executeNamed + db + [sql| + DELETE FROM display_names + WHERE user_id = :user_id + AND local_display_name in ( + SELECT cr.local_display_name FROM contact_requests cr JOIN user_contact_links uc USING (user_contact_link_id) WHERE uc.user_id = :user_id AND uc.local_display_name = '' ) - |] - [":user_id" := userId] - DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = ''" (Only userId) + |] + [":user_id" := userId] + DB.executeNamed + db + [sql| + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = :user_id + AND uc.local_display_name = '' + ) + |] + [":user_id" := userId] + DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = ''" (Only userId) -getUserContactLink :: StoreMonad m => SQLiteStore -> UserId -> m (ConnReqContact, Bool) -getUserContactLink st userId = - liftIOEither . withTransaction st $ \db -> - getUserContactLink_ db userId - -getUserContactLink_ :: DB.Connection -> UserId -> IO (Either StoreError (ConnReqContact, Bool)) -getUserContactLink_ db userId = - firstRow id SEUserContactLinkNotFound $ +getUserContactLink :: DB.Connection -> UserId -> ExceptT StoreError IO (ConnReqContact, Bool, Maybe MsgContent) +getUserContactLink db userId = + ExceptT . firstRow id SEUserContactLinkNotFound $ DB.query db [sql| - SELECT conn_req_contact, auto_accept + SELECT conn_req_contact, auto_accept, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND local_display_name = '' |] (Only userId) -updateUserContactLinkAutoAccept :: StoreMonad m => SQLiteStore -> UserId -> Bool -> m (ConnReqContact, Bool) -updateUserContactLinkAutoAccept st userId autoAccept = do - liftIOEither . withTransaction st $ \db -> runExceptT $ do - (cReqUri, _) <- ExceptT $ getUserContactLink_ db userId - liftIO $ updateUserContactLinkAutoAccept_ db - pure (cReqUri, autoAccept) +getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> IO (Maybe (ConnReqContact, Bool, Maybe MsgContent)) +getUserContactLinkById db userId userContactLinkId = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT conn_req_contact, auto_accept, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? + AND user_contact_link_id = ? + |] + (userId, userContactLinkId) + +updateUserContactLinkAutoAccept :: DB.Connection -> UserId -> Bool -> Maybe MsgContent -> ExceptT StoreError IO (ConnReqContact, Bool, Maybe MsgContent) +updateUserContactLinkAutoAccept db userId autoAccept msgContent = do + (cReqUri, _, _) <- getUserContactLink db userId + liftIO updateUserContactLinkAutoAccept_ + pure (cReqUri, autoAccept, msgContent) where - updateUserContactLinkAutoAccept_ :: DB.Connection -> IO () - updateUserContactLinkAutoAccept_ db = + updateUserContactLinkAutoAccept_ :: IO () + updateUserContactLinkAutoAccept_ = DB.execute db [sql| UPDATE user_contact_links - SET auto_accept = ? + SET auto_accept = ?, auto_reply_msg_content = ? WHERE user_id = ? AND local_display_name = '' |] - (autoAccept, userId) + (autoAccept, msgContent, userId) -createOrUpdateContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> m ContactOrRequest -createOrUpdateContactRequest st userId userContactLinkId invId profile xContactId_ = - liftIOEither . withTransaction st $ \db -> - createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_ - -createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError ContactOrRequest) -createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName, image} xContactId_ = - maybeM getContact' xContactId_ >>= \case - Just contact -> pure . Right $ CORContact contact - Nothing -> CORRequest <$$> createOrUpdate_ +createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image} xContactId_ = + liftIO (maybeM getContact' xContactId_) >>= \case + Just contact -> pure $ CORContact contact + Nothing -> CORRequest <$> createOrUpdate_ where maybeM = maybe (pure Nothing) - createOrUpdate_ :: IO (Either StoreError UserContactRequest) - createOrUpdate_ = runExceptT $ do + createOrUpdate_ :: ExceptT StoreError IO UserContactRequest + createOrUpdate_ = do cReqId <- ExceptT $ maybeM getContactRequest' xContactId_ >>= \case Nothing -> createContactRequest Just cr -> updateContactRequest cr $> Right (contactRequestId (cr :: UserContactRequest)) - ExceptT $ getContactRequest_ db userId cReqId + getContactRequest db userId cReqId createContactRequest :: IO (Either StoreError Int64) createContactRequest = do currentTs <- getCurrentTime @@ -722,15 +717,15 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = - fmap toContact . listToMaybe - <$> DB.query + maybeFirstRow toContact $ + DB.query db [sql| SELECT -- Contact ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -742,8 +737,8 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display (userId, xContactId) getContactRequest' :: XContactId -> IO (Maybe UserContactRequest) getContactRequest' xContactId = - fmap toContactRequest . listToMaybe - <$> DB.query + maybeFirstRow toContactRequest $ + DB.query db [sql| SELECT @@ -785,14 +780,9 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display |] (displayName, fullName, image, currentTs, userId, cReqId) -getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m UserContactRequest -getContactRequest st userId contactRequestId = - liftIOEither . withTransaction st $ \db -> - getContactRequest_ db userId contactRequestId - -getContactRequest_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError UserContactRequest) -getContactRequest_ db userId contactRequestId = - firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ +getContactRequest :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO UserContactRequest +getContactRequest db userId contactRequestId = + ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ DB.query db [sql| @@ -814,142 +804,137 @@ toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userCon let profile = Profile {displayName, fullName, image} in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} -getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 -getContactRequestIdByName st userId cName = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly (SEContactRequestNotFoundByName cName) $ - DB.query db "SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, cName) +getContactRequestIdByName :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Int64 +getContactRequestIdByName db userId cName = + ExceptT . firstRow fromOnly (SEContactRequestNotFoundByName cName) $ + DB.query db "SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, cName) -deleteContactRequest :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () -deleteContactRequest st userId contactRequestId = - liftIO . withTransaction st $ \db -> do - DB.execute - db - [sql| - DELETE FROM contact_profiles - WHERE contact_profile_id in ( - SELECT contact_profile_id - FROM contact_requests - WHERE user_id = ? AND contact_request_id = ? - ) - |] - (userId, contactRequestId) - DB.execute - db - [sql| - DELETE FROM display_names - WHERE user_id = ? AND local_display_name = ( - SELECT local_display_name FROM contact_requests - WHERE user_id = ? AND contact_request_id = ? - ) - |] - (userId, userId, contactRequestId) - DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) +deleteContactRequest :: DB.Connection -> UserId -> Int64 -> IO () +deleteContactRequest db userId contactRequestId = do + DB.execute + db + [sql| + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + |] + (userId, contactRequestId) + DB.execute + db + [sql| + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ( + SELECT local_display_name FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + |] + (userId, userId, contactRequestId) + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> Maybe XContactId -> m Contact -createAcceptedContact st userId agentConnId localDisplayName profileId profile xContactId = - liftIO . withTransaction st $ \db -> do - DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?)" - (userId, localDisplayName, profileId, currentTs, currentTs, xContactId) - contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 currentTs - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs, updatedAt = currentTs} +createAcceptedContact :: DB.Connection -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> Int64 -> Maybe XContactId -> IO Contact +createAcceptedContact db userId agentConnId localDisplayName profileId profile userContactLinkId xContactId = do + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?)" + (userId, localDisplayName, profileId, currentTs, currentTs, xContactId) + contactId <- insertedRowId db + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) 0 currentTs + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs, updatedAt = currentTs} -getLiveSndFileTransfers :: MonadUnliftIO m => SQLiteStore -> User -> m [SndFileTransfer] -getLiveSndFileTransfers st User {userId} = - liftIO . withTransaction st $ \db -> do - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT DISTINCT f.file_id - FROM files f - JOIN snd_files s - WHERE f.user_id = ? AND s.file_status IN (?, ?, ?) - |] - (userId, FSNew, FSAccepted, FSConnected) - concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds - where - liveTransfer :: SndFileTransfer -> Bool - liveTransfer SndFileTransfer {fileStatus} = fileStatus `elem` [FSNew, FSAccepted, FSConnected] - -getLiveRcvFileTransfers :: MonadUnliftIO m => SQLiteStore -> User -> m [RcvFileTransfer] -getLiveRcvFileTransfers st User {userId} = - liftIO . withTransaction st $ \db -> do - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT f.file_id - FROM files f - JOIN rcv_files r - WHERE f.user_id = ? AND r.file_status IN (?, ?) - |] - (userId, FSAccepted, FSConnected) - rights <$> mapM (getRcvFileTransfer_ db userId) fileIds - -getPendingSndChunks :: MonadUnliftIO m => SQLiteStore -> Int64 -> Int64 -> m [Integer] -getPendingSndChunks st fileId connId = - liftIO . withTransaction st $ \db -> +getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] +getLiveSndFileTransfers db User {userId} = do + fileIds :: [Int64] <- map fromOnly <$> DB.query db [sql| - SELECT chunk_number - FROM snd_file_chunks - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id IS NULL - ORDER BY chunk_number + SELECT DISTINCT f.file_id + FROM files f + JOIN snd_files s + WHERE f.user_id = ? AND s.file_status IN (?, ?, ?) |] - (fileId, connId) + (userId, FSNew, FSAccepted, FSConnected) + concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds + where + liveTransfer :: SndFileTransfer -> Bool + liveTransfer SndFileTransfer {fileStatus} = fileStatus `elem` [FSNew, FSAccepted, FSConnected] -getPendingConnections :: MonadUnliftIO m => SQLiteStore -> User -> m [Connection] -getPendingConnections st User {userId} = - liftIO . withTransaction st $ \db -> - map toConnection - <$> DB.queryNamed - db - [sql| - SELECT connection_id, agent_conn_id, conn_level, via_contact, - conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at - FROM connections - WHERE user_id = :user_id - AND conn_type = :conn_type - AND contact_id IS NULL - |] - [":user_id" := userId, ":conn_type" := ConnContact] - -getContactConnections :: StoreMonad m => SQLiteStore -> UserId -> Contact -> m [Connection] -getContactConnections st userId Contact {contactId} = - liftIOEither . withTransaction st $ \db -> - connections +getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer] +getLiveRcvFileTransfers db user@User {userId} = do + fileIds :: [Int64] <- + map fromOnly <$> DB.query db [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + SELECT f.file_id + FROM files f + JOIN rcv_files r + WHERE f.user_id = ? AND r.file_status IN (?, ?) + |] + (userId, FSAccepted, FSConnected) + rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds + +getPendingSndChunks :: DB.Connection -> Int64 -> Int64 -> IO [Integer] +getPendingSndChunks db fileId connId = + map fromOnly + <$> DB.query + db + [sql| + SELECT chunk_number + FROM snd_file_chunks + WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id IS NULL + ORDER BY chunk_number + |] + (fileId, connId) + +getPendingConnections :: DB.Connection -> User -> IO [Connection] +getPendingConnections db User {userId} = + map toConnection + <$> DB.queryNamed + db + [sql| + SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, + conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at + FROM connections + WHERE user_id = :user_id + AND conn_type = :conn_type + AND contact_id IS NULL + |] + [":user_id" := userId, ":conn_type" := ConnContact] + +getContactConnections :: DB.Connection -> UserId -> Contact -> ExceptT StoreError IO [Connection] +getContactConnections db userId Contact {contactId} = + connections =<< liftIO getConnections_ + where + getConnections_ = + DB.query + db + [sql| + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? |] (userId, userId, contactId) - where - connections [] = Left $ SEContactNotFound contactId - connections rows = Right $ map toConnection rows + connections [] = throwError $ SEContactNotFound contactId + connections rows = pure $ map toConnection rows -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, UTCTime) +type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, ConnStatus, ConnType) :. EntityIdsRow :. Only UTCTime + +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe ConnStatus, Maybe ConnType) :. EntityIdsRow :. Only (Maybe UTCTime) toConnection :: ConnectionRow -> Connection -toConnection (connId, acId, connLevel, viaContact, connStatus, connType, contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId, createdAt) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) = let entityId = entityId_ connType - in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, connStatus, connType, entityId, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, connStatus, connType, entityId, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -959,180 +944,172 @@ toConnection (connId, acId, connLevel, viaContact, connStatus, connType, contact entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection (Just connId, Just agentConnId, Just connLevel, viaContact, Just connStatus, Just connType, contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId, Just createdAt) = - Just $ toConnection (connId, agentConnId, connLevel, viaContact, connStatus, connType, contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId, createdAt) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just connStatus, Just connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only (Just createdAt)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) toMaybeConnection _ = Nothing -getMatchingContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [Contact] -getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName, image}} = - liftIO . withTransaction st $ \db -> do - contactIds <- - map fromOnly - <$> DB.queryNamed - db - [sql| - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id - AND p.display_name = :display_name AND p.full_name = :full_name - AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image) - |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":display_name" := displayName, - ":full_name" := fullName, - ":image" := image - ] - rights <$> mapM (getContact_ db userId) contactIds - -createSentProbe :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> UserId -> Contact -> m (Probe, Int64) -createSentProbe st gVar userId _to@Contact {contactId} = - liftIOEither . withTransaction st $ \db -> - createWithRandomBytes 32 gVar $ \probe -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO sent_probes (contact_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (contactId, probe, userId, currentTs, currentTs) - (Probe probe,) <$> insertedRowId db - -createSentProbeHash :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> Contact -> m () -createSentProbeHash st userId probeId _to@Contact {contactId} = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (probeId, contactId, userId, currentTs, currentTs) - -matchReceivedProbe :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Probe -> m (Maybe Contact) -matchReceivedProbe st userId _from@Contact {contactId} (Probe probe) = - liftIO . withTransaction st $ \db -> do - let probeHash = C.sha256Hash probe - contactIds <- - map fromOnly - <$> DB.query - db - [sql| - SELECT c.contact_id - FROM contacts c - JOIN received_probes r ON r.contact_id = c.contact_id - WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL - |] - (userId, probeHash) - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO received_probes (contact_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (contactId, probe, probeHash, userId, currentTs, currentTs) - case contactIds of - [] -> pure Nothing - cId : _ -> eitherToMaybe <$> getContact_ db userId cId - -matchReceivedProbeHash :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> ProbeHash -> m (Maybe (Contact, Probe)) -matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) = - liftIO . withTransaction st $ \db -> do - namesAndProbes <- - DB.query +getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact] +getMatchingContacts db userId Contact {contactId, profile = Profile {displayName, fullName, image}} = do + contactIds <- + map fromOnly + <$> DB.queryNamed db [sql| - SELECT c.contact_id, r.probe + SELECT ct.contact_id + FROM contacts ct + JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id + WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id + AND p.display_name = :display_name AND p.full_name = :full_name + AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image) + |] + [ ":user_id" := userId, + ":contact_id" := contactId, + ":display_name" := displayName, + ":full_name" := fullName, + ":image" := image + ] + rights <$> mapM (runExceptT . getContact db userId) contactIds + +createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64) +createSentProbe db gVar userId _to@Contact {contactId} = + createWithRandomBytes 32 gVar $ \probe -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO sent_probes (contact_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (contactId, probe, userId, currentTs, currentTs) + (Probe probe,) <$> insertedRowId db + +createSentProbeHash :: DB.Connection -> UserId -> Int64 -> Contact -> IO () +createSentProbeHash db userId probeId _to@Contact {contactId} = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (probeId, contactId, userId, currentTs, currentTs) + +matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact) +matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do + let probeHash = C.sha256Hash probe + contactIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT c.contact_id FROM contacts c JOIN received_probes r ON r.contact_id = c.contact_id - WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL + WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL |] (userId, probeHash) - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO received_probes (contact_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (contactId, probeHash, userId, currentTs, currentTs) - case namesAndProbes of - [] -> pure Nothing - (cId, probe) : _ -> - either (const Nothing) (Just . (,Probe probe)) - <$> getContact_ db userId cId + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO received_probes (contact_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (contactId, probe, probeHash, userId, currentTs, currentTs) + case contactIds of + [] -> pure Nothing + cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId) -matchSentProbe :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Probe -> m (Maybe Contact) -matchSentProbe st userId _from@Contact {contactId} (Probe probe) = - liftIO . withTransaction st $ \db -> do - contactIds <- - map fromOnly - <$> DB.query - db - [sql| - SELECT c.contact_id - FROM contacts c - JOIN sent_probes s ON s.contact_id = c.contact_id - JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id - WHERE c.user_id = ? AND s.probe = ? AND h.contact_id = ? - |] - (userId, probe, contactId) - case contactIds of - [] -> pure Nothing - cId : _ -> eitherToMaybe <$> getContact_ db userId cId - -mergeContactRecords :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Contact -> m () -mergeContactRecords st userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.executeNamed +matchReceivedProbeHash :: DB.Connection -> UserId -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe)) +matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash) = do + namesAndProbes <- + DB.query db [sql| - UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id + SELECT c.contact_id, r.probe + FROM contacts c + JOIN received_probes r ON r.contact_id = c.contact_id + WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + (userId, probeHash) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO received_probes (contact_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (contactId, probeHash, userId, currentTs, currentTs) + case namesAndProbes of + [] -> pure Nothing + (cId, probe) : _ -> + either (const Nothing) (Just . (,Probe probe)) + <$> runExceptT (getContact db userId cId) -getConnectionEntity :: StoreMonad m => SQLiteStore -> User -> ConnId -> m ConnectionEntity -getConnectionEntity st User {userId, userContactId} agentConnId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - c@Connection {connType, entityId} <- getConnection_ db - case entityId of - Nothing -> - if connType == ConnContact - then pure $ RcvDirectMsgConnection c Nothing - else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" - Just entId -> - case connType of - ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ db entId c - ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ db entId c - ConnSndFile -> SndFileConnection c <$> getConnSndFileTransfer_ db entId c - ConnRcvFile -> RcvFileConnection c <$> ExceptT (getRcvFileTransfer_ db userId entId) - ConnUserContact -> UserContactConnection c <$> getUserContact_ db entId +matchSentProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact) +matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do + contactIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT c.contact_id + FROM contacts c + JOIN sent_probes s ON s.contact_id = c.contact_id + JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id + WHERE c.user_id = ? AND s.probe = ? AND h.contact_id = ? + |] + (userId, probe, contactId) + case contactIds of + [] -> pure Nothing + cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId) + +mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () +mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.executeNamed + db + [sql| + UPDATE group_members + SET contact_id = :to_contact_id, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), + updated_at = :updated_at + WHERE contact_id = :from_contact_id + AND user_id = :user_id + |] + [ ":to_contact_id" := toContactId, + ":from_contact_id" := fromContactId, + ":user_id" := userId, + ":updated_at" := currentTs + ] + deleteContactProfile_ db userId fromContactId + DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + +getConnectionEntity :: DB.Connection -> User -> ConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity db user@User {userId, userContactId} agentConnId = do + c@Connection {connType, entityId} <- getConnection_ + case entityId of + Nothing -> + if connType == ConnContact + then pure $ RcvDirectMsgConnection c Nothing + else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" + Just entId -> + case connType of + ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ entId c + ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ entId c + ConnSndFile -> SndFileConnection c <$> getConnSndFileTransfer_ entId c + ConnRcvFile -> RcvFileConnection c <$> getRcvFileTransfer db user entId + ConnUserContact -> UserContactConnection c <$> getUserContact_ entId where - getConnection_ :: DB.Connection -> ExceptT StoreError IO Connection - getConnection_ db = ExceptT $ do + getConnection_ :: ExceptT StoreError IO Connection + getConnection_ = ExceptT $ do connection <$> DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_level, via_contact, + SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at FROM connections WHERE user_id = ? AND agent_conn_id = ? @@ -1141,8 +1118,8 @@ getConnectionEntity st User {userId, userContactId} agentConnId = connection :: [ConnectionRow] -> Either StoreError Connection connection (connRow : _) = Right $ toConnection connRow connection _ = Left . SEConnectionNotFound $ AgentConnId agentConnId - getContactRec_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO Contact - getContactRec_ db contactId c = ExceptT $ do + getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact + getContactRec_ contactId c = ExceptT $ do toContact' contactId c <$> DB.query db @@ -1158,8 +1135,8 @@ getConnectionEntity st User {userId, userContactId} agentConnId = let profile = Profile {displayName, fullName, image} in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" - getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) - getGroupAndMember_ db groupMemberId c = ExceptT $ do + getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) + getGroupAndMember_ groupMemberId c = ExceptT $ do firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ DB.query db @@ -1189,8 +1166,8 @@ getConnectionEntity st User {userId, userContactId} agentConnId = let groupInfo = toGroupInfo userContactId groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) - getConnSndFileTransfer_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer - getConnSndFileTransfer_ db fileId Connection {connId} = + getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer + getConnSndFileTransfer_ fileId Connection {connId} = ExceptT $ sndFileTransfer_ fileId connId <$> DB.query @@ -1210,8 +1187,8 @@ getConnectionEntity st User {userId, userContactId} agentConnId = Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId = AgentConnId agentConnId} Nothing -> Left $ SESndFileInvalid fileId sndFileTransfer_ fileId _ _ = Left $ SESndFileNotFound fileId - getUserContact_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UserContact - getUserContact_ db userContactLinkId = ExceptT $ do + getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact + getUserContact_ userContactLinkId = ExceptT $ do userContact_ <$> DB.query db @@ -1226,62 +1203,60 @@ getConnectionEntity st User {userId, userContactId} agentConnId = userContact_ [Only cReq] = Right UserContact {userContactLinkId, connReqContact = cReq} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionsContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> [ConnId] -> m [ContactRef] -getConnectionsContacts st userId agentConnIds = - liftIO . withTransaction st $ \db -> do - DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" - DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" - DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds - conns <- - map (uncurry ContactRef) - <$> DB.query - db - [sql| - SELECT ct.contact_id, ct.local_display_name - FROM contacts ct - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? - AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) - AND c.conn_type = ? - |] - (userId, ConnContact) - DB.execute_ db "DROP TABLE temp.conn_ids" - pure conns - -getGroupAndMember :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (GroupInfo, GroupMember) -getGroupAndMember st User {userId, userContactId} groupMemberId = - liftIOEither . withTransaction st $ \db -> - firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ - DB.query +getConnectionsContacts :: DB.Connection -> UserId -> [ConnId] -> IO [ContactRef] +getConnectionsContacts db userId agentConnIds = do + DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" + DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" + DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds + conns <- + map (uncurry ContactRef) + <$> DB.query db [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, - -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - JOIN groups g ON g.group_id = m.group_id - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.group_member_id = m.group_member_id - ) - WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + SELECT ct.contact_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? + AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + AND c.conn_type = ? |] - (groupMemberId, userId, userContactId) + (userId, ConnContact) + DB.execute_ db "DROP TABLE temp.conn_ids" + pure conns + +getGroupAndMember :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) +getGroupAndMember db User {userId, userContactId} groupMemberId = + ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupMemberId, userId, userContactId) where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) toGroupAndMember (groupInfoRow :. memberRow :. connRow) = @@ -1289,16 +1264,15 @@ getGroupAndMember st User {userId, userContactId} groupMemberId = member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) -updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m () -updateConnectionStatus st Connection {connId} connStatus = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) +updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO () +updateConnectionStatus db Connection {connId} connStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) -- | creates completely new group with a single member - the current user -createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m GroupInfo -createNewGroup st gVar user groupProfile = - liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do +createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo +createNewGroup db gVar user groupProfile = + checkConstraint SEDuplicateName . liftIO $ do let GroupProfile {displayName, fullName, image} = groupProfile uId = userId user currentTs <- getCurrentTime @@ -1318,24 +1292,22 @@ createNewGroup st gVar user groupProfile = groupId <- insertedRowId db memberId <- encodedRandomBytes gVar 12 membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs - pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs} + pure GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: - StoreMonad m => SQLiteStore -> User -> Contact -> GroupInvitation -> m GroupInfo -createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = - liftIOEither . withTransaction st $ \db -> do - getInvitationGroupId_ db >>= \case - Nothing -> createGroupInvitation_ db - -- TODO treat the case that the invitation details could've changed - Just gId -> getGroupInfo_ db user gId +createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> ExceptT StoreError IO GroupInfo +createGroupInvitation db user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = + liftIO getInvitationGroupId_ >>= \case + Nothing -> ExceptT createGroupInvitation_ + -- TODO treat the case that the invitation details could've changed + Just gId -> getGroupInfo db user gId where - getInvitationGroupId_ :: DB.Connection -> IO (Maybe Int64) - getInvitationGroupId_ db = - listToMaybe . map fromOnly - <$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) - createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo) - createGroupInvitation_ db = do + getInvitationGroupId_ :: IO (Maybe Int64) + getInvitationGroupId_ = + maybeFirstRow fromOnly $ + DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) + createGroupInvitation_ :: IO (Either StoreError GroupInfo) + createGroupInvitation_ = do let GroupProfile {displayName, fullName, image} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> do currentTs <- getCurrentTime @@ -1355,70 +1327,61 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroupByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Group -getGroupByName st user gName = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - groupId <- ExceptT $ getGroupIdByName_ db user gName - ExceptT $ getGroup_ db user groupId +getGroupByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO Group +getGroupByName db user gName = do + groupId <- getGroupIdByName db user gName + getGroup db user groupId -getGroup :: StoreMonad m => SQLiteStore -> User -> Int64 -> m Group -getGroup st user groupId = - liftIOEither . withTransaction st $ \db -> getGroup_ db user groupId - -getGroup_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError Group) -getGroup_ db user groupId = runExceptT $ do - gInfo <- ExceptT $ getGroupInfo_ db user groupId - members <- liftIO $ getGroupMembers_ db user gInfo +getGroup :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Group +getGroup db user groupId = do + gInfo <- getGroupInfo db user groupId + members <- liftIO $ getGroupMembers db user gInfo pure $ Group gInfo members -deleteGroup :: MonadUnliftIO m => SQLiteStore -> User -> Group -> m () -deleteGroup st User {userId} (Group GroupInfo {groupId, localDisplayName} members) = - liftIO . withTransaction st $ \db -> do - forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId m) - DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) - DB.execute +deleteGroup :: DB.Connection -> User -> Group -> IO () +deleteGroup db User {userId} (Group GroupInfo {groupId, localDisplayName} members) = do + forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId m) + DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) + DB.execute + db + [sql| + DELETE FROM group_profiles + WHERE group_profile_id in ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + |] + (userId, groupId) + DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId) + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + +getUserGroups :: DB.Connection -> User -> IO [Group] +getUserGroups db user@User {userId} = do + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getGroup db user) groupIds + +getUserGroupDetails :: DB.Connection -> User -> IO [GroupInfo] +getUserGroupDetails db User {userId, userContactId} = + map (toGroupInfo userContactId) + <$> DB.query db [sql| - DELETE FROM group_profiles - WHERE group_profile_id in ( - SELECT group_profile_id - FROM groups - WHERE user_id = ? AND group_id = ? - ) + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members m USING (group_id) + JOIN contact_profiles mp USING (contact_profile_id) + WHERE g.user_id = ? AND m.contact_id = ? |] - (userId, groupId) - DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + (userId, userContactId) -getUserGroups :: MonadUnliftIO m => SQLiteStore -> User -> m [Group] -getUserGroups st user@User {userId} = - liftIO . withTransaction st $ \db -> do - groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (getGroup_ db user) groupIds - -getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> User -> m [GroupInfo] -getUserGroupDetails st User {userId, userContactId} = - liftIO . withTransaction st $ \db -> - map (toGroupInfo userContactId) - <$> DB.query - db - [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, - m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members m USING (group_id) - JOIN contact_profiles mp USING (contact_profile_id) - WHERE g.user_id = ? AND m.contact_id = ? - |] - (userId, userContactId) - -getGroupInfoByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m GroupInfo -getGroupInfoByName st user gName = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - gId <- ExceptT $ getGroupIdByName_ db user gName - ExceptT $ getGroupInfo_ db user gId +getGroupInfoByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName db user gName = do + gId <- getGroupIdByName db user gName + getGroupInfo db user gId type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime, UTCTime) :. GroupMemberRow @@ -1427,11 +1390,8 @@ toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, im let membership = toGroupMember userContactId userMemberRow in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt, updatedAt} -getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember] -getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo - -getGroupMembers_ :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do +getGroupMembers :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers db User {userId, userContactId} GroupInfo {groupId} = do map toContactMember <$> DB.query db @@ -1439,7 +1399,7 @@ getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do SELECT m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -1458,20 +1418,19 @@ getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do -- TODO no need to load all members to find the member who invited the used, -- instead of findFromContact there could be a query -getGroupInvitation :: StoreMonad m => SQLiteStore -> User -> GroupName -> m ReceivedGroupInvitation -getGroupInvitation st user localDisplayName = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - cReq <- getConnRec_ db user - groupId <- ExceptT $ getGroupIdByName_ db user localDisplayName - Group groupInfo@GroupInfo {membership} members <- ExceptT $ getGroup_ db user groupId - when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined - case (cReq, findFromContact (invitedBy membership) members) of - (Just connRequest, Just fromMember) -> - pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} - _ -> throwError SEGroupInvitationNotFound +getGroupInvitation :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation db user localDisplayName = do + cReq <- getConnRec_ user + groupId <- getGroupIdByName db user localDisplayName + Group groupInfo@GroupInfo {membership} members <- getGroup db user groupId + when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined + case (cReq, findFromContact (invitedBy membership) members) of + (Just connRequest, Just fromMember) -> + pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} + _ -> throwError SEGroupInvitationNotFound where - getConnRec_ :: DB.Connection -> User -> ExceptT StoreError IO (Maybe ConnReqInvitation) - getConnRec_ db User {userId} = ExceptT $ do + getConnRec_ :: User -> ExceptT StoreError IO (Maybe ConnReqInvitation) + getConnRec_ User {userId} = ExceptT $ do firstRow fromOnly (SEGroupNotFoundByName localDisplayName) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.local_display_name = ? AND g.user_id = ?" (localDisplayName, userId) findFromContact :: InvitedBy -> [GroupMember] -> Maybe GroupMember @@ -1494,66 +1453,61 @@ toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberI Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) toMaybeGroupMember _ _ = Nothing -createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> m GroupMember -createContactMember st gVar user groupId contact memberRole agentConnId connRequest = - liftIOEither . withTransaction st $ \db -> - createWithRandomId gVar $ \memId -> do - currentTs <- getCurrentTime - member@GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact (MemberIdRole (MemberId memId) memberRole) GCInviteeMember GSMemInvited IBUser (Just connRequest) currentTs - void $ createMemberConnection_ db (userId user) groupMemberId agentConnId Nothing 0 currentTs - pure member - -getMemberInvitation :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Maybe ConnReqInvitation) -getMemberInvitation st User {userId} groupMemberId = - liftIO . withTransaction st $ \db -> - join . listToMaybe . map fromOnly - <$> DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) - -createMemberConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> ConnId -> m () -createMemberConnection st userId GroupMember {groupMemberId} agentConnId = - liftIO . withTransaction st $ \db -> do +createContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember +createContactMember db gVar user groupId contact memberRole agentConnId connRequest = + createWithRandomId gVar $ \memId -> do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + member@GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact (MemberIdRole (MemberId memId) memberRole) GCInviteeMember GSMemInvited IBUser (Just connRequest) currentTs + void $ createMemberConnection_ db (userId user) groupMemberId agentConnId Nothing 0 currentTs + pure member -updateGroupMemberStatus :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> GroupMemberStatus -> m () -updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.executeNamed - db - [sql| - UPDATE group_members - SET member_status = :member_status, updated_at = :updated_at - WHERE user_id = :user_id AND group_member_id = :group_member_id - |] - [ ":user_id" := userId, - ":group_member_id" := groupMemberId, - ":member_status" := memStatus, - ":updated_at" := currentTs - ] +getMemberInvitation :: DB.Connection -> User -> Int64 -> IO (Maybe ConnReqInvitation) +getMemberInvitation db User {userId} groupMemberId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) + +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId = do + currentTs <- getCurrentTime + void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + +updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () +updateGroupMemberStatus db userId GroupMember {groupMemberId} memStatus = do + currentTs <- getCurrentTime + DB.executeNamed + db + [sql| + UPDATE group_members + SET member_status = :member_status, updated_at = :updated_at + WHERE user_id = :user_id AND group_member_id = :group_member_id + |] + [ ":user_id" := userId, + ":group_member_id" := groupMemberId, + ":member_status" := memStatus, + ":updated_at" := currentTs + ] -- | add new member with profile -createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember -createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus = - liftIOEither . withTransaction st $ \db -> - withLocalDisplayName db userId displayName $ \localDisplayName -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO contact_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (displayName, fullName, image, userId, currentTs, currentTs) - memProfileId <- insertedRowId db - let newMember = - NewGroupMember - { memInfo, - memCategory, - memStatus, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Nothing, - memProfileId - } - createNewMember_ db user gInfo newMember currentTs +createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus = + ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (displayName, fullName, image, userId, currentTs, currentTs) + memProfileId <- insertedRowId db + let newMember = + NewGroupMember + { memInfo, + memCategory, + memStatus, + memInvitedBy = IBUnknown, + localDisplayName, + memContactId = Nothing, + memProfileId + } + createNewMember_ db user gInfo newMember currentTs createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ @@ -1584,25 +1538,21 @@ createNewMember_ groupMemberId <- insertedRowId db pure GroupMember {..} -deleteGroupMemberConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> m () -deleteGroupMemberConnection st userId m = - liftIO . withTransaction st $ \db -> deleteGroupMemberConnection_ db userId m - -deleteGroupMemberConnection_ :: DB.Connection -> UserId -> GroupMember -> IO () -deleteGroupMemberConnection_ db userId GroupMember {groupMemberId} = +deleteGroupMemberConnection :: DB.Connection -> UserId -> GroupMember -> IO () +deleteGroupMemberConnection db userId GroupMember {groupMemberId} = DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId) -createIntroductions :: MonadUnliftIO m => SQLiteStore -> [GroupMember] -> GroupMember -> m [GroupMemberIntro] -createIntroductions st members toMember = do +createIntroductions :: DB.Connection -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] +createIntroductions db members toMember = do let reMembers = filter (\m -> memberCurrent m && groupMemberId m /= groupMemberId toMember) members if null reMembers then pure [] - else liftIO . withTransaction st $ \db -> do + else do currentTs <- getCurrentTime - mapM (insertIntro_ db currentTs) reMembers + mapM (insertIntro_ currentTs) reMembers where - insertIntro_ :: DB.Connection -> UTCTime -> GroupMember -> IO GroupMemberIntro - insertIntro_ db ts reMember = do + insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro + insertIntro_ ts reMember = do DB.execute db [sql| @@ -1614,63 +1564,60 @@ createIntroductions st members toMember = do introId <- insertedRowId db pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} -updateIntroStatus :: MonadUnliftIO m => SQLiteStore -> Int64 -> GroupMemberIntroStatus -> m () -updateIntroStatus st introId introStatus = - liftIO . withTransaction st $ \db -> do +updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () +updateIntroStatus db introId introStatus = do + currentTs <- getCurrentTime + DB.executeNamed + db + [sql| + UPDATE group_member_intros + SET intro_status = :intro_status, updated_at = :updated_at + WHERE group_member_intro_id = :intro_id + |] + [":intro_status" := introStatus, ":updated_at" := currentTs, ":intro_id" := introId] + +saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro +saveIntroInvitation db reMember toMember introInv = do + intro <- getIntroduction_ db reMember toMember + liftIO $ do currentTs <- getCurrentTime DB.executeNamed db [sql| UPDATE group_member_intros - SET intro_status = :intro_status, updated_at = :updated_at - WHERE group_member_intro_id = :intro_id - |] - [":intro_status" := introStatus, ":updated_at" := currentTs, ":intro_id" := introId] - -saveIntroInvitation :: StoreMonad m => SQLiteStore -> GroupMember -> GroupMember -> IntroInvitation -> m GroupMemberIntro -saveIntroInvitation st reMember toMember introInv = do - liftIOEither . withTransaction st $ \db -> runExceptT $ do - intro <- getIntroduction_ db reMember toMember - liftIO $ do - currentTs <- getCurrentTime - DB.executeNamed - db - [sql| - UPDATE group_member_intros - SET intro_status = :intro_status, - group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info, - updated_at = :updated_at - WHERE group_member_intro_id = :intro_id - |] - [ ":intro_status" := GMIntroInvReceived, - ":group_queue_info" := groupConnReq introInv, - ":direct_queue_info" := directConnReq introInv, - ":updated_at" := currentTs, - ":intro_id" := introId intro - ] - pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} - -saveMemberInvitation :: StoreMonad m => SQLiteStore -> GroupMember -> IntroInvitation -> m () -saveMemberInvitation st GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.executeNamed - db - [sql| - UPDATE group_members - SET member_status = :member_status, + SET intro_status = :intro_status, group_queue_info = :group_queue_info, direct_queue_info = :direct_queue_info, updated_at = :updated_at - WHERE group_member_id = :group_member_id + WHERE group_member_intro_id = :intro_id |] - [ ":member_status" := GSMemIntroInvited, - ":group_queue_info" := groupConnReq, - ":direct_queue_info" := directConnReq, + [ ":intro_status" := GMIntroInvReceived, + ":group_queue_info" := groupConnReq introInv, + ":direct_queue_info" := directConnReq introInv, ":updated_at" := currentTs, - ":group_member_id" := groupMemberId + ":intro_id" := introId intro ] + pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} + +saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> IO () +saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = do + currentTs <- getCurrentTime + DB.executeNamed + db + [sql| + UPDATE group_members + SET member_status = :member_status, + group_queue_info = :group_queue_info, + direct_queue_info = :direct_queue_info, + updated_at = :updated_at + WHERE group_member_id = :group_member_id + |] + [ ":member_status" := GSMemIntroInvited, + ":group_queue_info" := groupConnReq, + ":direct_queue_info" := directConnReq, + ":updated_at" := currentTs, + ":group_member_id" := groupMemberId + ] getIntroduction_ :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro getIntroduction_ db reMember toMember = ExceptT $ do @@ -1690,40 +1637,38 @@ getIntroduction_ db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> m GroupMember -createIntroReMember st user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn - currentTs <- liftIO getCurrentTime - Connection {connId = directConnId} <- liftIO $ createContactConnection_ db userId directAgentConnId memberContactId cLevel currentTs - (localDisplayName, contactId, memProfileId) <- ExceptT $ createContact_ db userId directConnId memberProfile (Just groupId) currentTs - liftIO $ do - let newMember = - NewGroupMember - { memInfo, - memCategory = GCPreMember, - memStatus = GSMemIntroduced, - memInvitedBy = IBUnknown, - localDisplayName, - memContactId = Just contactId, - memProfileId - } - member <- createNewMember_ db user gInfo newMember currentTs - conn <- createMemberConnection_ db userId (groupMemberId member) groupAgentConnId memberContactId cLevel currentTs - pure (member :: GroupMember) {activeConn = Just conn} +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> ExceptT StoreError IO GroupMember +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = do + let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn + currentTs <- liftIO getCurrentTime + Connection {connId = directConnId} <- liftIO $ createMemberContactConnection_ db userId directAgentConnId memberContactId cLevel currentTs + (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile (Just groupId) currentTs + liftIO $ do + let newMember = + NewGroupMember + { memInfo, + memCategory = GCPreMember, + memStatus = GSMemIntroduced, + memInvitedBy = IBUnknown, + localDisplayName, + memContactId = Just contactId, + memProfileId + } + member <- createNewMember_ db user gInfo newMember currentTs + conn <- createMemberConnection_ db userId (groupMemberId member) groupAgentConnId memberContactId cLevel currentTs + pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: StoreMonad m => SQLiteStore -> UserId -> GroupMember -> GroupMember -> ConnId -> ConnId -> m () -createIntroToMemberContact st userId GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} groupAgentConnId directAgentConnId = - liftIO . withTransaction st $ \db -> do - let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn - currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs - Connection {connId = directConnId} <- createContactConnection_ db userId directAgentConnId viaContactId cLevel currentTs - contactId <- createMemberContact_ db directConnId currentTs - updateMember_ db contactId currentTs +createIntroToMemberContact :: DB.Connection -> UserId -> GroupMember -> GroupMember -> ConnId -> ConnId -> IO () +createIntroToMemberContact db userId GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} groupAgentConnId directAgentConnId = do + let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn + currentTs <- getCurrentTime + void $ createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs + Connection {connId = directConnId} <- createMemberContactConnection_ db userId directAgentConnId viaContactId cLevel currentTs + contactId <- createMemberContact_ directConnId currentTs + updateMember_ contactId currentTs where - createMemberContact_ :: DB.Connection -> Int64 -> UTCTime -> IO Int64 - createMemberContact_ db connId ts = do + createMemberContact_ :: Int64 -> UTCTime -> IO Int64 + createMemberContact_ connId ts = do DB.execute db [sql| @@ -1736,8 +1681,8 @@ createIntroToMemberContact st userId GroupMember {memberContactId = viaContactId contactId <- insertedRowId db DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId) pure contactId - updateMember_ :: DB.Connection -> Int64 -> UTCTime -> IO () - updateMember_ db contactId ts = + updateMember_ :: Int64 -> UTCTime -> IO () + updateMember_ contactId ts = DB.executeNamed db [sql| @@ -1748,7 +1693,7 @@ createIntroToMemberContact st userId GroupMember {memberContactId = viaContactId [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId = createConnection_ db userId ConnMember (Just groupMemberId) +createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId viaContact Nothing createContactMember_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> UTCTime -> IO GroupMember createContactMember_ db user groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy = @@ -1791,215 +1736,199 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ":updated_at" := createdAt ] -getViaGroupMember :: MonadUnliftIO m => SQLiteStore -> User -> Contact -> m (Maybe (GroupInfo, GroupMember)) -getViaGroupMember st User {userId, userContactId} Contact {contactId} = - liftIO . withTransaction st $ \db -> - toGroupAndMember - <$> DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, - -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? - |] - (userId, contactId, userContactId) - where - toGroupAndMember :: [GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow] -> Maybe (GroupInfo, GroupMember) - toGroupAndMember [groupInfoRow :. memberRow :. connRow] = - let groupInfo = toGroupInfo userContactId groupInfoRow - member = toGroupMember userContactId memberRow - in Just (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) - toGroupAndMember _ = Nothing - -getViaGroupContact :: MonadUnliftIO m => SQLiteStore -> User -> GroupMember -> m (Maybe Contact) -getViaGroupContact st User {userId} GroupMember {groupMemberId} = - liftIO . withTransaction st $ \db -> - toContact' - <$> DB.query - db - [sql| - SELECT - ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at, ct.updated_at, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = ct.via_group - JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? - |] - (userId, groupMemberId) - where - toContact' :: [(Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime) :. ConnectionRow] -> Maybe Contact - toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt) :. connRow] = - let profile = Profile {displayName, fullName, image} - activeConn = toConnection connRow - in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} - toContact' _ = Nothing - -createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m Int64 -createSndFileTransfer st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} acId chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - fileId <- insertedRowId db - Connection {connId} <- createSndFileConnection_ db userId fileId acId - let fileStatus = FSNew - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, fileStatus, connId, currentTs, currentTs) - pure fileId - -createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 -createSndGroupFileTransfer st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - insertedRowId db - -createSndGroupFileTransferConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () -createSndGroupFileTransferConnection st userId fileId acId GroupMember {groupMemberId} = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) - -updateFileCancelled :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () -updateFileCancelled st User {userId} fileId ciFileStatus = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) - -updateCIFileStatus :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () -updateCIFileStatus st user fileId ciFileStatus = - liftIO . withTransaction st $ \db -> updateCIFileStatus_ db user fileId ciFileStatus - -updateCIFileStatus_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () -updateCIFileStatus_ db User {userId} fileId ciFileStatus = do - currentTs <- getCurrentTime - DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) - -getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId -getSharedMsgIdByFileId st userId fileId = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly (SESharedMsgIdNotFoundByFileId fileId) $ - DB.query - db - [sql| - SELECT i.shared_msg_id - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE f.user_id = ? AND f.file_id = ? - |] - (userId, fileId) - -getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 -getFileIdBySharedMsgId st userId contactId sharedMsgId = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ - DB.query - db - [sql| - SELECT f.file_id - FROM files f - JOIN chat_items i ON i.chat_item_id = f.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.shared_msg_id = ? - |] - (userId, contactId, sharedMsgId) - -getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 -getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ - DB.query - db - [sql| - SELECT f.file_id - FROM files f - JOIN chat_items i ON i.chat_item_id = f.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? AND i.shared_msg_id = ? - |] - (userId, groupId, sharedMsgId) - -getChatRefByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m ChatRef -getChatRefByFileId st User {userId} fileId = do - r <- liftIO . withTransaction st $ \db -> do +getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) +getViaGroupMember db User {userId, userContactId} Contact {contactId} = + maybeFirstRow toGroupAndMember $ DB.query db [sql| - SELECT contact_id, group_id - FROM files - WHERE user_id = ? AND file_id = ? - LIMIT 1 + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, + -- via GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.group_member_id = m.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? + |] + (userId, contactId, userContactId) + where + toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) + toGroupAndMember (groupInfoRow :. memberRow :. connRow) = + let groupInfo = toGroupInfo userContactId groupInfoRow + member = toGroupMember userContactId memberRow + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + +getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) +getViaGroupContact db User {userId} GroupMember {groupMemberId} = + maybeFirstRow toContact' $ + DB.query + db + [sql| + SELECT + ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at, ct.updated_at, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM contacts ct + JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.contact_id = ct.contact_id + ) + JOIN groups g ON g.group_id = ct.via_group + JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id + WHERE ct.user_id = ? AND m.group_member_id = ? + |] + (userId, groupMemberId) + where + toContact' :: (Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime) :. ConnectionRow -> Contact + toContact' ((contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt) :. connRow) = + let profile = Profile {displayName, fullName, image} + activeConn = toConnection connRow + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} + +createSndFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> IO Int64 +createSndFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize} acId chunkSize = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) + fileId <- insertedRowId db + Connection {connId} <- createSndFileConnection_ db userId fileId acId + let fileStatus = FSNew + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, fileStatus, connId, currentTs, currentTs) + pure fileId + +createSndGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> IO Int64 +createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) + insertedRowId db + +createSndGroupFileTransferConnection :: DB.Connection -> UserId -> Int64 -> ConnId -> GroupMember -> IO () +createSndGroupFileTransferConnection db userId fileId acId GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + Connection {connId} <- createSndFileConnection_ db userId fileId acId + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) + +updateFileCancelled :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () +updateFileCancelled db User {userId} fileId ciFileStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) + +updateCIFileStatus :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () +updateCIFileStatus db User {userId} fileId ciFileStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) + +getSharedMsgIdByFileId :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO SharedMsgId +getSharedMsgIdByFileId db userId fileId = + ExceptT . firstRow fromOnly (SESharedMsgIdNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.shared_msg_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) - case r of + +getFileIdBySharedMsgId :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> ExceptT StoreError IO Int64 +getFileIdBySharedMsgId db userId contactId sharedMsgId = + ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.shared_msg_id = ? + |] + (userId, contactId, sharedMsgId) + +getGroupFileIdBySharedMsgId :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> ExceptT StoreError IO Int64 +getGroupFileIdBySharedMsgId db userId groupId sharedMsgId = + ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? AND i.shared_msg_id = ? + |] + (userId, groupId, sharedMsgId) + +getChatRefByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO ChatRef +getChatRefByFileId db User {userId} fileId = + liftIO getChatRef >>= \case [(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId [(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId _ -> throwError $ SEInternalError "could not retrieve chat ref by file id" + where + getChatRef = + DB.query + db + [sql| + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + |] + (userId, fileId) createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing 0 currentTs + createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing Nothing 0 currentTs -updateSndFileStatus :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> FileStatus -> m () -updateSndFileStatus st SndFileTransfer {fileId, connId} status = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId) +updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () +updateSndFileStatus db SndFileTransfer {fileId, connId} status = do + currentTs <- getCurrentTime + DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId) -createSndFileChunk :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> m (Maybe Integer) -createSndFileChunk st SndFileTransfer {fileId, connId, fileSize, chunkSize} = - liftIO . withTransaction st $ \db -> do - chunkNo <- getLastChunkNo db - insertChunk db chunkNo - pure chunkNo +createSndFileChunk :: DB.Connection -> SndFileTransfer -> IO (Maybe Integer) +createSndFileChunk db SndFileTransfer {fileId, connId, fileSize, chunkSize} = do + chunkNo <- getLastChunkNo + insertChunk chunkNo + pure chunkNo where - getLastChunkNo db = do + getLastChunkNo = do ns <- DB.query db "SELECT chunk_number FROM snd_file_chunks WHERE file_id = ? AND connection_id = ? AND chunk_sent = 1 ORDER BY chunk_number DESC LIMIT 1" (fileId, connId) pure $ case map fromOnly ns of [] -> Just 1 n : _ -> if n * chunkSize >= fileSize then Nothing else Just (n + 1) - insertChunk db = \case + insertChunk = \case Just chunkNo -> do currentTs <- getCurrentTime DB.execute @@ -2008,76 +1937,66 @@ createSndFileChunk st SndFileTransfer {fileId, connId, fileSize, chunkSize} = (fileId, connId, chunkNo, currentTs, currentTs) Nothing -> pure () -updateSndFileChunkMsg :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> Integer -> AgentMsgId -> m () -updateSndFileChunkMsg st SndFileTransfer {fileId, connId} chunkNo msgId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_agent_msg_id = ?, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_number = ? - |] - (msgId, currentTs, fileId, connId, chunkNo) +updateSndFileChunkMsg :: DB.Connection -> SndFileTransfer -> Integer -> AgentMsgId -> IO () +updateSndFileChunkMsg db SndFileTransfer {fileId, connId} chunkNo msgId = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE snd_file_chunks + SET chunk_agent_msg_id = ?, updated_at = ? + WHERE file_id = ? AND connection_id = ? AND chunk_number = ? + |] + (msgId, currentTs, fileId, connId, chunkNo) -updateSndFileChunkSent :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> AgentMsgId -> m () -updateSndFileChunkSent st SndFileTransfer {fileId, connId} msgId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_sent = 1, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id = ? - |] - (currentTs, fileId, connId, msgId) +updateSndFileChunkSent :: DB.Connection -> SndFileTransfer -> AgentMsgId -> IO () +updateSndFileChunkSent db SndFileTransfer {fileId, connId} msgId = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE snd_file_chunks + SET chunk_sent = 1, updated_at = ? + WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id = ? + |] + (currentTs, fileId, connId, msgId) -deleteSndFileChunks :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> m () -deleteSndFileChunks st SndFileTransfer {fileId, connId} = - liftIO . withTransaction st $ \db -> - DB.execute db "DELETE FROM snd_file_chunks WHERE file_id = ? AND connection_id = ?" (fileId, connId) +deleteSndFileChunks :: DB.Connection -> SndFileTransfer -> IO () +deleteSndFileChunks db SndFileTransfer {fileId, connId} = + DB.execute db "DELETE FROM snd_file_chunks WHERE file_id = ? AND connection_id = ?" (fileId, connId) -createRcvFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FileInvitation -> Integer -> m RcvFileTransfer -createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) - fileId <- insertedRowId db - DB.execute - db - "INSERT INTO rcv_files (file_id, file_status, file_queue_info, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, FSNew, fileConnReq, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing} +createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Integer -> IO RcvFileTransfer +createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) + fileId <- insertedRowId db + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, FSNew, fileConnReq, currentTs, currentTs) + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing} -createRcvGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> FileInvitation -> Integer -> m RcvFileTransfer -createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) - fileId <- insertedRowId db - DB.execute - db - "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupMember -> FileInvitation -> Integer -> IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) + fileId <- insertedRowId db + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} -getRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m RcvFileTransfer -getRcvFileTransfer st User {userId} fileId = - liftIOEither . withTransaction st $ \db -> - getRcvFileTransfer_ db userId fileId - -getRcvFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError RcvFileTransfer) -getRcvFileTransfer_ db userId fileId = - rcvFileTransfer - <$> DB.query +getRcvFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransfer db User {userId} fileId = + ExceptT . firstRow' rcvFileTransfer (SERcvFileNotFound fileId) $ + DB.query db [sql| SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, @@ -2093,9 +2012,9 @@ getRcvFileTransfer_ db userId fileId = (userId, fileId) where rcvFileTransfer :: - [(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId) -> Either StoreError RcvFileTransfer - rcvFileTransfer [(fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_, contactName_, memberName_, filePath_, connId_, agentConnId_)] = + rcvFileTransfer (fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_, contactName_, memberName_, filePath_, connId_, agentConnId_) = let fileInv = FileInvitation {fileName, fileSize, fileConnReq} fileInfo = (filePath_, connId_, agentConnId_) in case contactName_ <|> memberName_ of @@ -2115,45 +2034,41 @@ getRcvFileTransfer_ db userId fileId = (Just filePath, Just connId, Just agentConnId) -> Just RcvFileInfo {filePath, connId, agentConnId} _ -> Nothing cancelled = fromMaybe False cancelled_ - rcvFileTransfer _ = Left $ SERcvFileNotFound fileId -acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> ConnId -> ConnStatus -> FilePath -> m AChatItem -acceptRcvFileTransfer st user@User {userId} fileId agentConnId connStatus filePath = - liftIOEither . withTransaction st $ \db -> do +acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> ConnId -> ConnStatus -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer db user@User {userId} fileId agentConnId connStatus filePath = ExceptT $ do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" + (filePath, CIFSRcvAccepted, currentTs, userId, fileId) + DB.execute + db + "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" + (FSAccepted, currentTs, fileId) + DB.execute + db + "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (agentConnId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs) + runExceptT $ getChatItemByFileId db user fileId + +updateRcvFileStatus :: DB.Connection -> RcvFileTransfer -> FileStatus -> IO () +updateRcvFileStatus db RcvFileTransfer {fileId} status = do + currentTs <- getCurrentTime + DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" (status, currentTs, fileId) + +createRcvFileChunk :: DB.Connection -> RcvFileTransfer -> Integer -> AgentMsgId -> IO RcvChunkStatus +createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, chunkSize} chunkNo msgId = do + status <- getLastChunkNo + unless (status == RcvChunkError) $ do currentTs <- getCurrentTime DB.execute db - "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" - (filePath, CIFSRcvAccepted, currentTs, userId, fileId) - DB.execute - db - "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" - (FSAccepted, currentTs, fileId) - DB.execute - db - "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (agentConnId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs) - getChatItemByFileId_ db user fileId - -updateRcvFileStatus :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> FileStatus -> m () -updateRcvFileStatus st RcvFileTransfer {fileId} status = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" (status, currentTs, fileId) - -createRcvFileChunk :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> Integer -> AgentMsgId -> m RcvChunkStatus -createRcvFileChunk st RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, chunkSize} chunkNo msgId = - liftIO . withTransaction st $ \db -> do - status <- getLastChunkNo db - unless (status == RcvChunkError) $ do - currentTs <- getCurrentTime - DB.execute - db - "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, chunkNo, msgId, currentTs, currentTs) - pure status + "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, chunkNo, msgId, currentTs, currentTs) + pure status where - getLastChunkNo db = do + getLastChunkNo = do ns <- DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId) pure $ case map fromOnly ns of [] @@ -2174,71 +2089,60 @@ createRcvFileChunk st RcvFileTransfer {fileId, fileInvitation = FileInvitation { else RcvChunkOk | otherwise -> RcvChunkError -updatedRcvFileChunkStored :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> Integer -> m () -updatedRcvFileChunkStored st RcvFileTransfer {fileId} chunkNo = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE rcv_file_chunks - SET chunk_stored = 1, updated_at = ? - WHERE file_id = ? AND chunk_number = ? - |] - (currentTs, fileId, chunkNo) +updatedRcvFileChunkStored :: DB.Connection -> RcvFileTransfer -> Integer -> IO () +updatedRcvFileChunkStored db RcvFileTransfer {fileId} chunkNo = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE rcv_file_chunks + SET chunk_stored = 1, updated_at = ? + WHERE file_id = ? AND chunk_number = ? + |] + (currentTs, fileId, chunkNo) -deleteRcvFileChunks :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> m () -deleteRcvFileChunks st RcvFileTransfer {fileId} = - liftIO . withTransaction st $ \db -> - DB.execute db "DELETE FROM rcv_file_chunks WHERE file_id = ?" (Only fileId) +deleteRcvFileChunks :: DB.Connection -> RcvFileTransfer -> IO () +deleteRcvFileChunks db RcvFileTransfer {fileId} = + DB.execute db "DELETE FROM rcv_file_chunks WHERE file_id = ?" (Only fileId) -updateFileTransferChatItemId :: MonadUnliftIO m => SQLiteStore -> FileTransferId -> ChatItemId -> m () -updateFileTransferChatItemId st fileId ciId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ?" (ciId, currentTs, fileId) +updateFileTransferChatItemId :: DB.Connection -> FileTransferId -> ChatItemId -> IO () +updateFileTransferChatItemId db fileId ciId = do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ?" (ciId, currentTs, fileId) -getFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m FileTransfer -getFileTransfer st User {userId} fileId = - liftIOEither . withTransaction st $ \db -> - getFileTransfer_ db userId fileId +getFileTransferProgress :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransfer, [Integer]) +getFileTransferProgress db user fileId = do + ft <- getFileTransfer db user fileId + liftIO $ + (ft,) . map fromOnly <$> case ft of + FTSnd _ [] -> pure [Only 0] + FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) + FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) -getFileTransferProgress :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransfer, [Integer]) -getFileTransferProgress st User {userId} fileId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - ft <- ExceptT $ getFileTransfer_ db userId fileId - liftIO $ - (ft,) . map fromOnly <$> case ft of - FTSnd _ [] -> pure [Only 0] - FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) - FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) - -getFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError FileTransfer) -getFileTransfer_ db userId fileId = - fileTransfer - =<< DB.query - db - [sql| - SELECT s.file_id, r.file_id - FROM files f - LEFT JOIN snd_files s ON s.file_id = f.file_id - LEFT JOIN rcv_files r ON r.file_id = f.file_id - WHERE user_id = ? AND f.file_id = ? - |] - (userId, fileId) +getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer +getFileTransfer db user@User {userId} fileId = + fileTransfer =<< liftIO getFileTransferRow where - fileTransfer :: [(Maybe Int64, Maybe Int64)] -> IO (Either StoreError FileTransfer) - fileTransfer [(Nothing, Just _)] = FTRcv <$$> getRcvFileTransfer_ db userId fileId - fileTransfer _ = runExceptT $ do - (ftm, fts) <- ExceptT $ getSndFileTransfer_ db userId fileId + fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer + fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId + fileTransfer _ = do + (ftm, fts) <- getSndFileTransfer db user fileId pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} + getFileTransferRow :: IO [(Maybe Int64, Maybe Int64)] + getFileTransferRow = + DB.query + db + [sql| + SELECT s.file_id, r.file_id + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + |] + (userId, fileId) -getSndFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransferMeta, [SndFileTransfer]) -getSndFileTransfer st User {userId} fileId = - liftIOEither . withTransaction st $ \db -> getSndFileTransfer_ db userId fileId - -getSndFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (FileTransferMeta, [SndFileTransfer])) -getSndFileTransfer_ db userId fileId = runExceptT $ do +getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer]) +getSndFileTransfer db User {userId} fileId = do fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId pure (fileTransferMeta, sndFileTransfers) @@ -2284,37 +2188,35 @@ getFileTransferMeta_ db userId fileId = fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} -getContactFileInfo :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [CIFileInfo] -getContactFileInfo st userId Contact {contactId} = - liftIO . withTransaction st $ \db -> - map toFileInfo - <$> DB.query - db - [sql| +getContactFileInfo :: DB.Connection -> UserId -> Contact -> IO [CIFileInfo] +getContactFileInfo db userId Contact {contactId} = + map toFileInfo + <$> DB.query + db + [sql| SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.contact_id = ? |] - (userId, contactId) + (userId, contactId) toFileInfo :: (Int64, ACIFileStatus, Maybe FilePath) -> CIFileInfo toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} -getContactChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> User -> ContactId -> m [(ChatItemId, UTCTime, Maybe CIFileInfo)] -getContactChatItemIdsAndFileInfo st User {userId} contactId = - liftIO . withTransaction st $ \db -> - map toItemIdAndFileInfo - <$> DB.query - db - [sql| - SELECT i.chat_item_id, i.item_ts, f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? - ORDER BY i.item_ts ASC - |] - (userId, contactId) +getContactChatItemIdsAndFileInfo :: DB.Connection -> User -> ContactId -> IO [(ChatItemId, UTCTime, Maybe CIFileInfo)] +getContactChatItemIdsAndFileInfo db User {userId} contactId = + map toItemIdAndFileInfo + <$> DB.query + db + [sql| + SELECT i.chat_item_id, i.item_ts, f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? + ORDER BY i.item_ts ASC + |] + (userId, contactId) toItemIdAndFileInfo :: (ChatItemId, UTCTime, Maybe Int64, Maybe ACIFileStatus, Maybe FilePath) -> (ChatItemId, UTCTime, Maybe CIFileInfo) toItemIdAndFileInfo (chatItemId, itemTs, fileId_, fileStatus_, filePath) = @@ -2322,28 +2224,26 @@ toItemIdAndFileInfo (chatItemId, itemTs, fileId_, fileStatus_, filePath) = (Just fileId, Just fileStatus) -> (chatItemId, itemTs, Just CIFileInfo {fileId, fileStatus, filePath}) _ -> (chatItemId, itemTs, Nothing) -updateContactTs :: MonadUnliftIO m => SQLiteStore -> User -> Contact -> UTCTime -> m () -updateContactTs st User {userId} Contact {contactId} updatedAt = - liftIO . withTransaction st $ \db -> - DB.execute - db - "UPDATE contacts SET updated_at = ? WHERE user_id = ? AND contact_id = ?" - (updatedAt, userId, contactId) +updateContactTs :: DB.Connection -> User -> Contact -> UTCTime -> IO () +updateContactTs db User {userId} Contact {contactId} updatedAt = + DB.execute + db + "UPDATE contacts SET updated_at = ? WHERE user_id = ? AND contact_id = ?" + (updatedAt, userId, contactId) -getGroupChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> User -> Int64 -> m [(ChatItemId, UTCTime, Bool, Maybe CIFileInfo)] -getGroupChatItemIdsAndFileInfo st User {userId} groupId = - liftIO . withTransaction st $ \db -> - map toItemIdDeletedAndFileInfo - <$> DB.query - db - [sql| - SELECT i.chat_item_id, i.item_ts, i.item_deleted, f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? - ORDER BY i.item_ts ASC - |] - (userId, groupId) +getGroupChatItemIdsAndFileInfo :: DB.Connection -> User -> Int64 -> IO [(ChatItemId, UTCTime, Bool, Maybe CIFileInfo)] +getGroupChatItemIdsAndFileInfo db User {userId} groupId = + map toItemIdDeletedAndFileInfo + <$> DB.query + db + [sql| + SELECT i.chat_item_id, i.item_ts, i.item_deleted, f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? + ORDER BY i.item_ts ASC + |] + (userId, groupId) toItemIdDeletedAndFileInfo :: (ChatItemId, UTCTime, Bool, Maybe Int64, Maybe ACIFileStatus, Maybe FilePath) -> (ChatItemId, UTCTime, Bool, Maybe CIFileInfo) toItemIdDeletedAndFileInfo (chatItemId, itemTs, itemDeleted, fileId_, fileStatus_, filePath) = @@ -2351,79 +2251,73 @@ toItemIdDeletedAndFileInfo (chatItemId, itemTs, itemDeleted, fileId_, fileStatus (Just fileId, Just fileStatus) -> (chatItemId, itemTs, itemDeleted, Just CIFileInfo {fileId, fileStatus, filePath}) _ -> (chatItemId, itemTs, itemDeleted, Nothing) -updateGroupTs :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> UTCTime -> m () -updateGroupTs st User {userId} GroupInfo {groupId} updatedAt = - liftIO . withTransaction st $ \db -> +updateGroupTs :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () +updateGroupTs db User {userId} GroupInfo {groupId} updatedAt = + DB.execute + db + "UPDATE groups SET updated_at = ? WHERE user_id = ? AND group_id = ?" + (updatedAt, userId, groupId) + +createNewSndMessage :: DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> ExceptT StoreError IO SndMessage +createNewSndMessage db gVar connOrGroupId mkMessage = + createWithRandomId gVar $ \sharedMsgId -> do + let NewMessage {chatMsgEvent, msgBody} = mkMessage $ SharedMsgId sharedMsgId + createdAt <- getCurrentTime DB.execute db - "UPDATE groups SET updated_at = ? WHERE user_id = ? AND group_id = ?" - (updatedAt, userId, groupId) - -createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage -createNewSndMessage st gVar connOrGroupId mkMessage = - liftIOEither . withTransaction st $ \db -> - createWithRandomId gVar $ \sharedMsgId -> do - let NewMessage {chatMsgEvent, msgBody} = mkMessage $ SharedMsgId sharedMsgId - createdAt <- getCurrentTime - DB.execute - db - [sql| - INSERT INTO messages ( - msg_sent, chat_msg_event, msg_body, connection_id, group_id, - shared_msg_id, shared_msg_id_user, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?) - |] - (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, sharedMsgId, Just True, createdAt, createdAt) - msgId <- insertedRowId db - pure SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} + [sql| + INSERT INTO messages ( + msg_sent, chat_msg_event, msg_body, connection_id, group_id, + shared_msg_id, shared_msg_id_user, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + |] + (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, sharedMsgId, Just True, createdAt, createdAt) + msgId <- insertedRowId db + pure SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} where (connId_, groupId_) = case connOrGroupId of ConnectionId connId -> (Just connId, Nothing) GroupId groupId -> (Nothing, Just groupId) -createSndMsgDelivery :: MonadUnliftIO m => SQLiteStore -> SndMsgDelivery -> MessageId -> m () -createSndMsgDelivery st sndMsgDelivery messageId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId currentTs - createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs +createSndMsgDelivery :: DB.Connection -> SndMsgDelivery -> MessageId -> IO () +createSndMsgDelivery db sndMsgDelivery messageId = do + currentTs <- getCurrentTime + msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId currentTs + createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs -createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> ConnOrGroupId -> NewMessage -> Maybe SharedMsgId -> RcvMsgDelivery -> m RcvMessage -createNewMessageAndRcvMsgDelivery st connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id) VALUES (?,?,?,?,?,?,?,?)" - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_) - msgId <- insertedRowId db - DB.execute - db - "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta, currentTs, currentTs) - msgDeliveryId <- insertedRowId db - createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs - pure RcvMessage {msgId, chatMsgEvent, sharedMsgId_, msgBody} +createNewMessageAndRcvMsgDelivery :: DB.Connection -> ConnOrGroupId -> NewMessage -> Maybe SharedMsgId -> RcvMsgDelivery -> IO RcvMessage +createNewMessageAndRcvMsgDelivery db connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id) VALUES (?,?,?,?,?,?,?,?)" + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_) + msgId <- insertedRowId db + DB.execute + db + "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta, currentTs, currentTs) + msgDeliveryId <- insertedRowId db + createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs + pure RcvMessage {msgId, chatMsgEvent, sharedMsgId_, msgBody} where (connId_, groupId_) = case connOrGroupId of ConnectionId connId' -> (Just connId', Nothing) GroupId groupId -> (Nothing, Just groupId) -createSndMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> m () -createSndMsgDeliveryEvent st connId agentMsgId sndMsgDeliveryStatus = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId - liftIO $ do - currentTs <- getCurrentTime - createMsgDeliveryEvent_ db msgDeliveryId sndMsgDeliveryStatus currentTs +createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () +createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do + msgDeliveryId <- getMsgDeliveryId_ db connId agentMsgId + liftIO $ do + currentTs <- getCurrentTime + createMsgDeliveryEvent_ db msgDeliveryId sndMsgDeliveryStatus currentTs -createRcvMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDRcv -> m () -createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId - liftIO $ do - currentTs <- getCurrentTime - createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus currentTs +createRcvMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDRcv -> ExceptT StoreError IO () +createRcvMsgDeliveryEvent db connId agentMsgId rcvMsgDeliveryStatus = do + msgDeliveryId <- getMsgDeliveryId_ db connId agentMsgId + liftIO $ do + currentTs <- getCurrentTime + createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus currentTs createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> UTCTime -> IO Int64 createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId createdAt = do @@ -2448,9 +2342,9 @@ createMsgDeliveryEvent_ db msgDeliveryId msgDeliveryStatus createdAt = do |] (msgDeliveryId, msgDeliveryStatus, createdAt, createdAt) -getMsgDeliveryId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Either StoreError Int64) +getMsgDeliveryId_ :: DB.Connection -> Int64 -> AgentMsgId -> ExceptT StoreError IO Int64 getMsgDeliveryId_ db connId agentMsgId = - firstRow fromOnly (SENoMsgDelivery connId agentMsgId) $ + ExceptT . firstRow fromOnly (SENoMsgDelivery connId agentMsgId) $ DB.query db [sql| @@ -2461,47 +2355,43 @@ getMsgDeliveryId_ db connId agentMsgId = |] (connId, agentMsgId) -createPendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> Maybe Int64 -> m () -createPendingGroupMessage st groupMemberId messageId introId_ = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute +createPendingGroupMessage :: DB.Connection -> Int64 -> MessageId -> Maybe Int64 -> IO () +createPendingGroupMessage db groupMemberId messageId introId_ = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + |] + (groupMemberId, messageId, introId_, currentTs, currentTs) + +getPendingGroupMessages :: DB.Connection -> Int64 -> IO [PendingGroupMessage] +getPendingGroupMessages db groupMemberId = + map pendingGroupMessage + <$> DB.query db [sql| - INSERT INTO pending_group_messages - (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + SELECT pgm.message_id, m.chat_msg_event, m.msg_body, pgm.group_member_intro_id + FROM pending_group_messages pgm + JOIN messages m USING (message_id) + WHERE pgm.group_member_id = ? + ORDER BY pgm.message_id ASC |] - (groupMemberId, messageId, introId_, currentTs, currentTs) - -getPendingGroupMessages :: MonadUnliftIO m => SQLiteStore -> Int64 -> m [PendingGroupMessage] -getPendingGroupMessages st groupMemberId = - liftIO . withTransaction st $ \db -> - map pendingGroupMessage - <$> DB.query - db - [sql| - SELECT pgm.message_id, m.chat_msg_event, m.msg_body, pgm.group_member_intro_id - FROM pending_group_messages pgm - JOIN messages m USING (message_id) - WHERE pgm.group_member_id = ? - ORDER BY pgm.message_id ASC - |] - (Only groupMemberId) + (Only groupMemberId) where pendingGroupMessage (msgId, cmEventTag, msgBody, introId_) = PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -deletePendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> m () -deletePendingGroupMessage st groupMemberId messageId = - liftIO . withTransaction st $ \db -> - DB.execute db "DELETE FROM pending_group_messages WHERE group_member_id = ? AND message_id = ?" (groupMemberId, messageId) +deletePendingGroupMessage :: DB.Connection -> Int64 -> MessageId -> IO () +deletePendingGroupMessage db groupMemberId messageId = + DB.execute db "DELETE FROM pending_group_messages WHERE group_member_id = ? AND message_id = ?" (groupMemberId, messageId) type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) -createNewSndChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> UTCTime -> m ChatItemId -createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem createdAt = - liftIO . withTransaction st $ \db -> - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow createdAt createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem createdAt = + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow createdAt createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -2515,12 +2405,11 @@ createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent itemTs createdAt = - liftIO . withTransaction st $ \db -> do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemTs createdAt - quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg - pure (ciId, quotedItem) +createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemTs createdAt + quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg + pure (ciId, quotedItem) where quotedMsg = cmToQuotedMsg chatMsgEvent quoteRow :: NewQuoteRow @@ -2532,10 +2421,9 @@ createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent} shar CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> (Just $ Just userMemberId == memberId, memberId) -createNewChatItemNoMsg :: forall c d m. (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> m ChatItemId -createNewChatItemNoMsg st user chatDirection ciContent itemTs createdAt = - liftIO . withTransaction st $ \db -> - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow itemTs createdAt +createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItemNoMsg db user chatDirection ciContent itemTs createdAt = + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow itemTs createdAt where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) @@ -2589,8 +2477,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) getDirectChatItemQuote_ contactId userSent = do - ciQuoteDirect . listToMaybe . map fromOnly - <$> DB.query + fmap ciQuoteDirect . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ?" (userId, contactId, msgId, userSent) @@ -2599,15 +2487,15 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteDirect = (`ciQuote` if userSent then CIQDirectSnd else CIQDirectRcv) getUserGroupChatItemId_ :: Int64 -> IO (Maybe ChatItemId) getUserGroupChatItemId_ groupId = - listToMaybe . map fromOnly - <$> DB.query + maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL" (userId, groupId, msgId, MDSnd) getGroupChatItemId_ :: Int64 -> MemberId -> IO (Maybe ChatItemId) getGroupChatItemId_ groupId mId = - listToMaybe . map fromOnly - <$> DB.query + maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" (userId, groupId, msgId, MDRcv, mId) @@ -2636,14 +2524,13 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> Bool -> m [AChat] -getChatPreviews st user withPCC = - liftIO . withTransaction st $ \db -> do - directChats <- getDirectChatPreviews_ db user - groupChats <- getGroupChatPreviews_ db user - cReqChats <- getContactRequestChatPreviews_ db user - connChats <- getContactConnectionChatPreviews_ db user withPCC - pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats) +getChatPreviews :: DB.Connection -> User -> Bool -> IO [AChat] +getChatPreviews db user withPCC = do + directChats <- getDirectChatPreviews_ db user + groupChats <- getGroupChatPreviews_ db user + cReqChats <- getContactRequestChatPreviews_ db user + connChats <- getContactConnectionChatPreviews_ db user withPCC + pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats) where ts :: AChat -> UTCTime ts (AChat _ Chat {chatInfo, chatItems = ci : _}) = max (chatItemTs ci) (chatInfoUpdatedAt chatInfo) @@ -2661,7 +2548,7 @@ getDirectChatPreviews_ db User {userId} = do -- Contact ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), @@ -2821,55 +2708,52 @@ getContactConnectionChatPreviews_ db User {userId} _ = stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats -getPendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection -getPendingContactConnection st userId connId = - liftIOEither . withTransaction st $ \db -> do - firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ - DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at - FROM connections - WHERE user_id = ? - AND connection_id = ? - AND conn_type = ? - AND contact_id IS NULL - AND conn_level = 0 - AND via_contact IS NULL - |] - (userId, connId, ConnContact) - -deletePendingContactConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () -deletePendingContactConnection st userId connId = - liftIO . withTransaction st $ \db -> - DB.execute +getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection +getPendingContactConnection db userId connId = do + ExceptT . firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ + DB.query db [sql| - DELETE FROM connections - WHERE user_id = ? - AND connection_id = ? - AND conn_type = ? - AND contact_id IS NULL - AND conn_level = 0 - AND via_contact IS NULL + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at + FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL |] (userId, connId, ConnContact) +deletePendingContactConnection :: DB.Connection -> UserId -> Int64 -> IO () +deletePendingContactConnection db userId connId = + DB.execute + db + [sql| + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) + toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> PendingContactConnection toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt, updatedAt) = PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, createdAt, updatedAt} -getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) -getDirectChat st user contactId pagination = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - case pagination of - CPLast count -> getDirectChatLast_ db user contactId count - CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count - CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count +getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChat db user contactId pagination = do + case pagination of + CPLast count -> getDirectChatLast_ db user contactId count + CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count + CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatLast_ db User {userId} contactId count = do - contact <- ExceptT $ getContact_ db userId contactId + contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsLast_ pure $ Chat (DirectChat contact) (reverse chatItems) stats @@ -2900,7 +2784,7 @@ getDirectChatLast_ db User {userId} contactId count = do getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do - contact <- ExceptT $ getContact_ db userId contactId + contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsAfter_ pure $ Chat (DirectChat contact) chatItems stats @@ -2931,7 +2815,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do - contact <- ExceptT $ getContact_ db userId contactId + contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsBefore_ pure $ Chat (DirectChat contact) (reverse chatItems) stats @@ -2977,64 +2861,51 @@ getDirectChatStats_ db userId contactId = toChatStats' [statsRow] = toChatStats statsRow toChatStats' _ = ChatStats {unreadCount = 0, minUnreadItemId = 0} -getContactIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 -getContactIdByName st userId cName = - liftIOEither . withTransaction st $ \db -> getContactIdByName_ db userId cName - -getContactIdByName_ :: DB.Connection -> UserId -> ContactName -> IO (Either StoreError Int64) -getContactIdByName_ db userId cName = - firstRow fromOnly (SEContactNotFoundByName cName) $ +getContactIdByName :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Int64 +getContactIdByName db userId cName = + ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName) -getContact :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m Contact -getContact st userId contactId = - liftIOEither . withTransaction st $ \db -> getContact_ db userId contactId +getContact :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO Contact +getContact db userId contactId = + ExceptT . fmap join . firstRow toContactOrError (SEContactNotFound contactId) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + AND c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + LIMIT 1 + ) + ) + |] + (userId, contactId, ConnReady, ConnSndReady) -getContact_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError Contact) -getContact_ db userId contactId = - join - <$> firstRow - toContactOrError - (SEContactNotFound contactId) - ( DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.contact_id = ? - AND c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC - LIMIT 1 - ) - ) - |] - (userId, contactId, ConnReady, ConnSndReady) - ) - -getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTGroup) -getGroupChat st user groupId pagination = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - case pagination of - CPLast count -> getGroupChatLast_ db user groupId count - CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count - CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count +getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChat db user groupId pagination = do + case pagination of + CPLast count -> getGroupChatLast_ db user groupId count + CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count + CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatLast_ db user@User {userId, userContactId} groupId count = do - groupInfo <- ExceptT $ getGroupInfo_ db user groupId + groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId chatItems <- ExceptT getGroupChatItemsLast_ pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats @@ -3077,7 +2948,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId count = do - groupInfo <- ExceptT $ getGroupInfo_ db user groupId + groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId chatItems <- ExceptT getGroupChatItemsAfter_ pure $ Chat (GroupChat groupInfo) chatItems stats @@ -3120,7 +2991,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemId count = do - groupInfo <- ExceptT $ getGroupInfo_ db user groupId + groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId chatItems <- ExceptT getGroupChatItemsBefore_ pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats @@ -3178,14 +3049,9 @@ getGroupChatStats_ db userId groupId = toChatStats' [statsRow] = toChatStats statsRow toChatStats' _ = ChatStats {unreadCount = 0, minUnreadItemId = 0} -getGroupInfo :: StoreMonad m => SQLiteStore -> User -> Int64 -> m GroupInfo -getGroupInfo st user groupId = - liftIOEither . withTransaction st $ \db -> - getGroupInfo_ db user groupId - -getGroupInfo_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError GroupInfo) -getGroupInfo_ db User {userId, userContactId} groupId = - firstRow (toGroupInfo userContactId) (SEGroupNotFound groupId) $ +getGroupInfo :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo db User {userId, userContactId} groupId = + ExceptT . firstRow (toGroupInfo userContactId) (SEGroupNotFound groupId) $ DB.query db [sql| @@ -3204,13 +3070,12 @@ getGroupInfo_ db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getAllChatItems :: StoreMonad m => SQLiteStore -> User -> ChatPagination -> m [AChatItem] -getAllChatItems st user pagination = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - case pagination of - CPLast count -> getAllChatItemsLast_ db user count - CPAfter _afterId _count -> throwError $ SEInternalError "not implemented" - CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented" +getAllChatItems :: DB.Connection -> User -> ChatPagination -> ExceptT StoreError IO [AChatItem] +getAllChatItems db user pagination = do + case pagination of + CPLast count -> getAllChatItemsLast_ db user count + CPAfter _afterId _count -> throwError $ SEInternalError "not implemented" + CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented" getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem] getAllChatItemsLast_ db user@User {userId} count = do @@ -3229,23 +3094,15 @@ getAllChatItemsLast_ db user@User {userId} count = do (userId, count) mapM (uncurry $ getAChatItem_ db user) itemRefs -getGroupIdByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Int64 -getGroupIdByName st user gName = - liftIOEither . withTransaction st $ \db -> getGroupIdByName_ db user gName - -getGroupIdByName_ :: DB.Connection -> User -> GroupName -> IO (Either StoreError Int64) -getGroupIdByName_ db User {userId} gName = - firstRow fromOnly (SEGroupNotFoundByName gName) $ +getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO Int64 +getGroupIdByName db User {userId} gName = + ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $ DB.query db "SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ?" (userId, gName) -getChatItemIdByAgentMsgId :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> m (Maybe ChatItemId) -getChatItemIdByAgentMsgId st connId msgId = - liftIO . withTransaction st $ \db -> getChatItemIdByAgentMsgId_ db connId msgId - -getChatItemIdByAgentMsgId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Maybe ChatItemId) -getChatItemIdByAgentMsgId_ db connId msgId = - join . listToMaybe . map fromOnly - <$> DB.query +getChatItemIdByAgentMsgId :: DB.Connection -> Int64 -> AgentMsgId -> IO (Maybe ChatItemId) +getChatItemIdByAgentMsgId db connId msgId = + fmap join . maybeFirstRow fromOnly $ + DB.query db [sql| SELECT chat_item_id @@ -3259,28 +3116,26 @@ getChatItemIdByAgentMsgId_ db connId msgId = |] (connId, msgId) -updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) -updateDirectChatItemStatus st userId contactId itemId itemStatus = do - liftIOEither . withTransaction st $ \db -> runExceptT $ do - ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId - currentTs <- liftIO getCurrentTime - liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) - pure ci {meta = (meta ci) {itemStatus}} +updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItemStatus db userId contactId itemId itemStatus = do + ci <- liftEither . correctDir =<< getDirectChatItem db userId contactId itemId + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) + pure ci {meta = (meta ci) {itemStatus}} where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> Maybe MessageId -> m (ChatItem 'CTDirect d) -updateDirectChatItem st userId contactId itemId newContent msgId_ = - liftIOEither . withTransaction st $ \db -> do - currentTs <- liftIO getCurrentTime - ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs - when (isRight ci) . forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId currentTs - pure ci +updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem db userId contactId itemId newContent msgId_ = do + currentTs <- liftIO getCurrentTime + ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs + forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId currentTs + pure ci -updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> IO (Either StoreError (ChatItem 'CTDirect d)) -updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExceptT $ do - ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId +updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem_ db userId contactId itemId newContent currentTs = do + ci <- liftEither . correctDir =<< getDirectChatItem db userId contactId itemId let newText = ciContentToText newContent liftIO $ do DB.execute @@ -3296,15 +3151,14 @@ updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExcep correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -deleteDirectChatItemLocal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> m AChatItem -deleteDirectChatItemLocal st userId ct itemId mode = - liftIOEither . withTransaction st $ \db -> do - deleteChatItemMessages_ db itemId - deleteDirectChatItem_ db userId ct itemId mode +deleteDirectChatItemLocal :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> ExceptT StoreError IO AChatItem +deleteDirectChatItemLocal db userId ct itemId mode = do + liftIO $ deleteChatItemMessages_ db itemId + deleteDirectChatItem_ db userId ct itemId mode -deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> IO (Either StoreError AChatItem) -deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode = runExceptT $ do - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId +deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> ExceptT StoreError IO AChatItem +deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode = do + (CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId let toContent = msgDirToDeletedContent_ msgDir mode liftIO $ do DB.execute @@ -3347,16 +3201,15 @@ setChatItemMessagesDeleted_ db itemId = where xMsgDeletedBody = strEncode ChatMessage {msgId = Nothing, chatMsgEvent = XMsgDeleted} -deleteDirectChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> MessageId -> m AChatItem -deleteDirectChatItemRcvBroadcast st userId ct itemId msgId = - liftIOEither . withTransaction st $ \db -> do - currentTs <- liftIO getCurrentTime - insertChatItemMessage_ db itemId msgId currentTs - updateDirectChatItemRcvDeleted_ db userId ct itemId currentTs +deleteDirectChatItemRcvBroadcast :: DB.Connection -> UserId -> Contact -> ChatItemId -> MessageId -> ExceptT StoreError IO AChatItem +deleteDirectChatItemRcvBroadcast db userId ct itemId msgId = do + currentTs <- liftIO getCurrentTime + liftIO $ insertChatItemMessage_ db itemId msgId currentTs + updateDirectChatItemRcvDeleted_ db userId ct itemId currentTs -updateDirectChatItemRcvDeleted_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> UTCTime -> IO (Either StoreError AChatItem) -updateDirectChatItemRcvDeleted_ db userId ct@Contact {contactId} itemId currentTs = runExceptT $ do - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId +updateDirectChatItemRcvDeleted_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> UTCTime -> ExceptT StoreError IO AChatItem +updateDirectChatItemRcvDeleted_ db userId ct@Contact {contactId} itemId currentTs = do + (CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId let toContent = msgDirToDeletedContent_ msgDir CIDMBroadcast toText = ciDeleteModeToText CIDMBroadcast liftIO $ do @@ -3381,25 +3234,19 @@ deleteQuote_ db itemId = |] (Only itemId) -getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> ChatItemId -> m (CChatItem 'CTDirect) -getDirectChatItem st userId contactId itemId = - liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId +getDirectChatItemBySharedMsgId :: DB.Connection -> UserId -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectChatItemBySharedMsgId db userId contactId sharedMsgId = do + itemId <- getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId + getDirectChatItem db userId contactId itemId -getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> SharedMsgId -> m (CChatItem 'CTDirect) -getDirectChatItemBySharedMsgId st userId contactId sharedMsgId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId - liftIOEither $ getDirectChatItem_ db userId contactId itemId +getDirectChatItemByAgentMsgId :: DB.Connection -> UserId -> ContactId -> Int64 -> AgentMsgId -> IO (Maybe (CChatItem 'CTDirect)) +getDirectChatItemByAgentMsgId db userId contactId connId msgId = do + itemId_ <- getChatItemIdByAgentMsgId db connId msgId + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getDirectChatItem db userId contactId) itemId_ -getDirectChatItemByAgentMsgId :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactId -> Int64 -> AgentMsgId -> m (Maybe (CChatItem 'CTDirect)) -getDirectChatItemByAgentMsgId st userId contactId connId msgId = - liftIO . withTransaction st $ \db -> do - itemId_ <- getChatItemIdByAgentMsgId_ db connId msgId - maybe (pure Nothing) (fmap eitherToMaybe . getDirectChatItem_ db userId contactId) itemId_ - -getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) +getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> ExceptT StoreError IO Int64 getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = - firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ + ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query db [sql| @@ -3411,8 +3258,8 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = |] (userId, contactId, sharedMsgId) -getDirectChatItem_ :: DB.Connection -> UserId -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTDirect)) -getDirectChatItem_ db userId contactId itemId = do +getDirectChatItem :: DB.Connection -> UserId -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectChatItem db userId contactId itemId = ExceptT $ do tz <- getCurrentTimeZone currentTs <- getCurrentTime join <$> firstRow (toDirectChatItem tz currentTs) (SEChatItemNotFound itemId) getItem @@ -3435,28 +3282,23 @@ getDirectChatItem_ db userId contactId itemId = do |] (userId, contactId, itemId) -getDirectChatItemIdByText :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SMsgDirection d -> Text -> m ChatItemId -getDirectChatItemIdByText st userId contactId msgDir quotedMsg = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly SEQuotedChatItemNotFound $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text like ? - ORDER BY chat_item_id DESC - LIMIT 1 - |] - (userId, contactId, msgDir, quotedMsg <> "%") +getDirectChatItemIdByText :: DB.Connection -> UserId -> Int64 -> SMsgDirection d -> Text -> ExceptT StoreError IO ChatItemId +getDirectChatItemIdByText db userId contactId msgDir quotedMsg = + ExceptT . firstRow fromOnly SEQuotedChatItemNotFound $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, contactId, msgDir, quotedMsg <> "%") -updateGroupChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d) -updateGroupChatItem st user groupId itemId newContent msgId = - liftIOEither . withTransaction st $ \db -> updateGroupChatItem_ db user groupId itemId newContent msgId - -updateGroupChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTGroup d)) -updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = runExceptT $ do - ci <- ExceptT $ (correctDir =<<) <$> getGroupChatItem_ db user groupId itemId +updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> ExceptT StoreError IO (ChatItem 'CTGroup d) +updateGroupChatItem db user@User {userId} groupId itemId newContent msgId = do + ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId currentTs <- liftIO getCurrentTime let newText = ciContentToText newContent liftIO $ do @@ -3474,36 +3316,34 @@ updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = run correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -deleteGroupChatItemInternal :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> m AChatItem -deleteGroupChatItemInternal st user gInfo itemId = - liftIOEither . withTransaction st $ \db -> do - currentTs <- liftIO getCurrentTime - ci <- deleteGroupChatItem_ db user gInfo itemId CIDMInternal True currentTs - setChatItemMessagesDeleted_ db itemId - DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId) - pure ci +deleteGroupChatItemInternal :: DB.Connection -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO AChatItem +deleteGroupChatItemInternal db user gInfo itemId = do + currentTs <- liftIO getCurrentTime + ci <- deleteGroupChatItem_ db user gInfo itemId CIDMInternal True currentTs + liftIO $ setChatItemMessagesDeleted_ db itemId + liftIO $ DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId) + pure ci -deleteGroupChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> MessageId -> m AChatItem -deleteGroupChatItemRcvBroadcast st user gInfo itemId msgId = - liftIOEither . withTransaction st $ \db -> deleteGroupChatItemBroadcast_ db user gInfo itemId False msgId +deleteGroupChatItemRcvBroadcast :: DB.Connection -> User -> GroupInfo -> ChatItemId -> MessageId -> ExceptT StoreError IO AChatItem +deleteGroupChatItemRcvBroadcast db user gInfo itemId msgId = + deleteGroupChatItemBroadcast_ db user gInfo itemId False msgId -deleteGroupChatItemSndBroadcast :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> MessageId -> m AChatItem -deleteGroupChatItemSndBroadcast st user gInfo itemId msgId = - liftIOEither . withTransaction st $ \db -> do - ci <- deleteGroupChatItemBroadcast_ db user gInfo itemId True msgId - setChatItemMessagesDeleted_ db itemId - DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId) - pure ci +deleteGroupChatItemSndBroadcast :: DB.Connection -> User -> GroupInfo -> ChatItemId -> MessageId -> ExceptT StoreError IO AChatItem +deleteGroupChatItemSndBroadcast db user gInfo itemId msgId = do + ci <- deleteGroupChatItemBroadcast_ db user gInfo itemId True msgId + liftIO $ setChatItemMessagesDeleted_ db itemId + liftIO $ DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId) + pure ci -deleteGroupChatItemBroadcast_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Bool -> MessageId -> IO (Either StoreError AChatItem) +deleteGroupChatItemBroadcast_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Bool -> MessageId -> ExceptT StoreError IO AChatItem deleteGroupChatItemBroadcast_ db user gInfo itemId itemDeleted msgId = do currentTs <- liftIO getCurrentTime - insertChatItemMessage_ db itemId msgId currentTs + liftIO $ insertChatItemMessage_ db itemId msgId currentTs deleteGroupChatItem_ db user gInfo itemId CIDMBroadcast itemDeleted currentTs -deleteGroupChatItem_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> CIDeleteMode -> Bool -> UTCTime -> IO (Either StoreError AChatItem) -deleteGroupChatItem_ db user@User {userId} gInfo@GroupInfo {groupId} itemId mode itemDeleted currentTs = runExceptT $ do - (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId +deleteGroupChatItem_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> CIDeleteMode -> Bool -> UTCTime -> ExceptT StoreError IO AChatItem +deleteGroupChatItem_ db user@User {userId} gInfo@GroupInfo {groupId} itemId mode itemDeleted currentTs = do + (CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId let toContent = msgDirToDeletedContent_ msgDir mode liftIO $ do DB.execute @@ -3519,34 +3359,26 @@ deleteGroupChatItem_ db user@User {userId} gInfo@GroupInfo {groupId} itemId mode where toText = ciDeleteModeToText mode -getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup) -getGroupChatItem st user groupId itemId = - liftIOEither . withTransaction st $ \db -> getGroupChatItem_ db user groupId itemId +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> Int64 -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} groupId sharedMsgId = do + itemId <- + ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, sharedMsgId) + getGroupChatItem db user groupId itemId -getGroupChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> User -> Int64 -> SharedMsgId -> m (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId st user groupId sharedMsgId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - itemId <- ExceptT $ getGroupChatItemIdBySharedMsgId_ db user groupId sharedMsgId - liftIOEither $ getGroupChatItem_ db user groupId itemId - -getGroupChatItemIdBySharedMsgId_ :: DB.Connection -> User -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) -getGroupChatItemIdBySharedMsgId_ db User {userId} groupId sharedMsgId = - firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - |] - (userId, groupId, sharedMsgId) - -getGroupChatItem_ :: DB.Connection -> User -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTGroup)) -getGroupChatItem_ db User {userId, userContactId} groupId itemId = do +getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do tz <- getCurrentTimeZone - currentTs <- liftIO getCurrentTime + currentTs <- getCurrentTime join <$> firstRow (toGroupChatItem tz currentTs userContactId) (SEChatItemNotFound itemId) getItem where getItem = @@ -3579,16 +3411,15 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do |] (userId, groupId, itemId) -getGroupChatItemIdByText :: StoreMonad m => SQLiteStore -> User -> Int64 -> Maybe ContactName -> Text -> m ChatItemId -getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId contactName_ quotedMsg = - liftIOEither . withTransaction st $ \db -> - firstRow fromOnly SEQuotedChatItemNotFound $ case contactName_ of - Nothing -> anyMemberChatItem_ db - Just cName - | userName == cName -> userChatItem_ db - | otherwise -> memberChatItem_ db cName +getGroupChatItemIdByText :: DB.Connection -> User -> Int64 -> Maybe ContactName -> Text -> ExceptT StoreError IO ChatItemId +getGroupChatItemIdByText db User {userId, localDisplayName = userName} groupId contactName_ quotedMsg = + ExceptT . firstRow fromOnly SEQuotedChatItemNotFound $ case contactName_ of + Nothing -> anyMemberChatItem_ + Just cName + | userName == cName -> userChatItem_ + | otherwise -> memberChatItem_ cName where - anyMemberChatItem_ db = + anyMemberChatItem_ = DB.query db [sql| @@ -3599,7 +3430,7 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c LIMIT 1 |] (userId, groupId, quotedMsg <> "%") - userChatItem_ db = + userChatItem_ = DB.query db [sql| @@ -3610,7 +3441,7 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c LIMIT 1 |] (userId, groupId, quotedMsg <> "%") - memberChatItem_ db cName = + memberChatItem_ cName = DB.query db [sql| @@ -3624,51 +3455,42 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c |] (userId, groupId, cName, quotedMsg <> "%") -getChatItemByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m AChatItem -getChatItemByFileId st user fileId = - liftIOEither . withTransaction st $ \db -> - getChatItemByFileId_ db user fileId - -getChatItemByFileId_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError AChatItem) -getChatItemByFileId_ db user@User {userId} fileId = runExceptT $ do - (itemId, chatRef) <- ExceptT $ getChatItemIdByFileId_ db userId fileId +getChatItemByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId db user@User {userId} fileId = do + (itemId, chatRef) <- + ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.chat_item_id, i.contact_id, i.group_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + |] + (userId, fileId) getAChatItem_ db user itemId chatRef getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem getAChatItem_ db user@User {userId} itemId = \case ChatRef CTDirect contactId -> do - ct <- ExceptT $ getContact_ db userId contactId - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + ct <- getContact db userId contactId + (CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci ChatRef CTGroup groupId -> do - gInfo <- ExceptT $ getGroupInfo_ db user groupId - (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + gInfo <- getGroupInfo db user groupId + (CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci _ -> throwError $ SEChatItemNotFound itemId -getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, ChatRef)) -getChatItemIdByFileId_ db userId fileId = - firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ - DB.query - db - [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE f.user_id = ? AND f.file_id = ? - LIMIT 1 - |] - (userId, fileId) - -updateDirectCIFileStatus :: forall d m. (MsgDirectionI d, StoreMonad m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m AChatItem -updateDirectCIFileStatus st user fileId fileStatus = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - aci@(AChatItem cType d cInfo ci) <- ExceptT $ getChatItemByFileId_ db user fileId - case (cType, testEquality d $ msgDirection @d) of - (SCTDirect, Just Refl) -> do - liftIO $ updateCIFileStatus_ db user fileId fileStatus - pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus - _ -> pure aci +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus db user fileId fileStatus = do + aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId + case (cType, testEquality d $ msgDirection @d) of + (SCTDirect, Just Refl) -> do + liftIO $ updateCIFileStatus db user fileId fileStatus + pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus + _ -> pure aci toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef) toChatItemRef = \case @@ -3676,49 +3498,47 @@ toChatItemRef = \case (itemId, Nothing, Just groupId) -> Right (itemId, ChatRef CTGroup groupId) (itemId, _, _) -> Left $ SEBadChatItem itemId -updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> Maybe (ChatItemId, ChatItemId) -> m () -updateDirectChatItemsRead st contactId itemsRange_ = do - currentTs <- liftIO getCurrentTime - liftIO . withTransaction st $ \db -> - case itemsRange_ of - Just (fromItemId, toItemId) -> - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE contact_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? - |] - (CISRcvRead, currentTs, contactId, fromItemId, toItemId, CISRcvNew) - _ -> - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE contact_id = ? AND item_status = ? - |] - (CISRcvRead, currentTs, contactId, CISRcvNew) +updateDirectChatItemsRead :: DB.Connection -> Int64 -> Maybe (ChatItemId, ChatItemId) -> IO () +updateDirectChatItemsRead db contactId itemsRange_ = do + currentTs <- getCurrentTime + case itemsRange_ of + Just (fromItemId, toItemId) -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE contact_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? + |] + (CISRcvRead, currentTs, contactId, fromItemId, toItemId, CISRcvNew) + _ -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE contact_id = ? AND item_status = ? + |] + (CISRcvRead, currentTs, contactId, CISRcvNew) -updateGroupChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> Maybe (ChatItemId, ChatItemId) -> m () -updateGroupChatItemsRead st groupId itemsRange_ = do - currentTs <- liftIO getCurrentTime - liftIO . withTransaction st $ \db -> - case itemsRange_ of - Just (fromItemId, toItemId) -> - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE group_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? - |] - (CISRcvRead, currentTs, groupId, fromItemId, toItemId, CISRcvNew) - _ -> - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE group_id = ? AND item_status = ? - |] - (CISRcvRead, currentTs, groupId, CISRcvNew) +updateGroupChatItemsRead :: DB.Connection -> Int64 -> Maybe (ChatItemId, ChatItemId) -> IO () +updateGroupChatItemsRead db groupId itemsRange_ = do + currentTs <- getCurrentTime + case itemsRange_ of + Just (fromItemId, toItemId) -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE group_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? + |] + (CISRcvRead, currentTs, groupId, fromItemId, toItemId, CISRcvNew) + _ -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE group_id = ? AND item_status = ? + |] + (CISRcvRead, currentTs, groupId, CISRcvNew) type ChatStatsRow = (Int, ChatItemId) @@ -3816,25 +3636,24 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] -getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] -getSMPServers st User {userId} = - liftIO . withTransaction st $ \db -> - map toSmpServer - <$> DB.query - db - [sql| - SELECT host, port, key_hash - FROM smp_servers - WHERE user_id = ?; - |] - (Only userId) +getSMPServers :: DB.Connection -> User -> IO [SMPServer] +getSMPServers db User {userId} = + map toSmpServer + <$> DB.query + db + [sql| + SELECT host, port, key_hash + FROM smp_servers + WHERE user_id = ?; + |] + (Only userId) where toSmpServer :: (String, String, C.KeyHash) -> SMPServer toSmpServer (host, port, keyHash) = SMPServer host port keyHash -overwriteSMPServers :: StoreMonad m => SQLiteStore -> User -> [SMPServer] -> m () -overwriteSMPServers st User {userId} smpServers = do - liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do +overwriteSMPServers :: DB.Connection -> User -> [SMPServer] -> ExceptT StoreError IO () +overwriteSMPServers db User {userId} smpServers = + checkConstraint SEUniqueID . ExceptT $ do currentTs <- getCurrentTime DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) forM_ smpServers $ \ProtocolServer {host, port, keyHash} -> @@ -3848,6 +3667,39 @@ overwriteSMPServers st User {userId} smpServers = do (host, port, keyHash, userId, currentTs, currentTs) pure $ Right () +createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () +createCall db User {userId} Call {contactId, callId, chatItemId, callState} callTs = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO calls + (contact_id, shared_call_id, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (contactId, callId, chatItemId, callState, callTs, userId, currentTs, currentTs) + +deleteCalls :: DB.Connection -> User -> ContactId -> IO () +deleteCalls db User {userId} contactId = do + DB.execute db "DELETE FROM calls WHERE user_id = ? AND contact_id = ?" (userId, contactId) + +getCalls :: DB.Connection -> User -> IO [Call] +getCalls db User {userId} = do + map toCall + <$> DB.query + db + [sql| + SELECT + contact_id, shared_call_id, chat_item_id, call_state, call_ts + FROM calls + WHERE user_id = ? + ORDER BY call_ts ASC + |] + (Only userId) + where + toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call + toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} + -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) @@ -3886,21 +3738,21 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate |] (ldn, displayName, ldnSuffix, userId, ts, ts) -createWithRandomId :: forall a. TVar ChaChaDRG -> (ByteString -> IO a) -> IO (Either StoreError a) +createWithRandomId :: forall a. TVar ChaChaDRG -> (ByteString -> IO a) -> ExceptT StoreError IO a createWithRandomId = createWithRandomBytes 12 -createWithRandomBytes :: forall a. Int -> TVar ChaChaDRG -> (ByteString -> IO a) -> IO (Either StoreError a) +createWithRandomBytes :: forall a. Int -> TVar ChaChaDRG -> (ByteString -> IO a) -> ExceptT StoreError IO a createWithRandomBytes size gVar create = tryCreate 3 where - tryCreate :: Int -> IO (Either StoreError a) - tryCreate 0 = pure $ Left SEUniqueID + tryCreate :: Int -> ExceptT StoreError IO a + tryCreate 0 = throwError SEUniqueID tryCreate n = do - id' <- encodedRandomBytes gVar size - E.try (create id') >>= \case - Right x -> pure $ Right x + id' <- liftIO $ encodedRandomBytes gVar size + liftIO (E.try $ create id') >>= \case + Right x -> pure x Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1) - | otherwise -> pure . Left . SEInternalError $ show e + | otherwise -> throwError . SEInternalError $ show e encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar @@ -3908,13 +3760,6 @@ encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate -listToEither :: e -> [a] -> Either e a -listToEither _ (x : _) = Right x -listToEither e _ = Left e - -firstRow' :: (a -> Either e b) -> e -> IO [a] -> IO (Either e b) -firstRow' f e a = (f <=< listToEither e) <$> a - -- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index c1bfcc8bb..45686804e 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -28,7 +28,7 @@ terminalChatConfig = "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" ], - ntf = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] + ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"] } } diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d123a9035..f980bb145 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -99,7 +99,11 @@ data UserContact = UserContact { userContactLinkId :: Int64, connReqContact :: ConnReqContact } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + +instance ToJSON UserContact where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions data UserContactRequest = UserContactRequest { contactRequestId :: Int64, @@ -665,7 +669,8 @@ data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, connLevel :: Int, - viaContact :: Maybe Int64, + viaContact :: Maybe Int64, -- group member contact ID, if not direct connection + viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" connType :: ConnType, connStatus :: ConnStatus, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index a5e4c8b1e..835dedce5 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -1,6 +1,5 @@ module Simplex.Chat.Util where -import Control.Monad (when) import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8With) @@ -10,14 +9,5 @@ safeDecodeUtf8 = decodeUtf8With onError where onError _ _ = Just '?' -ifM :: Monad m => m Bool -> m a -> m a -> m a -ifM ba t f = ba >>= \b -> if b then t else f - -whenM :: Monad m => m Bool -> m () -> m () -whenM ba a = ba >>= (`when` a) - -unlessM :: Monad m => m Bool -> m () -> m () -unlessM b = ifM b $ pure () - -eitherToMaybe :: Either a b -> Maybe b -eitherToMaybe = either (const Nothing) Just +uncurry3 :: (a -> b -> c -> d) -> ((a, b, c) -> d) +uncurry3 f ~(a, b, c) = f a b c diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6f7f31a66..e1dd53fa0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -53,7 +53,9 @@ responseToView :: Bool -> ChatResponse -> [StyledString] responseToView testView = \case CRActiveUser User {profile} -> viewUserProfile profile CRChatStarted -> ["chat started"] - CRChatRunning -> [] + CRChatRunning -> ["chat is running"] + CRChatStopped -> ["chat stopped"] + CRChatSuspended -> ["chat suspended"] CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] @@ -77,8 +79,8 @@ responseToView testView = \case HSMarkdown -> markdownInfo CRWelcome user -> chatWelcome user CRContactsList cs -> viewContactsList cs - CRUserContactLink cReqUri _ -> connReqContact_ "Your chat address:" cReqUri - CRUserContactLinkUpdated _ autoAccept -> ["auto_accept " <> if autoAccept then "on" else "off"] + CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply + CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] CRGroupCreated g -> viewGroupCreated g CRGroupMembers g -> viewGroupMembers g @@ -150,16 +152,19 @@ responseToView testView = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey + CRCallInvitation RcvCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey CRCallOffer {contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey CRCallAnswer {contact, answer} -> viewCallAnswer contact answer CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact] CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"] + CRCallInvitations _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRNewContactConnection _ -> [] CRContactConnectionDeleted PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] + CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] + CRNtfMessages {} -> [] CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e where @@ -357,6 +362,11 @@ connReqContact_ intro cReq = "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] +autoAcceptStatus_ :: Bool -> Maybe MsgContent -> [StyledString] +autoAcceptStatus_ autoAccept autoReply = + ("auto_accept " <> if autoAccept then "on" else "off") : + maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply + viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest c Profile {fullName} = [ ttyFullName c fullName <> " wants to connect to you!", @@ -721,6 +731,8 @@ viewChatError = \case CENoActiveUser -> ["error: active user is required"] CEActiveUserExists -> ["error: active user already exists"] CEChatNotStarted -> ["error: chat not started"] + CEChatNotStopped -> ["error: chat not stopped"] + CEChatStoreChanged -> ["error: chat store changed"] CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage e -> ["chat message error: " <> sShow e] CEContactNotReady c -> [ttyContact' c <> ": not ready"] diff --git a/stack.yaml b/stack.yaml index 5b6b00b39..86ec49a70 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 964daf5442e1069634762450bc28cfd69a2968a1 + commit: e75846aa38dd26fa70e3faa38ec780edf245e022 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 @@ -59,8 +59,10 @@ extra-deps: # extra-deps: [] # Override default flag values for local packages and extra-deps -# flags: {} - +flags: + zip: + disable-bzip2: true + disable-zstd: true # Extra package databases containing global packages # extra-package-dbs: [] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 2aba3feb2..b45adf8cf 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -14,7 +14,7 @@ import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad.Except import Data.List (dropWhileEnd, find) -import Data.Maybe (fromJust) +import Data.Maybe (fromJust, isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat @@ -27,9 +27,11 @@ import Simplex.Chat.Terminal.Output (newChatTerminal) import Simplex.Chat.Types (Profile, User (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval +import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport +import Simplex.Messaging.Version import System.Directory (createDirectoryIfMissing, removePathForcibly) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) @@ -42,8 +44,8 @@ testDBPrefix = "tests/tmp/test" serverPort :: ServiceName serverPort = "5001" -opts :: ChatOpts -opts = +testOpts :: ChatOpts +testOpts = ChatOpts { dbFilePrefix = undefined, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], @@ -51,7 +53,8 @@ opts = logAgent = False, chatCmd = "", chatCmdDelay = 3, - chatServerPort = Nothing + chatServerPort = Nothing, + maintenance = False } termSettings :: VirtualTerminalSettings @@ -74,34 +77,48 @@ data TestCC = TestCC aCfg :: AgentConfig aCfg = agentConfig defaultChatConfig -cfg :: ChatConfig -cfg = +testAgentCfg :: AgentConfig +testAgentCfg = aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}} + +testCfg :: ChatConfig +testCfg = defaultChatConfig - { agentConfig = - aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}}, + { agentConfig = testAgentCfg, testView = True } -createTestChat :: String -> Profile -> IO TestCC -createTestChat dbPrefix profile = do - let dbFilePrefix = testDBPrefix <> dbPrefix - st <- createStore (dbFilePrefix <> "_chat.db") 1 False - Right user <- runExceptT $ createUser st profile True - startTestChat_ st dbFilePrefix user +testAgentCfgV1 :: AgentConfig +testAgentCfgV1 = + testAgentCfg + { smpAgentVersion = 1, + smpAgentVRange = mkVersionRange 1 1, + smpCfg = (smpCfg testAgentCfg) {smpServerVRange = mkVersionRange 1 1} + } -startTestChat :: String -> IO TestCC -startTestChat dbPrefix = do - let dbFilePrefix = testDBPrefix <> dbPrefix - st <- createStore (dbFilePrefix <> "_chat.db") 1 False - Just user <- find activeUser <$> getUsers st - startTestChat_ st dbFilePrefix user +testCfgV1 :: ChatConfig +testCfgV1 = testCfg {agentConfig = testAgentCfgV1} -startTestChat_ :: SQLiteStore -> FilePath -> User -> IO TestCC -startTestChat_ st dbFilePrefix user = do +createTestChat :: ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC +createTestChat cfg opts dbPrefix profile = do + let dbFilePrefix = testDBPrefix <> dbPrefix + st <- createStore (dbFilePrefix <> "_chat.db") False + Right user <- withTransaction st $ \db -> runExceptT $ createUser db profile True + startTestChat_ st cfg opts dbFilePrefix user + +startTestChat :: ChatConfig -> ChatOpts -> String -> IO TestCC +startTestChat cfg opts dbPrefix = do + let dbFilePrefix = testDBPrefix <> dbPrefix + st <- createStore (dbFilePrefix <> "_chat.db") False + Just user <- find activeUser <$> withTransaction st getUsers + startTestChat_ st cfg opts dbFilePrefix user + +startTestChat_ :: SQLiteStore -> ChatConfig -> ChatOpts -> FilePath -> User -> IO TestCC +startTestChat_ st cfg opts dbFilePrefix user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t cc <- newChatController st (Just user) cfg opts {dbFilePrefix} Nothing -- no notifications - chatAsync <- async . runSimplexChat user cc . const $ runChatTerminal ct + chatAsync <- async . runSimplexChat opts user cc . const $ runChatTerminal ct + atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} @@ -113,10 +130,34 @@ stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do uninterruptibleCancel chatAsync withNewTestChat :: String -> Profile -> (TestCC -> IO a) -> IO a -withNewTestChat dbPrefix profile = bracket (createTestChat dbPrefix profile) (\cc -> cc > stopTestChat cc) +withNewTestChat = withNewTestChatCfgOpts testCfg testOpts + +withNewTestChatV1 :: String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChatV1 = withNewTestChatCfg testCfgV1 + +withNewTestChatCfg :: ChatConfig -> String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChatCfg cfg = withNewTestChatCfgOpts cfg testOpts + +withNewTestChatOpts :: ChatOpts -> String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChatOpts = withNewTestChatCfgOpts testCfg + +withNewTestChatCfgOpts :: ChatConfig -> ChatOpts -> String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChatCfgOpts cfg opts dbPrefix profile = bracket (createTestChat cfg opts dbPrefix profile) (\cc -> cc > stopTestChat cc) + +withTestChatV1 :: String -> (TestCC -> IO a) -> IO a +withTestChatV1 = withTestChatCfg testCfgV1 withTestChat :: String -> (TestCC -> IO a) -> IO a -withTestChat dbPrefix = bracket (startTestChat dbPrefix) (\cc -> cc > stopTestChat cc) +withTestChat = withTestChatCfgOpts testCfg testOpts + +withTestChatCfg :: ChatConfig -> String -> (TestCC -> IO a) -> IO a +withTestChatCfg cfg = withTestChatCfgOpts cfg testOpts + +withTestChatOpts :: ChatOpts -> String -> (TestCC -> IO a) -> IO a +withTestChatOpts = withTestChatCfgOpts testCfg + +withTestChatCfgOpts :: ChatConfig -> ChatOpts -> String -> (TestCC -> IO a) -> IO a +withTestChatCfgOpts cfg opts dbPrefix = bracket (startTestChat cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do @@ -147,8 +188,8 @@ withTmpFiles = (createDirectoryIfMissing False "tests/tmp") (removePathForcibly "tests/tmp") -testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () -testChatN ps test = withTmpFiles $ do +testChatN :: ChatConfig -> ChatOpts -> [Profile] -> ([TestCC] -> IO ()) -> IO () +testChatN cfg opts ps test = withTmpFiles $ do tcs <- getTestCCs (zip ps [1 ..]) [] test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat (show db) p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat cfg opts (show db) p <*> getTestCCs envs' tcs ( Int -> Expectation ( IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () -testChat2 p1 p2 test = testChatN [p1, p2] test_ +testChat2 = testChatCfgOpts2 testCfg testOpts + +testChatCfg2 :: ChatConfig -> Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () +testChatCfg2 cfg = testChatCfgOpts2 cfg testOpts + +testChatOpts2 :: ChatOpts -> Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () +testChatOpts2 = testChatCfgOpts2 testCfg + +testChatCfgOpts2 :: ChatConfig -> ChatOpts -> Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () +testChatCfgOpts2 cfg opts p1 p2 test = testChatN cfg opts [p1, p2] test_ where test_ :: [TestCC] -> IO () test_ [tc1, tc2] = test tc1 tc2 test_ _ = error "expected 2 chat clients" testChat3 :: Profile -> Profile -> Profile -> (TestCC -> TestCC -> TestCC -> IO ()) -> IO () -testChat3 p1 p2 p3 test = testChatN [p1, p2, p3] test_ +testChat3 = testChatCfgOpts3 testCfg testOpts + +testChatCfg3 :: ChatConfig -> Profile -> Profile -> Profile -> (TestCC -> TestCC -> TestCC -> IO ()) -> IO () +testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts + +testChatCfgOpts3 :: ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (TestCC -> TestCC -> TestCC -> IO ()) -> IO () +testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where test_ :: [TestCC] -> IO () test_ [tc1, tc2, tc3] = test tc1 tc2 tc3 test_ _ = error "expected 3 chat clients" testChat4 :: Profile -> Profile -> Profile -> Profile -> (TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> IO () -testChat4 p1 p2 p3 p4 test = testChatN [p1, p2, p3, p4] test_ +testChat4 p1 p2 p3 p4 test = testChatN testCfg testOpts [p1, p2, p3, p4] test_ where test_ :: [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 @@ -209,6 +265,7 @@ serverCfg = queueIdBytes = 12, msgIdBytes = 6, storeLogFile = Nothing, + storeMsgsFile = Nothing, allowNewQueues = True, messageExpiration = Just defaultMessageExpiration, inactiveClientExpiration = Just defaultInactiveClientExpiration, @@ -216,7 +273,9 @@ serverCfg = privateKeyFile = "tests/fixtures/tls/server.key", certificateFile = "tests/fixtures/tls/server.crt", logStatsInterval = Just 86400, - logStatsStartTime = 0 + logStatsStartTime = 0, + serverStatsFile = Nothing, + smpServerVRange = supportedSMPServerVRange } withSmpServer :: IO a -> IO a diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 4d60fe215..51d4219ef 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -18,9 +18,11 @@ import Data.Char (isDigit) import qualified Data.Text as T import Simplex.Chat.Call import Simplex.Chat.Controller (ChatController (..)) +import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) -import Simplex.Chat.Util (unlessM) -import System.Directory (copyFile, doesFileExist) +import Simplex.Messaging.Util (unlessM) +import System.Directory (copyFile, doesDirectoryExist, doesFileExist) +import System.FilePath (()) import Test.Hspec aliceProfile :: Profile @@ -38,12 +40,12 @@ danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing} chatTests :: Spec chatTests = do describe "direct messages" $ do - it "add contact and send/receive message" testAddContact + describe "add contact and send/receive message" testAddContact it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message delete" testDirectMessageDelete describe "chat groups" $ do - it "add contacts, create group and send/receive messages" testGroup + describe "add contacts, create group and send/receive messages" testGroup it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete it "invitee delete group when in status invited" testGroupDeleteWhenInvited @@ -65,37 +67,85 @@ chatTests = do it "send and receive file to group" testGroupFileTransfer it "sender cancelled group file transfer before transfer" testGroupFileSndCancelBeforeTransfer describe "messages with files" $ do - it "send and receive message with file" testMessageWithFile + describe "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage it "files folder: send and receive image" testFilesFoldersSendImage it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete it "send and receive image with text and quote" testSendImageWithTextAndQuote - it "send and receive image to group" testGroupSendImage + describe "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote describe "user contact link" $ do - it "create and connect via contact link" testUserContactLink + describe "create and connect via contact link" testUserContactLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests it "deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange it "reject contact and delete contact link" testRejectContactAndDeleteUserContact it "delete connection requests when contact link deleted" testDeleteConnectionRequests + it "auto-reply message" testAutoReplyMessage describe "SMP servers" $ it "get and set SMP servers" testGetSetSMPServers describe "async connection handshake" $ do it "connect when initiating client goes offline" testAsyncInitiatingOffline it "connect when accepting client goes offline" testAsyncAcceptingOffline - it "connect, fully asynchronous (when clients are never simultaneously online)" testFullAsync - xdescribe "async sending and receiving files" $ do - it "send and receive file, fully asynchronous" testAsyncFileTransfer - it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer + describe "connect, fully asynchronous (when clients are never simultaneously online)" $ do + it "v2" testFullAsync + it "v1" testFullAsyncV1 + it "v1 to v2" testFullAsyncV1toV2 + it "v2 to v1" testFullAsyncV2toV1 + describe "async sending and receiving files" $ do + xdescribe "send and receive file, fully asynchronous" $ do + it "v2" testAsyncFileTransfer + it "v1" testAsyncFileTransferV1 + xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall + describe "maintenance mode" $ do + it "start/stop/export/import chat" testMaintenanceMode + it "export/import chat with files" testMaintenanceModeWithFiles -testAddContact :: IO () -testAddContact = - testChat2 aliceProfile bobProfile $ - \alice bob -> do +versionTestMatrix2 :: (TestCC -> TestCC -> IO ()) -> Spec +versionTestMatrix2 runTest = do + it "v2" $ testChat2 aliceProfile bobProfile $ runTest + it "v1" $ testChatCfg2 testCfgV1 aliceProfile bobProfile $ runTest + it "v1 to v2" . withTmpFiles $ + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChatV1 "bob" bobProfile $ \bob -> + runTest alice bob + it "v2 to v1" . withTmpFiles $ + withNewTestChatV1 "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + runTest alice bob + +versionTestMatrix3 :: (TestCC -> TestCC -> TestCC -> IO ()) -> Spec +versionTestMatrix3 runTest = do + it "v2" $ testChat3 aliceProfile bobProfile cathProfile $ runTest + it "v1" $ testChatCfg3 testCfgV1 aliceProfile bobProfile cathProfile $ runTest + it "v1 to v2" . withTmpFiles $ + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChatV1 "bob" bobProfile $ \bob -> + withNewTestChatV1 "cath" cathProfile $ \cath -> + runTest alice bob cath + it "v2+v1 to v2" . withTmpFiles $ + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + withNewTestChatV1 "cath" cathProfile $ \cath -> + runTest alice bob cath + it "v2 to v1" . withTmpFiles $ + withNewTestChatV1 "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + withNewTestChat "cath" cathProfile $ \cath -> + runTest alice bob cath + it "v2+v1 to v1" . withTmpFiles $ + withNewTestChatV1 "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + withNewTestChatV1 "cath" cathProfile $ \cath -> + runTest alice bob cath + +testAddContact :: Spec +testAddContact = versionTestMatrix2 runTestAddContact + where + runTestAddContact alice bob = do alice ##> "/c" inv <- getInvitation alice bob ##> ("/c " <> inv) @@ -136,7 +186,6 @@ testAddContact = alice #$> ("/_get chat @2 count=100", chat, []) bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob #$> ("/_get chat @2 count=100", chat, []) - where chatsEmpty alice bob = do alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) @@ -308,10 +357,10 @@ testDirectMessageDelete = bob @@@ [("@alice", "do you receive my messages?")] bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))]) -testGroup :: IO () -testGroup = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do +testGroup :: Spec +testGroup = versionTestMatrix3 runTestGroup + where + runTestGroup alice bob cath = do connectUsers alice bob connectUsers alice cath alice ##> "/g team" @@ -402,7 +451,7 @@ testGroup = bob #$> ("/_get chat #1 count=100", chat, []) cath #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") cath #$> ("/_get chat #1 count=100", chat, []) - where + getReadChats :: TestCC -> TestCC -> TestCC -> IO () getReadChats alice bob cath = do alice @@@ [("#team", "hey team"), ("@cath", ""), ("@bob", "")] alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) @@ -1199,10 +1248,10 @@ testGroupFileSndCancelBeforeTransfer = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.txt" -testMessageWithFile :: IO () -testMessageWithFile = - testChat2 aliceProfile bobProfile $ - \alice bob -> do +testMessageWithFile :: Spec +testMessageWithFile = versionTestMatrix2 runTestMessageWithFile + where + runTestMessageWithFile alice bob = do connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" @@ -1410,10 +1459,10 @@ testSendImageWithTextAndQuote = (alice <## "completed sending file 3 (test.jpg) to bob") B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: IO () -testGroupSendImage = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do +testGroupSendImage :: Spec +testGroupSendImage = versionTestMatrix3 runTestGroupSendImage + where + runTestGroupSendImage alice bob cath = do createGroup3 "team" alice bob cath alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -1514,32 +1563,31 @@ testGroupSendImageWithTextAndQuote = cath #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) cath @@@ [("#team", "hey bob"), ("@alice", ""), ("@bob", "")] -testUserContactLink :: IO () -testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - alice ##> "/ad" - cLink <- getContactLink alice True - bob ##> ("/c " <> cLink) - alice <#? bob - alice @@@ [("<@bob", "")] - alice ##> "/ac bob" - alice <## "bob (Bob): accepting contact request..." - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - alice @@@ [("@bob", "")] - alice <##> bob +testUserContactLink :: Spec +testUserContactLink = versionTestMatrix3 $ \alice bob cath -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice @@@ [("@bob", "")] + alice <##> bob - cath ##> ("/c " <> cLink) - alice <#? cath - alice @@@ [("<@cath", ""), ("@bob", "hey")] - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request..." - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - alice @@@ [("@cath", ""), ("@bob", "hey")] - alice <##> cath + cath ##> ("/c " <> cLink) + alice <#? cath + alice @@@ [("<@cath", ""), ("@bob", "hey")] + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice @@@ [("@cath", ""), ("@bob", "hey")] + alice <##> cath testUserContactLinkAutoAccept :: IO () testUserContactLinkAutoAccept = @@ -1725,6 +1773,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr alice ##> "/sa" cLink' <- getContactLink alice False + alice <## "auto_accept off" cLink' `shouldBe` cLink alice ##> "/da" @@ -1756,6 +1805,28 @@ testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink') alice <#? cath +testAutoReplyMessage :: IO () +testAutoReplyMessage = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + alice ##> "/auto_accept on text hello!" + alice <## "auto_accept on" + alice <## "auto reply:" + alice <## "hello!" + + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting contact request..." + concurrentlyN_ + [ do + bob <## "alice (Alice): contact is connected" + bob <# "alice> hello!", + do + alice <## "bob (Bob): contact is connected" + alice <# "@bob hello!" + ] + testGetSetSMPServers :: IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ @@ -1803,11 +1874,8 @@ testFullAsync = withTmpFiles $ do withNewTestChat "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat "alice" $ \_ -> pure () - withTestChat "bob" $ \_ -> pure () - withTestChat "alice" $ \alice -> - alice <## "1 contacts connected (use /cs for the list)" - withTestChat "bob" $ \_ -> pure () + withTestChat "alice" $ \_ -> pure () -- connecting... notification in UI + withTestChat "bob" $ \_ -> pure () -- connecting... notification in UI withTestChat "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "bob (Bob): contact is connected" @@ -1815,6 +1883,81 @@ testFullAsync = withTmpFiles $ do bob <## "1 contacts connected (use /cs for the list)" bob <## "alice (Alice): contact is connected" +testFullAsyncV1 :: IO () +testFullAsyncV1 = withTmpFiles $ do + inv <- withNewAlice $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewBob $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withAlice $ \_ -> pure () + withBob $ \_ -> pure () + withAlice $ \alice -> + alice <## "1 contacts connected (use /cs for the list)" + withBob $ \_ -> pure () + withAlice $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "bob (Bob): contact is connected" + withBob $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "alice (Alice): contact is connected" + where + withNewAlice = withNewTestChatV1 "alice" aliceProfile + withAlice = withTestChatV1 "alice" + withNewBob = withNewTestChatV1 "bob" bobProfile + withBob = withTestChatV1 "bob" + +testFullAsyncV1toV2 :: IO () +testFullAsyncV1toV2 = withTmpFiles $ do + inv <- withNewAlice $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewBob $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withAlice $ \_ -> pure () + withBob $ \_ -> pure () + withAlice $ \alice -> + alice <## "1 contacts connected (use /cs for the list)" + withBob $ \_ -> pure () + withAlice $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "bob (Bob): contact is connected" + withBob $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "alice (Alice): contact is connected" + where + withNewAlice = withNewTestChat "alice" aliceProfile + withAlice = withTestChat "alice" + withNewBob = withNewTestChatV1 "bob" bobProfile + withBob = withTestChatV1 "bob" + +testFullAsyncV2toV1 :: IO () +testFullAsyncV2toV1 = withTmpFiles $ do + inv <- withNewAlice $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewBob $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withAlice $ \_ -> pure () + withBob $ \_ -> pure () + withAlice $ \alice -> + alice <## "1 contacts connected (use /cs for the list)" + withBob $ \_ -> pure () + withAlice $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "bob (Bob): contact is connected" + withBob $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "alice (Alice): contact is connected" + where + withNewAlice = withNewTestChatV1 "alice" aliceProfile + withAlice = withTestChatV1 "alice" + withNewBob = withNewTestChat "bob" bobProfile + withBob = withTestChat "bob" + testAsyncFileTransfer :: IO () testAsyncFileTransfer = withTmpFiles $ do withNewTestChat "alice" aliceProfile $ \alice -> @@ -1831,8 +1974,8 @@ testAsyncFileTransfer = withTmpFiles $ do bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - withTestChatContactConnected' "alice" - withTestChatContactConnected' "bob" + -- withTestChatContactConnected' "alice" -- TODO not needed in v2 + -- withTestChatContactConnected' "bob" -- TODO not needed in v2 withTestChatContactConnected' "alice" withTestChatContactConnected' "bob" withTestChatContactConnected "alice" $ \alice -> do @@ -1845,6 +1988,36 @@ testAsyncFileTransfer = withTmpFiles $ do dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src +testAsyncFileTransferV1 :: IO () +testAsyncFileTransferV1 = withTmpFiles $ do + withNewTestChatV1 "alice" aliceProfile $ \alice -> + withNewTestChatV1 "bob" bobProfile $ \bob -> + connectUsers alice bob + withTestChatContactConnectedV1 "alice" $ \alice -> do + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" + alice <# "@bob hi, sending a file" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + withTestChatContactConnectedV1 "bob" $ \bob -> do + bob <# "alice> hi, sending a file" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + withTestChatContactConnectedV1' "alice" -- TODO not needed in v2 + withTestChatContactConnectedV1' "bob" -- TODO not needed in v2 + withTestChatContactConnectedV1' "alice" + withTestChatContactConnectedV1' "bob" + withTestChatContactConnectedV1 "alice" $ \alice -> do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob" + withTestChatContactConnectedV1 "bob" $ \bob -> do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + testAsyncGroupFileTransfer :: IO () testAsyncGroupFileTransfer = withTmpFiles $ do withNewTestChat "alice" aliceProfile $ \alice -> @@ -1868,9 +2041,9 @@ testAsyncGroupFileTransfer = withTmpFiles $ do withTestChatGroup3Connected' "alice" withTestChatGroup3Connected' "bob" withTestChatGroup3Connected' "cath" - withTestChatGroup3Connected' "alice" - withTestChatGroup3Connected' "bob" - withTestChatGroup3Connected' "cath" + -- withTestChatGroup3Connected' "alice" -- TODO not needed in v2 + -- withTestChatGroup3Connected' "bob" -- TODO not needed in v2 + -- withTestChatGroup3Connected' "cath" -- TODO not needed in v2 withTestChatGroup3Connected' "alice" withTestChatGroup3Connected "bob" $ \bob -> do bob <## "started receiving file 1 (test.jpg) from alice" @@ -1920,6 +2093,8 @@ testNegotiateCall :: IO () testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob + -- just for testing db query + alice ##> "/_call get" -- alice invite bob to call alice ##> ("/_call invite @2 " <> serialize testCallType) alice <## "ok" @@ -1963,6 +2138,82 @@ testNegotiateCall = alice <## "message updated" alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: ended (00:00)")]) +testMaintenanceMode :: IO () +testMaintenanceMode = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatOpts testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do + alice ##> "/c" + alice <## "error: chat not started" + alice ##> "/_start" + alice <## "chat started" + connectUsers alice bob + alice #> "@bob hi" + bob <# "alice> hi" + alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice <## "error: chat not stopped" + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/_start" + alice <## "chat started" + -- chat works after start + alice <## "1 contacts connected (use /cs for the list)" + alice #> "@bob hi again" + bob <# "alice> hi again" + bob #> "@alice hello" + alice <# "bob> hello" + -- export / delete / import + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice <## "ok" + doesFileExist "./tests/tmp/alice-chat.zip" `shouldReturn` True + alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice <## "ok" + -- cannot start chat after import + alice ##> "/_start" + alice <## "error: chat store changed" + -- works after full restart + withTestChat "alice" $ \alice -> testChatWorking alice bob + +testChatWorking :: TestCC -> TestCC -> IO () +testChatWorking alice bob = do + alice <## "1 contacts connected (use /cs for the list)" + alice #> "@bob hello again" + bob <# "alice> hello again" + bob #> "@alice hello too" + alice <# "bob> hello too" + +testMaintenanceModeWithFiles :: IO () +testMaintenanceModeWithFiles = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatOpts testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do + alice ##> "/_start" + alice <## "chat started" + alice ##> "/_files_folder ./tests/tmp/alice_files" + alice <## "ok" + connectUsers alice bob + startFileTransferWithDest' bob alice "test.jpg" "136.5 KiB / 139737 bytes" Nothing + bob <## "completed sending file 1 (test.jpg) to alice" + alice <## "completed receiving file 1 (test.jpg) from bob" + src <- B.readFile "./tests/fixtures/test.jpg" + B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + threadDelay 500000 + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice <## "ok" + alice ##> "/_db delete" + alice <## "ok" + -- cannot start chat after delete + alice ##> "/_start" + alice <## "error: chat store changed" + doesDirectoryExist "./tests/tmp/alice_files" `shouldReturn` False + alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice <## "ok" + B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + -- works after full restart + withTestChat "alice" $ \alice -> testChatWorking alice bob + withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a withTestChatContactConnected dbPrefix action = withTestChat dbPrefix $ \cc -> do @@ -1972,6 +2223,15 @@ withTestChatContactConnected dbPrefix action = withTestChatContactConnected' :: String -> IO () withTestChatContactConnected' dbPrefix = withTestChatContactConnected dbPrefix $ \_ -> pure () +withTestChatContactConnectedV1 :: String -> (TestCC -> IO a) -> IO a +withTestChatContactConnectedV1 dbPrefix action = + withTestChatV1 dbPrefix $ \cc -> do + cc <## "1 contacts connected (use /cs for the list)" + action cc + +withTestChatContactConnectedV1' :: String -> IO () +withTestChatContactConnectedV1' dbPrefix = withTestChatContactConnectedV1 dbPrefix $ \_ -> pure () + withTestChatGroup3Connected :: String -> (TestCC -> IO a) -> IO a withTestChatGroup3Connected dbPrefix action = do withTestChat dbPrefix $ \cc -> do @@ -1987,16 +2247,21 @@ startFileTransfer alice bob = startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" startFileTransfer' :: TestCC -> TestCC -> String -> String -> IO () -startFileTransfer' alice bob fileName fileSize = do - alice #> ("/f @bob ./tests/fixtures/" <> fileName) - alice <## "use /fc 1 to cancel sending" - bob <# ("alice> sends file " <> fileName <> " (" <> fileSize <> ")") - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## ("saving file 1 from alice to ./tests/tmp/" <> fileName) +startFileTransfer' cc1 cc2 fileName fileSize = startFileTransferWithDest' cc1 cc2 fileName fileSize $ Just "./tests/tmp" + +startFileTransferWithDest' :: TestCC -> TestCC -> String -> String -> Maybe String -> IO () +startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do + name1 <- userName cc1 + name2 <- userName cc2 + cc1 #> ("/f @" <> name2 <> " ./tests/fixtures/" <> fileName) + cc1 <## "use /fc 1 to cancel sending" + cc2 <# (name1 <> "> sends file " <> fileName <> " (" <> fileSize <> ")") + cc2 <## "use /fr 1 [/ | ] to receive it" + cc2 ##> ("/fr 1" <> maybe "" (" " <>) fileDest_) + cc2 <## ("saving file 1 from " <> name1 <> " to " <> maybe id () fileDest_ fileName) concurrently_ - (bob <## ("started receiving file 1 (" <> fileName <> ") from alice")) - (alice <## ("started sending file 1 (" <> fileName <> ") to bob")) + (cc2 <## ("started receiving file 1 (" <> fileName <> ") from " <> name1)) + (cc1 <## ("started sending file 1 (" <> fileName <> ") to " <> name2)) checkPartialTransfer :: String -> IO () checkPartialTransfer fileName = do diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 9e73060f9..ee6eae98c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -43,6 +43,34 @@ chatStarted = "{\"resp\":{\"chatStarted\":{}}}" chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" #endif +contactSubSummary :: String +#if defined(darwin_HOST_OS) && defined(swiftJSON) +contactSubSummary = "{\"resp\":{\"contactSubSummary\":{\"contactSubscriptions\":[]}}}" +#else +contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\",\"contactSubscriptions\":[]}}" +#endif + +memberSubErrors :: String +#if defined(darwin_HOST_OS) && defined(swiftJSON) +memberSubErrors = "{\"resp\":{\"memberSubErrors\":{\"memberSubErrors\":[]}}}" +#else +memberSubErrors = "{\"resp\":{\"type\":\"memberSubErrors\",\"memberSubErrors\":[]}}" +#endif + +pendingSubSummary :: String +#if defined(darwin_HOST_OS) && defined(swiftJSON) +pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{\"pendingSubStatus\":[]}}}" +#else +pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\",\"pendingSubStatus\":[]}}" +#endif + +parsedMarkdown :: String +#if defined(darwin_HOST_OS) && defined(swiftJSON) +parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" +#else +parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" +#endif + testChatApiNoUser :: IO () testChatApiNoUser = withTmpFiles $ do cc <- chatInit testDBPrefix @@ -54,9 +82,15 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do let f = chatStoreFile $ testDBPrefix <> "1" - st <- createStore f 1 True - Right _ <- runExceptT $ createUser st aliceProfile True + st <- createStore f True + Right _ <- withTransaction st $ \db -> runExceptT $ createUser db aliceProfile True cc <- chatInit $ testDBPrefix <> "1" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted + chatRecvMsg cc `shouldReturn` contactSubSummary + chatRecvMsg cc `shouldReturn` memberSubErrors + chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary + chatRecvMsgWait cc 10000 `shouldReturn` "" + chatParseMarkdown "hello" `shouldBe` "{}" + chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 3b615ddff..e7ffcc2d0 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -15,19 +15,15 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (ProtocolServer (..), smpClientVRange) +import Simplex.Messaging.Protocol (smpClientVRange) +import Simplex.Messaging.Version import Test.Hspec protocolTests :: Spec protocolTests = decodeChatMessageTest srv :: SMPServer -srv = - ProtocolServer - { host = "smp.simplex.im", - port = "5223", - keyHash = C.KeyHash "\215m\248\251" - } +srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") queue :: SMPQueueUri queue = @@ -42,7 +38,7 @@ connReqData :: ConnReqUriData connReqData = ConnReqUriData { crScheme = simplexChat, - crAgentVRange = smpAgentVRange, + crAgentVRange = mkVersionRange 1 1, crSmpQueues = [queue] } diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 03ef2a47d..923d73531 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -3,6 +3,7 @@ module SchemaDump where import ChatClient (withTmpFiles) +import Control.DeepSeq import Control.Monad (void) import Simplex.Chat.Store (createStore) import System.Process (readCreateProcess, shell) @@ -21,10 +22,10 @@ schemaDumpTest = testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do - void $ createStore testDB 1 False + void $ createStore testDB False void $ readCreateProcess (shell $ "touch " <> schema) "" savedSchema <- readFile schema - savedSchema `seq` pure () - void $ readCreateProcess (shell $ "sqlite3 " <> testDB <> " .schema > " <> schema) "" + savedSchema `deepseq` pure () + void $ readCreateProcess (shell $ "sqlite3 " <> testDB <> " '.schema --indent' > " <> schema) "" currentSchema <- readFile schema savedSchema `shouldBe` currentSchema