Merge pull request #353 from simplex-chat/master

v1.2.1 terminal app
This commit is contained in:
Evgeny Poberezkin
2022-02-22 19:28:17 +00:00
committed by GitHub
77 changed files with 4359 additions and 531 deletions
+3 -10
View File
@@ -7,18 +7,11 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
/.idea/misc.xml
.DS_Store
build/
release/
debug/
/build
/captures
.externalNativeBuild
.cxx
local.properties
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
+21
View File
@@ -1,5 +1,11 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
@@ -113,5 +119,20 @@
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="RIGHT_MARGIN" value="120" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="METHOD_ANNOTATION_WRAP" value="0" />
<option name="CLASS_ANNOTATION_WRAP" value="0" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
+1
View File
@@ -1,5 +1,6 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
@@ -0,0 +1,20 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/drawable-v24/ic_launcher_foreground.xml" value="0.2328125" />
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.2328125" />
<entry key="app/src/main/res/layout/activity_main.xml" value="1.0" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+1
View File
@@ -0,0 +1 @@
/build
+41 -9
View File
@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}
android {
@@ -8,7 +9,7 @@ android {
defaultConfig {
applicationId "chat.simplex.app"
minSdk 24
minSdk 26
targetSdk 32
versionCode 1
versionName "1.0"
@@ -17,6 +18,9 @@ android {
ndk {
abiFilters 'arm64-v8a'
}
vectorDrawables {
useSupportLibrary true
}
externalNativeBuild {
cmake {
cppFlags ''
@@ -44,17 +48,45 @@ android {
}
}
buildFeatures {
viewBinding true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
testImplementation 'junit:junit:4.+'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
def camerax_version = "1.1.0-beta01"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
//Barcode
implementation 'com.google.zxing:core:3.4.0'
implementation 'com.google.mlkit:barcode-scanning:17.0.2'
//Camera Permission
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
@@ -1,13 +1,11 @@
package chat.simplex.app
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
+14 -2
View File
@@ -2,11 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.simplex.app">
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -15,12 +18,21 @@
android:theme="@style/Theme.SimpleX">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplex" />
</intent-filter>
</activity>
</application>
+10 -32
View File
@@ -8,7 +8,7 @@ void setLineBuffering(void);
int pipe_std_to_socket(const char * name);
JNIEXPORT jint JNICALL
Java_chat_simplex_app_MainActivityKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
int ret = pipe_std_to_socket(name);
(*env)->ReleaseStringUTFChars(env, socket_name, name);
@@ -16,50 +16,28 @@ Java_chat_simplex_app_MainActivityKt_pipeStdOutToSocket(JNIEnv *env, __unused jc
}
JNIEXPORT void JNICALL
Java_chat_simplex_app_MainActivityKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
hs_init(NULL, NULL);
setLineBuffering();
}
// from simplex-chat
typedef void* chat_store;
typedef void* controller;
typedef void* chat_ctrl;
extern chat_store chat_init_store(const char * path);
extern char *chat_get_user(chat_store store);
extern char *chat_create_user(chat_store store, const char *data);
extern controller chat_start(chat_store store);
extern char *chat_send_cmd(controller ctl, const char *cmd);
extern char *chat_recv_msg(controller ctl);
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);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init_store(_data);
jlong res = (jlong)chat_init(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatGetUser(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_get_user((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatCreateUser(JNIEnv *env, __unused jclass clazz, jlong controller, jstring data) {
const char *_data = (*env)->GetStringUTFChars(env, data, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_create_user((void*)controller, _data));
(*env)->ReleaseStringUTFChars(env, data, _data);
return res;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatStart(JNIEnv *env, jclass clazz, jlong controller) {
return (jlong)chat_start((void*)controller);
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
@@ -67,6 +45,6 @@ Java_chat_simplex_app_MainActivityKt_chatSendCmd(JNIEnv *env, __unused jclass cl
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
}
@@ -1,123 +1,160 @@
package chat.simplex.app
import android.net.LocalServerSocket
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.UserProfileView
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
// ghc's rts
external fun initHS()
// android-support
external fun pipeStdOutToSocket(socketName: String) : Int
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
class MainActivity: ComponentActivity() {
private val vm by viewModels<SimplexViewModel>()
// simplex-chat
typealias Store = Long
typealias Controller = Long
external fun chatInit(filesDir: String): Store
external fun chatGetUser(controller: Store) : String
external fun chatCreateUser(controller: Store, data: String) : String
external fun chatStart(controller: Store) : Controller
external fun chatSendCmd(controller: Controller, msg: String) : String
external fun chatRecvMsg(controller: Controller) : String
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
weakActivity = WeakReference(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val store : Store = chatInit(this.applicationContext.filesDir.toString())
// create user if needed
if(chatGetUser(store) == "{}") {
chatCreateUser(store, """
{"displayName": "test", "fullName": "android test"}
""".trimIndent())
}
Log.d("SIMPLEX (user)", chatGetUser(store))
val controller = chatStart(store)
val cmdinput = this.findViewById<AppCompatEditText>(R.id.cmdInput)
cmdinput.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_SEND -> {
Log.d("SIMPLEX SEND", chatSendCmd(controller, cmdinput.text.toString()))
cmdinput.text?.clear()
true
}
else -> false
}
}
thread(name="receiver") {
val chatlog = FifoQueue<String>(500)
while(true) {
val msg = chatRecvMsg(controller)
Log.d("SIMPLEX RECV", msg)
chatlog.add(msg)
val currentText = chatlog.joinToString("\n")
weakActivity.get()?.runOnUiThread {
val log = weakActivity.get()?.findViewById<TextView>(R.id.chatlog)
val scroll = weakActivity.get()?.findViewById<ScrollView>(R.id.scroller)
log?.text = currentText
scroll?.scrollTo(0, scroll.getChildAt(0).height)
}
connectIfOpenedViaUri(intent, vm.chatModel)
setContent {
SimpleXTheme {
Navigation(vm.chatModel)
}
}
}
}
companion object {
lateinit var weakActivity : WeakReference<MainActivity>
init {
val socketName = "local.socket.address.listen.native.cmd2"
@DelicateCoroutinesApi
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val chatModel = getApplication<SimplexApp>().chatModel
}
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d("SIMPLEX", "starting server")
val server = LocalServerSocket(socketName)
Log.d("SIMPLEX", "started server")
s.release()
val receiver = server.accept()
Log.d("SIMPLEX", "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun MainPage(chatModel: ChatModel, nav: NavController) {
when (chatModel.userCreated.value) {
null -> SplashView()
false -> WelcomeView(chatModel) { nav.navigate(Pages.ChatList.route) }
true -> ChatListView(chatModel, nav)
}
}
while(true) {
val line = input.readLine() ?: break
Log.d("SIMPLEX (stdout/stderr)", line)
logbuffer.add(line)
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun Navigation(chatModel: ChatModel) {
val nav = rememberNavController()
Box {
NavHost(navController = nav, startDestination = Pages.Home.route) {
composable(route = Pages.Home.route) {
MainPage(chatModel, nav)
}
composable(route = Pages.Welcome.route) {
WelcomeView(chatModel) {
nav.navigate(Pages.Home.route) {
popUpTo(Pages.Home.route) { inclusive = true }
}
}
}
composable(route = Pages.ChatList.route) {
ChatListView(chatModel, nav)
}
composable(route = Pages.Chat.route) {
ChatView(chatModel, nav)
}
composable(route = Pages.AddContact.route) {
AddContactView(chatModel, nav)
}
composable(route = Pages.Connect.route) {
ConnectContactView(chatModel, nav)
}
composable(route = Pages.ChatInfo.route) {
ChatInfoView(chatModel, nav)
}
composable(route = Pages.Terminal.route) {
TerminalView(chatModel, nav)
}
composable(
Pages.TerminalItemDetails.route + "/{identifier}",
arguments = listOf(
navArgument("identifier") {
type = NavType.LongType
}
)
) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
composable(route = Pages.Settings.route) {
SettingsView(chatModel, nav)
}
composable(route = Pages.UserProfile.route) {
UserProfileView(chatModel, nav)
}
}
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
}
}
System.loadLibrary("app-lib")
sealed class Pages(val route: String) {
object Home: Pages("home")
object Terminal: Pages("terminal")
object Welcome: Pages("welcome")
object TerminalItemDetails: Pages("details")
object ChatList: Pages("chats")
object Chat: Pages("chat")
object AddContact: Pages("add_contact")
object Connect: Pages("connect")
object ChatInfo: Pages("chat_info")
object Settings: Pages("settings")
object UserProfile: Pages("user_profile")
}
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
@DelicateCoroutinesApi
fun connectIfOpenedViaUri(intent: Intent?, chatModel: ChatModel) {
val uri = intent?.data
if (intent?.action == "android.intent.action.VIEW" && uri != null) {
Log.d("SIMPLEX", "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = uri
} else {
withUriAction(chatModel, uri) { action ->
chatModel.alertManager.showAlertMsg(
title = "Connect via $action link?",
text = "Your profile will be sent to the contact that you received this link from.",
confirmText = "Connect",
onConfirm = {
withApi {
Log.d("SIMPLEX", "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
}
}
)
}
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}
@@ -0,0 +1,121 @@
package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.model.ChatController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread
// ghc's rts
external fun initHS()
// android-support
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
@DelicateCoroutinesApi
class SimplexApp: Application() {
private lateinit var controller: ChatController
lateinit var chatModel: ChatModel
override fun onCreate() {
super.onCreate()
val ctrl = chatInit(applicationContext.filesDir.toString())
controller = ChatController(ctrl, AlertManager())
chatModel = controller.chatModel
withApi {
val user = controller.apiGetActiveUser()
if (user != null) controller.startChat(user)
}
}
class AlertManager {
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
var presentAlert = mutableStateOf<Boolean>(false)
fun showAlert(alert: @Composable () -> Unit) {
Log.d("SIMPLEX", "AlertManager.showAlert")
alertView.value = alert
presentAlert.value = true
}
fun hideAlert() {
presentAlert.value = false
alertView.value = null
}
fun showAlertMsg(title: String, text: String? = null,
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}
)
}
}
}
companion object {
init {
val socketName = "local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d("SIMPLEX", "starting server")
val server = LocalServerSocket(socketName)
Log.d("SIMPLEX", "started server")
s.release()
val receiver = server.accept()
Log.d("SIMPLEX", "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
while(true) {
val line = input.readLine() ?: break
Log.d("SIMPLEX (stdout/stderr)", line)
logbuffer.add(line)
}
}
}
System.loadLibrary("app-lib")
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}
@@ -0,0 +1,571 @@
package chat.simplex.app.model
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.SimplexApp
import kotlinx.datetime.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
var currentUser = mutableStateOf<User?>(null)
var userCreated = mutableStateOf<Boolean?>(null)
var chats = mutableStateListOf<Chat>()
var chatId = mutableStateOf<String?>(null)
var chatItems = mutableStateListOf<ChatItem>()
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf<TerminalItem>()
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf<Uri?>(null)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
if (user != null) {
currentUser.value = user.copy(profile = profile)
}
}
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
fun updateChatInfo(cInfo: ChatInfo) {
val i = getChatIndex(cInfo.id)
if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo)
}
fun updateContact(contact: Contact) {
val cInfo = ChatInfo.Direct(contact)
if (hasChat(contact.id)) {
updateChatInfo(cInfo)
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
}
}
fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) {
val i = getChatIndex(contact.id)
if (i >= 0) {
val chat = chats[i]
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
}
}
// func replaceChat(_ id: String, _ chat: Chat) {
// if let i = getChatIndex(id) {
// chats[i] = chat
// } else {
// // invalid state, correcting
// chats.insert(chat, at: 0)
// }
// }
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
chats[i] = chat.copy(
chatItems = arrayListOf(cItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId)
}
else
chat.chatStats
)
if (i > 0) {
popChat_(i)
}
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
// add to current chat
if (chatId.value == cInfo.id) {
chatItems.add(cItem)
}
}
fun markChatItemsRead(cInfo: ChatInfo) {
val chatIdx = getChatIndex(cInfo.id)
// update current chat
if (chatId.value == cInfo.id) {
var i = 0
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew) {
chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead()))
}
i += 1
}
val chat = chats[chatIdx]
chats[chatIdx] = chat.copy(
chatItems = chatItems,
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
)
}
}
//
// func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
// // update previews
// var res: Bool
// if let chat = getChat(cInfo.id) {
// if let pItem = chat.chatItems.last, pItem.id == cItem.id {
// chat.chatItems = [cItem]
// }
// res = false
// } else {
// addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
// res = true
// }
// // update current chat
// if chatId == cInfo.id {
// if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
// withAnimation(.default) {
// self.chatItems[i] = cItem
// }
// return false
// } else {
// withAnimation { chatItems.append(cItem) }
// return true
// }
// } else {
// return res
// }
// }
//
//
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
// }
// }
//
private fun popChat_(i: Int) {
val chat = chats.removeAt(i)
chats.add(index = 0, chat)
}
fun removeChat(id: String) {
chats.removeAll { it.id == id }
}
}
enum class ChatType(val type: String) {
Direct("@"),
Group("#"),
ContactRequest("<@");
val chatTypeName: String get () =
when (this) {
Direct -> "contact"
Group -> "group"
ContactRequest -> "contact request"
}
}
@Serializable
data class User(
val userId: Long,
val userContactId: Long,
val localDisplayName: String,
val profile: Profile,
val activeUser: Boolean
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
companion object {
val sampleData = User(
userId = 1,
userContactId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
activeUser = true
)
}
}
typealias ChatId = String
interface NamedChat {
val displayName: String
val fullName: String
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
}
interface SomeChat {
val chatType: ChatType
val localDisplayName: String
val id: ChatId
val apiId: Long
val ready: Boolean
val createdAt: Instant
}
@Serializable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
val chatStats: ChatStats = ChatStats(),
val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown())
) {
val id: String get() = chatInfo.id
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
@Serializable
data class ServerInfo(val networkStatus: NetworkStatus)
@Serializable
sealed class NetworkStatus {
val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…"
val statusExplanation: String get() =
when {
this is Connected -> "You are connected to the server you use to receve messages from this contact."
this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)."
else -> "Trying to connect to the server you use to receve messages from this contact."
}
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@Serializable @SerialName("connected") class Connected: NetworkStatus()
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
}
}
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
@Serializable @SerialName("direct")
class Direct(val contact: Contact): ChatInfo() {
override val chatType get() = ChatType.Direct
override val localDisplayName get() = contact.localDisplayName
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
companion object {
val sampleData = Direct(Contact.sampleData)
}
}
@Serializable @SerialName("group")
class Group(val groupInfo: GroupInfo): ChatInfo() {
override val chatType get() = ChatType.Group
override val localDisplayName get() = groupInfo.localDisplayName
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
companion object {
val sampleData = Group(GroupInfo.sampleData)
}
}
@Serializable @SerialName("contactRequest")
class ContactRequest(val contactRequest: UserContactRequest): ChatInfo() {
override val chatType get() = ChatType.ContactRequest
override val localDisplayName get() = contactRequest.localDisplayName
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
}
}
}
@Serializable
class Contact(
val contactId: Long,
override val localDisplayName: String,
val profile: Profile,
val activeConn: Connection,
val viaGroup: Long? = null,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
override val id get() = "@$contactId"
override val apiId get() = contactId
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
companion object {
val sampleData = Contact(
contactId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
activeConn = Connection.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class Connection(val connStatus: String) {
companion object {
val sampleData = Connection(connStatus = "ready")
}
}
@Serializable
class Profile(
val displayName: String,
val fullName: String
) {
companion object {
val sampleData = Profile(
displayName = "alice",
fullName = "Alice"
)
}
}
@Serializable
class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = true
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
companion object {
val sampleData = GroupInfo(
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class GroupProfile (
override val displayName: String,
override val fullName: String
): NamedChat {
companion object {
val sampleData = GroupProfile(
displayName = "team",
fullName = "My Team"
)
}
}
@Serializable
class GroupMember (
val groupMemberId: Long,
val memberId: String,
// var memberRole: GroupMemberRole
// var memberCategory: GroupMemberCategory
// var memberStatus: GroupMemberStatus
// var invitedBy: InvitedBy
val localDisplayName: String,
val memberProfile: Profile,
val memberContactId: Long?
// var activeConn: Connection?
) {
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
memberId = "abcd",
localDisplayName = "alice",
memberProfile = Profile.sampleData,
memberContactId = 1
)
}
}
@Serializable
class UserContactRequest (
val contactRequestId: Long,
override val localDisplayName: String,
val profile: Profile,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val ready get() = true
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
companion object {
val sampleData = UserContactRequest(
contactRequestId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class AChatItem (
val chatInfo: ChatInfo,
val chatItem: ChatItem
)
@Serializable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
val content: CIContent
) {
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
companion object {
fun getSampleData(id: Long, dir: CIDirection, ts: Instant, text: String,status: CIStatus = CIStatus.SndNew()) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text))
)
}
}
@Serializable
sealed class CIDirection {
abstract val sent: Boolean
@Serializable @SerialName("directSnd")
class DirectSnd: CIDirection() {
override val sent get() = true
}
@Serializable @SerialName("directRcv")
class DirectRcv: CIDirection() {
override val sent get() = false
}
@Serializable @SerialName("groupSnd")
class GroupSnd: CIDirection() {
override val sent get() = true
}
@Serializable @SerialName("groupRcv")
class GroupRcv(val groupMember: GroupMember): CIDirection() {
override val sent get() = false
}
}
@Serializable
data class CIMeta (
val itemId: Long,
val itemTs: Instant,
val itemText: String,
val itemStatus: CIStatus,
val createdAt: Instant
) {
val timestampText: String get() = getTimestampText(itemTs)
companion object {
fun getSample(id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew()): CIMeta =
CIMeta(
itemId = id,
itemTs = ts,
itemText = text,
itemStatus = status,
createdAt = ts
)
}
}
fun getTimestampText(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
val time: LocalDateTime = t.toLocalDateTime(tz)
val recent = now.date == time.date ||
(now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 )
return if (recent) String.format("%02d:%02d", time.hour, time.minute)
else String.format("%02d/%02d", time.dayOfMonth, time.monthNumber)
}
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew")
class SndNew: CIStatus()
@Serializable @SerialName("sndSent")
class SndSent: CIStatus()
@Serializable @SerialName("sndErrorAuth")
class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError")
class SndError(val agentError: AgentErrorType): CIStatus()
@Serializable @SerialName("rcvNew")
class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead")
class RcvRead: CIStatus()
}
@Serializable
sealed class CIContent {
abstract val text: String
@Serializable @SerialName("sndMsgContent")
class SndMsgContent(val msgContent: MsgContent): CIContent() {
override val text get() = msgContent.text
}
@Serializable @SerialName("rcvMsgContent")
class RcvMsgContent(val msgContent: MsgContent): CIContent() {
override val text get() = msgContent.text
}
@Serializable @SerialName("sndFileInvitation")
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
override val text get() = "sending files is not supported yet"
}
@Serializable @SerialName("rcvFileInvitation")
class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() {
override val text get() = "receiving files is not supported yet"
}
}
@Serializable
sealed class MsgContent {
abstract val text: String
abstract val cmdString: String
@Serializable @SerialName("text")
class MCText(override val text: String): MsgContent() {
override val cmdString get() = "text $text"
}
}
@Serializable
class RcvFileTransfer {
}
@@ -0,0 +1,682 @@
package chat.simplex.app.model
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlin.concurrent.thread
typealias ChatCtrl = Long
@DelicateCoroutinesApi
open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.AlertManager) {
var chatModel = ChatModel(this, alertManager)
suspend fun startChat(u: User) {
chatModel.currentUser = mutableStateOf(u)
chatModel.userCreated.value = true
Log.d("SIMPLEX (user)", u.toString())
try {
apiStartChat()
chatModel.chats.addAll(apiGetChats())
startReceiver()
Log.d("SIMPLEX", "started chat")
} catch(e: Error) {
Log.d("SIMPLEX", "failed starting chat $e")
throw e
}
}
fun startReceiver() {
thread(name="receiver") {
// val chatlog = FifoQueue<String>(500)
while(true) {
val json = chatRecvMsg(ctrl)
val r = APIResponse.decodeStr(json).resp
Log.d("SIMPLEX", "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d("SIMPLEX", "chatRecvMsg json: $json")
processReceivedMsg(r)
}
}
}
suspend fun sendCmd(cmd: CC): CR {
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
val json = chatSendCmd(ctrl, c)
Log.d("SIMPLEX", "sendCmd: ${cmd.cmdType}")
val r = APIResponse.decodeStr(json)
Log.d("SIMPLEX", "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d("SIMPLEX", "sendCmd response json $json")
}
chatModel.terminalItems.add(TerminalItem.resp(r.resp))
r.resp
}
}
suspend fun apiGetActiveUser(): User? {
val r = sendCmd(CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false
return null
}
suspend fun apiCreateActiveUser(p: Profile): User {
val r = sendCmd(CC.CreateActiveUser(p))
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiCreateActiveUser: ${r.responseType} ${r.details}")
throw Error("user not created ${r.responseType} ${r.details}")
}
suspend fun apiStartChat() {
val r = sendCmd(CC.StartChat())
if (r is CR.ChatStarted ) return
throw Error("failed starting chat: ${r.responseType} ${r.details}")
}
suspend fun apiGetChats(): List<Chat> {
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}")
}
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id))
if (r is CR.ApiChat ) return r.chat
Log.d("SIMPLEX", "apiGetChat bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, mc: MsgContent): AChatItem? {
val r = sendCmd(CC.ApiSendMessage(type, id, mc))
if (r is CR.NewChatItem ) return r.chatItem
Log.d("SIMPLEX", "apiSendMessage bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
if (r is CR.Invitation) return r.connReqInvitation
Log.d("SIMPLEX", "apiAddContact bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiConnect(connReq: String): Boolean {
val r = sendCmd(CC.Connect(connReq))
when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
alertManager.showAlertMsg("Contact already exists",
"You are already connected to ${r.contact.displayName} via this link"
)
return false
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
alertManager.showAlertMsg("Invalid connection link",
"Please check that you used the correct link or ask your contact to send you another one."
)
return false
}
else -> {
apiErrorAlert("apiConnect", "Connection error", r)
return false
}
}
}
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id))
when {
r is CR.ContactDeleted -> return true // TODO groups
r is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
alertManager.showAlertMsg(
"Can't delete contact!",
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
)
return false
}
}
}
apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
return false
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val r = sendCmd(CC.UpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.d("SIMPLEX", "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
if (r is CR.UserContactLinkCreated) return r.connReqContact
Log.d("SIMPLEX", "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiDeleteUserAddress(): Boolean {
val r = sendCmd(CC.DeleteMyAddress())
if (r is CR.UserContactLinkDeleted) return true
Log.d("SIMPLEX", "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
return false
}
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
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
return null
}
Log.d("SIMPLEX", "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
if (r is CR.AcceptingContactRequest) return r.contact
Log.d("SIMPLEX", "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
val r = sendCmd(CC.ApiRejectContact(contactReqId))
if (r is CR.ContactRequestRejected) return true
Log.d("SIMPLEX", "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true
Log.d("SIMPLEX", "apiChatRead bad response: ${r.responseType} ${r.details}")
return false
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e("SIMPLEX", "$method bad response: $errMsg")
alertManager.showAlertMsg(title, errMsg)
}
fun processReceivedMsg(r: CR) {
chatModel.terminalItems.add(TerminalItem.resp(r))
when (r) {
is CR.ContactConnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
}
// is CR.ReceivedContactRequest -> return
is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
if (chatModel.hasChat(r.toContact.id)) {
chatModel.updateChatInfo(cInfo)
}
}
is CR.ContactSubscribed -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
}
is CR.ContactDisconnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
}
is CR.ContactSubError -> {
chatModel.updateContact(r.contact)
val e = r.chatError
val err: String =
if (e is ChatError.ChatErrorAgent) {
val a = e.agentError
when {
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
else -> e.string
}
}
else e.string
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Error(err))
}
is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
// switch res {
// case let .receivedContactRequest(contactRequest):
// chatModel.addChat(Chat(
// chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
// chatItems: []
// ))
// NtfManager.shared.notifyContactRequest(contactRequest)
//
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// if chatModel.upsertChatItem(cInfo, cItem) {
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
// }
// default:
// logger.debug("unsupported event: \(res.responseType)")
// }
}
}
}
// ChatCommand
sealed class CC {
class Console(val cmd: String): CC()
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile): CC()
class StartChat: CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class UpdateProfile(val profile: Profile): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
val cmdString: String get() = when (this) {
is Console -> cmd
is ShowActiveUser -> "/u"
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
is StartChat -> "/_start"
is ApiGetChats -> "/_get chats"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
}
val cmdType: String get() = when (this) {
is Console -> "console command"
is ShowActiveUser -> "showActiveUser"
is CreateActiveUser -> "createActiveUser"
is StartChat -> "startChat"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
is UpdateProfile -> "updateProfile"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
is ApiChatRead -> "apiChatRead"
}
class ItemRange(val from: Long, val to: Long)
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
}
}
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
@Serializable
class APIResponse(val resp: CR, val corr: String? = null) {
companion object {
fun decodeStr(str: String): APIResponse {
return try {
json.decodeFromString(str)
} catch(e: Exception) {
try {
val data = json.parseToJsonElement(str).jsonObject
APIResponse(
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
corr = data["corr"]?.toString()
)
} catch(e: Exception) {
APIResponse(CR.Invalid(str))
}
}
}
}
}
// ChatResponse
@Serializable
sealed class CR {
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR()
@Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
@Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
val responseType: String get() = when(this) {
is ActiveUser -> "activeUser"
is ChatStarted -> "chatStarted"
is ApiChats -> "apiChats"
is ApiChat -> "apiChats"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists"
is ContactDeleted -> "contactDeleted"
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
is UserContactLink -> "userContactLink"
is UserContactLinkCreated -> "userContactLinkCreated"
is UserContactLinkDeleted -> "userContactLinkDeleted"
is ContactConnected -> "contactConnected"
is ReceivedContactRequest -> "receivedContactRequest"
is AcceptingContactRequest -> "acceptingContactRequest"
is ContactRequestRejected -> "contactRequestRejected"
is ContactUpdated -> "contactUpdated"
is ContactSubscribed -> "contactSubscribed"
is ContactDisconnected -> "contactDisconnected"
is ContactSubError -> "contactSubError"
is GroupSubscribed -> "groupSubscribed"
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem"
is ChatItemUpdated -> "chatItemUpdated"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
val details: String get() = when(this) {
is ActiveUser -> json.encodeToString(user)
is ChatStarted -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
is ContactAlreadyExists -> json.encodeToString(contact)
is ContactDeleted -> json.encodeToString(contact)
is UserProfileNoChange -> noDetails()
is UserProfileUpdated -> json.encodeToString(toProfile)
is UserContactLink -> connReqContact
is UserContactLinkCreated -> connReqContact
is UserContactLinkDeleted -> noDetails()
is ContactConnected -> json.encodeToString(contact)
is ReceivedContactRequest -> json.encodeToString(contactRequest)
is AcceptingContactRequest -> json.encodeToString(contact)
is ContactRequestRejected -> noDetails()
is ContactUpdated -> json.encodeToString(toContact)
is ContactSubscribed -> json.encodeToString(contact)
is ContactDisconnected -> json.encodeToString(contact)
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
is GroupSubscribed -> json.encodeToString(group)
is GroupEmpty -> json.encodeToString(group)
is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
is Response -> json
is Invalid -> str
}
fun noDetails(): String ="${responseType}: no details"
}
abstract class TerminalItem {
abstract val id: Long
val date: Instant = Clock.System.now()
abstract val label: String
abstract val details: String
class Cmd(override val id: Long, val cmd: CC): TerminalItem() {
override val label get() = "> ${cmd.cmdString}"
override val details get() = cmd.cmdString
}
class Resp(override val id: Long, val resp: CR): TerminalItem() {
override val label get() = "< ${resp.responseType}"
override val details get() = resp.details
}
companion object {
val sampleData = listOf(
Cmd(0, CC.ShowActiveUser()),
Resp(1, CR.ActiveUser(User.sampleData))
)
fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c)
fun resp(r: CR) = Resp(System.currentTimeMillis(), r)
}
}
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
is ChatErrorChat -> "chat ${errorType.string}"
is ChatErrorAgent -> "agent ${agentError.string}"
is ChatErrorStore -> "store ${storeError.string}"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
}
@Serializable
sealed class ChatErrorType {
val string: String get() = when (this) {
is InvalidConnReq -> "invalidConnReq"
is ContactGroups -> "groupNames $groupNames"
}
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
}
@Serializable
sealed class StoreError {
val string: String get() = when (this) {
is UserContactLinkNotFound -> "userContactLinkNotFound"
}
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
}
@Serializable
sealed class AgentErrorType {
val string: String get() = when (this) {
is CMD -> "CMD ${cmdErr.string}"
is CONN -> "CONN ${connErr.string}"
is SMP -> "SMP ${smpErr.string}"
is BROKER -> "BROKER ${brokerErr.string}"
is AGENT -> "AGENT ${agentErr.string}"
is INTERNAL -> "INTERNAL $internalErr"
}
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
}
@Serializable
sealed class CommandErrorType {
val string: String get() = when (this) {
is PROHIBITED -> "PROHIBITED"
is SYNTAX -> "SYNTAX"
is NO_CONN -> "NO_CONN"
is SIZE -> "SIZE"
is LARGE -> "LARGE"
}
@Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType()
@Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType()
@Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType()
@Serializable @SerialName("SIZE") class SIZE: CommandErrorType()
@Serializable @SerialName("LARGE") class LARGE: CommandErrorType()
}
@Serializable
sealed class ConnectionErrorType {
val string: String get() = when (this) {
is NOT_FOUND -> "NOT_FOUND"
is DUPLICATE -> "DUPLICATE"
is SIMPLEX -> "SIMPLEX"
is NOT_ACCEPTED -> "NOT_ACCEPTED"
is NOT_AVAILABLE -> "NOT_AVAILABLE"
}
@Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType()
@Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType()
@Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType()
@Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType()
@Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType()
}
@Serializable
sealed class BrokerErrorType {
val string: String get() = when (this) {
is RESPONSE -> "RESPONSE ${smpErr.string}"
is UNEXPECTED -> "UNEXPECTED"
is NETWORK -> "NETWORK"
is TRANSPORT -> "TRANSPORT ${transportErr.string}"
is TIMEOUT -> "TIMEOUT"
}
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
@Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
@Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
@Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
@Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
}
@Serializable
sealed class SMPErrorType {
val string: String get() = when (this) {
is BLOCK -> "BLOCK"
is SESSION -> "SESSION"
is CMD -> "CMD ${cmdErr.string}"
is AUTH -> "AUTH"
is QUOTA -> "QUOTA"
is NO_MSG -> "NO_MSG"
is LARGE_MSG -> "LARGE_MSG"
is INTERNAL -> "INTERNAL"
}
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
@Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType()
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
}
@Serializable
sealed class SMPCommandError {
val string: String get() = when (this) {
is UNKNOWN -> "UNKNOWN"
is SYNTAX -> "SYNTAX"
is NO_AUTH -> "NO_AUTH"
is HAS_AUTH -> "HAS_AUTH"
is NO_QUEUE -> "NO_QUEUE"
}
@Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError()
@Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError()
@Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError()
@Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError()
@Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError()
}
@Serializable
sealed class SMPTransportError {
val string: String get() = when (this) {
is BadBlock -> "badBlock"
is LargeMsg -> "largeMsg"
is BadSession -> "badSession"
is Handshake -> "handshake ${handshakeErr.string}"
}
@Serializable @SerialName("badBlock") class BadBlock: SMPTransportError()
@Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError()
@Serializable @SerialName("badSession") class BadSession: SMPTransportError()
@Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError()
}
@Serializable
sealed class SMPHandshakeError {
val string: String get() = when (this) {
is PARSE -> "PARSE"
is VERSION -> "VERSION"
is IDENTITY -> "IDENTITY"
}
@Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError()
@Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError()
@Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError()
}
@Serializable
sealed class SMPAgentError {
val string: String get() = when (this) {
is A_MESSAGE -> "A_MESSAGE"
is A_PROHIBITED -> "A_PROHIBITED"
is A_VERSION -> "A_VERSION"
is A_ENCRYPTION -> "A_ENCRYPTION"
}
@Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
@Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
@Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
@Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
}
@@ -0,0 +1,14 @@
package chat.simplex.app.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val SimplexBlue = Color(0, 136, 255, 255)
val SimplexGreen = Color(98, 196, 103, 255)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
@@ -0,0 +1,11 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
@@ -0,0 +1,48 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = SimplexBlue,
primaryVariant = SimplexGreen,
secondary = DarkGray,
// background = Color.Black,
// surface = Color.Black,
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
// error = Color(0xFFCF6679),
// onPrimary = Color.Black,
// onSecondary = Color.Black,
// onBackground = Color.White,
// onSurface = Color.White,
// onError: Color = Color.Black,
)
private val LightColorPalette = lightColors(
primary = SimplexBlue,
primaryVariant = SimplexGreen,
secondary = LightGray,
// background = Color.White,
// surface = Color.White
// onPrimary = Color.White,
// onSecondary = Color.Black,
// onBackground = Color.Black,
// onSurface = Color.Black,
)
@Composable
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
@@ -0,0 +1,46 @@
package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
h1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
),
h2 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp
),
h3 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 18.sp
),
body2 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
)
)
@@ -0,0 +1,23 @@
package chat.simplex.app.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@Composable
fun SplashView() {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Simplex Icon",
modifier = Modifier
.height(230.dp)
.align(Alignment.Center)
)
}
}
@@ -0,0 +1,111 @@
package chat.simplex.app.views
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
@Composable
fun TerminalView(chatModel: ChatModel, nav: NavController) {
TerminalLayout(chatModel.terminalItems, nav::popBackStack, nav::navigate) { cmd ->
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(cmd))
// hide "in progress"
}
}
}
@Composable
fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, navigate: (String) -> Unit,
sendCommand: (String) -> Unit) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = { SendMsgView(sendCommand) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
TerminalLog(terminalItems, navigate)
}
}
}
}
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>, navigate: (String) -> Unit) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { navigate("details/${item.id}") })
}
val len = terminalItems.count()
if (len > 1) {
scope.launch {
listState.animateScrollToItem(len - 1)
}
}
}
}
@Composable
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, navController: NavController){
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Button(onClick = { navController.popBackStack() }) {
Text("Back")
}
SelectionContainer {
Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
navigate = {},
sendCommand = {}
)
}
}
@@ -0,0 +1,59 @@
package chat.simplex.app.views
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Image(
painter=painterResource(R.drawable.logo), contentDescription = "Simplex Logo",
)
Text("You control your chat!")
Text("The messaging and application platform protecting your privacy and security.")
Spacer(Modifier.height(8.dp))
Text("We don't store any of your contacts or messages (once delivered) on the servers.")
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel, routeHome)
}
}
@DelicateCoroutinesApi
@Composable
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
Column {
Text("Create profile")
Text("Your profile is stored on your device and shared only with your contacts.")
Text("Display Name")
TextField(value = displayName, onValueChange = { value -> displayName = value })
Text("Full Name (Optional)")
TextField(value = fullName, onValueChange = { fullName = it })
Button(onClick={
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName)
)
chatModel.controller.startChat(user)
routeHome()
}
},
enabled = displayName.isNotEmpty()
) { Text("Create")}
}
}
@@ -0,0 +1,136 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ChatInfoView(chatModel: ChatModel, nav: NavController) {
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
close = { nav.popBackStack() },
deleteContact = {
chatModel.alertManager.showAlertMsg(
title = "Delete contact?",
text = "Contact and all messages will be deleted - this cannot be undone!",
confirmText = "Delete",
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
nav.navigate(Pages.ChatList.route)
}
}
}
)
}
)
}
}
@Composable
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Column(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
Text(
cInfo.displayName, style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
)
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 16.dp)
)
if (cInfo is ChatInfo.Direct) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.padding(horizontal = 32.dp)) {
ServerImage(chat)
Text(
chat.serverInfo.networkStatus.statusString,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(start = 8.dp)
)
}
Text(
chat.serverInfo.networkStatus.statusExplanation,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
)
}
Spacer(Modifier.weight(1F))
Box(Modifier.padding(24.dp)) {
SimpleButton(
"Delete contact", icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
)
}
}
}
}
@Composable
fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight)
else ->
Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
)
}
}
@@ -0,0 +1,195 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import java.util.*
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@Composable
fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
delay(1000L)
if (chat.chatItems.count() > 0) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
}
}
ChatLayout(chat, chatModel.chatItems,
back = { nav.popBackStack() },
info = { nav.navigate(Pages.ChatInfo.route) },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
}
}
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
back: () -> Unit,
info: () -> Unit,
sendMessage: (String) -> Unit
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
ChatItemsList(chatItems)
}
}
}
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
Box(Modifier.height(60.dp).padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBack,
"Back",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Row(
Modifier
.padding(horizontal = 68.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
Column(Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(cInfo.displayName, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatItemsList(chatItems: List<ChatItem>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(chatItems) { cItem ->
ChatItemView(cItem)
}
val len = chatItems.count()
if (len > 1) {
scope.launch {
listState.animateScrollToItem(len - 1)
}
}
}
}
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatLayout() {
SimpleXTheme {
val chatItems = listOf(
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
3, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
5, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
ChatLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
chatItems = chatItems,
back = {},
info = {},
sendMessage = {}
)
}
}
@@ -0,0 +1,90 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun SendMsgView(sendMessage: (String) -> Unit) {
var cmd by remember { mutableStateOf("") }
BasicTextField(
value = cmd,
onValueChange = { cmd = it },
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(8.dp),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Bottom
) {
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 5.dp)
.padding(bottom = 7.dp)
) {
innerTextField()
}
val color = if (cmd.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
Icons.Outlined.ArrowUpward,
"Send Message",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cmd.isNotEmpty()) {
sendMessage(cmd)
cmd = ""
}
}
)
}
}
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
sendMessage = { msg -> println(msg) }
)
}
}
@@ -0,0 +1,29 @@
package chat.simplex.app.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = HighOrLowlight,
style = MaterialTheme.typography.body2
)
}
@Preview
@Composable
fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
@@ -0,0 +1,43 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
@Composable
fun ChatItemView(chatItem: ChatItem) {
val sent = chatItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(
start = if (sent) 60.dp else 16.dp,
end = if (sent) 16.dp else 60.dp,
),
contentAlignment = alignment,
) {
TextItemView(chatItem)
}
}
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@@ -0,0 +1,76 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EF1F0F5)
@Composable
fun TextItemView(chatItem: ChatItem) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
Box(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
Column {
Text(text = chatItem.content.text)
CIMetaView(chatItem)
}
}
}
}
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewLong() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)
)
}
}
@@ -0,0 +1,168 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.outlined.PersonAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import chat.simplex.app.Pages
import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.usersettings.SettingsView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.*
@ExperimentalMaterialApi
class ScaffoldController(val state: BottomSheetScaffoldState, val scope: CoroutineScope) {
fun expand() = scope.launch { state.bottomSheetState.expand() }
fun collapse() = scope.launch { state.bottomSheetState.collapse() }
fun toggle() = scope.launch {
val s = state.bottomSheetState
if (s.isExpanded) s.collapse() else s.expand()
}
fun toggleDrawer() = scope.launch {
state.drawerState.apply {
if (isClosed) open() else close()
}
}
}
@ExperimentalMaterialApi
@Composable
fun scaffoldController(): ScaffoldController {
return ScaffoldController(
state = rememberBottomSheetScaffoldState(),
scope = rememberCoroutineScope()
)
}
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun ChatListView(chatModel: ChatModel, nav: NavController) {
val scaffoldCtrl = scaffoldController()
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
topBar = {
ChatListToolbar(
scaffoldCtrl,
settings = { scaffoldCtrl.toggleDrawer() }
)
},
drawerContent = {
SettingsView(chatModel, nav)
},
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl, nav) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
) {
Column(
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatList(chatModel, nav)
}
if (scaffoldCtrl.state.bottomSheetState.isExpanded) {
Surface(
Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
}
}
}
@ExperimentalMaterialApi
@Composable
fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.height(60.dp)
) {
IconButton(onClick = settings) {
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Text(
"Your chats",
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { newChatSheetCtrl.toggle() }) {
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@DelicateCoroutinesApi
fun goToChat(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
withApi {
val cInfo = chatPreview.chatInfo
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
if (chat != null) {
chatModel.chatId.value = cInfo.id
chatModel.chatItems = chat.chatItems.toMutableStateList()
navController.navigate(Pages.Chat.route)
} else {
// TODO show error? or will apiGetChat show it
}
}
}
@DelicateCoroutinesApi
@Composable
fun ChatList(chatModel: ChatModel, navController: NavController) {
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
ChatPreviewView(chat) { goToChat(chat, chatModel, navController) }
}
}
}
//@Preview
//@Composable
//fun PreviewChatListView() {
// SimpleXTheme {
// ChatListView(
// chats = listOf(
// Chat()
// ),
//
// )
// }
//}
@@ -0,0 +1,101 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
import kotlinx.datetime.Clock
@Composable
fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
Surface(
border = BorderStroke(0.5.dp, MaterialTheme.colors.secondary),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = goToChat)
.height(88.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
ChatInfoImage(chat, size = 72.dp)
Column(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
if (chat.chatItems.count() > 0) {
Text(
chat.chatItems.last().content.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top) {
Text(ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom=5.dp)
)
if (chat.chatStats.unreadCount > 0) {
Text(
chat.chatStats.unreadCount.toString(),
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.body2,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(2.dp)
)
}
}
}
}
}
@Preview
@Composable
fun ChatPreviewViewExample() {
SimpleXTheme {
ChatPreviewView(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)),
chatStats = Chat.ChatStats()
),
goToChat = {}
)
}
}
@@ -0,0 +1,44 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
Box(Modifier.size(size)) {
Icon(icon,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
}
}
@Preview
@Composable
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
size = 55.dp
)
}
}
@@ -0,0 +1,46 @@
package chat.simplex.app.views.helpers
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CloseSheetBar(close: () -> Unit) {
Row (
Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = close) {
Icon(
Icons.Outlined.Close,
"Close button",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
CloseSheetBar(close = {})
}
}
@@ -0,0 +1,17 @@
package chat.simplex.app.views.helpers
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// based on the expectation of only one line of text
val minPadding = placeable.height / 4
val width = maxOf(placeable.width + minPadding, placeable.height)
layout(width, placeable.height) {
placeable.place((width - placeable.width) / 2, 0)
}
}
@@ -0,0 +1,38 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SimpleButton(text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { click() }
) {
Icon(icon, text, tint = color,
modifier = Modifier.padding(horizontal = 10.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
@Preview
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
}
}
@@ -0,0 +1,7 @@
package chat.simplex.app.views.helpers
import kotlinx.coroutines.*
@DelicateCoroutinesApi
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
@@ -0,0 +1,100 @@
package chat.simplex.app.views.newchat
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun AddContactView(chatModel: ChatModel, nav: NavController) {
val connReq = chatModel.connReqInvitation
if (connReq != null) {
val cxt = LocalContext.current
AddContactLayout(
connReq = connReq,
close = { nav.popBackStack() },
share = { shareText(cxt, connReq) }
)
}
}
@Composable
fun AddContactLayout(connReq: String, close: () -> Unit, share: () -> Unit) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Text(
"Add contact",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.dp)
)
QRCode(connReq)
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can share the invitation link via any other channel.")
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
.padding(bottom = 16.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
}
}
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
@Preview
@Composable
fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
close = {},
share = {}
)
}
}
@@ -0,0 +1,125 @@
package chat.simplex.app.views.newchat
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ConnectContactView(chatModel: ChatModel, nav: NavController) {
ConnectContactLayout(
qrCodeScanner = {
QRCodeScanner { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(chatModel, uri) { action ->
connectViaUri(chatModel, action, uri)
}
} catch(e: RuntimeException) {
chatModel.alertManager.showAlertMsg(
title = "Invalid QR code",
text = "This QR code is not a link!"
)
}
nav.popBackStack()
}
},
close = { nav.popBackStack() }
)
}
@DelicateCoroutinesApi
fun withUriAction(chatModel: ChatModel, uri: Uri,
run: suspend (String) -> Unit) {
val action = uri.path?.drop(1)
if (action == "contact" || action == "invitation") {
withApi { run(action) }
} else {
chatModel.alertManager.showAlertMsg(
title = "Invalid link!",
text = "This link is not a valid connection link!"
)
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
val r = chatModel.controller.apiConnect(uri.toString())
if (r) {
val whenConnected =
if (action == "contact") "your connection request is accepted"
else "your contact's device is online"
chatModel.alertManager.showAlertMsg(
title = "Connection request sent!",
text = "You will be connected when $whenConnected, please wait or check later!"
)
}
}
@Composable
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
Box (
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can create the invitation link.")
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
}
}
@Preview
@Composable
fun PreviewConnectContactLayout() {
SimpleXTheme {
ConnectContactLayout(
qrCodeScanner = { Surface {} },
close = {},
)
}
}
@@ -0,0 +1,123 @@
package chat.simplex.app.views.newchat
import android.Manifest
import androidx.compose.foundation.clickable
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.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.DarkGray
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController, nav: NavController) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
NewChatSheetLayout(
addContact = {
withApi {
// show spinner
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
// hide spinner
if (chatModel.connReqInvitation != null) {
newChatCtrl.collapse()
nav.navigate(Pages.AddContact.route)
}
}
},
scanCode = {
newChatCtrl.collapse()
nav.navigate(Pages.Connect.route)
cameraPermissionState.launchPermissionRequest()
},
close = {
newChatCtrl.collapse()
}
)
}
@Composable
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, close: () -> Unit) {
Row(Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Add contact", "(create QR code\nor link)",
Icons.Outlined.PersonAdd, click = addContact
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Scan QR code", "(in person or in video call)",
Icons.Outlined.QrCode, click = scanCode
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Create Group", "(coming soon!)",
Icons.Outlined.GroupAdd, disabled = true
)
}
}
}
@Composable
fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
.clickable(onClick = click)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) DarkGray else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
Text(text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
}
}
@Preview
@Composable
fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
addContact = {},
scanCode = {},
close = {},
)
}
}
@@ -0,0 +1,40 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.ui.theme.SimpleXTheme
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QRCode(connReq: String) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
contentDescription = "QR Code"
)
}
fun qrCodeBitmap(content: String, size: Int): Bitmap {
val hints = hashMapOf<EncodeHintType, Int>().also { it[EncodeHintType.MARGIN] = 1 }
val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also {
for (x in 0 until size) {
for (y in 0 until size) {
it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
}
}
}
}
@Preview
@Composable
fun PreviewQRCode() {
SimpleXTheme {
QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
}
}
@@ -0,0 +1,108 @@
package chat.simplex.app.views.newchat
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.*
// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner
@Composable
fun QRCodeScanner(onBarcode: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var preview by remember { mutableStateOf<Preview?>(null) }
AndroidView(
factory = { AndroidViewContext ->
PreviewView(AndroidViewContext).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
},
// modifier = Modifier.fillMaxSize(),
update = { previewView ->
val cameraSelector: CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val barcodeAnalyser = BarCodeAnalyser { barcodes ->
barcodes.firstOrNull()?.rawValue?.let(onBarcode)
}
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) }
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d("SIMPLEX", "CameraPreview: ${e.localizedMessage}")
}
}, ContextCompat.getMainExecutor(context))
}
)
}
class BarCodeAnalyser(
private val onBarcodeDetected: (barcodes: List<Barcode>) -> Unit,
): ImageAnalysis.Analyzer {
private var lastAnalyzedTimeStamp = 0L
@ExperimentalGetImage
override fun analyze(image: ImageProxy) {
val currentTimestamp = System.currentTimeMillis()
if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
image.image?.let { imageToAnalyze ->
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
val barcodeScanner = BarcodeScanning.getClient(options)
val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees)
barcodeScanner.process(imageToProcess)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
onBarcodeDetected(barcodes)
} else {
Log.d("SIMPLEX", "BarcodeAnalyser: No barcode Scanned")
}
}
.addOnFailureListener { exception ->
Log.d("SIMPLEX", "BarcodeAnalyser: Something went wrong $exception")
}
.addOnCompleteListener {
image.close()
}
}
lastAnalyzedTimeStamp = currentTimestamp
} else {
image.close()
}
}
}
@@ -0,0 +1,219 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun SettingsView(chatModel: ChatModel, nav: NavController) {
val user = chatModel.currentUser.value
if (user != null) {
SettingsLayout(
profile = user.profile,
navigate = nav::navigate
)
}
}
val simplexTeamUri =
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
@Composable
fun SettingsLayout(
profile: Profile,
navigate: (String) -> Unit
) {
val uriHandler = LocalUriHandler.current
Column(
Modifier
.fillMaxSize()
// .background(MaterialTheme.colors.background)
.padding(8.dp)
) {
Text(
"Your Settings",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(30.dp))
SettingsSectionView(
content = {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
Text(
profile.fullName,
color = MaterialTheme.colors.onBackground
)
}
},
func = { navigate(Pages.UserProfile.route) },
height = 60.dp
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Your SimpleX contact address",
color = HighOrLowlight
)
},
func = { println("navigate to address") }
)
Spacer(Modifier.height(24.dp))
SettingsSectionView(
content = {
Icon(
Icons.Outlined.HelpOutline,
contentDescription = "Help",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"How to use SimpleX Chat",
color = MaterialTheme.colors.onBackground
)
},
func = { println("navigate to help") }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Icon(
Icons.Outlined.Tag,
contentDescription = "SimpleX Team",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri(simplexTeamUri) }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri("mailto:chat@simplex.chat") }
)
Spacer(Modifier.height(24.dp))
SettingsSectionView(
content = {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Chat console",
color = MaterialTheme.colors.onBackground
)
},
func = { navigate(Pages.Terminal.route) }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Install ",
color = MaterialTheme.colors.onBackground
)
Text(
"SimpleX Chat for terminal",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }
)
}
}
@Composable
fun SettingsSectionView(content: (@Composable () -> Unit), func: () -> Unit, height: Dp = 48.dp) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = func)
.height(height),
) {
Row(
Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
content.invoke()
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
navigate = {}
)
}
}
@@ -0,0 +1,215 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
@Composable
fun UserProfileView(chatModel: ChatModel, nav: NavController) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile by remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
editProfile = editProfile,
profile = profile,
back = { nav.popBackStack() },
editProfileOff = { editProfile = false },
editProfileOn = { editProfile = true },
saveProfile = { displayName: String, fullName: String ->
withApi {
val newProfile = chatModel.controller.apiUpdateProfile(
profile = Profile(displayName, fullName)
)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
editProfile = false
}
}
)
}
}
@Composable
fun UserProfileLayout(
editProfile: Boolean,
profile: Profile,
back: () -> Unit,
editProfileOff: () -> Unit,
editProfileOn: () -> Unit,
saveProfile: (String, String) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(back)
Text(
"Your chat profile",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your profile is stored on your device and shared only with your contacts.\n" +
"SimpleX servers cannot see your profile.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
if (editProfile) {
var displayName by remember { mutableStateOf(profile.displayName) }
var fullName by remember { mutableStateOf(profile.fullName) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
// TODO hints
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Row {
Text(
"Cancel",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOff),
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save (and notify contacts)",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = { saveProfile(displayName, fullName) })
)
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Display name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.displayName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Full name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.fullName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Text(
"Edit",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOn)
)
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = false,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = true,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
)
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android drawable generated by fa5ad-free project:
https://github.com/diwanoczko/fa5ad-free
Resource generated base on Font Awesome 5 Free icons set:
https://fontawesome.com/
All brand icons are trademarks of their respective owners.
Please do not use brand logos for any purpose except to represent the
company, product, or service to which they refer.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="23.25dp"
android:height="24dp"
android:viewportWidth="496"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4H4C2.89,4 2,4.9 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.11,4 20,4zM20,18H4V8h16V18zM18,17h-6v-2h6V17zM7.5,17l-1.41,-1.41L8.67,13l-2.59,-2.59L7.5,9l4,4L7.5,17z"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/cmdInput"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:autofillHints=""
android:ems="10"
android:imeOptions="actionSend"
android:inputType="textPersonName"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="SpeakableTextPresentCheck" />
<ScrollView
android:id="@+id/scroller"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/cmdInput"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/chatlog"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,16 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -1,16 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SimpleX" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>
@@ -1,9 +1,8 @@
package chat.simplex.app
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
+11 -2
View File
@@ -1,16 +1,25 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
compose_version = '1.1.0'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.4"
classpath 'com.android.tools.build:gradle:7.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.1' apply false
id 'com.android.library' version '7.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
task clean(type: Delete) {
+7 -3
View File
@@ -15,7 +15,11 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
Binary file not shown.
+2 -2
View File
@@ -1,6 +1,6 @@
#Fri Jan 21 23:13:54 GMT 2022
#Mon Feb 14 14:23:51 GMT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+7 -1
View File
@@ -1,9 +1,15 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}
rootProject.name = "SimpleX"
+5 -1
View File
@@ -571,7 +571,11 @@ struct CIMeta: Decodable {
func timestampText(_ date: Date) -> String {
date.formatted(date: .omitted, time: .shortened)
let now = Calendar.current.dateComponents([.day, .hour], from: .now)
let dc = Calendar.current.dateComponents([.day, .hour], from: date)
return now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
? date.formatted(date: .omitted, time: .shortened)
: String(date.formatted(date: .numeric, time: .omitted).prefix(5))
}
enum CIStatus: Decodable {
+7 -4
View File
@@ -237,6 +237,9 @@ func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)")
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
@@ -682,10 +685,10 @@ enum SMPCommandError: Decodable {
}
enum SMPTransportError: Decodable {
case TEBadBlock
case TELargeMsg
case TEBadSession
case TEHandshake(handshakeErr: SMPHandshakeError)
case badBlock
case largeMsg
case badSession
case handshake(handshakeErr: SMPHandshakeError)
}
enum SMPHandshakeError: Decodable {
@@ -8,8 +8,6 @@
import SwiftUI
private var dateFormatter: DateFormatter?
struct ChatItemView: View {
var chatItem: ChatItem
var width: CGFloat
@@ -23,14 +21,6 @@ struct ChatItemView: View {
}
}
func getDateFormatter() -> DateFormatter {
if let df = dateFormatter { return df }
let df = DateFormatter()
df.dateFormat = "HH:mm"
dateFormatter = df
return df
}
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
+22 -22
View File
@@ -25,15 +25,15 @@
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C499F2D27BAF1E300ECB4C5 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2827BAF1E300ECB4C5 /* libffi.a */; };
5C499F2E27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */; };
5C499F2F27BAF1E300ECB4C5 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */; };
5C499F3027BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */; };
5C499F3127BAF1E300ECB4C5 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2C27BAF1E300ECB4C5 /* libgmp.a */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059727B5CD9300BE3227 /* libgmp.a */; };
5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */; };
5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059927B5CD9300BE3227 /* libffi.a */; };
5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */; };
5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059B27B5CD9300BE3227 /* libgmpxx.a */; };
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
@@ -126,13 +126,13 @@
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = "<group>"; };
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C499F2827BAF1E300ECB4C5 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a"; sourceTree = "<group>"; };
5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a"; sourceTree = "<group>"; };
5C499F2C27BAF1E300ECB4C5 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
5C75059727B5CD9300BE3227 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a"; sourceTree = "<group>"; };
5C75059927B5CD9300BE3227 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a"; sourceTree = "<group>"; };
5C75059B27B5CD9300BE3227 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
@@ -178,14 +178,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C499F3027BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a in Frameworks */,
5C499F2F27BAF1E300ECB4C5 /* libgmpxx.a in Frameworks */,
5C499F3127BAF1E300ECB4C5 /* libgmp.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */,
5C499F2E27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */,
5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */,
5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */,
5C499F2D27BAF1E300ECB4C5 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -246,11 +246,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C75059927B5CD9300BE3227 /* libffi.a */,
5C75059727B5CD9300BE3227 /* libgmp.a */,
5C75059B27B5CD9300BE3227 /* libgmpxx.a */,
5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */,
5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */,
5C499F2827BAF1E300ECB4C5 /* libffi.a */,
5C499F2C27BAF1E300ECB4C5 /* libgmp.a */,
5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */,
5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */,
5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -799,7 +799,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -839,7 +839,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
+1 -1
View File
@@ -3,7 +3,7 @@ packages: .
source-repository-package
type: git
location: git://github.com/simplex-chat/simplexmq.git
tag: 229e2607d76f3d6baf0d2623b186c084e3908b8f
tag: dff5cad1bef67376e82c3dc15cccdb5ba9e675ab
source-repository-package
type: git
+31 -4
View File
@@ -75,7 +75,7 @@
mkdir -p $out/_pkg
cp libsupport.so $out/_pkg
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-android-libsupport.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
@@ -107,7 +107,7 @@
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsimplex.so
${pkgs.tree}/bin/tree $out/_pkg
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-android-libsimplex.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
@@ -121,7 +121,7 @@
# we need threaded here, otherwise all the queing logic doesn't work properly.
# for iOS we also use -staticlib, to get one rolled up library.
# still needs mac2ios patching of the archives.
ghcOptions = [ "-staticlib" "-threaded" ];
ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ];
postInstall = ''
${pkgs.tree}/bin/tree $out
mkdir -p $out/_pkg
@@ -134,7 +134,34 @@
find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \;
# There is no static libc
${pkgs.tree}/bin/tree $out/_pkg
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
};
"x86_64-darwin" = {
"x86_64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override {
smallAddressSpace = true; enableShared = false;
# we need threaded here, otherwise all the queing logic doesn't work properly.
# for iOS we also use -staticlib, to get one rolled up library.
# still needs mac2ios patching of the archives.
ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ];
postInstall = ''
${pkgs.tree}/bin/tree $out
mkdir -p $out/_pkg
# copy over includes, we might want those, but maybe not.
# cp -r $out/lib/*/*/include $out/_pkg/
# find the libHS...ghc-X.Y.Z.a static library; this is the
# rolled up one with all dependencies included.
find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \;
find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \;
find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \;
# There is no static libc
${pkgs.tree}/bin/tree $out/_pkg
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
+1 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 1.2.0
version: 1.2.1
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
+1 -1
View File
@@ -1,5 +1,5 @@
{
"git://github.com/simplex-chat/simplexmq.git"."229e2607d76f3d6baf0d2623b186c084e3908b8f" = "1w7mrrsyjh2wskqgdp8ml33xivzzqhzig217q3f92nkzc87ziq4g";
"git://github.com/simplex-chat/simplexmq.git"."dff5cad1bef67376e82c3dc15cccdb5ba9e675ab" = "06291v6vw7i00r0j13qx5apkz794jak68n1yr875gi32dxx5lhnp";
"git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 1.2.0
version: 1.2.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+38 -39
View File
@@ -37,6 +37,7 @@ import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Data.Word (Word32)
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Options (ChatOpts (..))
import Simplex.Chat.Protocol
@@ -336,9 +337,9 @@ processChatCommand = \case
let ciContent = CISndFileInvitation fileId f
createdAt <- liftIO getCurrentTime
let ci = mkNewChatItem ciContent 0 createdAt createdAt
ciMeta@CIMeta {itemId} <- saveChatItem userId (CDGroupSnd gInfo) ci
cItem@ChatItem {meta = CIMeta {itemId}} <- saveChatItem userId (CDGroupSnd gInfo) ci
withStore $ \st -> updateFileTransferChatItemId st fileId itemId
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ ChatItem CIGroupSnd ciMeta ciContent
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem
ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do
ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId
unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName
@@ -382,17 +383,17 @@ processChatCommand = \case
withAgentLock a . withLock l $ action
-- below code would make command responses asynchronous where they can be slow
-- in View.hs `r'` should be defined as `id` in this case
procCmd :: m ChatResponse -> m ChatResponse
procCmd action = do
ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask
corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8
void . forkIO $
withAgentLock a . withLock l $
(atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError))
pure $ CRCmdAccepted corrId
-- use function below to make commands "synchronous"
-- procCmd :: m ChatResponse -> m ChatResponse
-- procCmd = id
-- procCmd action = do
-- ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask
-- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8
-- void . forkIO $
-- withAgentLock a . withLock l $
-- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError))
-- pure $ CRCmdAccepted corrId
-- use function below to make commands "synchronous"
procCmd :: m ChatResponse -> m ChatResponse
procCmd = id
connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse
connectViaContact userId cReq profile = withChatLock $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
@@ -891,7 +892,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m ()
newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do
ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvMsgContent mc)
ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvMsgContent mc)
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
showToast (c <> "> ") $ msgContentText mc
@@ -899,9 +900,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContent -> MessageId -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do
ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvMsgContent mc)
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvMsgContent mc)
groupMsgToView gInfo ci msgMeta
let g = groupName' gInfo
showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc
setActive $ ActiveG g
@@ -911,7 +911,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- TODO chunk size has to be sent as part of invitation
chSize <- asks $ fileChunkSize . config
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize
ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvFileInvitation ft)
ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvFileInvitation ft)
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
@@ -922,14 +922,18 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msgId msgMeta = do
chSize <- asks $ fileChunkSize . config
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize
ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvFileInvitation ft)
ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvFileInvitation ft)
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
groupMsgToView gInfo ci msgMeta
let g = groupName' gInfo
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
setActive $ ActiveG g
groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m ()
groupMsgToView gInfo ci msgMeta = do
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
processGroupInvitation :: Contact -> GroupInvitation -> m ()
processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
@@ -1267,36 +1271,31 @@ saveRcvMSG Connection {connId} agentMsgMeta msgBody = do
pure (msgId, chatMsgEvent)
sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd)
sendDirectChatItem userId contact chatMsgEvent ciContent = do
msgId <- sendDirectContactMessage contact chatMsgEvent
createdAt <- liftIO getCurrentTime
ciMeta <- saveChatItem userId (CDDirectSnd contact) $ mkNewChatItem ciContent msgId createdAt createdAt
pure $ ChatItem CIDirectSnd ciMeta ciContent
sendDirectChatItem userId ct chatMsgEvent ciContent = do
msgId <- sendDirectContactMessage ct chatMsgEvent
saveSndChatItem userId (CDDirectSnd ct) msgId ciContent
sendGroupChatItem :: ChatMonad m => UserId -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTGroup 'MDSnd)
sendGroupChatItem userId (Group g ms) chatMsgEvent ciContent = do
msgId <- sendGroupMessage ms chatMsgEvent
createdAt <- liftIO getCurrentTime
ciMeta <- saveChatItem userId (CDGroupSnd g) $ mkNewChatItem ciContent msgId createdAt createdAt
pure $ ChatItem CIGroupSnd ciMeta ciContent
saveSndChatItem userId (CDGroupSnd g) msgId ciContent
saveRcvDirectChatItem :: ChatMonad m => UserId -> Contact -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTDirect 'MDRcv)
saveRcvDirectChatItem userId ct msgId MsgMeta {broker = (_, brokerTs)} ciContent = do
saveSndChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDSnd -> MessageId -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd)
saveSndChatItem userId cd msgId ciContent = do
createdAt <- liftIO getCurrentTime
ciMeta <- saveChatItem userId (CDDirectRcv ct) $ mkNewChatItem ciContent msgId brokerTs createdAt
pure $ ChatItem CIDirectRcv ciMeta ciContent
saveChatItem userId cd $ mkNewChatItem ciContent msgId createdAt createdAt
saveRcvGroupChatItem :: ChatMonad m => UserId -> GroupInfo -> GroupMember -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTGroup 'MDRcv)
saveRcvGroupChatItem userId g m msgId MsgMeta {broker = (_, brokerTs)} ciContent = do
saveRcvChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDRcv -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv)
saveRcvChatItem userId cd msgId MsgMeta {broker = (_, brokerTs)} ciContent = do
createdAt <- liftIO getCurrentTime
ciMeta <- saveChatItem userId (CDGroupRcv g m) $ mkNewChatItem ciContent msgId brokerTs createdAt
pure $ ChatItem (CIGroupRcv m) ciMeta ciContent
saveChatItem userId cd $ mkNewChatItem ciContent msgId brokerTs createdAt
saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (CIMeta d)
saveChatItem userId cd ci@NewChatItem {itemTs, itemText, createdAt} = do
saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (ChatItem c d)
saveChatItem userId cd ci@NewChatItem {itemContent, itemTs, itemText, createdAt} = do
tz <- liftIO getCurrentTimeZone
ciId <- withStore $ \st -> createNewChatItem st userId cd ci
pure $ mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt
let ciMeta = mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt
pure $ ChatItem (toCIDirection cd) ciMeta itemContent $ parseMarkdownList itemText
mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d
mkNewChatItem itemContent msgId itemTs createdAt =
+1 -1
View File
@@ -36,7 +36,7 @@ import System.IO (Handle)
import UnliftIO.STM
versionNumber :: String
versionNumber = "1.2.0"
versionNumber = "1.2.1"
versionStr :: String
versionStr = "SimpleX Chat v" <> versionNumber
+9 -9
View File
@@ -20,10 +20,10 @@ import Simplex.Chat.Types (Profile (..), User (..))
import System.Console.ANSI.Types
highlight :: Text -> Markdown
highlight = Markdown (Colored Cyan)
highlight = markdown (colored Cyan)
green :: Text -> Markdown
green = Markdown (Colored Green)
green = markdown (colored Green)
indent :: Markdown
indent = " "
@@ -150,11 +150,11 @@ markdownInfo =
map
styleMarkdown
[ green "Markdown:",
indent <> highlight "*bold* " <> " - " <> Markdown Bold "bold text",
indent <> highlight "_italic_ " <> " - " <> Markdown Italic "italic text" <> " (shown as underlined)",
indent <> highlight "+underlined+ " <> " - " <> Markdown Underline "underlined text",
indent <> highlight "~strikethrough~" <> " - " <> Markdown StrikeThrough "strikethrough text" <> " (shown as inverse)",
indent <> highlight "`code snippet` " <> " - " <> Markdown Snippet "a + b // no *markdown* here",
indent <> highlight "!1 text! " <> " - " <> Markdown (Colored Red) "red text" <> " (1-6: red, green, blue, yellow, cyan, magenta)",
indent <> highlight "#secret# " <> " - " <> Markdown Secret "secret text" <> " (can be copy-pasted)"
indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text",
indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)",
indent <> highlight "+underlined+ " <> " - " <> markdown Underline "underlined text",
indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)",
indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here",
indent <> highlight "!1 text! " <> " - " <> markdown (colored Red) "red text" <> " (1-6: red, green, blue, yellow, cyan, magenta)",
indent <> highlight "#secret# " <> " - " <> markdown Secret "secret text" <> " (can be copy-pasted)"
]
+85 -29
View File
@@ -1,11 +1,15 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Markdown where
import Control.Applicative ((<|>))
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.Bifunctor (second)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Map.Strict (Map)
@@ -13,9 +17,11 @@ import qualified Data.Map.Strict as M
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics
import Simplex.Messaging.Parsers (fstToLower, sumTypeJSON)
import System.Console.ANSI.Types
data Markdown = Markdown Format Text | Markdown :|: Markdown
data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown
deriving (Eq, Show)
data Format
@@ -25,18 +31,60 @@ data Format
| StrikeThrough
| Snippet
| Secret
| Colored Color
| NoFormat
deriving (Eq, Show)
| Colored FormatColor
| Uri
deriving (Eq, Show, Generic)
instance Semigroup Markdown where (<>) = (:|:)
colored :: Color -> Format
colored = Colored . FormatColor
markdown :: Format -> Text -> Markdown
markdown = Markdown . Just
instance ToJSON Format where toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower
instance Semigroup Markdown where
m <> (Markdown _ "") = m
(Markdown _ "") <> m = m
m1@(Markdown f1 s1) <> m2@(Markdown f2 s2)
| f1 == f2 = Markdown f1 $ s1 <> s2
| otherwise = m1 :|: m2
m1@(Markdown f1 s1) <> ms@(Markdown f2 s2 :|: m3)
| f1 == f2 = Markdown f1 (s1 <> s2) :|: m3
| otherwise = m1 :|: ms
ms@(m1 :|: Markdown f2 s2) <> m3@(Markdown f3 s3)
| f2 == f3 = m1 :|: Markdown f2 (s2 <> s3)
| otherwise = ms :|: m3
m1 <> m2 = m1 :|: m2
instance Monoid Markdown where mempty = unmarked ""
instance IsString Markdown where fromString = unmarked . T.pack
newtype FormatColor = FormatColor Color
deriving (Eq, Show)
instance ToJSON FormatColor where
toJSON (FormatColor c) = case c of
Red -> "red"
Green -> "green"
Blue -> "blue"
Yellow -> "yellow"
Cyan -> "cyan"
Magenta -> "magenta"
Black -> "black"
White -> "white"
data FormattedText = FormattedText {format :: Maybe Format, text :: Text}
deriving (Eq, Show, Generic)
instance ToJSON FormattedText where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
type MarkdownList = [FormattedText]
unmarked :: Text -> Markdown
unmarked = Markdown NoFormat
unmarked = Markdown Nothing
colorMD :: Char
colorMD = '!'
@@ -53,12 +101,12 @@ formats =
('~', StrikeThrough),
('`', Snippet),
(secretMD, Secret),
(colorMD, Colored White)
(colorMD, colored White)
]
colors :: Map Text Color
colors :: Map Text FormatColor
colors =
M.fromList
M.fromList . map (second FormatColor) $
[ ("red", Red),
("green", Green),
("blue", Blue),
@@ -79,40 +127,45 @@ colors =
("6", Magenta)
]
parseMarkdownList :: Text -> MarkdownList
parseMarkdownList = markdownToList . parseMarkdown
markdownToList :: Markdown -> MarkdownList
markdownToList (Markdown f s) = [FormattedText f s]
markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2
parseMarkdown :: Text -> Markdown
parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s
markdownP :: Parser Markdown
markdownP = merge <$> A.many' fragmentP
markdownP = mconcat <$> A.many' fragmentP
where
merge :: [Markdown] -> Markdown
merge [] = ""
merge fs = foldr1 (:|:) fs
fragmentP :: Parser Markdown
fragmentP =
A.anyChar >>= \case
' ' -> unmarked . T.cons ' ' <$> A.takeWhile (== ' ')
c -> case M.lookup c formats of
Just Secret -> secretP
Just (Colored White) -> coloredP
Just f -> formattedP c "" f
Nothing -> unformattedP c
A.peekChar >>= \case
Just ' ' -> unmarked <$> A.takeWhile (== ' ')
Just c -> case M.lookup c formats of
Just Secret -> A.char secretMD *> secretP
Just (Colored (FormatColor White)) -> A.char colorMD *> coloredP
Just f -> A.char c *> formattedP c "" f
Nothing -> wordsP
Nothing -> fail ""
formattedP :: Char -> Text -> Format -> Parser Markdown
formattedP c p f = do
s <- A.takeTill (== c)
(A.char c $> markdown c p f s) <|> noFormat (c `T.cons` p <> s)
markdown :: Char -> Text -> Format -> Text -> Markdown
markdown c p f s
(A.char c $> md c p f s) <|> noFormat (c `T.cons` p <> s)
md :: Char -> Text -> Format -> Text -> Markdown
md c p f s
| T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ c `T.cons` p <> s `T.snoc` c
| otherwise = Markdown f s
| otherwise = markdown f s
secretP :: Parser Markdown
secretP = secret <$> A.takeWhile (== secretMD) <*> A.takeTill (== secretMD) <*> A.takeWhile (== secretMD)
secret :: Text -> Text -> Text -> Markdown
secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ secretMD `T.cons` ss
| otherwise = Markdown Secret $ T.init ss
| otherwise = markdown Secret $ T.init ss
where
ss = b <> s <> a
coloredP :: Parser Markdown
@@ -124,15 +177,18 @@ markdownP = merge <$> A.many' fragmentP
in (A.char ' ' *> formattedP colorMD (color `T.snoc` ' ') f)
<|> noFormat (colorMD `T.cons` color)
_ -> noFormat (colorMD `T.cons` color)
unformattedP :: Char -> Parser Markdown
unformattedP c = unmarked . T.cons c <$> wordsP
wordsP :: Parser Text
wordsP :: Parser Markdown
wordsP = do
s <- (<>) <$> A.takeTill (== ' ') <*> A.takeWhile (== ' ')
word <- wordMD <$> A.takeTill (== ' ')
s <- (word <>) <$> (unmarked <$> A.takeWhile (== ' '))
A.peekChar >>= \case
Nothing -> pure s
Just c -> case M.lookup c formats of
Just _ -> pure s
Nothing -> (s <>) <$> wordsP
wordMD :: Text -> Markdown
wordMD s
| "http://" `T.isPrefixOf` s || "https://" `T.isPrefixOf` s || "simplex:/" `T.isPrefixOf` s = markdown Uri s
| otherwise = unmarked s
noFormat :: Text -> Parser Markdown
noFormat = pure . unmarked
+47 -4
View File
@@ -29,13 +29,14 @@ import Data.Typeable (Typeable)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
import Simplex.Chat.Markdown
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8)
import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgMeta (..))
import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, singleFieldJSON, sumTypeJSON)
import Simplex.Messaging.Protocol (MsgBody)
import Simplex.Messaging.Util ((<$?>))
@@ -76,7 +77,8 @@ jsonChatInfo = \case
data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
{ chatDir :: CIDirection c d,
meta :: CIMeta d,
content :: CIContent d
content :: CIContent d,
formattedText :: [FormattedText]
}
deriving (Show, Generic)
@@ -134,6 +136,13 @@ data ChatDirection (c :: ChatType) (d :: MsgDirection) where
CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd
CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv
toCIDirection :: ChatDirection c d -> CIDirection c d
toCIDirection = \case
CDDirectSnd _ -> CIDirectSnd
CDDirectRcv _ -> CIDirectRcv
CDGroupSnd _ -> CIGroupSnd
CDGroupRcv _ m -> CIGroupRcv m
data NewChatItem d = NewChatItem
{ createdByMsgId :: Maybe MessageId,
itemSent :: SMsgDirection d,
@@ -302,20 +311,25 @@ ciContentToText = \case
CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath
CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName
-- platform independent
instance ToField (CIContent d) where
toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode
toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode . dbJsonCIContent
-- platform specific
instance ToJSON (CIContent d) where
toJSON = J.toJSON . jsonCIContent
toEncoding = J.toEncoding . jsonCIContent
data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d)
-- platform specific
instance FromJSON ACIContent where
parseJSON = fmap aciContentJSON . J.parseJSON
instance FromField ACIContent where fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8
-- platform independent
instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContentDBJSON . J.decode . LB.fromStrict . encodeUtf8
-- platform specific
data JSONCIContent
= JCISndMsgContent {msgContent :: MsgContent}
| JCIRcvMsgContent {msgContent :: MsgContent}
@@ -344,6 +358,35 @@ aciContentJSON = \case
JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
-- platform independent
data DBJSONCIContent
= DBJCISndMsgContent {msgContent :: MsgContent}
| DBJCIRcvMsgContent {msgContent :: MsgContent}
| DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath}
| DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer}
deriving (Generic)
instance FromJSON DBJSONCIContent where
parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "DBJCI"
instance ToJSON DBJSONCIContent where
toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "DBJCI"
toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "DBJCI"
dbJsonCIContent :: CIContent d -> DBJSONCIContent
dbJsonCIContent = \case
CISndMsgContent mc -> DBJCISndMsgContent mc
CIRcvMsgContent mc -> DBJCIRcvMsgContent mc
CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath
CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft
aciContentDBJSON :: DBJSONCIContent -> ACIContent
aciContentDBJSON = \case
DBJCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc
DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
data SChatType (c :: ChatType) where
SCTDirect :: SChatType 'CTDirect
SCTGroup :: SChatType 'CTGroup
-1
View File
@@ -1,6 +1,5 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
+15 -18
View File
@@ -147,11 +147,11 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
import Data.Type.Equality
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.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Migrations.M20220101_initial
import Simplex.Chat.Migrations.M20220122_v1_1
@@ -2184,13 +2184,14 @@ getChatPreviews st user =
pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats)
where
ts :: AChat -> UTCTime
ts (AChat _ (Chat _ (ci : _) _)) = chatItemTs ci
ts (AChat _ (Chat (DirectChat Contact {createdAt}) [] _)) = createdAt
ts (AChat _ (Chat (GroupChat GroupInfo {createdAt}) [] _)) = createdAt
ts (AChat _ (Chat (ContactRequest UserContactRequest {createdAt}) [] _)) = createdAt
ts (AChat _ Chat {chatItems = ci : _}) = chatItemTs ci
ts (AChat _ Chat {chatInfo}) = case chatInfo of
DirectChat Contact {createdAt} -> createdAt
GroupChat GroupInfo {createdAt} -> createdAt
ContactRequest UserContactRequest {createdAt} -> createdAt
chatItemTs :: CChatItem d -> UTCTime
chatItemTs (CChatItem _ (ChatItem _ CIMeta {itemTs} _)) = itemTs
chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
getDirectChatPreviews_ db User {userId} = do
@@ -2703,14 +2704,12 @@ type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe
toDirectChatItem :: TimeZone -> ChatItemRow -> Either StoreError (CChatItem 'CTDirect)
toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdAt) =
case (itemContent, itemStatus) of
(ACIContent d@SMDSnd ciContent, ACIStatus d'@SMDSnd ciStatus) -> case testEquality d d' of
Just Refl -> Right $ CChatItem d (ChatItem CIDirectSnd (ciMeta ciStatus) ciContent)
_ -> badItem
(ACIContent d@SMDRcv ciContent, ACIStatus d'@SMDRcv ciStatus) -> case testEquality d d' of
Just Refl -> Right $ CChatItem d (ChatItem CIDirectRcv (ciMeta ciStatus) ciContent)
_ -> badItem
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent
(ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent
_ -> badItem
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c
cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMarkdownList itemText)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt
@@ -2728,14 +2727,12 @@ toGroupChatItem :: TimeZone -> Int64 -> GroupChatItemRow -> Either StoreError (C
toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) = do
let member_ = toMaybeGroupMember userContactId memberRow_
case (itemContent, itemStatus, member_) of
(ACIContent d@SMDSnd ciContent, ACIStatus d'@SMDSnd ciStatus, Nothing) -> case testEquality d d' of
Just Refl -> Right $ CChatItem d (ChatItem CIGroupSnd (ciMeta ciStatus) ciContent)
_ -> badItem
(ACIContent d@SMDRcv ciContent, ACIStatus d'@SMDRcv ciStatus, Just member) -> case testEquality d d' of
Just Refl -> Right $ CChatItem d (ChatItem (CIGroupRcv member) (ciMeta ciStatus) ciContent)
_ -> badItem
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Nothing) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent
(ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent
_ -> badItem
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c
cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMarkdownList itemText)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt
+15 -10
View File
@@ -5,7 +5,7 @@ module Simplex.Chat.Styled
( StyledString (..),
StyledFormat (..),
styleMarkdown,
styleMarkdownText,
styleMarkdownList,
unStyle,
sLength,
sShow,
@@ -29,14 +29,20 @@ instance Monoid StyledString where mempty = plain ""
instance IsString StyledString where fromString = plain
styleMarkdownText :: Text -> StyledString
styleMarkdownText = styleMarkdown . parseMarkdown
styleMarkdown :: Markdown -> StyledString
styleMarkdown (s1 :|: s2) = styleMarkdown s1 <> styleMarkdown s2
styleMarkdown (Markdown Snippet s) = '`' `wrap` styled Snippet s
styleMarkdown (Markdown Secret s) = '#' `wrap` styled Secret s
styleMarkdown (Markdown f s) = styled f s
styleMarkdown (Markdown f s) = styleFormat f s
styleMarkdownList :: MarkdownList -> StyledString
styleMarkdownList [] = plain ""
styleMarkdownList [FormattedText f s] = styleFormat f s
styleMarkdownList (FormattedText f s : ts) = styleFormat f s <> styleMarkdownList ts
styleFormat :: Maybe Format -> Text -> StyledString
styleFormat (Just Snippet) s = '`' `wrap` styled Snippet s
styleFormat (Just Secret) s = '#' `wrap` styled Secret s
styleFormat (Just f) s = styled f s
styleFormat Nothing s = plain s
wrap :: Char -> StyledString -> StyledString
wrap c s = plain [c] <> s <> plain [c]
@@ -66,10 +72,9 @@ sgr = \case
Italic -> [SetUnderlining SingleUnderline, SetItalicized True]
Underline -> [SetUnderlining SingleUnderline]
StrikeThrough -> [SetSwapForegroundBackground True]
Colored c -> [SetColor Foreground Vivid c]
Colored (FormatColor c) -> [SetColor Foreground Vivid c]
Secret -> [SetColor Foreground Dull Black, SetColor Background Dull Black]
Snippet -> []
NoFormat -> []
_ -> []
unStyle :: StyledString -> String
unStyle (Styled _ s) = s
+21 -3
View File
@@ -5,15 +5,19 @@
module Simplex.Chat.Terminal.Input where
import Control.Monad.IO.Unlift
import Control.Monad.Except
import Control.Monad.Reader
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
import Data.List (dropWhileEnd)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Styled
import Simplex.Chat.Terminal.Output
import Simplex.Chat.View
import Simplex.Messaging.Parsers (parseAll)
import System.Exit (exitSuccess)
import System.Terminal hiding (insertChars)
import UnliftIO.STM
@@ -28,9 +32,23 @@ getKey =
runInputLoop :: ChatTerminal -> ChatController -> IO ()
runInputLoop ct cc = forever $ do
s <- atomically . readTBQueue $ inputQ cc
r <- runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc
let bs = encodeUtf8 $ T.pack s
cmd = parseAll chatCommandP $ B.dropWhileEnd isSpace bs
unless (isMessage cmd) $ echo s
r <- runReaderT (execChatCommand bs) cc
case r of
CRChatCmdError _ -> when (isMessage cmd) $ echo s
_ -> pure ()
let testV = testView $ config cc
printToTerminal ct $ responseToView s testV r
printToTerminal ct $ responseToView testV r
where
echo s = printToTerminal ct [plain s]
isMessage = \case
Right SendMessage {} -> True
Right SendGroupMessage {} -> True
Right SendFile {} -> True
Right SendGroupFile {} -> True
_ -> False
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
runTerminalInput ct cc = withChatTerm ct $ do
+1 -1
View File
@@ -76,7 +76,7 @@ runTerminalOutput :: ChatTerminal -> ChatController -> IO ()
runTerminalOutput ct cc = do
let testV = testView $ config cc
forever $
atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" testV . snd
atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView testV . snd
printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
printToTerminal ct s =
+58 -60
View File
@@ -7,6 +7,7 @@
module Simplex.Chat.View where
import qualified Data.Aeson as J
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intersperse, sortOn)
@@ -27,61 +28,62 @@ import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Encoding.String
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (bshow)
import System.Console.ANSI.Types
serializeChatResponse :: ChatResponse -> String
serializeChatResponse = unlines . map unStyle . responseToView "" False
serializeChatResponse = unlines . map unStyle . responseToView False
responseToView :: String -> Bool -> ChatResponse -> [StyledString]
responseToView cmd testView = \case
CRActiveUser User {profile} -> r $ viewUserProfile profile
CRChatStarted -> r ["chat started"]
CRApiChats chats -> r $ if testView then testViewChats chats else [sShow chats]
CRApiChat chat -> r $ if testView then testViewChat chat else [sShow chat]
responseToView :: Bool -> ChatResponse -> [StyledString]
responseToView testView = \case
CRActiveUser User {profile} -> viewUserProfile profile
CRChatStarted -> ["chat started"]
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]
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
CRChatItemUpdated _ -> []
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> r []
CRCmdOk -> r ["ok"]
CRCmdAccepted _ -> []
CRCmdOk -> ["ok"]
CRChatHelp section -> case section of
HSMain -> r chatHelpInfo
HSFiles -> r filesHelpInfo
HSGroups -> r groupsHelpInfo
HSMyAddress -> r myAddressHelpInfo
HSMarkdown -> r markdownInfo
CRWelcome user -> r $ chatWelcome user
CRContactsList cs -> r $ viewContactsList cs
CRUserContactLink cReqUri _ -> r $ connReqContact_ "Your chat address:" cReqUri
CRUserContactLinkUpdated _ autoAccept -> r ["auto_accept " <> if autoAccept then "on" else "off"]
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> r [ttyContact c <> ": contact request rejected"]
CRGroupCreated g -> r $ viewGroupCreated g
CRGroupMembers g -> r $ viewGroupMembers g
CRGroupsList gs -> r $ viewGroupsList gs
CRSentGroupInvitation g c -> r ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
CRFileTransferStatus ftStatus -> r $ viewFileTransferStatus ftStatus
CRUserProfile p -> r $ viewUserProfile p
CRUserProfileNoChange -> r ["user profile did not change"]
CRVersionInfo _ -> r [plain versionStr, plain updateStr]
CRChatCmdError e -> r $ viewChatError e
CRInvitation cReq -> r' $ viewConnReqInvitation cReq
CRSentConfirmation -> r' ["confirmation sent!"]
CRSentInvitation -> r' ["connection request sent!"]
CRContactDeleted c -> r' [ttyContact' c <> ": contact is deleted"]
CRAcceptingContactRequest c -> r' [ttyFullContact c <> ": accepting contact request..."]
CRContactAlreadyExists c -> r [ttyFullContact c <> ": contact already exists"]
CRContactRequestAlreadyAccepted c -> r' [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"]
CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq
CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted
CRUserAcceptedGroupSent _g -> r' [] -- [ttyGroup' g <> ": joining the group..."]
CRUserDeletedMember g m -> r' [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"]
CRLeftMemberUser g -> r' $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g
CRGroupDeletedUser g -> r' [ttyGroup' g <> ": you deleted the group"]
HSMain -> chatHelpInfo
HSFiles -> filesHelpInfo
HSGroups -> groupsHelpInfo
HSMyAddress -> myAddressHelpInfo
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"]
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"]
CRGroupCreated g -> viewGroupCreated g
CRGroupMembers g -> viewGroupMembers g
CRGroupsList gs -> viewGroupsList gs
CRSentGroupInvitation g c -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus
CRUserProfile p -> viewUserProfile p
CRUserProfileNoChange -> ["user profile did not change"]
CRVersionInfo _ -> [plain versionStr, plain updateStr]
CRChatCmdError e -> viewChatError e
CRInvitation cReq -> viewConnReqInvitation cReq
CRSentConfirmation -> ["confirmation sent!"]
CRSentInvitation -> ["connection request sent!"]
CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"]
CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."]
CRContactAlreadyExists c -> [ttyFullContact c <> ": contact already exists"]
CRContactRequestAlreadyAccepted c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"]
CRUserContactLinkCreated cReq -> connReqContact_ "Your new chat address is created!" cReq
CRUserContactLinkDeleted -> viewUserContactLinkDeleted
CRUserAcceptedGroupSent _g -> [] -- [ttyGroup' g <> ": joining the group..."]
CRUserDeletedMember g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"]
CRLeftMemberUser g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g
CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"]
CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath ->
r' ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath]
CRRcvFileAcceptedSndCancelled ft -> r' $ viewRcvFileSndCancelled ft
CRSndGroupFileCancelled fts -> r' $ viewSndGroupFileCancelled fts
CRRcvFileCancelled ft -> r' $ receivingFile_ "cancelled" ft
CRUserProfileUpdated p p' -> r' $ viewUserProfileUpdated p p'
["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath]
CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft
CRSndGroupFileCancelled fts -> viewSndGroupFileCancelled fts
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
CRContactUpdated c c' -> viewContactUpdated c c'
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
@@ -123,10 +125,6 @@ responseToView cmd testView = \case
CRMessageError prefix err -> [plain prefix <> ": " <> plain err]
CRChatError e -> viewChatError e
where
r = (plain cmd :)
-- this function should be `r` for "synchronous", `id` for "asynchronous" command responses
-- r' = r
r' = id
testViewChats :: [AChat] -> [StyledString]
testViewChats chats = [sShow $ map toChatView chats]
where
@@ -144,7 +142,7 @@ responseToView cmd testView = \case
toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta)
viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString]
viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of
viewChatItem chat (ChatItem cd meta content _) = case (chat, cd) of
(DirectChat c, CIDirectSnd) -> case content of
CISndMsgContent mc -> viewSentMessage to mc meta
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
@@ -180,7 +178,7 @@ viewMsgIntegrityError err = msgError $ case err of
MsgDuplicate -> "duplicate message ID"
where
msgError :: String -> [StyledString]
msgError s = [styled (Colored Red) s]
msgError s = [styled (colored Red) s]
viewInvalidConnReq :: [StyledString]
viewInvalidConnReq =
@@ -373,7 +371,7 @@ prependFirst s [] = [s]
prependFirst s (s' : ss) = (s <> s') : ss
msgPlain :: Text -> [StyledString]
msgPlain = map styleMarkdownText . T.lines
msgPlain = map (styleMarkdownList . parseMarkdownList) . T.lines
viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString]
viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} =
@@ -519,7 +517,7 @@ viewChatError = \case
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
ttyContact :: ContactName -> StyledString
ttyContact = styled (Colored Green)
ttyContact = styled (colored Green)
ttyContact' :: Contact -> StyledString
ttyContact' Contact {localDisplayName = c} = ttyContact c
@@ -539,13 +537,13 @@ ttyFullName :: ContactName -> Text -> StyledString
ttyFullName c fullName = ttyContact c <> optFullName c fullName
ttyToContact :: ContactName -> StyledString
ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " "
ttyToContact c = styled (colored Cyan) $ "@" <> c <> " "
ttyFromContact :: ContactName -> StyledString
ttyFromContact c = styled (Colored Yellow) $ c <> "> "
ttyFromContact c = styled (colored Yellow) $ c <> "> "
ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (Colored Blue) $ "#" <> g
ttyGroup g = styled (colored Blue) $ "#" <> g
ttyGroup' :: GroupInfo -> StyledString
ttyGroup' = ttyGroup . groupName'
@@ -560,10 +558,10 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN
ttyGroup g <> optFullName g fullName
ttyFromGroup :: GroupInfo -> ContactName -> StyledString
ttyFromGroup GroupInfo {localDisplayName = g} c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> "
ttyFromGroup GroupInfo {localDisplayName = g} c = styled (colored Yellow) $ "#" <> g <> " " <> c <> "> "
ttyToGroup :: GroupInfo -> StyledString
ttyToGroup GroupInfo {localDisplayName = g} = styled (Colored Cyan) $ "#" <> g <> " "
ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " "
ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain
@@ -574,7 +572,7 @@ optFullName localDisplayName fullName
| otherwise = plain (" (" <> fullName <> ")")
highlight :: StyledFormat a => a -> StyledString
highlight = styled (Colored Cyan)
highlight = styled (colored Cyan)
highlight' :: String -> StyledString
highlight' = highlight
+1 -1
View File
@@ -48,7 +48,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 229e2607d76f3d6baf0d2623b186c084e3908b8f
commit: dff5cad1bef67376e82c3dc15cccdb5ba9e675ab
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
+55 -40
View File
@@ -13,116 +13,131 @@ markdownTests = do
textFormat
secretText
textColor
textWithUri
textFormat :: Spec
textFormat = describe "text format (bold)" do
it "correct markdown" do
parseMarkdown "this is *bold formatted* text"
`shouldBe` "this is " <> Markdown Bold "bold formatted" <> " " <> "text"
`shouldBe` "this is " <> markdown Bold "bold formatted" <> " text"
parseMarkdown "*bold formatted* text"
`shouldBe` Markdown Bold "bold formatted" <> " " <> "text"
`shouldBe` markdown Bold "bold formatted" <> " text"
parseMarkdown "this is *bold*"
`shouldBe` "this is " <> Markdown Bold "bold"
`shouldBe` "this is " <> markdown Bold "bold"
parseMarkdown " *bold* text"
`shouldBe` " " <> Markdown Bold "bold" <> " " <> "text"
`shouldBe` " " <> markdown Bold "bold" <> " text"
parseMarkdown " *bold* text"
`shouldBe` " " <> Markdown Bold "bold" <> " " <> "text"
`shouldBe` " " <> markdown Bold "bold" <> " text"
parseMarkdown "this is *bold* "
`shouldBe` "this is " <> Markdown Bold "bold" <> " "
`shouldBe` "this is " <> markdown Bold "bold" <> " "
parseMarkdown "this is *bold* "
`shouldBe` "this is " <> Markdown Bold "bold" <> " "
`shouldBe` "this is " <> markdown Bold "bold" <> " "
it "ignored as markdown" do
parseMarkdown "this is * unformatted * text"
`shouldBe` "this is " <> "* unformatted *" <> " " <> "text"
`shouldBe` "this is * unformatted * text"
parseMarkdown "this is *unformatted * text"
`shouldBe` "this is " <> "*unformatted *" <> " " <> "text"
`shouldBe` "this is *unformatted * text"
parseMarkdown "this is * unformatted* text"
`shouldBe` "this is " <> "* unformatted*" <> " " <> "text"
`shouldBe` "this is * unformatted* text"
parseMarkdown "this is **unformatted** text"
`shouldBe` "this is " <> "**" <> "unformatted** text"
`shouldBe` "this is **unformatted** text"
parseMarkdown "this is*unformatted* text"
`shouldBe` "this is*unformatted* text"
parseMarkdown "this is *unformatted text"
`shouldBe` "this is " <> "*unformatted text"
`shouldBe` "this is *unformatted text"
it "ignored internal markdown" do
parseMarkdown "this is *long _bold_ (not italic)* text"
`shouldBe` "this is " <> Markdown Bold "long _bold_ (not italic)" <> " " <> "text"
`shouldBe` "this is " <> markdown Bold "long _bold_ (not italic)" <> " text"
parseMarkdown "snippet: `this is *bold text*`"
`shouldBe` "snippet: " <> Markdown Snippet "this is *bold text*"
`shouldBe` "snippet: " <> markdown Snippet "this is *bold text*"
secretText :: Spec
secretText = describe "secret text" do
it "correct markdown" do
parseMarkdown "this is #black_secret# text"
`shouldBe` "this is " <> Markdown Secret "black_secret" <> " " <> "text"
`shouldBe` "this is " <> markdown Secret "black_secret" <> " text"
parseMarkdown "##black_secret### text"
`shouldBe` Markdown Secret "#black_secret##" <> " " <> "text"
`shouldBe` markdown Secret "#black_secret##" <> " text"
parseMarkdown "this is #black secret# text"
`shouldBe` "this is " <> Markdown Secret "black secret" <> " " <> "text"
`shouldBe` "this is " <> markdown Secret "black secret" <> " text"
parseMarkdown "##black secret### text"
`shouldBe` Markdown Secret "#black secret##" <> " " <> "text"
`shouldBe` markdown Secret "#black secret##" <> " text"
parseMarkdown "this is #secret#"
`shouldBe` "this is " <> Markdown Secret "secret"
`shouldBe` "this is " <> markdown Secret "secret"
parseMarkdown " #secret# text"
`shouldBe` " " <> Markdown Secret "secret" <> " " <> "text"
`shouldBe` " " <> markdown Secret "secret" <> " text"
parseMarkdown " #secret# text"
`shouldBe` " " <> Markdown Secret "secret" <> " " <> "text"
`shouldBe` " " <> markdown Secret "secret" <> " text"
parseMarkdown "this is #secret# "
`shouldBe` "this is " <> Markdown Secret "secret" <> " "
`shouldBe` "this is " <> markdown Secret "secret" <> " "
parseMarkdown "this is #secret# "
`shouldBe` "this is " <> Markdown Secret "secret" <> " "
`shouldBe` "this is " <> markdown Secret "secret" <> " "
it "ignored as markdown" do
parseMarkdown "this is # unformatted # text"
`shouldBe` "this is " <> "# unformatted #" <> " " <> "text"
`shouldBe` "this is # unformatted # text"
parseMarkdown "this is #unformatted # text"
`shouldBe` "this is " <> "#unformatted #" <> " " <> "text"
`shouldBe` "this is #unformatted # text"
parseMarkdown "this is # unformatted# text"
`shouldBe` "this is " <> "# unformatted#" <> " " <> "text"
`shouldBe` "this is # unformatted# text"
parseMarkdown "this is ## unformatted ## text"
`shouldBe` "this is " <> "## unformatted ##" <> " " <> "text"
`shouldBe` "this is ## unformatted ## text"
parseMarkdown "this is#unformatted# text"
`shouldBe` "this is#unformatted# text"
parseMarkdown "this is #unformatted text"
`shouldBe` "this is " <> "#unformatted text"
`shouldBe` "this is #unformatted text"
it "ignored internal markdown" do
parseMarkdown "snippet: `this is #secret_text#`"
`shouldBe` "snippet: " <> Markdown Snippet "this is #secret_text#"
`shouldBe` "snippet: " <> markdown Snippet "this is #secret_text#"
red :: Text -> Markdown
red = Markdown (Colored Red)
red = markdown (colored Red)
textColor :: Spec
textColor = describe "text color (red)" do
it "correct markdown" do
parseMarkdown "this is !1 red color! text"
`shouldBe` "this is " <> red "red color" <> " " <> "text"
`shouldBe` "this is " <> red "red color" <> " text"
parseMarkdown "!1 red! text"
`shouldBe` red "red" <> " " <> "text"
`shouldBe` red "red" <> " text"
parseMarkdown "this is !1 red!"
`shouldBe` "this is " <> red "red"
parseMarkdown " !1 red! text"
`shouldBe` " " <> red "red" <> " " <> "text"
`shouldBe` " " <> red "red" <> " text"
parseMarkdown " !1 red! text"
`shouldBe` " " <> red "red" <> " " <> "text"
`shouldBe` " " <> red "red" <> " text"
parseMarkdown "this is !1 red! "
`shouldBe` "this is " <> red "red" <> " "
parseMarkdown "this is !1 red! "
`shouldBe` "this is " <> red "red" <> " "
it "ignored as markdown" do
parseMarkdown "this is !1 unformatted ! text"
`shouldBe` "this is " <> "!1 unformatted !" <> " " <> "text"
`shouldBe` "this is !1 unformatted ! text"
parseMarkdown "this is !1 unformatted ! text"
`shouldBe` "this is " <> "!1 unformatted !" <> " " <> "text"
`shouldBe` "this is !1 unformatted ! text"
parseMarkdown "this is !1 unformatted! text"
`shouldBe` "this is " <> "!1 unformatted!" <> " " <> "text"
`shouldBe` "this is !1 unformatted! text"
-- parseMarkdown "this is !!1 unformatted!! text"
-- `shouldBe` "this is " <> "!!1" <> "unformatted!! text"
parseMarkdown "this is!1 unformatted! text"
`shouldBe` "this is!1 unformatted! text"
parseMarkdown "this is !1 unformatted text"
`shouldBe` "this is " <> "!1 unformatted text"
`shouldBe` "this is !1 unformatted text"
it "ignored internal markdown" do
parseMarkdown "this is !1 long *red* (not bold)! text"
`shouldBe` "this is " <> red "long *red* (not bold)" <> " " <> "text"
`shouldBe` "this is " <> red "long *red* (not bold)" <> " text"
parseMarkdown "snippet: `this is !1 red text!`"
`shouldBe` "snippet: " <> Markdown Snippet "this is !1 red text!"
`shouldBe` "snippet: " <> markdown Snippet "this is !1 red text!"
uri :: Text -> Markdown
uri = Markdown $ Just Uri
textWithUri :: Spec
textWithUri = describe "text with Uri" do
it "correct markdown" do
parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat"
parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat"
parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat"
parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site"
it "ignored as markdown" do
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
+22 -2
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE NamedFieldPuns #-}
module MobileTests where
@@ -16,13 +17,32 @@ mobileTests = do
it "start new chat with existing user" testChatApi
noActiveUser :: String
#if defined(darwin_HOST_OS)
noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}"
#else
noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}"
#endif
activeUserExists :: String
#if defined(darwin_HOST_OS)
activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"activeUserExists\":{}}}}}}}"
#else
activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"activeUserExists\"}}}}"
#endif
activeUser :: String
#if defined(darwin_HOST_OS)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}"
#else
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}"
#endif
chatStarted :: String
#if defined(darwin_HOST_OS)
chatStarted = "{\"resp\":{\"chatStarted\":{}}}"
#else
chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}"
#endif
testChatApiNoUser :: IO ()
testChatApiNoUser = withTmpFiles $ do
@@ -30,7 +50,7 @@ testChatApiNoUser = withTmpFiles $ do
chatSendCmd cc "/u" `shouldReturn` noActiveUser
chatSendCmd cc "/_start" `shouldReturn` noActiveUser
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser
chatSendCmd cc "/_start" `shouldReturn` "{\"resp\":{\"chatStarted\":{}}}"
chatSendCmd cc "/_start" `shouldReturn` chatStarted
testChatApi :: IO ()
testChatApi = withTmpFiles $ do
@@ -40,4 +60,4 @@ testChatApi = withTmpFiles $ do
cc <- chatInit testDBPrefix
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` "{\"resp\":{\"chatStarted\":{}}}"
chatSendCmd cc "/_start" `shouldReturn` chatStarted