Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6086f76d95 | |||
| 268eaaa9ca | |||
| ad1612d84a | |||
| 0389a58f64 | |||
| 6ee2f334f6 | |||
| a5afdf4e91 | |||
| ecaa570ff3 | |||
| 1d2d1e6df7 | |||
| c242f0079c | |||
| 727c533f93 | |||
| 5961b7d951 | |||
| bbab069bcd | |||
| 1cf3b776d7 | |||
| 1aa2643c18 | |||
| 1150c04298 | |||
| 9c57ab5221 | |||
| 3e61b8c21a | |||
| 99bed645f3 | |||
| 51f5982205 | |||
| b7a06dd0cf | |||
| e071e4cdbf | |||
| 470b18786e | |||
| 8f21453e82 | |||
| fb76917ec3 | |||
| c53500812c | |||
| 5e6b9e578b | |||
| f9c495a596 | |||
| e4ec8cccfd | |||
| 3be350786d | |||
| f698a05d53 | |||
| 518a15934f | |||
| 48dbd079cf | |||
| efa22715d5 | |||
| 0d88fcc758 | |||
| 353e04bddd | |||
| 0a6c03079c | |||
| a0a4549045 | |||
| 69c79c5e0a | |||
| 1edf60362e | |||
| 739990c732 | |||
| c9cfead9bc | |||
| d37f493c6a | |||
| b3153ae0fd | |||
| 7fc5b833aa | |||
| d48d4ed8f9 | |||
| f57a7009a3 | |||
| 6c4888d275 | |||
| 3820d08af8 | |||
| bba2783aa4 | |||
| f650308986 | |||
| bd13181042 | |||
| 6daad10210 | |||
| 52f758c6e1 | |||
| 290a88fd90 | |||
| 423f54e95d | |||
| 9e46b5117d | |||
| e8ff6f509b | |||
| e7e777ec7b | |||
| f74f932dcd | |||
| 7fafb25821 | |||
| dd256be4ec | |||
| d743804b1d | |||
| f8951b44fc | |||
| ec70670630 | |||
| ee07921d42 | |||
| 5548494a44 | |||
| 7c8ad4aee4 | |||
| 12b4325435 | |||
| 241d02584a | |||
| ce02c514cf | |||
| 322ab9d854 | |||
| d40ee71a2c | |||
| c81bb0f15d | |||
| b7fda194c8 | |||
| c37f41c171 |
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -1,5 +1,12 @@
|
||||
<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_EXPRESSION_BODY_FUNCTIONS" value="0" />
|
||||
<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 +120,25 @@
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="RIGHT_MARGIN" value="140" />
|
||||
<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="CALL_PARAMETERS_WRAP" value="0" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
||||
<option name="EXTENDS_LIST_WRAP" value="0" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
|
||||
<option name="ASSIGNMENT_WRAP" 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,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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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,15 +9,18 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 24
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 3
|
||||
versionName "0.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -2,25 +2,36 @@
|
||||
<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:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -1,123 +1,181 @@
|
||||
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.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import chat.simplex.app.model.*
|
||||
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.*
|
||||
import com.google.accompanist.insets.ExperimentalAnimatedInsets
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.serialization.decodeFromString
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
@ExperimentalTextApi
|
||||
@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)
|
||||
}
|
||||
// testJson()
|
||||
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)
|
||||
@ExperimentalTextApi
|
||||
@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)
|
||||
@ExperimentalTextApi
|
||||
@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.UserProfile.route) {
|
||||
UserProfileView(chatModel, nav)
|
||||
}
|
||||
composable(route = Pages.UserAddress.route) {
|
||||
UserAddressView(chatModel, nav)
|
||||
}
|
||||
composable(route = Pages.Help.route) {
|
||||
HelpView(chatModel, nav)
|
||||
}
|
||||
composable(route = Pages.Markdown.route) {
|
||||
MarkdownHelpView(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 UserProfile: Pages("user_profile")
|
||||
object UserAddress: Pages("user_address")
|
||||
object Help: Pages("help")
|
||||
object Markdown: Pages("markdown")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
fun testJson() {
|
||||
val str = """
|
||||
{}
|
||||
""".trimIndent()
|
||||
|
||||
println(json.decodeFromString<ChatItem>(str))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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 showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = "Ok",
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = "Cancel",
|
||||
onDismiss: (() -> 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) }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,667 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
|
||||
var currentUser = mutableStateOf<User?>(null)
|
||||
var userCreated = mutableStateOf<Boolean?>(null)
|
||||
var chats = mutableStateListOf<Chat>()
|
||||
var chatsLoaded = mutableStateOf<Boolean?>(null)
|
||||
var chatId = mutableStateOf<String?>(null)
|
||||
var chatItems = mutableStateListOf<ChatItem>()
|
||||
|
||||
var connReqInvitation: String? = null
|
||||
var terminalItems = mutableStateListOf<TerminalItem>()
|
||||
var userAddress = mutableStateOf<String?>(null)
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceChat(id: String, chat: Chat) {
|
||||
val i = getChatIndex(id)
|
||||
if (i >= 0) {
|
||||
chats[i] = chat
|
||||
} else {
|
||||
// invalid state, correcting
|
||||
chats.add(index = 0, chat)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(ChatItem.getSampleData())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 ContactSubStatus(
|
||||
val contact: Contact,
|
||||
val contactError: ChatError? = null
|
||||
)
|
||||
|
||||
@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 MemberSubError (
|
||||
val member: GroupMember,
|
||||
val memberError: ChatError
|
||||
)
|
||||
|
||||
@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 formattedText: List<FormattedText>? = null
|
||||
) {
|
||||
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 = 1,
|
||||
dir: CIDirection = CIDirection.DirectSnd(),
|
||||
ts: Instant = Clock.System.now(),
|
||||
text: String = "hello\nthere",
|
||||
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 FormattedText(val text: String, val format: Format? = null) {
|
||||
val link: String? = when (format) {
|
||||
is Format.Uri -> text
|
||||
is Format.Email -> "mailto:$text"
|
||||
is Format.Phone -> "tel:$text"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class Format {
|
||||
@Serializable @SerialName("bold") class Bold: Format()
|
||||
@Serializable @SerialName("italic") class Italic: Format()
|
||||
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
|
||||
@Serializable @SerialName("snippet") class Snippet: Format()
|
||||
@Serializable @SerialName("secret") class Secret: Format()
|
||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||
@Serializable @SerialName("uri") class Uri: Format()
|
||||
@Serializable @SerialName("email") class Email: Format()
|
||||
@Serializable @SerialName("phone") class Phone: Format()
|
||||
|
||||
val style: SpanStyle @Composable get() = when (this) {
|
||||
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
|
||||
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
||||
is Colored -> SpanStyle(color = this.color.uiColor)
|
||||
is Uri -> linkStyle
|
||||
is Email -> linkStyle
|
||||
is Phone -> linkStyle
|
||||
}
|
||||
|
||||
companion object {
|
||||
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class FormatColor(val color: String) {
|
||||
red("red"),
|
||||
green("green"),
|
||||
blue("blue"),
|
||||
yellow("yellow"),
|
||||
cyan("cyan"),
|
||||
magenta("magenta"),
|
||||
black("black"),
|
||||
white("white");
|
||||
|
||||
val uiColor: Color @Composable get() = when (this) {
|
||||
red -> Color.Red
|
||||
green -> Color.Green
|
||||
blue -> Color.Blue
|
||||
yellow -> Color.Yellow
|
||||
cyan -> Color.Cyan
|
||||
magenta -> Color.Magenta
|
||||
black -> MaterialTheme.colors.onBackground
|
||||
white -> MaterialTheme.colors.onBackground
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class RcvFileTransfer
|
||||
@@ -0,0 +1,706 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
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.userAddress.value = apiGetUserAddress()
|
||||
chatModel.chats.addAll(apiGetChats())
|
||||
chatModel.chatsLoaded.value = true
|
||||
startReceiver()
|
||||
Log.d("SIMPLEX", "started chat")
|
||||
} catch(e: Error) {
|
||||
Log.d("SIMPLEX", "failed starting chat $e")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun startReceiver() {
|
||||
thread(name="receiver") {
|
||||
withApi { recvMspLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
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 recvMsg(): CR {
|
||||
return withContext(Dispatchers.IO) {
|
||||
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")
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun recvMspLoop() {
|
||||
processReceivedMsg(recvMsg())
|
||||
recvMspLoop()
|
||||
}
|
||||
|
||||
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 -> {
|
||||
val contactRequest = r.contactRequest
|
||||
val cInfo = ChatInfo.ContactRequest(contactRequest)
|
||||
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
|
||||
// NtfManager.shared.notifyContactRequest(contactRequest)
|
||||
}
|
||||
is CR.ContactUpdated -> {
|
||||
val cInfo = ChatInfo.Direct(r.toContact)
|
||||
if (chatModel.hasChat(r.toContact.id)) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
}
|
||||
}
|
||||
is CR.ContactSubscribed -> processContactSubscribed(r.contact)
|
||||
is CR.ContactDisconnected -> {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
|
||||
}
|
||||
is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
|
||||
is CR.ContactSubSummary -> {
|
||||
for (sub in r.contactSubscriptions) {
|
||||
val err = sub.contactError
|
||||
if (err == null) processContactSubscribed(sub.contact)
|
||||
else processContactSubError(sub.contact, sub.contactError)
|
||||
}
|
||||
}
|
||||
is CR.NewChatItem -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
// 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)")
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
fun processContactSubscribed(contact: Contact) {
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
|
||||
}
|
||||
|
||||
fun processContactSubError(contact: Contact, chatError: ChatError) {
|
||||
chatModel.updateContact(contact)
|
||||
val e = 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(contact, Chat.NetworkStatus.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 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("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List<ContactSubStatus>): CR()
|
||||
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
|
||||
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List<MemberSubError>): 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 ContactSubSummary -> "contactSubSummary"
|
||||
is GroupSubscribed -> "groupSubscribed"
|
||||
is MemberSubErrors -> "memberSubErrors"
|
||||
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 ContactSubSummary -> json.encodeToString(contactSubscriptions)
|
||||
is GroupSubscribed -> json.encodeToString(group)
|
||||
is MemberSubErrors -> json.encodeToString(memberSubErrors)
|
||||
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,15 @@
|
||||
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 SecretColor = Color(0x40808080)
|
||||
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,29 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
|
||||
@Composable
|
||||
fun SplashView() {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.White)
|
||||
) {
|
||||
// Image(
|
||||
// painter = painterResource(R.drawable.logo),
|
||||
// contentDescription = "Simplex Icon",
|
||||
// modifier = Modifier
|
||||
// .height(230.dp)
|
||||
// .align(Alignment.Center)
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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.text.style.TextOverflow
|
||||
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),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
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>, nav: NavController){
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
CloseSheetBar(nav::popBackStack)
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
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,163 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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 com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
@Composable
|
||||
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
contentDescription = "Simplex Logo",
|
||||
modifier = Modifier.padding(vertical = 15.dp)
|
||||
)
|
||||
Text(
|
||||
"You control your chat!",
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"The messaging and application platform protecting your privacy and security.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"We don't store any of your contacts or messages (once delivered) on the servers.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CreateProfilePanel(chatModel, routeHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var fullName by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier=Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
"Create profile",
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
Text(
|
||||
"Your profile is stored on your device and shared only with your contacts.",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Display Name",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
BasicTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
|
||||
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
"Full Name (Optional)",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
BasicTextField(
|
||||
value = fullName,
|
||||
onValueChange = { fullName = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
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,200 @@
|
||||
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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
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
|
||||
|
||||
@ExperimentalTextApi
|
||||
@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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@DelicateCoroutinesApi
|
||||
@ExperimentalAnimatedInsets
|
||||
@Composable
|
||||
fun ChatItemsList(chatItems: List<ChatItem>) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
LazyColumn(state = listState) {
|
||||
items(chatItems) { cItem ->
|
||||
ChatItemView(cItem, uriHandler)
|
||||
}
|
||||
val len = chatItems.count()
|
||||
if (len > 1) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@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,30 @@
|
||||
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 androidx.compose.ui.unit.sp
|
||||
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,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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.platform.UriHandler
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
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
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
|
||||
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, uriHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
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.platform.UriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
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.LightGray
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO move to theme
|
||||
val SentColorLight = Color(0x1E45B8FF)
|
||||
val ReceivedColorLight = Color(0x1EB1B0B5)
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
|
||||
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)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true)
|
||||
CIMetaView(chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
|
||||
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
|
||||
if (chatItem.chatDir is CIDirection.GroupRcv) {
|
||||
val name = chatItem.chatDir.groupMember.memberProfile.displayName
|
||||
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
|
||||
else b.append(name)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
chatItem: ChatItem,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
uriHandler: UriHandler? = null,
|
||||
groupMemberBold: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (chatItem.formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendGroupMember(this, chatItem, groupMemberBold)
|
||||
append(chatItem.content.text)
|
||||
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
} else {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendGroupMember(this, chatItem, groupMemberBold)
|
||||
for (ft in chatItem.formattedText) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else {
|
||||
val link = ft.link
|
||||
if (link != null) {
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
|
||||
}
|
||||
if (uriHandler != null) {
|
||||
SelectionContainer {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SelectionContainer {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewSnd() {
|
||||
SimpleXTheme {
|
||||
TextItemView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewTextItemViewRcv() {
|
||||
SimpleXTheme {
|
||||
TextItemView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@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,170 @@
|
||||
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.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
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.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
@Composable
|
||||
fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Text(
|
||||
"Thank you for installing SimpleX Chat!",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append("You can ")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
|
||||
append("connect to SimpleX team")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(".")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { uriHandler.openUri(simplexTeamUri) })
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"To start a new chat",
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
style = MaterialTheme.typography.h2
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Tap button",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
"Add Contact",
|
||||
modifier = if (doAddContact) Modifier.clickable(onClick = addContact) else Modifier,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
"above, then:",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
|
||||
append("Add new contact")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(": to create your one-time QR Code for your contact.")
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
|
||||
append("Scan QR code")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(": to connect to your contact who shows QR code to you.")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"To connect via link",
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
style = MaterialTheme.typography.h2
|
||||
)
|
||||
Text(
|
||||
"If you received SimpleX Chat invitation link you can open it in your browser:",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
|
||||
}
|
||||
withStyle(
|
||||
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
|
||||
) {
|
||||
append("Scan QR code")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append("\uD83D\uDCF1 mobile: tap ")
|
||||
}
|
||||
withStyle(
|
||||
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
|
||||
) {
|
||||
append("Open in mobile app")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(", then tap ")
|
||||
}
|
||||
withStyle(
|
||||
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
|
||||
) {
|
||||
append("Connect")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(" in the app.")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatHelpLayout() {
|
||||
SimpleXTheme {
|
||||
ChatHelpView({}, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
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.helpers.withApi
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel, nav: NavController) {
|
||||
ChatListNavLink(
|
||||
chat = chat,
|
||||
action = {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> chatNavLink(chat, chatModel, nav)
|
||||
is ChatInfo.Group -> chatNavLink(chat, chatModel, nav)
|
||||
is ChatInfo.ContactRequest -> contactRequestNavLink(chat.chatInfo, chatModel, nav)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
fun chatNavLink(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
|
||||
withApi {
|
||||
val chatInfo = chatPreview.chatInfo
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null) {
|
||||
chatModel.chatId.value = chatInfo.id
|
||||
chatModel.chatItems = chat.chatItems.toMutableStateList()
|
||||
navController.navigate(Pages.Chat.route)
|
||||
} else {
|
||||
// TODO show error? or will apiGetChat show it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
fun contactRequestNavLink(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, navController: NavController) {
|
||||
chatModel.alertManager.showAlertDialog(
|
||||
title = "Accept connection request?",
|
||||
text = "If you choose to reject sender will NOT be notified",
|
||||
confirmText = "Accept",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||
if (contact != null) {
|
||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissText = "Reject",
|
||||
onDismiss = {
|
||||
withApi {
|
||||
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
||||
chatModel.removeChat(contactRequest.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun ChatListNavLink(chat: Chat, action: () -> Unit) {
|
||||
ChatListNavLinkLayout(
|
||||
content = {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> ChatPreviewView(chat)
|
||||
is ChatInfo.Group -> ChatPreviewView(chat)
|
||||
is ChatInfo.ContactRequest -> ContactRequestView(chat)
|
||||
}
|
||||
},
|
||||
action = action
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = action)
|
||||
.height(88.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
// TODO?
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
content.invoke()
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkDirect() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLink(
|
||||
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()
|
||||
),
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkGroup() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLink(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Group.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()
|
||||
),
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLink(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.ContactRequest.sampleData,
|
||||
chatItems = listOf(),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.chat.ChatHelpView
|
||||
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 scope: CoroutineScope) {
|
||||
lateinit var state: BottomSheetScaffoldState
|
||||
val expanded = mutableStateOf(false)
|
||||
|
||||
fun expand() {
|
||||
expanded.value = true
|
||||
scope.launch { state.bottomSheetState.expand() }
|
||||
}
|
||||
|
||||
fun collapse() {
|
||||
expanded.value = false
|
||||
scope.launch { state.bottomSheetState.collapse() }
|
||||
}
|
||||
|
||||
fun toggleSheet() {
|
||||
if (state.bottomSheetState.isExpanded ?: false) collapse() else expand()
|
||||
}
|
||||
|
||||
fun toggleDrawer() = scope.launch {
|
||||
state.drawerState.apply { if (isClosed) open() else close() }
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun scaffoldController(): ScaffoldController {
|
||||
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
|
||||
val bottomSheetState = rememberBottomSheetState(
|
||||
BottomSheetValue.Collapsed,
|
||||
confirmStateChange = {
|
||||
ctrl.expanded.value = it == BottomSheetValue.Expanded
|
||||
true
|
||||
}
|
||||
)
|
||||
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@DelicateCoroutinesApi
|
||||
@ExperimentalPermissionsApi
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, nav: NavController) {
|
||||
val scaffoldCtrl = scaffoldController()
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel, nav) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl, nav) },
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ChatListToolbar(scaffoldCtrl)
|
||||
when (chatModel.chatsLoaded.value) {
|
||||
true -> if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel, nav)
|
||||
} else {
|
||||
val user = chatModel.currentUser.value
|
||||
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
|
||||
}
|
||||
else -> ChatList(chatModel, nav)
|
||||
}
|
||||
}
|
||||
if (scaffoldCtrl.expanded.value) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { scaffoldCtrl.collapse() },
|
||||
color = Color.Black.copy(alpha = 0.12F)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
ChatHelpView({ scaffoldCtrl.toggleSheet() }, true)
|
||||
Row(
|
||||
Modifier.padding(top = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"This text is available in settings",
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
"Settings",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.height(60.dp)
|
||||
) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
|
||||
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 = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
"Add Contact",
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@DelicateCoroutinesApi
|
||||
@Composable
|
||||
fun ChatList(chatModel: ChatModel, navController: NavController) {
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chatModel.chats) { chat ->
|
||||
ChatListNavLinkView(chat, chatModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
//@Preview
|
||||
//@Composable
|
||||
//fun PreviewChatListView() {
|
||||
// SimpleXTheme {
|
||||
// ChatListView(
|
||||
// chats = listOf(
|
||||
// Chat()
|
||||
// ),
|
||||
//
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,93 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
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.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.Chat
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
import chat.simplex.app.views.helpers.badgeLayout
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat) {
|
||||
Row {
|
||||
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) {
|
||||
MarkdownText(
|
||||
chat.chatItems.last(),
|
||||
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)
|
||||
)
|
||||
val n = chat.chatStats.unreadCount
|
||||
if (n > 0) {
|
||||
Text(
|
||||
if (n < 1000) "$n" else "${n / 1000}k",
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.align(Alignment.End)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.Chat
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
fun ContactRequestView(chat: Chat) {
|
||||
Row {
|
||||
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,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(
|
||||
"wants to connect to you!",
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
val ts = getTimestampText(chat.chatInfo.createdAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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,99 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
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
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
|
||||
@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
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CloseSheetBar(close)
|
||||
Text(
|
||||
"Add contact",
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"Show QR code to your contact\nto scan from the app",
|
||||
style = MaterialTheme.typography.h2,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
QRCode(connReq)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append("If you cannot meet in person, you can ")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
|
||||
append("scan QR code in the video call")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
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(bottom = 16.dp)
|
||||
)
|
||||
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@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,139 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
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
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CloseSheetBar(close)
|
||||
Text(
|
||||
"Scan QR code",
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"Your chat profile will be sent\nto your contact",
|
||||
style = MaterialTheme.typography.h2,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) { qrCodeScanner() }
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append("If you cannot meet in person, you can ")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
|
||||
append("scan QR code in the video call")
|
||||
}
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
|
||||
append(", or you can create the invitation link.")
|
||||
}
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@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.HighOrLowlight
|
||||
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) HighOrLowlight 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,64 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
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 androidx.navigation.NavController
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ChatHelpView
|
||||
import chat.simplex.app.views.helpers.CloseSheetBar
|
||||
|
||||
@Composable
|
||||
fun HelpView(chatModel: ChatModel, nav: NavController) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
HelpLayout(
|
||||
displayName = user.profile.displayName,
|
||||
back = nav::popBackStack
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HelpLayout(displayName: String, back: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
CloseSheetBar(back)
|
||||
Text(
|
||||
"Welcome $displayName!",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
ChatHelpView({}, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewHelpView() {
|
||||
SimpleXTheme {
|
||||
HelpLayout(
|
||||
displayName = "Alice",
|
||||
back = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.CloseSheetBar
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView(nav: NavController) {
|
||||
MarkdownHelpLayout(nav::popBackStack)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpLayout(back: () -> Unit) {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
CloseSheetBar(back)
|
||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(
|
||||
"How to use markdown",
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
"You can use markdown to format messages:",
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
MdFormat("*bold*", "bold text", Format.Bold())
|
||||
MdFormat("_italic_", "italic text", Format.Italic())
|
||||
MdFormat("~strike~", "strikethrough text", Format.StrikeThrough())
|
||||
MdFormat("`code`", "a = b + c", Format.Snippet())
|
||||
Row {
|
||||
MdSyntax("!1 colored!")
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Colored(FormatColor.red).style) { append("red text") }
|
||||
append(" (")
|
||||
appendColor(this, "1", FormatColor.red, ", ")
|
||||
appendColor(this, "2", FormatColor.green, ", ")
|
||||
appendColor(this, "3", FormatColor.blue, ", ")
|
||||
appendColor(this, "4", FormatColor.yellow, ", ")
|
||||
appendColor(this, "5", FormatColor.cyan, ", ")
|
||||
appendColor(this, "6", FormatColor.magenta, ")")
|
||||
})
|
||||
}
|
||||
Row {
|
||||
MdSyntax("#secret")
|
||||
SelectionContainer {
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Secret().style) { append("secret text") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdSyntax(markdown: String) {
|
||||
Text(markdown, Modifier
|
||||
.width(100.dp)
|
||||
.padding(bottom = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdFormat(markdown: String, example: String, format: Format) {
|
||||
Row {
|
||||
MdSyntax(markdown)
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(format.style) { append(example) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
|
||||
b.withStyle(Format.Colored(c).style) { append(s)}
|
||||
b.append(after)
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkdownHelpView() {
|
||||
SimpleXTheme {
|
||||
MarkdownHelpLayout(back = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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.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.*
|
||||
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.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
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(8.dp)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Your Settings",
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView({ navigate(Pages.UserProfile.route) }, 60.dp) {
|
||||
Icon(
|
||||
Icons.Outlined.AccountCircle,
|
||||
contentDescription = "Avatar Placeholder",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profile.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(profile.fullName)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ navigate(Pages.UserAddress.route) }) {
|
||||
Icon(
|
||||
Icons.Outlined.QrCode,
|
||||
contentDescription = "Address",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Your SimpleX contact address")
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SettingsSectionView({ navigate(Pages.Help.route) }) {
|
||||
Icon(
|
||||
Icons.Outlined.HelpOutline,
|
||||
contentDescription = "Chat help",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("How to use SimpleX Chat")
|
||||
}
|
||||
SettingsSectionView({ navigate(Pages.Markdown.route) }) {
|
||||
Icon(
|
||||
Icons.Outlined.TextFormat,
|
||||
contentDescription = "Markdown help",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Markdown in messages")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
|
||||
Icon(
|
||||
Icons.Outlined.Tag,
|
||||
contentDescription = "SimpleX Team",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
"Get help & advice via chat",
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Email,
|
||||
contentDescription = "Email",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
"Ask questions via email",
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SettingsSectionView({ navigate(Pages.Terminal.route) }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = "Chat console",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Chat console")
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append("Install ")
|
||||
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
|
||||
append("SimpleX Chat for terminal")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionView(func: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = func)
|
||||
.height(height),
|
||||
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,147 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
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.*
|
||||
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.platform.LocalContext
|
||||
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.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun UserAddressView(chatModel: ChatModel, nav: NavController) {
|
||||
val cxt = LocalContext.current
|
||||
UserAddressLayout(
|
||||
userAddress = chatModel.userAddress.value,
|
||||
back = { nav.popBackStack() },
|
||||
createAddress = {
|
||||
withApi {
|
||||
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
|
||||
}
|
||||
},
|
||||
share = { userAddress: String -> shareText(cxt, userAddress) },
|
||||
deleteAddress = {
|
||||
chatModel.alertManager.showAlertMsg(
|
||||
title = "Delete address?",
|
||||
text = "All your contacts will remain connected",
|
||||
confirmText = "Delete",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiDeleteUserAddress()
|
||||
chatModel.userAddress.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAddressLayout(
|
||||
userAddress: String?,
|
||||
back: () -> Unit,
|
||||
createAddress: () -> Unit,
|
||||
share: (String) -> Unit,
|
||||
deleteAddress: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
CloseSheetBar(back)
|
||||
Text(
|
||||
"Your chat address",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
|
||||
"and if you later delete it - you won't lose your contacts.",
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
if (userAddress == null) {
|
||||
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
|
||||
} else {
|
||||
QRCode(userAddress)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
"Share link",
|
||||
icon = Icons.Outlined.Share,
|
||||
click = { share(userAddress) })
|
||||
SimpleButton(
|
||||
"Delete address",
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutNoAddress() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = null,
|
||||
back = {},
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutAddressCreated() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
back = {},
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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>
|
||||
|
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,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@mipmap/icon_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@color/white"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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,10 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="icon_background">#FFFFFF</color>
|
||||
</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/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
</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).
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -65,3 +65,5 @@ fastlane/test_output
|
||||
iOSInjectionProject/
|
||||
|
||||
Libraries/
|
||||
|
||||
Shared/MyPlayground.playground/*
|
||||
|
||||
@@ -13,24 +13,26 @@ struct ContentView: View {
|
||||
@State private var showNotificationAlert = false
|
||||
|
||||
var body: some View {
|
||||
if let user = chatModel.currentUser {
|
||||
ChatListView(user: user)
|
||||
.onAppear {
|
||||
do {
|
||||
try apiStartChat()
|
||||
chatModel.chats = try apiGetChats()
|
||||
} catch {
|
||||
fatalError("Failed to start or load chats: \(error)")
|
||||
ZStack {
|
||||
if let user = chatModel.currentUser {
|
||||
ChatListView(user: user)
|
||||
.onAppear {
|
||||
do {
|
||||
try apiStartChat()
|
||||
chatModel.chats = try apiGetChats()
|
||||
} catch {
|
||||
fatalError("Failed to start or load chats: \(error)")
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
} else {
|
||||
WelcomeView()
|
||||
} else {
|
||||
WelcomeView()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
}
|
||||
|
||||
func notificationAlert() -> Alert {
|
||||
|
||||
@@ -97,7 +97,7 @@ final class ChatModel: ObservableObject {
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if self.chatId == cInfo.id {
|
||||
SimpleX.markChatItemRead(cInfo, cItem)
|
||||
Task { await SimpleX.markChatItemRead(cInfo, cItem) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,11 @@ struct Contact: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
struct ContactSubStatus: Decodable {
|
||||
var contact: Contact
|
||||
var contactError: ChatError?
|
||||
}
|
||||
|
||||
struct Connection: Decodable {
|
||||
var connStatus: String
|
||||
|
||||
@@ -503,6 +508,11 @@ struct GroupMember: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
struct MemberSubError: Decodable {
|
||||
var member: GroupMember
|
||||
var memberError: ChatError
|
||||
}
|
||||
|
||||
struct AChatItem: Decodable {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
@@ -512,6 +522,7 @@ struct ChatItem: Identifiable, Decodable {
|
||||
var chatDir: CIDirection
|
||||
var meta: CIMeta
|
||||
var content: CIContent
|
||||
var formattedText: [FormattedText]?
|
||||
|
||||
var id: Int64 { get { meta.itemId } }
|
||||
|
||||
@@ -571,7 +582,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 {
|
||||
@@ -653,3 +668,46 @@ extension MsgContent: Decodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FormattedText: Decodable {
|
||||
var text: String
|
||||
var format: Format?
|
||||
}
|
||||
|
||||
enum Format: Decodable {
|
||||
case bold
|
||||
case italic
|
||||
case strikeThrough
|
||||
case snippet
|
||||
case secret
|
||||
case colored(color: FormatColor)
|
||||
case uri
|
||||
case email
|
||||
case phone
|
||||
}
|
||||
|
||||
enum FormatColor: String, Decodable {
|
||||
case red = "red"
|
||||
case green = "green"
|
||||
case blue = "blue"
|
||||
case yellow = "yellow"
|
||||
case cyan = "cyan"
|
||||
case magenta = "magenta"
|
||||
case black = "black"
|
||||
case white = "white"
|
||||
|
||||
var uiColor: Color {
|
||||
get {
|
||||
switch (self) {
|
||||
case .red: return .red
|
||||
case .green: return .green
|
||||
case .blue: return .blue
|
||||
case .yellow: return .yellow
|
||||
case .cyan: return .cyan
|
||||
case .magenta: return .purple
|
||||
case .black: return .primary
|
||||
case .white: return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
|
||||
let chatId = content.userInfo["chatId"] as? String,
|
||||
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
acceptContactRequest(contactRequest)
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
}
|
||||
|
||||
@@ -114,7 +114,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case contactSubscribed(contact: Contact)
|
||||
case contactDisconnected(contact: Contact)
|
||||
case contactSubError(contact: Contact, chatError: ChatError)
|
||||
case contactSubSummary(contactSubscriptions: [ContactSubStatus])
|
||||
case groupSubscribed(groupInfo: GroupInfo)
|
||||
case memberSubErrors(memberSubErrors: [MemberSubError])
|
||||
case groupEmpty(groupInfo: GroupInfo)
|
||||
case userContactLinkSubscribed
|
||||
case newChatItem(chatItem: AChatItem)
|
||||
@@ -148,7 +150,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case .contactSubscribed: return "contactSubscribed"
|
||||
case .contactDisconnected: return "contactDisconnected"
|
||||
case .contactSubError: return "contactSubError"
|
||||
case .contactSubSummary: return "contactSubSummary"
|
||||
case .groupSubscribed: return "groupSubscribed"
|
||||
case .memberSubErrors: return "memberSubErrors"
|
||||
case .groupEmpty: return "groupEmpty"
|
||||
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
|
||||
case .newChatItem: return "newChatItem"
|
||||
@@ -185,7 +189,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case let .contactSubscribed(contact): return String(describing: contact)
|
||||
case let .contactDisconnected(contact): return String(describing: contact)
|
||||
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
|
||||
case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions)
|
||||
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
|
||||
case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors)
|
||||
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
|
||||
case .userContactLinkSubscribed: return noDetails
|
||||
case let .newChatItem(chatItem): return String(describing: chatItem)
|
||||
@@ -232,11 +238,14 @@ enum TerminalItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
|
||||
func chatSendCmdSync(_ cmd: ChatCommand) -> ChatResponse {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
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))
|
||||
@@ -244,13 +253,22 @@ func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
func chatRecvMsg() throws -> ChatResponse {
|
||||
chatResponse(chat_recv_msg(getChatCtrl())!)
|
||||
func chatSendCmd(_ cmd: ChatCommand) async -> ChatResponse {
|
||||
await withCheckedContinuation { cont in
|
||||
cont.resume(returning: chatSendCmdSync(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func chatRecvMsg() async -> ChatResponse {
|
||||
await withCheckedContinuation { cont in
|
||||
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
|
||||
cont.resume(returning: resp)
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetActiveUser() throws -> User? {
|
||||
let _ = getChatCtrl()
|
||||
let r = try chatSendCmd(.showActiveUser)
|
||||
let r = chatSendCmdSync(.showActiveUser)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
case .chatCmdError(.error(.noActiveUser)): return nil
|
||||
@@ -259,43 +277,43 @@ func apiGetActiveUser() throws -> User? {
|
||||
}
|
||||
|
||||
func apiCreateActiveUser(_ p: Profile) throws -> User {
|
||||
let r = try chatSendCmd(.createActiveUser(profile: p))
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiStartChat() throws {
|
||||
let r = try chatSendCmd(.startChat)
|
||||
let r = chatSendCmdSync(.startChat)
|
||||
if case .chatStarted = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChats() throws -> [Chat] {
|
||||
let r = try chatSendCmd(.apiGetChats)
|
||||
let r = chatSendCmdSync(.apiGetChats)
|
||||
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
|
||||
let r = try chatSendCmd(.apiGetChat(type: type, id: id))
|
||||
func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id))
|
||||
if case let .apiChat(chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem {
|
||||
let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
|
||||
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
|
||||
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiAddContact() throws -> String {
|
||||
let r = try chatSendCmd(.addContact)
|
||||
func apiAddContact() async throws -> String {
|
||||
let r = await chatSendCmd(.addContact)
|
||||
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnect(connReq: String) throws {
|
||||
let r = try chatSendCmd(.connect(connReq: connReq))
|
||||
func apiConnect(connReq: String) async throws {
|
||||
let r = await chatSendCmd(.connect(connReq: connReq))
|
||||
switch r {
|
||||
case .sentConfirmation: return
|
||||
case .sentInvitation: return
|
||||
@@ -303,14 +321,14 @@ func apiConnect(connReq: String) throws {
|
||||
}
|
||||
}
|
||||
|
||||
func apiDeleteChat(type: ChatType, id: Int64) throws {
|
||||
let r = try chatSendCmd(.apiDeleteChat(type: type, id: id))
|
||||
func apiDeleteChat(type: ChatType, id: Int64) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id))
|
||||
if case .contactDeleted = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiUpdateProfile(profile: Profile) throws -> Profile? {
|
||||
let r = try chatSendCmd(.updateProfile(profile: profile))
|
||||
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
let r = await chatSendCmd(.updateProfile(profile: profile))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(_, toProfile): return toProfile
|
||||
@@ -318,20 +336,20 @@ func apiUpdateProfile(profile: Profile) throws -> Profile? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateUserAddress() throws -> String {
|
||||
let r = try chatSendCmd(.createMyAddress)
|
||||
func apiCreateUserAddress() async throws -> String {
|
||||
let r = await chatSendCmd(.createMyAddress)
|
||||
if case let .userContactLinkCreated(connReq) = r { return connReq }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUserAddress() throws {
|
||||
let r = try chatSendCmd(.deleteMyAddress)
|
||||
func apiDeleteUserAddress() async throws {
|
||||
let r = await chatSendCmd(.deleteMyAddress)
|
||||
if case .userContactLinkDeleted = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetUserAddress() throws -> String? {
|
||||
let r = try chatSendCmd(.showMyAddress)
|
||||
func apiGetUserAddress() async throws -> String? {
|
||||
let r = await chatSendCmd(.showMyAddress)
|
||||
switch r {
|
||||
case let .userContactLink(connReq):
|
||||
return connReq
|
||||
@@ -341,59 +359,59 @@ func apiGetUserAddress() throws -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact {
|
||||
let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
|
||||
func apiAcceptContactRequest(contactReqId: Int64) async throws -> Contact {
|
||||
let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
|
||||
if case let .acceptingContactRequest(contact) = r { return contact }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiRejectContactRequest(contactReqId: Int64) throws {
|
||||
let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
|
||||
func apiRejectContactRequest(contactReqId: Int64) async throws {
|
||||
let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
|
||||
if case .contactRequestRejected = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
|
||||
let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
|
||||
let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) async {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let contact = try await apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
||||
} catch let error {
|
||||
logger.error("acceptContactRequest error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
func rejectContactRequest(_ contactRequest: UserContactRequest) async {
|
||||
do {
|
||||
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
ChatModel.shared.removeChat(contactRequest.id)
|
||||
try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
|
||||
} catch let error {
|
||||
logger.error("rejectContactRequest: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat) {
|
||||
func markChatRead(_ chat: Chat) async {
|
||||
do {
|
||||
let minItemId = chat.chatStats.minUnreadItemId
|
||||
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
ChatModel.shared.markChatItemsRead(cInfo)
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
|
||||
} catch {
|
||||
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
} catch {
|
||||
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -408,7 +426,7 @@ func initializeChat() {
|
||||
}
|
||||
|
||||
class ChatReceiver {
|
||||
private var receiveLoop: DispatchWorkItem?
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
private var _lastMsgTime = Date.now
|
||||
|
||||
@@ -421,18 +439,16 @@ class ChatReceiver {
|
||||
receiveMessages = true
|
||||
_lastMsgTime = .now
|
||||
if receiveLoop != nil { return }
|
||||
let loop = DispatchWorkItem(qos: .default, flags: []) {
|
||||
while self.receiveMessages {
|
||||
do {
|
||||
processReceivedMsg(try chatRecvMsg())
|
||||
self._lastMsgTime = .now
|
||||
} catch {
|
||||
logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
receiveLoop = Task { await receiveMsgLoop() }
|
||||
}
|
||||
|
||||
func receiveMsgLoop() async {
|
||||
let msg = await chatRecvMsg()
|
||||
self._lastMsgTime = .now
|
||||
processReceivedMsg(msg)
|
||||
if self.receiveMessages {
|
||||
await receiveMsgLoop()
|
||||
}
|
||||
receiveLoop = loop
|
||||
DispatchQueue.global().async(execute: loop)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -465,20 +481,20 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
}
|
||||
case let .contactSubscribed(contact):
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .connected)
|
||||
processContactSubscribed(contact)
|
||||
case let .contactDisconnected(contact):
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .disconnected)
|
||||
case let .contactSubError(contact, chatError):
|
||||
chatModel.updateContact(contact)
|
||||
var err: String
|
||||
switch chatError {
|
||||
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
|
||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||
default: err = String(describing: chatError)
|
||||
processContactSubError(contact, chatError)
|
||||
case let .contactSubSummary(contactSubscriptions):
|
||||
for sub in contactSubscriptions {
|
||||
if let err = sub.contactError {
|
||||
processContactSubError(sub.contact, err)
|
||||
} else {
|
||||
processContactSubscribed(sub.contact)
|
||||
}
|
||||
}
|
||||
chatModel.updateNetworkStatus(contact, .error(err))
|
||||
case let .newChatItem(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
@@ -496,12 +512,30 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
func processContactSubscribed(_ contact: Contact) {
|
||||
let m = ChatModel.shared
|
||||
m.updateContact(contact)
|
||||
m.updateNetworkStatus(contact, .connected)
|
||||
}
|
||||
|
||||
func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
|
||||
let m = ChatModel.shared
|
||||
m.updateContact(contact)
|
||||
var err: String
|
||||
switch chatError {
|
||||
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
|
||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||
default: err = String(describing: chatError)
|
||||
}
|
||||
m.updateNetworkStatus(contact, .error(err))
|
||||
}
|
||||
|
||||
private struct UserResponse: Decodable {
|
||||
var user: User?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
|
||||
private func chatResponse(_ cjson: UnsafeMutablePointer<CChar>) -> ChatResponse {
|
||||
let s = String.init(cString: cjson)
|
||||
let d = s.data(using: .utf8)!
|
||||
// TODO is there a way to do it without copying the data? e.g:
|
||||
@@ -525,6 +559,7 @@ private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
|
||||
}
|
||||
json = prettyJSON(j)
|
||||
}
|
||||
free(cjson)
|
||||
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
|
||||
}
|
||||
|
||||
@@ -682,10 +717,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 {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
var greeting = "Hello, playground"
|
||||
|
||||
let jsonEncoder = JSONEncoder()
|
||||
|
||||
//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!)
|
||||
|
||||
|
||||
var a = [1, 2, 3]
|
||||
|
||||
a.removeAll(where: { $0 == 1} )
|
||||
|
||||
print(a)
|
||||
|
||||
let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected."
|
||||
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count))
|
||||
|
||||
print(matches)
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: input) else { continue }
|
||||
let url = input[range]
|
||||
print(url)
|
||||
}
|
||||
|
||||
let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
|
||||
|
||||
print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
|
||||
|
||||
let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
|
||||
<timeline fileName='timeline.xctimeline'/>
|
||||
</playground>
|
||||