Compare commits

..

78 Commits

Author SHA1 Message Date
Evgeny Poberezkin
bd3d4467c7 3.1.0 2022-08-06 16:30:39 +01:00
Evgeny Poberezkin
5345199829 update readme image 2022-08-05 22:32:24 +01:00
Evgeny Poberezkin
481c4c0763 ios: version 3.1 (68) 2022-08-05 13:27:14 +01:00
Evgeny Poberezkin
bf2b3855b7 android: update version 3.1 (48) 2022-08-05 08:15:40 +01:00
sh
a254d5f050 build-android: specify commit (#904) 2022-08-05 08:14:32 +01:00
Evgeny Poberezkin
e8749debec ios: fix notification badge count (#903) 2022-08-04 22:25:52 +01:00
Evgeny Poberezkin
afbc7dd2c1 update f-droid description 2022-08-04 21:07:45 +01:00
Evgeny Poberezkin
7a00a3e324 core: remove logs, remove log for A_DUPLICATE error (#896) 2022-08-04 20:59:05 +01:00
Evgeny Poberezkin
03d9d86aba android: fix crash on invalid base64 image, show placeholder image instead (#902) 2022-08-04 20:32:01 +01:00
Evgeny Poberezkin
13e7925348 core: fully remove invited member (#901)
* core: fully remove invited member

* deleteMemberConnection
2022-08-04 18:39:31 +01:00
Evgeny Poberezkin
46319044f8 core: fix race condition in --execute option, closes #890 (#898) 2022-08-04 17:07:50 +01:00
JRoberts
8a7e320d12 ios: version 3.1 (67) 2022-08-04 19:54:30 +04:00
Evgeny Poberezkin
152ed96ac0 android: static vars for NetCfg (#900) 2022-08-04 16:23:59 +01:00
JRoberts
8dc7bea724 ios: advanced network settings translations (#899) 2022-08-04 19:20:00 +04:00
JRoberts
497cf86eb0 android: advanced network settings (#895) 2022-08-04 18:40:36 +04:00
Stanislav Dmitrenko
9508ea5c97 App icon chooser (#894)
* App icon chooser
- ability to choose an icon from a predefined list

* dark icons

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-04 14:16:35 +01:00
Evgeny Poberezkin
257133db3b ios: remove modal sheets before authentication (#897)
* ios: remove modal sheets before authentication

* line break

* add reference to source
2022-08-04 12:41:05 +01:00
Evgeny Poberezkin
c4bc88b49b Merge branch 'stable' 2022-08-04 12:05:57 +01:00
sh
80389ffe93 android: check nix hash (#893) 2022-08-04 11:20:58 +01:00
sh
e53540f43f android: remove cmake version pin from gradle (#889) 2022-08-04 11:20:37 +01:00
Evgeny Poberezkin
55adbb4692 core: clear group content on deletion, break transaction to prevent error on Android, more logs (#892)
* core: log group deletion

* clear group content, break transaction, add logs
2022-08-04 11:12:50 +01:00
Evgeny Poberezkin
91baf9f362 terminal: update active group when message is updated (#891)
* terminal: update active group when message is updated

* fix
2022-08-04 11:12:37 +01:00
sh
04b9243d7e android: change nix config logic (#888) 2022-08-04 09:36:36 +01:00
sh
b3d74933c2 build-android: fix git compatibility (#884)
* build-android: fix git compatibility

* move to scripts
2022-08-03 21:37:31 +01:00
sh
90ab6f34bf android: add fastlane metadata (#885)
* android: add fastlane metadata

* update fastlane info

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-03 21:04:27 +01:00
Stanislav Dmitrenko
57e7034b4d Update to Compose 1.2.0 beta2 (#874)
- fixes issue with multiple backspaces in a BasicTextField. Before that update text field stops deleting characters after long press on the backspace key
2022-08-03 18:46:38 +01:00
Stanislav Dmitrenko
8455cca9c3 Button in notification that routes to settings for that specific notification channel. Android O+ (#875) 2022-08-03 18:10:36 +01:00
Evgeny Poberezkin
9fdc2a4631 ios: remove option to not show pending contact connections (#883) 2022-08-03 18:02:59 +01:00
Evgeny Poberezkin
a5cdbc90f8 ios: alternative app icon (#881) 2022-08-03 17:46:05 +01:00
Evgeny Poberezkin
a5972c7de1 ios: register group defaults to correctly read network settings in NSE (#882) 2022-08-03 17:39:01 +01:00
Evgeny Poberezkin
c74a4fcbca update logos on SimpleX info page for dark mode (#880) 2022-08-03 15:17:42 +01:00
Stanislav Dmitrenko
4c6ee95eb7 Removed gesture interception while long clicking on a chat bubble (#871)
* Removed gesture interception while long clicking on a chat bubble with a link
- allowed to skip motion event consuming based on touch offset
- long clicking on a link copies it to a clipboard

* Long click on a link shows menu instead of copying to clipboard

* EOLs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-03 14:33:19 +01:00
Evgeny Poberezkin
9e210256d2 core: add delete group logs (#879) 2022-08-03 16:56:35 +04:00
sh
d67f86ada5 install: add android build script (#877) 2022-08-03 13:52:16 +01:00
JRoberts
7a03f87822 mobile: update logo (#876)
* ios: logo

* logo

* bigger logo
2022-08-03 13:30:29 +01:00
JRoberts
d6a4a245dc update simplexmq (reconnect on network config change) (#878) 2022-08-03 15:49:31 +04:00
Evgeny Poberezkin
0fe7e64989 ios: advanced network settings (#873)
* ios: advanced network settings

* save network config

* update network settins, set in NSE

* update UI, update simplexmq

* show advanced network settings only with dev tools on
2022-08-03 15:36:51 +04:00
Stanislav Dmitrenko
e39f9bc251 QRCodeScanner will close camera on back press (#872) 2022-08-03 08:47:51 +01:00
JRoberts
cbd7882ff4 ios: group ui translations; android: empty lists ui fixes (#870) 2022-08-03 11:40:36 +04:00
Evgeny Poberezkin
4ad1abcbfa core: support passing all network configuration to the agent (#868)
* core: support passing all network configuration to the agent

* update simplexmq
2022-08-02 15:36:12 +01:00
JRoberts
a36c367b81 mobile: filter out members in statuses left and removed (#869) 2022-08-02 18:07:40 +04:00
JRoberts
a14859d8c0 mobile: developer tools (#867) 2022-08-02 17:00:12 +04:00
JRoberts
9e23150938 ios: fix Servers section flickering on info view; android: button text (#866) 2022-08-02 14:48:31 +04:00
JRoberts
35eeac194e core: split group deletion into two transactions to prevent crashes on android (#865) 2022-08-02 14:10:03 +04:00
Evgeny Poberezkin
0b4a6cf9eb readme: add monero wallet for donations (#863) 2022-08-01 21:12:06 +01:00
JRoberts
2422f36d61 android: version 3.1 (47) 2022-08-01 20:54:22 +04:00
JRoberts
60117d0853 ios: version 3.1 (66) 2022-08-01 18:22:37 +04:00
JRoberts
95757ed562 android: edit group profile (#862) 2022-08-01 16:32:42 +04:00
Evgeny Poberezkin
cc0a74fae4 mobile: show errors when joining group (#861)
* mobile: show errors when joining group

* correct titles

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* improvements

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-01 08:34:07 +01:00
Evgeny Poberezkin
ce91dcde7f android: save SOCKS setting to preference and enable on start (#848)
* android: save SOCKS setting to preference and enable on start

* use socks proxy preference
2022-07-31 20:46:09 +01:00
Evgeny Poberezkin
999923bcf9 core: allow creating groups with the same display name; mobile: update group status when group deleted by another member or user removed (#859) 2022-07-31 18:54:49 +01:00
JRoberts
30c345933b android: create group view (#855)
* android: create group view wip

* wip

* android: add group view image wip (#856)

* new chat sheet layout

* alternative layout for new chat sheet

* simpler layout for new chat sheet

* fix add image sheet

* fix creating group

* add members when creating a group

* update text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-31 16:49:32 +01:00
Evgeny Poberezkin
1b8c55a0a3 ios: add group members when group is created (#857)
* ios: add group members when group is created

* refactor

* more refactor
2022-07-30 18:46:10 +01:00
JRoberts
4f4935256c ios: move GroupChatInfoView (#854) 2022-07-30 16:59:06 +04:00
JRoberts
1dd7520bbd mobile: refine allowed group actions; inactive group indicator (#852) 2022-07-30 16:49:34 +04:00
Evgeny Poberezkin
de0f231c60 ios: edit group profile (#853) 2022-07-30 16:03:44 +04:00
Evgeny Poberezkin
0c58adff08 core: editing group profiles (no conflict resolution) (#851)
* core: editing group profiles with conflict resolution

* update group profiles

* fix group update

* add test, add group profile to chat items, update terminal output

* Update apps/android/.idea/gradle.xml
2022-07-29 19:04:32 +01:00
JRoberts
e87c78e997 android: groups ui (#850) 2022-07-29 20:11:00 +04:00
Evgeny Poberezkin
ee6f6462cf ios: create group with profile image (#849)
* ios: create group with profile image

* update libs
2022-07-28 14:49:36 +04:00
Evgeny Poberezkin
7b9164f95a core: allow getting and setting network config when chat is not started (#847) 2022-07-28 11:12:23 +01:00
Evgeny Poberezkin
4a931bc145 ios: only show notification on received messages, do not remove non-current group members from contacts that can be added to the group (#846) 2022-07-28 10:11:16 +01:00
Evgeny Poberezkin
bf4072b365 trigger build 2022-07-28 08:39:19 +01:00
Evgeny Poberezkin
658cc1af56 update readme 2022-07-27 15:07:46 +01:00
Evgeny Poberezkin
68bc572800 trigger build 2022-07-27 14:40:40 +01:00
Evgeny Poberezkin
2286752fe0 core: create group with JSON profile, including image (#845) 2022-07-27 12:15:09 +01:00
JRoberts
9864533dae ios: update chat info view (#844) 2022-07-27 13:40:26 +04:00
JRoberts
aa7e377bce ios: groups miscellaneous (#843)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-27 11:16:07 +04:00
JRoberts
a4aaf36774 ios: group & group member info views (#841)
* ios: group member wip

* wip

* wip

* wip

* wip

* refactor alerts

* .navigationBarHidden(true)

* await MainActor.run

* refactor

* fix

* update layout

* tex

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-26 12:33:10 +04:00
JRoberts
608030dcaf ios: add member ui wip (#834)
* ios: add member ui wip

* AddGroupMembersView

* clean up

* cleanup

* change new chat button

* update adding members

* add group name and image to adding members view

* adjust layout

* layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-07-26 10:55:58 +04:00
Evgeny Poberezkin
6069108bb9 android: UI to access servers via SOCKS proxy (#840)
* android: UI to access servers via SOCKS proxy

* UI to connect via socks

* add server hosts to contact info

* ios: types for network/info commands
2022-07-26 07:29:48 +01:00
Evgeny Poberezkin
e7f3dc3f41 terminal: help for /i and /net commands (#842)
* terminal: help for /i and /net commands

* fix servers output

* update message

* EOL
2022-07-26 07:29:28 +01:00
Evgeny Poberezkin
f150932e44 core: commands to get/set network configuration (#839) 2022-07-25 17:04:27 +04:00
Evgeny Poberezkin
7dcde32680 update readme 2022-07-24 08:34:15 +01:00
Evgeny Poberezkin
552397d938 fix install.sh script 2022-07-23 22:42:07 +01:00
Evgeny Poberezkin
cfa4b44d1f update install.sh 2022-07-23 22:14:05 +01:00
Evgeny Poberezkin
9fcd127c48 update readme link 2022-07-23 21:15:51 +01:00
Evgeny Poberezkin
7c01ad7d4f blog: v3.1-beta release (#838)
* blog: v3.1-beta release

* corrections

* add images

* update post

* update TOC, readme
2022-07-23 21:13:41 +01:00
Evgeny Poberezkin
13b236f754 allow passing version to install.sh (#837)
* allow passing version to install.sh

* add echo
2022-07-23 17:02:05 +01:00
243 changed files with 7361 additions and 1090 deletions

View File

@@ -81,6 +81,8 @@ You can use SimpleX with your own servers and still communicate with people usin
Selected updates:
[Jul 23, 2022. v3.1-beta: access via Tor SOCKS5 proxy in terminal app, join and leave chat groups in mobile apps, up to 90x reduced battery and traffic usage, docker configurations for self-hosted servers](./blog/20220723-simplex-chat-v3.1-tor-groups-efficiency.md)
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
@@ -105,6 +107,12 @@ The channel through which you share the link does not have to be secure - it is
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash
```
or to install v3.1.0-beta.0 that supports accessing SimpleX servers via SOCKS5 proxy:
```
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash -s -- v3.1.0-beta.0
```
Once the chat client is installed, simply run `simplex-chat` from your terminal.
![simplex-chat](./images/connection.gif)
@@ -143,7 +151,8 @@ We plan to add soon:
1. Access to messaging servers via Tor. Currently it is supported only for [terminal CLI clients](./docs/CLI.md); mobile clients access servers via public Internet, and the servers can observe IP addresses of the clients. Depending which platform you use, you might be able to configure access via Tor independently. The servers provided by SimpleX Chat do not correlate users by or log IP addresses, and you can use your own servers, but in some scenarios that may be not a sufficient level of privacy.
2. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
3. Local database encryption. Currently the local chat database stored on your device is not encrypted.
4. Independent implementation audit.
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Independent implementation audit.
## For developers
@@ -170,9 +179,9 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
- ✅ Chat database export and import
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- 🏗 Connecting to messaging servers via Tor (in progress).
- 🏗 Chat groups in mobile apps (in progress).
- 🏗 Connecting to messaging servers via Tor (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- Chat database encryption.
- Disappearing messages, with mutual agreement.
- Web widgets for custom interactivity in the chats.
@@ -183,8 +192,8 @@ If you are considering developing with SimpleX platform please get in touch for
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
- Media server to optimize sending large files to groups.
- Channels server for large groups and broadcast channels.
- Media server to optimize sending large files to groups.
## Help us pay for 3rd party security audit
@@ -198,7 +207,11 @@ Our pledge to our users is that SimpleX protocols are and will remain open, and
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
It is possible to [donate via GitHub](https://github.com/sponsors/simplex-chat), which is commission-free for us, or [via OpenCollective](https://opencollective.com/simplex-chat), that also accepts donations in crypto-currencies, but charges a commission.
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- [Monero wallet](monero:8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt): 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
Thank you,

View File

@@ -11,7 +11,7 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 46
versionCode 48
versionName "3.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -52,7 +52,6 @@ android {
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.10.2'
}
}
buildFeatures {
@@ -80,6 +79,7 @@ dependencies {
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.compose.ui:ui-util:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
implementation 'androidx.webkit:webkit:1.4.0'

View File

@@ -38,7 +38,6 @@
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>
@@ -65,6 +64,35 @@
</intent-filter>
</activity>
<activity-alias
android:name=".MainActivity_default"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/icon"
android:enabled="true"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivity_dark_blue"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/icon_dark_blue"
android:enabled="false"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -90,6 +90,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
runAuthenticate()
}
}
else -> {}
}
}
}

View File

@@ -98,12 +98,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
Log.d(TAG, "starting receiver loop")
while (true) {
val line = input.readLine() ?: break
Log.w("$TAG (stdout/stderr)", line)
logbuffer.add(line)
}
Log.w(TAG, "exited receiver loop")
}
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.app
import android.app.*
import android.content.*
import android.os.*
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
@@ -117,7 +118,8 @@ class SimplexService: Service() {
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
@@ -125,7 +127,18 @@ class SimplexService: Service() {
.setContentIntent(pendingIntent)
.setSilent(true)
.setShowWhen(false) // no date/time
.build()
// Shows a button which opens notification channel settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
builder.addAction(0, getString(R.string.hide_notification), setup)
}
return builder.build()
}
override fun onBind(intent: Intent): IBinder? {

View File

@@ -26,8 +26,11 @@ class ChatModel(val controller: ChatController) {
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chats = mutableStateListOf<Chat>()
// current chat
val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>()
val groupMembers = mutableStateListOf<GroupMember>()
var connReqInvitation: String? = null
val terminalItems = mutableStateListOf<TerminalItem>()
@@ -315,7 +318,12 @@ data class Chat (
@Serializable
sealed class NetworkStatus {
val statusString: String get() = if (this is Connected) generalGetString(R.string.server_connected) else generalGetString(R.string.server_connecting)
val statusString: String get() =
when (this) {
is Connected -> generalGetString(R.string.server_connected)
is Error -> generalGetString(R.string.server_error)
else -> generalGetString(R.string.server_connecting)
}
val statusExplanation: String get() =
when (this) {
is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact)
@@ -479,7 +487,7 @@ class Profile(
override val fullName: String,
override val image: String? = null
): NamedChat {
val displayNameWithOptionalFullName: String
val profileViewName: String
get() {
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
@@ -516,12 +524,14 @@ class GroupInfo (
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
val canEdit: Boolean
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
val canDelete: Boolean
get() {
val s = membership.memberStatus
return membership.memberRole == GroupMemberRole.Owner
|| (s == GroupMemberStatus.MemRemoved || s == GroupMemberStatus.MemLeft || s == GroupMemberStatus.MemGroupDeleted || s == GroupMemberStatus.MemInvited)
}
get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent
val canAddMembers: Boolean
get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
companion object {
val sampleData = GroupInfo(
@@ -563,6 +573,14 @@ class GroupMember (
val memberContactId: Long? = null,
var activeConn: Connection? = null
) {
val id: String get() = "#$groupId @$groupMemberId"
val displayName: String get() = memberProfile.displayName
val fullName: String get() = memberProfile.fullName
val image: String? get() = memberProfile.image
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
val memberActive: Boolean get() = when (this.memberStatus) {
GroupMemberStatus.MemRemoved -> false
GroupMemberStatus.MemLeft -> false
@@ -577,6 +595,26 @@ class GroupMember (
GroupMemberStatus.MemCreator -> true
}
val memberCurrent: Boolean get() = when (this.memberStatus) {
GroupMemberStatus.MemRemoved -> false
GroupMemberStatus.MemLeft -> false
GroupMemberStatus.MemGroupDeleted -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemIntroduced -> true
GroupMemberStatus.MemIntroInvited -> true
GroupMemberStatus.MemAccepted -> true
GroupMemberStatus.MemAnnounced -> true
GroupMemberStatus.MemConnected -> true
GroupMemberStatus.MemComplete -> true
GroupMemberStatus.MemCreator -> true
}
fun canBeRemoved(membership: GroupMember): Boolean {
val userRole = membership.memberRole
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
}
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
@@ -595,10 +633,16 @@ class GroupMember (
}
@Serializable
enum class GroupMemberRole {
@SerialName("member") Member,
@SerialName("admin") Admin,
@SerialName("owner") Owner;
enum class GroupMemberRole(val memberRole: String) {
@SerialName("member") Member("member"), // order matters in comparisons
@SerialName("admin") Admin("admin"),
@SerialName("owner") Owner("owner");
val text: String get() = when (this) {
Member -> generalGetString(R.string.group_member_role_member)
Admin -> generalGetString(R.string.group_member_role_admin)
Owner -> generalGetString(R.string.group_member_role_owner)
}
}
@Serializable
@@ -623,6 +667,34 @@ enum class GroupMemberStatus {
@SerialName("connected") MemConnected,
@SerialName("complete") MemComplete,
@SerialName("creator") MemCreator;
val text: String get() = when (this) {
MemRemoved -> generalGetString(R.string.group_member_status_removed)
MemLeft -> generalGetString(R.string.group_member_status_left)
MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
MemInvited -> generalGetString(R.string.group_member_status_invited)
MemIntroduced -> generalGetString(R.string.group_member_status_introduced)
MemIntroInvited -> generalGetString(R.string.group_member_status_intro_invitation)
MemAccepted -> generalGetString(R.string.group_member_status_accepted)
MemAnnounced -> generalGetString(R.string.group_member_status_announced)
MemConnected -> generalGetString(R.string.group_member_status_connected)
MemComplete -> generalGetString(R.string.group_member_status_complete)
MemCreator -> generalGetString(R.string.group_member_status_creator)
}
val shortText: String get() = when (this) {
MemRemoved -> generalGetString(R.string.group_member_status_removed)
MemLeft -> generalGetString(R.string.group_member_status_left)
MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
MemInvited -> generalGetString(R.string.group_member_status_invited)
MemIntroduced -> generalGetString(R.string.group_member_status_connecting)
MemIntroInvited -> generalGetString(R.string.group_member_status_connecting)
MemAccepted -> generalGetString(R.string.group_member_status_connecting)
MemAnnounced -> generalGetString(R.string.group_member_status_connecting)
MemConnected -> generalGetString(R.string.group_member_status_connected)
MemComplete -> generalGetString(R.string.group_member_status_complete)
MemCreator -> generalGetString(R.string.group_member_status_creator)
}
}
@Serializable
@@ -1295,14 +1367,16 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
val text: String get() = when (this) {
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.displayNameWithOptionalFullName)
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.displayNameWithOptionalFullName)
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(R.string.rcv_group_event_updated_group_profile)
}
}
@@ -1310,9 +1384,11 @@ sealed class RcvGroupEvent() {
sealed class SndGroupEvent() {
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
val text: String get() = when (this) {
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.displayNameWithOptionalFullName)
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
}
}

View File

@@ -84,6 +84,15 @@ class AppPreferences(val context: Context) {
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout)
val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval)
val networkEnableKeepAlive = mkBoolPreference(SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE, NetCfg.defaults.enableKeepAlive)
val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle)
val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl)
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
private fun mkIntPreference(prefName: String, default: Int) =
Preference(
@@ -91,6 +100,20 @@ class AppPreferences(val context: Context) {
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
)
private fun mkLongPreference(prefName: String, default: Long) =
Preference(
get = fun() = sharedPreferences.getLong(prefName, default),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference<Long> {
val d = if (networkUseSocksProxy.get()) proxyDefault else default
return Preference(
get = fun() = sharedPreferences.getLong(prefName, d),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
}
private fun mkBoolPreference(prefName: String, default: Boolean) =
Preference(
get = fun() = sharedPreferences.getBoolean(prefName, default),
@@ -128,6 +151,15 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout"
private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval"
private const val SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE = "NetworkEnableKeepAlive"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
}
}
@@ -146,6 +178,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
Log.d(TAG, "user: $user")
try {
if (chatModel.chatRunning.value == true) return
apiSetNetworkConfig(getNetCfg())
val justStarted = apiStartChat()
if (justStarted) {
apiSetFilesFolder(getAppFilesDirectory(appContext))
@@ -332,6 +365,42 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun apiGetNetworkConfig(): NetCfg? {
val r = sendCmd(CC.APIGetNetworkConfig())
if (r is CR.NetworkConfig) return r.networkConfig
Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean {
val r = sendCmd(CC.APISetNetworkConfig(cfg))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(R.string.error_setting_network_config),
"${r.responseType}: ${r.details}"
)
false
}
}
}
suspend fun apiContactInfo(contactId: Long): ConnectionStats? {
val r = sendCmd(CC.APIContactInfo(contactId))
if (r is CR.ContactInfo) return r.connectionStats
Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? {
val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId))
if (r is CR.GroupMemberInfo) return r.connectionStats_
Log.e(TAG, "apiGroupMemberInfo 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
@@ -409,6 +478,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiListContacts(): List<Contact>? {
val r = sendCmd(CC.ListContacts())
if (r is CR.ContactsList) return r.contacts
Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val r = sendCmd(CC.ApiUpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
@@ -516,10 +592,45 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiJoinGroup(groupId: Long): GroupInfo? {
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
val r = sendCmd(CC.NewGroup(p))
if (r is CR.GroupCreated) return r.groupInfo
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) {
val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
if (r is CR.SentGroupInvitation) return
Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}")
}
suspend fun apiJoinGroup(groupId: Long) {
val r = sendCmd(CC.ApiJoinGroup(groupId))
if (r is CR.UserAcceptedGroupSent) return r.groupInfo
Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}")
when (r) {
is CR.UserAcceptedGroupSent ->
chatModel.updateGroup(r.groupInfo)
is CR.ChatCmdError -> {
val e = r.chatError
suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } }
if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) {
deleteGroup()
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_group_invitation_expired), generalGetString(R.string.alert_message_group_invitation_expired))
} else if (e is ChatError.ChatErrorStore && e.storeError is StoreError.GroupNotFound) {
deleteGroup()
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_no_group), generalGetString(R.string.alert_message_no_group))
} else {
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_join_group_error), "$e")
}
}
else -> Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}")
}
}
suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? {
val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))
if (r is CR.UserDeletedMember) return r.member
Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}")
return null
}
@@ -530,6 +641,31 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiListMembers(groupId: Long): List<GroupMember> {
val r = sendCmd(CC.ApiListMembers(groupId))
if (r is CR.GroupMembers) return r.group.members
Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}")
return emptyList()
}
suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? {
return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) {
is CR.GroupUpdated -> r.toGroup
is CR.ChatCmdError -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_saving_group_profile), "$r.chatError")
null
}
else -> {
Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(R.string.error_saving_group_profile),
"${r.responseType}: ${r.details}"
)
null
}
}
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e(TAG, "$method bad response: $errMsg")
@@ -589,7 +725,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
}
if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -622,6 +758,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
is CR.UserJoinedGroup ->
chatModel.updateGroup(r.groupInfo)
is CR.GroupDeleted ->
chatModel.updateGroup(r.groupInfo)
is CR.DeletedMemberUser ->
chatModel.updateGroup(r.groupInfo)
is CR.GroupUpdated ->
chatModel.updateGroup(r.toGroup)
is CR.RcvFileStart ->
chatItemSimpleUpdate(r.chatItem)
is CR.RcvFileComplete ->
@@ -697,13 +839,6 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun joinGroup(groupId: Long) {
val groupInfo = apiJoinGroup(groupId)
if (groupInfo != null) {
chatModel.updateGroup(groupInfo)
}
}
suspend fun leaveGroup(groupId: Long) {
val groupInfo = apiLeaveGroup(groupId)
if (groupInfo != null) {
@@ -914,6 +1049,45 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
context.startActivity(this)
}
}
fun getNetCfg(): NetCfg {
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
val socksProxy = if (useSocksProxy) ":9050" else null
val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
val tcpTimeout = appPrefs.networkTCPTimeout.get()
val smpPingInterval = appPrefs.networkSMPPingInterval.get()
val enableKeepAlive = appPrefs.networkEnableKeepAlive.get()
val tcpKeepAlive = if (enableKeepAlive) {
val keepIdle = appPrefs.networkTCPKeepIdle.get()
val keepIntvl = appPrefs.networkTCPKeepIntvl.get()
val keepCnt = appPrefs.networkTCPKeepCnt.get()
KeepAliveOpts(keepIdle = keepIdle, keepIntvl = keepIntvl, keepCnt = keepCnt)
} else {
null
}
return NetCfg(
socksProxy = socksProxy,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpKeepAlive = tcpKeepAlive,
smpPingInterval = smpPingInterval
)
}
fun setNetCfg(cfg: NetCfg) {
appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
if (cfg.tcpKeepAlive != null) {
appPrefs.networkEnableKeepAlive.set(true)
appPrefs.networkTCPKeepIdle.set(cfg.tcpKeepAlive.keepIdle)
appPrefs.networkTCPKeepIntvl.set(cfg.tcpKeepAlive.keepIntvl)
appPrefs.networkTCPKeepCnt.set(cfg.tcpKeepAlive.keepCnt)
} else {
appPrefs.networkEnableKeepAlive.set(false)
}
}
}
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
@@ -941,12 +1115,18 @@ sealed class CC {
class ApiRemoveMember(val groupId: Long, val memberId: Long): CC()
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class GetUserSMPServers(): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APIContactInfo(val contactId: Long): CC()
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ListContacts: CC()
class ApiUpdateProfile(val profile: Profile): CC()
class ApiParseMarkdown(val text: String): CC()
class CreateMyAddress: CC()
@@ -979,18 +1159,24 @@ sealed class CC {
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is NewGroup -> "/group ${groupProfile.displayName} ${groupProfile.fullName}"
is ApiAddMember -> "/_add #$groupId $contactId $memberRole"
is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiRemoveMember -> "/_remove #$groupId $memberId"
is ApiLeaveGroup -> "/_leave #$groupId"
is ApiListMembers -> "/_members #$groupId"
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APIContactInfo -> "/_info @$contactId"
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ListContacts -> "/contacts"
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is ApiParseMarkdown -> "/_parse $text"
is CreateMyAddress -> "/address"
@@ -1030,12 +1216,18 @@ sealed class CC {
is ApiRemoveMember -> "apiRemoveMember"
is ApiLeaveGroup -> "apiLeaveGroup"
is ApiListMembers -> "apiListMembers"
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
is APIContactInfo -> "apiContactInfo"
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
is ApiClearChat -> "apiClearChat"
is ListContacts -> "listContacts"
is ApiUpdateProfile -> "updateProfile"
is ApiParseMarkdown -> "apiParseMarkdown"
is CreateMyAddress -> "createMyAddress"
@@ -1069,6 +1261,50 @@ class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgCon
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
@Serializable
data class NetCfg(
val socksProxy: String? = null,
val tcpConnectTimeout: Long, // microseconds
val tcpTimeout: Long, // microseconds
val tcpKeepAlive: KeepAliveOpts?,
val smpPingInterval: Long // microseconds
) {
val useSocksProxy: Boolean get() = socksProxy != null
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
companion object {
val defaults: NetCfg =
NetCfg(
socksProxy = null,
tcpConnectTimeout = 7_500_000,
tcpTimeout = 5_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
)
val proxyDefaults: NetCfg =
NetCfg(
socksProxy = ":9050",
tcpConnectTimeout = 15_000_000,
tcpTimeout = 10_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
)
}
}
@Serializable
data class KeepAliveOpts(
val keepIdle: Int, // seconds
val keepIntvl: Int, // seconds
val keepCnt: Int // times
) {
companion object {
val defaults: KeepAliveOpts =
KeepAliveOpts(keepIdle = 30, keepIntvl = 15, keepCnt = 4)
}
}
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
@@ -1106,6 +1342,9 @@ sealed class CR {
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@@ -1136,6 +1375,7 @@ sealed class CR {
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact): CR()
@@ -1156,6 +1396,7 @@ sealed class CR {
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@@ -1187,6 +1428,9 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
is GroupMemberInfo -> "groupMemberInfo"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -1217,6 +1461,7 @@ sealed class CR {
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemDeleted -> "chatItemDeleted"
is ContactsList -> "contactsList"
is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
@@ -1236,6 +1481,7 @@ sealed class CR {
is JoinedGroupMember -> "joinedGroupMember"
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupRemoved -> "groupRemoved"
is GroupUpdated -> "groupUpdated"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
@@ -1266,6 +1512,9 @@ sealed class CR {
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> json.encodeToString(smpServers)
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
@@ -1296,6 +1545,7 @@ sealed class CR {
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is ContactsList -> json.encodeToString(contacts)
is GroupCreated -> json.encodeToString(groupInfo)
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact"
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
@@ -1315,6 +1565,7 @@ sealed class CR {
is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is GroupRemoved -> json.encodeToString(groupInfo)
is GroupUpdated -> json.encodeToString(toGroup)
is RcvFileAccepted -> json.encodeToString(chatItem)
is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)
@@ -1367,6 +1618,9 @@ abstract class TerminalItem {
}
}
@Serializable
class ConnectionStats(val rcvServers: List<String>?, val sndServers: List<String>?)
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
@@ -1395,8 +1649,10 @@ sealed class ChatErrorType {
sealed class StoreError {
val string: String get() = when (this) {
is UserContactLinkNotFound -> "userContactLinkNotFound"
is GroupNotFound -> "groupNotFound"
}
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
@Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
}
@Serializable

View File

@@ -18,6 +18,7 @@ val MessagePreviewLight = Color(49, 45, 44, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
val SettingsBackgroundLight = Color(220, 216, 215, 90)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)

View File

@@ -1,7 +1,12 @@
package chat.simplex.app.views.chat
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -13,7 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@@ -22,23 +27,25 @@ import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
ChatInfoLayout(
chat,
close = close,
deleteContact = { deleteChatDialog(chat.chatInfo, chatModel, close) },
connStats,
developerTools,
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
)
}
}
fun deleteChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_chat_question),
text = generalGetString(R.string.delete_chat_all_messages_deleted_cannot_undo_warning),
title = generalGetString(R.string.delete_contact_question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
@@ -72,122 +79,186 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
)
}
// TODO move to GroupChatInfoView
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.leave_group_question),
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
confirmText = generalGetString(R.string.leave_group_button),
onConfirm = {
withApi { chatModel.controller.leaveGroup(groupInfo.groupId) }
}
)
}
@Composable
fun ChatInfoLayout(
chat: Chat,
close: () -> Unit,
connStats: ConnectionStats?,
developerTools: Boolean,
deleteContact: () -> Unit,
clearChat: () -> Unit
) {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 192.dp)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
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)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
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)
)
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SectionItemView {
NetworkStatusRow(chat.serverInfo.networkStatus)
}
val rcvServers = connStats.rcvServers
if (rcvServers != null && rcvServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
}
val sndServers = connStats.sndServers
if (sndServers != null && sndServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
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)
)
}
SectionSpacer()
}
Spacer(Modifier.weight(1F))
SectionView {
SectionItemView {
ClearChatButton(clearChat)
}
SectionDivider()
SectionItemView {
DeleteContactButton(deleteContact)
}
}
SectionSpacer()
Box(Modifier.padding(4.dp)) {
SimpleButton(
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
}
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.button_delete_contact),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
)
}
} else if (cInfo is ChatInfo.Group) {
Spacer(Modifier.weight(1F))
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun ServerImage(chat: Chat) {
when (chat.serverInfo.networkStatus) {
is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
fun ChatInfoHeader(cInfo: ChatInfo) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier
.fillMaxSize()
.clickable {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.network_status),
networkStatus.statusExplanation
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(stringResource(R.string.network_status))
Icon(
Icons.Outlined.Info,
stringResource(R.string.network_status),
tint = MaterialTheme.colors.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
networkStatus.statusString,
color = HighOrLowlight
)
ServerImage(networkStatus)
}
}
}
@Composable
fun ServerImage(networkStatus: Chat.NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
}
}
}
@Composable
fun SimplexServers(text: String, servers: List<String>) {
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
InfoRow(text, info)
}
@Composable
fun ClearChatButton(clearChat: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { clearChat() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Restore,
stringResource(R.string.clear_chat_button),
tint = WarningOrange
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
}
}
@Composable
fun DeleteContactButton(deleteContact: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { deleteContact() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_delete_contact), color = Color.Red)
}
}
@@ -201,7 +272,9 @@ fun PreviewChatInfoLayout() {
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}, clearChat = {}
developerTools = false,
connStats = null,
deleteContact = {}, clearChat = {}
)
}
}

View File

@@ -26,14 +26,18 @@ import androidx.compose.ui.res.stringResource
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.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.group.AddGroupMembersView
import chat.simplex.app.views.chat.group.GroupChatInfoView
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@@ -88,7 +92,32 @@ fun ChatView(chatModel: ChatModel) {
chatModel.chatItems,
useLinkPreviews = useLinkPreviews,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
info = {
withApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val connStats = chatModel.controller.apiContactInfo(cInfo.apiId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ChatInfoView(chatModel, connStats, close)
}
}
} else if (cInfo is ChatInfo.Group) {
populateGroupMembers(cInfo.groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupChatInfoView(chatModel, close)
}
}
}
}
},
openDirectChat = { contactId ->
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
@@ -111,7 +140,7 @@ fun ChatView(chatModel: ChatModel) {
withApi { chatModel.controller.receiveFile(fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.joinGroup(groupId) }
withApi { chatModel.controller.apiJoinGroup(groupId) }
},
startCall = { media ->
val cInfo = chat.chatInfo
@@ -128,6 +157,19 @@ fun ChatView(chatModel: ChatModel) {
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
},
addMembers = { groupInfo ->
withApi {
populateGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
}
)
}
@@ -151,7 +193,8 @@ fun ChatLayout(
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit
acceptCall: (Contact) -> Unit,
addMembers: (GroupInfo) -> Unit
) {
Surface(
Modifier
@@ -172,7 +215,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
@@ -186,7 +229,13 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
fun ChatInfoToolbar(
chat: Chat,
back: () -> Unit,
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
addMembers: (GroupInfo) -> Unit
) {
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
IconButton(onClick, modifier = modifier) {
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
@@ -214,37 +263,53 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (
startCall(CallMediaType.Video)
}
}
}
Row(
Modifier
.padding(horizontal = 80.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
ChatInfoImage(cInfo, size = 40.dp)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
} else if (cInfo is ChatInfo.Group) {
if (cInfo.groupInfo.canAddMembers) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
toolbarButton(Icons.Outlined.PersonAdd, R.string.icon_descr_add_members) {
addMembers(cInfo.groupInfo)
}
}
}
}
Box(
Modifier
.padding(horizontal = 80.dp).fillMaxWidth()
.clickable(onClick = info),
contentAlignment = Alignment.Center
) {
ChatInfoToolbarTitle(cInfo)
}
}
Divider()
}
}
@Composable
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
ChatInfoImage(cInfo, size = imageSize, iconColor)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}
}
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
@@ -393,7 +458,8 @@ fun PreviewChatLayout() {
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> }
acceptCall = { _ -> },
addMembers = { _ -> }
)
}
}
@@ -441,7 +507,8 @@ fun PreviewGroupChatLayout() {
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> }
acceptCall = { _ -> },
addMembers = { _ -> }
)
}
}

View File

@@ -0,0 +1,317 @@
package chat.simplex.app.views.chat.group
import SectionCustomFooter
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
import chat.simplex.app.views.helpers.*
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
inviteMembers = {
withApi {
selectedContacts.forEach {
chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
}
close.invoke()
}
},
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
)
}
fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
val memberContactIds = chatModel.groupMembers
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
return chatModel.chats
.asSequence()
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { it.contactId !in memberContactIds }
.sortedBy { it.displayName.lowercase() }
.toList()
}
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
contactsToAdd: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
selectedRole: MutableState<GroupMemberRole>,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoToolbarTitle(
ChatInfo.Group(groupInfo),
imageSize = 60.dp,
iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight
)
}
SectionSpacer()
if (contactsToAdd.isEmpty()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
stringResource(R.string.no_contacts_to_add),
Modifier.padding(),
color = HighOrLowlight
)
}
} else {
SectionView {
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole)
}
SectionDivider()
SectionItemView {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
}
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
}
SectionSpacer()
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact)
}
SectionSpacer()
}
}
}
@Composable
fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.new_member_role))
RoleDropdownMenu(groupInfo, selectedRole)
}
}
@Composable
fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
val options = GroupMemberRole.values()
.filter { it <= groupInfo.membership.memberRole }
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
Row(
Modifier.fillMaxWidth(0.7f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
selectedRole.value.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(4.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.invite_to_group_button),
modifier = Modifier.padding(start = 8.dp),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
options.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selectedRole.value = selectionOption
expanded = false
}
) {
Text(
selectionOption.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
Row(
modifier.fillMaxSize(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Text(stringResource(R.string.invite_to_group_button), color = color)
Spacer(Modifier.size(8.dp))
Icon(
Icons.Outlined.Check,
stringResource(R.string.invite_to_group_button),
tint = color
)
}
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedContactsCount >= 1) {
Box(
Modifier.clickable { clearSelection() }
) {
Text(
stringResource(R.string.clear_contacts_selection_button),
color = MaterialTheme.colors.primary,
fontSize = 12.sp
)
}
Text(
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
color = HighOrLowlight,
fontSize = 12.sp
)
} else {
Text(
stringResource(R.string.no_contacts_selected),
color = HighOrLowlight,
fontSize = 12.sp
)
}
}
}
@Composable
fun ContactList(
contacts: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit
) {
Column {
contacts.forEachIndexed { index, contact ->
SectionItemView {
ContactCheckRow(
contact, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
}
if (index < contacts.lastIndex) {
SectionDivider()
}
}
}
}
@Composable
fun ContactCheckRow(
contact: Contact,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
checked: Boolean
) {
Row(
Modifier
.fillMaxSize()
.clickable { if (!checked) addContact(contact.apiId) else removeContact(contact.apiId) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 36.dp, contact.image)
Text(contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(
if (checked) Icons.Filled.CheckCircle else Icons.Outlined.Circle,
contentDescription = stringResource(R.string.icon_descr_contact_checked),
tint = if (checked) MaterialTheme.colors.primary else HighOrLowlight
)
}
}
@Preview
@Composable
fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {}
)
}
}

View File

@@ -0,0 +1,328 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
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.Color
import androidx.compose.ui.res.stringResource
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.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.helpers.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && chat.chatInfo is ChatInfo.Group) {
val groupInfo = chat.chatInfo.groupInfo
GroupChatInfoLayout(
chat,
groupInfo,
members = chatModel.groupMembers
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
developerTools,
addMembers = {
withApi {
populateGroupMembers(groupInfo, chatModel)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
},
showMemberInfo = { member ->
withApi {
val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, connStats, chatModel, close)
}
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }
)
}
}
fun deleteGroupDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_group_question),
text = generalGetString(R.string.delete_group_for_all_members_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
}
)
}
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.leave_group_question),
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
confirmText = generalGetString(R.string.leave_group_button),
onConfirm = {
withApi {
chatModel.controller.leaveGroup(groupInfo.groupId)
close?.invoke()
}
}
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
groupInfo: GroupInfo,
members: List<GroupMember>,
developerTools: Boolean,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView {
AddMembersButton(addMembers)
}
SectionDivider()
}
SectionItemView(height = 50.dp) {
MemberRow(groupInfo.membership, user = true)
}
if (members.isNotEmpty()) {
SectionDivider()
}
MembersList(members, showMemberInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
EditGroupProfileButton(editGroupProfile)
}
SectionDivider()
}
SectionItemView {
ClearChatButton(clearChat)
}
if (groupInfo.canDelete) {
SectionDivider()
SectionItemView {
DeleteGroupButton(deleteGroup)
}
}
if (groupInfo.membership.memberCurrent) {
SectionDivider()
SectionItemView {
LeaveGroupButton(leaveGroup)
}
}
}
SectionSpacer()
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun AddMembersButton(addMembers: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { addMembers() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Add,
stringResource(R.string.button_add_members),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_add_members), color = MaterialTheme.colors.primary)
}
}
@Composable
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView(height = 50.dp) {
MemberRow(member, showMemberInfo)
}
if (index < members.lastIndex) {
SectionDivider()
}
}
}
}
@Composable
fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
Row(
modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ProfileImage(size = 46.dp, member.image)
Column {
Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis)
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
statusDescr,
color = HighOrLowlight,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
val role = member.memberRole
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
Text(role.text, color = HighOrLowlight)
}
}
}
@Composable
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { editGroupProfile() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
}
}
@Composable
fun LeaveGroupButton(leaveGroup: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { leaveGroup() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Logout,
stringResource(R.string.button_leave_group),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_leave_group), color = Color.Red)
}
}
@Composable
fun DeleteGroupButton(deleteGroup: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { deleteGroup() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_group),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_delete_group), color = Color.Red)
}
}
@Preview
@Composable
fun PreviewGroupChatInfoLayout() {
SimpleXTheme {
GroupChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}
)
}
}

View File

@@ -0,0 +1,187 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
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.Color
import androidx.compose.ui.res.stringResource
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.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.helpers.*
@Composable
fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null) {
GroupMemberInfoLayout(
groupInfo,
member,
connStats,
developerTools,
removeMember = { removeMemberDialog(member, chatModel, close) }
)
}
}
fun removeMemberDialog(member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.button_remove_member),
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
confirmText = generalGetString(R.string.remove_member_confirmation),
onConfirm = {
withApi {
chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
close?.invoke()
}
}
)
}
@Composable
fun GroupMemberInfoLayout(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
developerTools: Boolean,
removeMember: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupMemberInfoHeader(member)
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
val conn = member.activeConn
if (conn != null) {
SectionDivider()
val connLevelDesc =
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
}
}
SectionSpacer()
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (rcvServers != null && rcvServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
if (sndServers != null && sndServers.isNotEmpty()) {
SectionDivider()
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
} else if (sndServers != null && sndServers.isNotEmpty()) {
SimplexServers(stringResource(R.string.sending_via), sndServers)
}
}
SectionSpacer()
}
}
if (member.canBeRemoved(groupInfo.membership)) {
SectionView {
SectionItemView {
RemoveMemberButton(removeMember)
}
}
SectionSpacer()
}
if (developerTools) {
SectionView(title = stringResource(R.string.section_title_for_console)) {
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
SectionDivider()
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
}
SectionSpacer()
}
}
}
@Composable
fun GroupMemberInfoHeader(member: GroupMember) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
member.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun RemoveMemberButton(removeMember: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { removeMember() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.button_remove_member),
tint = Color.Red
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_remove_member), color = Color.Red)
}
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
SimpleXTheme {
GroupMemberInfoLayout(
groupInfo = GroupInfo.sampleData,
member = GroupMember.sampleData,
connStats = null,
developerTools = false,
removeMember = {}
)
}
}

View File

@@ -0,0 +1,173 @@
package chat.simplex.app.views.chat.group
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
GroupProfileLayout(
close = close,
groupProfile = groupInfo.groupProfile,
saveProfile = { p ->
withApi {
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
if (gInfo != null) {
chatModel.updateGroup(gInfo)
close.invoke()
}
}
}
)
}
@Composable
fun GroupProfileLayout(
close: () -> Unit,
groupProfile: GroupProfile,
saveProfile: (GroupProfile) -> Unit,
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(groupProfile.displayName) }
val fullName = remember { mutableStateOf(groupProfile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(groupProfile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
},
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = close) {
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.group_profile_is_stored_on_members_devices),
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(192.dp, profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(16.dp))
Row {
TextButton(stringResource(R.string.cancel_verb)) {
close.invoke()
}
Spacer(Modifier.padding(horizontal = 8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
Text(
stringResource(R.string.save_group_profile),
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
color = MaterialTheme.colors.primary
)
} else {
Text(
stringResource(R.string.save_group_profile),
color = HighOrLowlight
)
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewGroupProfileLayout() {
SimpleXTheme {
GroupProfileLayout(
close = {},
groupProfile = GroupProfile.sampleData,
saveProfile = { _ -> }
)
}
}

View File

@@ -40,6 +40,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(status.duration(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}
Text(

View File

@@ -1,6 +1,6 @@
package chat.simplex.app.views.chat.item
import android.content.Context
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,8 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -62,7 +61,8 @@ fun ChatItemView(
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
}
DropdownMenu(
expanded = showMenu.value,

View File

@@ -39,7 +39,8 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {}
) {
val sent = ci.chatDir.sent
@@ -136,9 +137,9 @@ fun FramedItemView(
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, uriHandler)
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
}
}
@@ -151,11 +152,17 @@ fun FramedItemView(
}
@Composable
fun CIMarkdownText(ci: ChatItem, showMember: Boolean, uriHandler: UriHandler?) {
fun CIMarkdownText(
ci: ChatItem,
showMember: Boolean,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
metaText = ci.timestampText, edited = ci.meta.itemEdited,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
}

View File

@@ -1,17 +1,19 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
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.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -45,7 +47,8 @@ fun MarkdownText (
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onLinkLongClick: (link: String) -> Unit = {}
) {
val reserve = if (edited) " " else " "
if (formattedText == null) {
@@ -65,8 +68,9 @@ fun MarkdownText (
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ft.format.style) { append(ft.text) }
withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
@@ -77,9 +81,16 @@ fun MarkdownText (
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
}
)
} else {
@@ -87,3 +98,53 @@ fun MarkdownText (
}
}
}
@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit = {},
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
detectGesture(onLongPress = { pos ->
layoutResult.value?.let { layoutResult ->
onLongClick(layoutResult.getOffsetForPosition(pos))
}
}, onPress = { pos ->
layoutResult.value?.let { layoutResult ->
val res = tryAwaitRelease()
if (res) {
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
}
consume
}
)
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}

View File

@@ -18,6 +18,8 @@ import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.WarningOrange
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
@@ -94,29 +96,28 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
}
}
suspend fun populateGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
chatModel.groupMembers.clear()
chatModel.groupMembers.addAll(groupMembers)
}
@Composable
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ClearChatAction(chat, chatModel, showMenu)
DeleteChatAction(chat, chatModel, showMenu)
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> {
ItemAction(
stringResource(R.string.join_group_button),
Icons.Outlined.Login,
onClick = {
withApi { chatModel.controller.joinGroup(groupInfo.groupId) }
showMenu.value = false
}
)
JoinGroupAction(groupInfo, chatModel, showMenu)
if (groupInfo.canDelete) {
DeleteChatAction(chat, chatModel, showMenu)
DeleteGroupAction(chat, chatModel, showMenu)
}
}
else -> {
@@ -124,19 +125,11 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
MarkReadChatAction(chat, chatModel, showMenu)
}
ClearChatAction(chat, chatModel, showMenu)
if (groupInfo.membership.memberStatus != GroupMemberStatus.MemLeft) {
ItemAction(
stringResource(R.string.leave_group_button),
Icons.Outlined.Logout,
onClick = {
leaveGroupDialog(groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(groupInfo, chatModel, showMenu)
}
if (groupInfo.canDelete) {
DeleteChatAction(chat, chatModel, showMenu)
DeleteGroupAction(chat, chatModel, showMenu)
}
}
}
@@ -169,12 +162,50 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
}
@Composable
fun DeleteChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteChatDialog(chat.chatInfo, chatModel)
deleteContactDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteGroupDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun JoinGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.join_group_button),
Icons.Outlined.Login,
onClick = {
withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) }
showMenu.value = false
}
)
}
@Composable
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.leave_group_button),
Icons.Outlined.Logout,
onClick = {
leaveGroupDialog(groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
@@ -328,7 +359,7 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel)
title = generalGetString(R.string.join_group_question),
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
confirmText = generalGetString(R.string.join_group_button),
onConfirm = { withApi { chatModel.controller.joinGroup(groupInfo.groupId) } },
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
dismissText = generalGetString(R.string.delete_verb),
onDismiss = { deleteGroup(groupInfo, chatModel) }
)

View File

@@ -8,8 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -133,10 +132,10 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
if (!stopped) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
Icons.Outlined.AddCircle,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
} else {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -29,6 +30,28 @@ import chat.simplex.app.views.helpers.badgeLayout
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
val cInfo = chat.chatInfo
@Composable
fun groupInactiveIcon() {
Icon(
Icons.Filled.Cancel,
stringResource(R.string.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
tint = HighOrLowlight
)
}
@Composable
fun chatPreviewImageOverlayIcon() {
if (cInfo is ChatInfo.Group) {
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemLeft -> groupInactiveIcon()
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
else -> {}
}
}
}
@Composable
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
Text(
@@ -50,18 +73,6 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary)
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
GroupMemberStatus.MemLeft ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.group_left_description),
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = HighOrLowlight
)
chatPreviewTitleText()
}
else -> chatPreviewTitleText()
}
else -> chatPreviewTitleText()
@@ -87,7 +98,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> Text(stringResource(R.string.you_are_invited_to_group))
GroupMemberStatus.MemInvited -> Text(stringResource(R.string.group_preview_you_are_invited))
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
else -> {}
}
@@ -97,7 +108,12 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
}
Row {
ChatInfoImage(cInfo, size = 72.dp)
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 72.dp)
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
chatPreviewImageOverlayIcon()
}
}
Column(
modifier = Modifier
.padding(horizontal = 8.dp)

View File

@@ -1,5 +1,8 @@
package chat.simplex.app.views.database
import SectionDivider
import SectionTextFooter
import SectionView
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
@@ -25,8 +28,7 @@ import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import chat.simplex.app.views.usersettings.SettingsSectionView
import chat.simplex.app.views.usersettings.*
import kotlinx.datetime.*
import java.io.BufferedOutputStream
import java.io.File
@@ -57,21 +59,20 @@ fun ChatArchiveLayout(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
title,
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.chat_archive_section)) {
SectionView(stringResource(R.string.chat_archive_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.save_archive),
saveArchive,
textColor = MaterialTheme.colors.primary
)
divider()
SectionDivider()
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.delete_archive),
@@ -80,7 +81,7 @@ fun ChatArchiveLayout(
)
}
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
SettingsSectionFooter(
SectionTextFooter(
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
)
}

View File

@@ -1,5 +1,10 @@
package chat.simplex.app.views.database
import SectionDivider
import SectionTextFooter
import SectionItemView
import SectionSpacer
import SectionView
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
@@ -23,7 +28,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
@@ -114,19 +118,18 @@ fun DatabaseLayout(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.your_chat_database),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.run_chat_section)) {
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
}
Spacer(Modifier.height(30.dp))
SectionSpacer()
SettingsSectionView(stringResource(R.string.chat_database_section)) {
SectionView(stringResource(R.string.chat_database_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
@@ -134,7 +137,7 @@ fun DatabaseLayout(
textColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
divider()
SectionDivider()
SettingsActionItem(
Icons.Outlined.FileDownload,
stringResource(R.string.import_database),
@@ -142,7 +145,7 @@ fun DatabaseLayout(
textColor = Color.Red,
disabled = operationsDisabled
)
divider()
SectionDivider()
val chatArchiveNameVal = chatArchiveName.value
val chatArchiveTimeVal = chatArchiveTime.value
val chatLastStartVal = chatLastStart.value
@@ -154,7 +157,7 @@ fun DatabaseLayout(
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
disabled = operationsDisabled
)
divider()
SectionDivider()
}
SettingsActionItem(
Icons.Outlined.DeleteForever,
@@ -164,7 +167,7 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
}
SettingsSectionFooter(
SectionTextFooter(
if (chatDbChanged) {
stringResource(R.string.restart_the_app_to_use_new_chat_database)
} else {
@@ -186,7 +189,7 @@ fun RunChatSetting(
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
SettingsItemView() {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
Icon(
@@ -225,11 +228,6 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
@Composable
fun SettingsSectionFooter(text: String) {
Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
withApi {
try {

View File

@@ -24,11 +24,11 @@ import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
val icon =
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
ProfileImage(size, chatInfo.image, icon)
ProfileImage(size, chatInfo.image, icon, iconColor)
}
@Composable

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package chat.simplex.app.views.helpers
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
* */
interface PressGestureScope : Density {
suspend fun tryAwaitRelease(): Boolean
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
suspend fun PointerInputScope.detectGesture(
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
shouldConsumeEvent: (Offset) -> Boolean
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectGesture)
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown()
// If shouldConsumeEvent == false, all touches will be propagated to parent
val shouldConsume = shouldConsumeEvent(down.position)
if (shouldConsume)
down.consumeDownChange()
pressScope.reset()
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
try {
val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
pressScope.cancel()
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
// If onLongPress event is needed, cancel short press event
if (onLongPress != null)
pressScope.cancel()
else
pressScope.release()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
if (shouldConsume)
consumeUntilUp()
pressScope.release()
}
}
}
}
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consumeAllChanges() }
} while (event.changes.fastAny { it.pressed })
}
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true
): PointerInputChange =
awaitFirstDownOnPass(pass = PointerEventPass.Main, requireUnconsumed = requireUnconsumed)
internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
pass: PointerEventPass,
requireUnconsumed: Boolean
): PointerInputChange {
var event: PointerEvent
do {
event = awaitPointerEvent(pass)
} while (
!event.changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
)
return event.changes[0]
}
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.fastAll { it.changedToUp() }) {
return event.changes[0]
}
if (event.changes.fastAny {
it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding)
}
) {
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
}
}
}
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
fun cancel() {
isCanceled = true
mutex.unlock()
}
fun release() {
isReleased = true
mutex.unlock()
}
fun reset() {
mutex.tryLock()
isReleased = false
isCanceled = false
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
}
return isCanceled
}
}

View File

@@ -88,12 +88,20 @@ private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
return stream
}
val errorBitmapBytes = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==", Base64.NO_WRAP)
val errorBitmap: Bitmap = BitmapFactory.decodeByteArray(errorBitmapBytes, 0, errorBitmapBytes.size)
fun base64ToBitmap(base64ImageString: String): Bitmap {
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
try {
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
} catch (e: Exception) {
Log.e(TAG, "base64ToBitmap error: $e")
return errorBitmap
}
}
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {

View File

@@ -0,0 +1,82 @@
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
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.unit.*
import chat.simplex.app.ui.theme.GroupDark
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
Column {
if (title != null) {
Text(
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
)
}
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
}
}
}
@Composable
fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
val modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.height(height)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun SectionTextFooter(text: String) {
Text(
text,
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F),
color = HighOrLowlight,
fontSize = 12.sp
)
}
@Composable
fun SectionCustomFooter(content: (@Composable () -> Unit)) {
Row(
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp)
) {
content()
}
}
@Composable
fun SectionDivider() {
Divider(Modifier.padding(horizontal = 8.dp))
}
@Composable
fun SectionSpacer() {
Spacer(Modifier.height(30.dp))
}
@Composable
fun InfoRow(title: String, value: String) {
SectionItemView {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title)
Text(value, color = HighOrLowlight)
}
}
}

View File

@@ -29,13 +29,12 @@ fun SimpleButton(text: String, icon: ImageVector,
}
@Composable
fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) {
fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
Surface(shape = RoundedCornerShape(20.dp)) {
val modifier = if (disabled) Modifier else Modifier.clickable { click() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { click() }
.padding(8.dp)
modifier = modifier.padding(8.dp)
) { content() }
}
}

View File

@@ -0,0 +1,177 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.chat.group.AddGroupMembersView
import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.onboarding.ReadableText
import chat.simplex.app.views.usersettings.DeleteImageButton
import chat.simplex.app.views.usersettings.EditImageButton
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
AddGroupLayout(
createGroup = { groupProfile ->
withApi {
val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
if (groupInfo != null) {
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
chatModel.chatItems.clear()
chatModel.chatId.value = groupInfo.id
populateGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
}
},
close
)
}
@Composable
fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val displayName = remember { mutableStateOf("") }
val fullName = remember { mutableStateOf("") }
val profileImage = remember { mutableStateOf<String?>(null) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val focusRequester = remember { FocusRequester() }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
},
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close) {
Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
Text(
stringResource(R.string.create_secret_group_title),
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(vertical = 5.dp)
)
ReadableText(R.string.group_is_decentralized)
Spacer(Modifier.height(10.dp))
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(size = 192.dp, image = profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
.padding(8.dp))
} else {
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
}
}
}
@Composable
fun CreateGroupButton(color: Color, modifier: Modifier) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Surface(shape = RoundedCornerShape(20.dp)) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color)
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = color)
}
}
}
}
@Preview
@Composable
fun PreviewAddGroupLayout() {
SimpleXTheme {
AddGroupLayout(
createGroup = {},
close = {}
)
}
}

View File

@@ -1,7 +1,7 @@
package chat.simplex.app.views.newchat
import android.Manifest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -48,66 +48,108 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
pasteLink = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
},
createGroup = {
newChatCtrl.collapse()
ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) }
}
)
}
@Composable
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
fun NewChatSheetLayout(
addContact: () -> Unit,
scanCode: () -> Unit,
pasteLink: () -> Unit,
createGroup: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.add_contact_to_start_new_chat),
modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp)
stringResource(R.string.add_contact_or_create_group),
modifier = Modifier.padding(horizontal = 4.dp).padding(top = 20.dp, bottom = 20.dp),
style = MaterialTheme.typography.body2
)
val boxModifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 0.dp)
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.create_one_time_link),
stringResource(R.string.to_share_with_your_contact),
Icons.Outlined.AddLink,
click = addContact
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.paste_received_link),
stringResource(R.string.paste_received_link_from_clipboard),
Icons.Outlined.Article,
click = pasteLink
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.scan_QR_code),
stringResource(R.string.in_person_or_in_video_call__bracketed),
Icons.Outlined.QrCode,
click = scanCode
)
}
Divider(Modifier.padding(horizontal = 8.dp))
Box(boxModifier) {
ActionRowButton(
stringResource(R.string.create_group),
stringResource(R.string.only_stored_on_members_devices),
icon = Icons.Outlined.Group,
click = createGroup
)
}
}
}
@Composable
fun ActionRowButton(
text: String, comment: String? = null, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}
) {
Surface(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 24.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.SpaceEvenly
Modifier.clickable(onClick = click).size(48.dp).padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.create_one_time_link),
stringResource(R.string.to_share_with_your_contact),
Icons.Outlined.AddLink,
click = addContact
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.paste_received_link),
stringResource(R.string.paste_received_link_from_clipboard),
Icons.Outlined.Article,
click = pasteLink
)
}
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
stringResource(R.string.scan_QR_code),
stringResource(R.string.in_person_or_in_video_call__bracketed),
Icons.Outlined.QrCode,
click = scanCode
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text, tint = tint, modifier = Modifier.size(48.dp).padding(start = 4.dp, end = 16.dp))
Column {
Text(
text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint
)
if (comment != null) {
Text(
comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
}
}
}
}
}
@Composable
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
fun ActionButton(
text: String?,
comment: String?,
icon: ImageVector,
disabled: Boolean = false,
click: () -> Unit = {}
) {
Surface(shape = RoundedCornerShape(18.dp)) {
Column(
Modifier
@@ -148,7 +190,8 @@ fun PreviewNewChatSheet() {
NewChatSheetLayout(
addContact = {},
scanCode = {},
pasteLink = {}
pasteLink = {},
createGroup = {}
)
}
}

View File

@@ -30,6 +30,16 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
var lastAnalyzedTimeStamp = 0L
var contactLink = ""
val cameraProviderFuture by produceState<ListenableFuture<ProcessCameraProvider>?>(initialValue = null) {
value = ProcessCameraProvider.getInstance(context)
}
DisposableEffect(lifecycleOwner) {
onDispose {
cameraProviderFuture?.get()?.unbindAll()
}
}
AndroidView(
factory = { AndroidViewContext ->
PreviewView(AndroidViewContext).apply {
@@ -46,14 +56,10 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
cameraProviderFuture?.addListener({
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val detector: QrCodeDetector<GrayU8> = FactoryFiducial.qrcode(null, GrayU8::class.java)
fun getQR(imageProxy: ImageProxy) {
val currentTimeStamp = System.currentTimeMillis()
@@ -78,8 +84,8 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
.build()
.also { it.setAnalyzer(cameraExecutor, imageAnalyzer) }
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
cameraProviderFuture?.get()?.unbindAll()
cameraProviderFuture?.get()?.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -76,7 +77,7 @@ fun SimpleXInfoLayout(
@Composable
fun SimpleXLogo() {
Image(
painter = painterResource(R.drawable.logo),
painter = painterResource(if (isSystemInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
contentDescription = stringResource(R.string.image_descr_simplex_logo),
modifier = Modifier
.padding(vertical = 20.dp)

View File

@@ -0,0 +1,432 @@
package chat.simplex.app.views.usersettings
import SectionCustomFooter
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import java.text.DecimalFormat
@Composable
fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
val currentCfg = remember { mutableStateOf(chatModel.controller.getNetCfg()) }
val currentCfgVal = currentCfg.value // used only on initialization
val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) }
val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) }
val networkSMPPingInterval = remember { mutableStateOf(currentCfgVal.smpPingInterval) }
val networkEnableKeepAlive = remember { mutableStateOf(currentCfgVal.enableKeepAlive) }
val networkTCPKeepIdle: MutableState<Int>
val networkTCPKeepIntvl: MutableState<Int>
val networkTCPKeepCnt: MutableState<Int>
if (currentCfgVal.tcpKeepAlive != null) {
networkTCPKeepIdle = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepIdle) }
networkTCPKeepIntvl = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepIntvl) }
networkTCPKeepCnt = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepCnt) }
} else {
networkTCPKeepIdle = remember { mutableStateOf(KeepAliveOpts.defaults.keepIdle) }
networkTCPKeepIntvl = remember { mutableStateOf(KeepAliveOpts.defaults.keepIntvl) }
networkTCPKeepCnt = remember { mutableStateOf(KeepAliveOpts.defaults.keepCnt) }
}
fun buildCfg(): NetCfg {
val socksProxy = currentCfg.value.socksProxy
val tcpConnectTimeout = networkTCPConnectTimeout.value
val tcpTimeout = networkTCPTimeout.value
val smpPingInterval = networkSMPPingInterval.value
val enableKeepAlive = networkEnableKeepAlive.value
val tcpKeepAlive = if (enableKeepAlive) {
val keepIdle = networkTCPKeepIdle.value
val keepIntvl = networkTCPKeepIntvl.value
val keepCnt = networkTCPKeepCnt.value
KeepAliveOpts(keepIdle = keepIdle, keepIntvl = keepIntvl, keepCnt = keepCnt)
} else {
null
}
return NetCfg(
socksProxy = socksProxy,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpKeepAlive = tcpKeepAlive,
smpPingInterval = smpPingInterval
)
}
fun updateView(cfg: NetCfg) {
networkTCPConnectTimeout.value = cfg.tcpConnectTimeout
networkTCPTimeout.value = cfg.tcpTimeout
networkSMPPingInterval.value = cfg.smpPingInterval
networkEnableKeepAlive.value = cfg.enableKeepAlive
if (cfg.tcpKeepAlive != null) {
networkTCPKeepIdle.value = cfg.tcpKeepAlive.keepIdle
networkTCPKeepIntvl.value = cfg.tcpKeepAlive.keepIntvl
networkTCPKeepCnt.value = cfg.tcpKeepAlive.keepCnt
} else {
networkTCPKeepIdle.value = KeepAliveOpts.defaults.keepIdle
networkTCPKeepIntvl.value = KeepAliveOpts.defaults.keepIntvl
networkTCPKeepCnt.value = KeepAliveOpts.defaults.keepCnt
}
}
fun saveCfg(cfg: NetCfg) {
withApi {
chatModel.controller.apiSetNetworkConfig(cfg)
currentCfg.value = cfg
chatModel.controller.setNetCfg(cfg)
}
}
fun reset() {
val newCfg = if (currentCfg.value.useSocksProxy) NetCfg.proxyDefaults else NetCfg.defaults
updateView(newCfg)
saveCfg(newCfg)
}
fun updateSettingsDialog(action: () -> Unit) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.update_network_settings_question),
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
confirmText = generalGetString(R.string.update_network_settings_confirmation),
onConfirm = action
)
}
AdvancedNetworkSettingsLayout(
networkTCPConnectTimeout,
networkTCPTimeout,
networkSMPPingInterval,
networkEnableKeepAlive,
networkTCPKeepIdle,
networkTCPKeepIntvl,
networkTCPKeepCnt,
resetDisabled = if (currentCfg.value.useSocksProxy) currentCfg.value == NetCfg.proxyDefaults else currentCfg.value == NetCfg.defaults,
reset = { updateSettingsDialog(::reset) },
footerDisabled = buildCfg() == currentCfg.value,
revert = { updateView(currentCfg.value) },
save = { updateSettingsDialog { saveCfg(buildCfg()) } }
)
}
@Composable fun AdvancedNetworkSettingsLayout(
networkTCPConnectTimeout: MutableState<Long>,
networkTCPTimeout: MutableState<Long>,
networkSMPPingInterval: MutableState<Long>,
networkEnableKeepAlive: MutableState<Boolean>,
networkTCPKeepIdle: MutableState<Int>,
networkTCPKeepIntvl: MutableState<Int>,
networkTCPKeepCnt: MutableState<Int>,
resetDisabled: Boolean,
reset: () -> Unit,
footerDisabled: Boolean,
revert: () -> Unit,
save: () -> Unit
) {
val secondsLabel = stringResource(R.string.network_option_seconds_label)
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.network_settings_title),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionSpacer()
SectionView {
SectionItemView {
ResetToDefaultsButton(reset, disabled = resetDisabled)
}
SectionDivider()
SectionItemView {
TimeoutSettingRow(
stringResource(R.string.network_option_tcp_connection_timeout), networkTCPConnectTimeout,
listOf(2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000), secondsLabel
)
}
SectionDivider()
SectionItemView {
TimeoutSettingRow(
stringResource(R.string.network_option_protocol_timeout), networkTCPTimeout,
listOf(1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000), secondsLabel
)
}
SectionDivider()
SectionItemView {
TimeoutSettingRow(
stringResource(R.string.network_option_ping_interval), networkSMPPingInterval,
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000), secondsLabel
)
}
SectionDivider()
SectionItemView {
EnableKeepAliveSwitch(networkEnableKeepAlive)
}
SectionDivider()
if (networkEnableKeepAlive.value) {
SectionItemView {
IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel)
}
SectionDivider()
SectionItemView {
IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel)
}
SectionDivider()
SectionItemView {
IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "")
}
} else {
SectionItemView {
Text("TCP_KEEPIDLE", color = HighOrLowlight)
}
SectionDivider()
SectionItemView {
Text("TCP_KEEPINTVL", color = HighOrLowlight)
}
SectionDivider()
SectionItemView {
Text("TCP_KEEPCNT", color = HighOrLowlight)
}
}
}
SectionCustomFooter {
SettingsSectionFooter(revert, save, footerDisabled)
}
SectionSpacer()
}
}
@Composable
fun ResetToDefaultsButton(reset: () -> Unit, disabled: Boolean) {
val modifier = if (disabled) Modifier else Modifier.clickable { reset() }
Row(
modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Text(stringResource(R.string.network_options_reset_to_defaults), color = color)
}
}
@Composable
fun EnableKeepAliveSwitch(
networkEnableKeepAlive: MutableState<Boolean>
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.network_option_enable_tcp_keep_alive))
Switch(
checked = networkEnableKeepAlive.value,
onCheckedChange = { networkEnableKeepAlive.value = it },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
}
@Composable
fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>, label: String) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
var expanded by remember { mutableStateOf(false) }
Text(title)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
Row(
Modifier.width(140.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
"${selection.value} $label",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(4.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.invite_to_group_button),
modifier = Modifier.padding(start = 8.dp),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
values.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selection.value = selectionOption
expanded = false
}
) {
Text(
"$selectionOption $label",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@Composable
fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List<Long>, label: String) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
var expanded by remember { mutableStateOf(false) }
Text(title)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
val df = DecimalFormat("#.#")
Row(
Modifier.width(140.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
"${df.format(selection.value / 1_000_000.toDouble())} $label",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(4.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.invite_to_group_button),
modifier = Modifier.padding(start = 8.dp),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
values.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selection.value = selectionOption
expanded = false
}
) {
Text(
"${df.format(selectionOption / 1_000_000.toDouble())} $label",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@Composable
fun SettingsSectionFooter(revert: () -> Unit, save: () -> Unit, disabled: Boolean) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
FooterButton(Icons.Outlined.Replay, stringResource(R.string.network_options_revert), revert, disabled)
FooterButton(Icons.Outlined.Check, stringResource(R.string.network_options_save), save, disabled)
}
}
@Composable
fun FooterButton(icon: ImageVector, title: String, action: () -> Unit, disabled: Boolean) {
Surface(
shape = RoundedCornerShape(20.dp),
color = Color.Black.copy(alpha = 0f)
) {
val modifier = if (disabled) Modifier else Modifier.clickable { action() }
Row(
modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
icon,
title,
tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
)
Text(
title,
color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewAdvancedNetworkSettingsLayout() {
SimpleXTheme {
AdvancedNetworkSettingsLayout(
networkTCPConnectTimeout = remember { mutableStateOf(10_000000) },
networkTCPTimeout = remember { mutableStateOf(10_000000) },
networkSMPPingInterval = remember { mutableStateOf(10_000000) },
networkEnableKeepAlive = remember { mutableStateOf(true) },
networkTCPKeepIdle = remember { mutableStateOf(10) },
networkTCPKeepIntvl = remember { mutableStateOf(10) },
networkTCPKeepCnt = remember { mutableStateOf(10) },
resetDisabled = false,
reset = {},
footerDisabled = false,
revert = {},
save = {}
)
}
}

View File

@@ -0,0 +1,118 @@
package chat.simplex.app.views.usersettings
import SectionView
import android.content.ComponentName
import android.content.pm.PackageManager
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
enum class AppIcon(val resId: Int) {
DEFAULT(R.mipmap.icon),
DARK_BLUE(R.mipmap.icon_dark_blue),
}
@Composable
fun AppearanceView() {
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
fun setAppIcon(newIcon: AppIcon) {
if (appIcon.value == newIcon) return
val newComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
val oldComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}")
SimplexApp.context.packageManager.setComponentEnabledSetting(
newComponent,
COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
)
SimplexApp.context.packageManager.setComponentEnabledSetting(
oldComponent,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
)
appIcon.value = newIcon
}
AppearanceLayout(
appIcon,
changeIcon = ::setAppIcon
)
}
@Composable fun AppearanceLayout(
icon: MutableState<AppIcon>,
changeIcon: (AppIcon) -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.appearance_settings),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.settings_section_title_icon)) {
LazyRow(
Modifier
.padding(horizontal = 8.dp)
) {
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
val item = AppIcon.values()[index]
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
Image(
bitmap = mipmap.toBitmap().asImageBitmap(),
contentDescription = "",
contentScale = ContentScale.Fit,
modifier = Modifier
.shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondary)
.size(70.dp)
.clickable { changeIcon(item) }
.padding(10.dp)
)
if (index + 1 != AppIcon.values().size) {
Spacer(Modifier.padding(horizontal = 4.dp))
}
}
}
}
}
}
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
SimplexApp.context.packageManager.getComponentEnabledSetting(
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED }
}
@Preview(showBackground = true)
@Composable
fun PreviewAppearanceSettings() {
SimpleXTheme {
AppearanceLayout(
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
changeIcon = {}
)
}
}

View File

@@ -1,5 +1,8 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -29,18 +32,17 @@ fun CallSettingsLayout(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
Text(
stringResource(R.string.your_calls),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
Box(Modifier.padding(start = 10.dp)) {
SectionView(stringResource(R.string.settings_section_title_settings)) {
SectionItemView() {
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
}
divider()
SectionDivider()
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
Text(stringResource(R.string.call_on_lock_screen))
@@ -75,8 +77,7 @@ fun SharedPreferenceToggle(
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp)
)
)
}
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.views.usersettings
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -25,7 +26,7 @@ fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boo
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
SettingsSectionView("") {
SectionView("") {
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
}
}

View File

@@ -0,0 +1,144 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NetCfg
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun NetworkAndServersView(
chatModel: ChatModel,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val netCfg: MutableState<NetCfg> = remember { mutableStateOf(chatModel.controller.getNetCfg()) }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.value.useSocksProxy) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
showModal = showModal,
showSettingsModal = showSettingsModal,
toggleSocksProxy = { enable ->
if (enable) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.network_enable_socks),
text = generalGetString(R.string.network_enable_socks_info),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
withApi {
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
networkUseSocksProxy.value = true
}
}
)
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.network_disable_socks),
text = generalGetString(R.string.network_disable_socks_info),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
withApi {
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
chatModel.controller.setNetCfg(NetCfg.defaults)
networkUseSocksProxy.value = false
}
}
)
}
}
)
}
@Composable fun NetworkAndServersLayout(
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
toggleSocksProxy: (Boolean) -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.network_and_servers),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView {
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
SectionDivider()
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
}
if (developerTools) {
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
}
}
}
@Composable
fun UseSocksProxySwitch(
networkUseSocksProxy: MutableState<Boolean>,
toggleSocksProxy: (Boolean) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Outlined.SettingsEthernet,
stringResource(R.string.network_socks_toggle),
tint = HighOrLowlight
)
Text(stringResource(R.string.network_socks_toggle))
}
Switch(
checked = networkUseSocksProxy.value,
onCheckedChange = toggleSocksProxy,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewNetworkAndServersLayout() {
SimpleXTheme {
NetworkAndServersLayout(
developerTools = true,
networkUseSocksProxy = remember { mutableStateOf(true) },
showModal = { {} },
showSettingsModal = { {} },
toggleSocksProxy = {}
)
}
}

View File

@@ -1,5 +1,8 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionSpacer
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -15,7 +18,6 @@ import chat.simplex.app.model.ChatModel
@Composable
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
@@ -25,14 +27,14 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
}
Spacer(Modifier.height(30.dp))
SectionSpacer()
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
SectionView(stringResource(R.string.settings_section_title_chats)) {
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
divider()
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
}
}

View File

@@ -1,5 +1,9 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -47,6 +51,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
profile = user.profile,
stopped,
runServiceInBackground = chatModel.runServiceInBackground,
developerTools = chatModel.controller.appPrefs.developerTools,
setRunServiceInBackground = ::setRunServiceInBackground,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
@@ -57,7 +62,19 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
}
} } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
showAppearance = {
withApi {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AppearanceView()
}
}
}
}
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
@@ -66,17 +83,29 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val simplexTeamUri =
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
// TODO pass close
//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) {
// ModalManager.shared.showCustomModal { close ->
// ModalView(close = close, modifier = Modifier,
// background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
// modalView(chatModel)
// }
// }
//}
@Composable
fun SettingsLayout(
profile: Profile,
stopped: Boolean,
runServiceInBackground: MutableState<Boolean>,
developerTools: Preference<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showAppearance: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -87,77 +116,67 @@ fun SettingsLayout(
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
.padding(top = 16.dp)
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
Text(
stringResource(R.string.your_settings),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp)
)
Spacer(Modifier.height(30.dp))
SectionSpacer()
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
SectionView(stringResource(R.string.settings_section_title_you)) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
divider()
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
divider()
SectionDivider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
spacer()
SectionSpacer()
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
divider()
SectionView(stringResource(R.string.settings_section_title_settings)) {
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
divider()
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showAppearance, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
}
spacer()
SectionSpacer()
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
SectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
divider()
SectionDivider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
divider()
SectionDivider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
divider()
SectionDivider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
divider()
SectionDivider()
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
}
spacer()
SectionSpacer()
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
SectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal, stopped)
divider()
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
SectionDivider()
InstallTerminalAppItem(uriHandler)
divider()
SectionDivider()
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// divider()
// SectionDivider()
AppVersionItem()
}
}
}
}
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
Column {
Text(
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
)
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp)) { content() }
}
}
}
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
SettingsItemView(openDatabaseView) {
SectionItemView(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -188,7 +207,7 @@ fun SettingsLayout(
setRunServiceInBackground: (Boolean) -> Unit,
stopped: Boolean
) {
SettingsItemView(disabled = stopped) {
SectionItemView(disabled = stopped) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Bolt,
@@ -219,7 +238,7 @@ fun SettingsLayout(
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SettingsItemView() {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Lock,
@@ -239,15 +258,14 @@ fun SettingsLayout(
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp)
)
)
}
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SettingsItemView(showTerminal, disabled = stopped) {
SectionItemView(showTerminal, disabled = stopped) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = stringResource(R.string.chat_console),
@@ -262,7 +280,7 @@ fun SettingsLayout(
}
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
@@ -274,7 +292,7 @@ fun SettingsLayout(
}
@Composable private fun AppVersionItem() {
SettingsItemView() {
SectionItemView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -296,23 +314,9 @@ fun SettingsLayout(
}
}
@Composable
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
val modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.height(height)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
SettingsItemView(click, disabled = disabled) {
SectionItemView(click, disabled = disabled) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = if (disabled) HighOrLowlight else textColor)
@@ -321,7 +325,7 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
@Composable
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
SettingsItemView() {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
@@ -343,12 +347,14 @@ fun PreviewSettingsLayout() {
profile = Profile.sampleData,
stopped = false,
runServiceInBackground = remember { mutableStateOf(true) },
developerTools = Preference({ false }, {}),
setRunServiceInBackground = {},
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },
showTerminal = {},
showAppearance = {},
// showVideoChatPrototype = {}
)
}

View File

@@ -200,7 +200,7 @@ fun UserProfileLayout(
}
@Composable
private fun ProfileNameTextField(name: MutableState<String>) {
fun ProfileNameTextField(name: MutableState<String>) {
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
@@ -218,7 +218,7 @@ private fun ProfileNameTextField(name: MutableState<String>) {
}
@Composable
private fun ProfileNameRow(label: String, text: String) {
fun ProfileNameRow(label: String, text: String) {
Row(Modifier.padding(bottom = 24.dp)) {
Text(
label,
@@ -234,7 +234,7 @@ private fun ProfileNameRow(label: String, text: String) {
}
@Composable
private fun TextButton(text: String, click: () -> Unit) {
fun TextButton(text: String, click: () -> Unit) {
Text(
text,
color = MaterialTheme.colors.primary,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,5 +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"/>
<background android:drawable="@color/icon_background"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
</adaptive-icon>
</adaptive-icon>

View File

@@ -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/icon_dark_blue_background"/>
<foreground android:drawable="@mipmap/icon_dark_blue_foreground"/>
</adaptive-icon>

View File

@@ -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/icon_dark_blue_background"/>
<foreground android:drawable="@mipmap/icon_dark_blue_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +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>
<background android:drawable="@color/icon_background"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
</adaptive-icon>

View File

@@ -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/icon_background"/>
<foreground android:drawable="@mipmap/icon_dark_blue_round"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -10,8 +10,9 @@
<string name="connect_via_link_verb">Соединиться</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Соединение установлено</string>
<string name="server_connecting">Соединение устанавливается…</string>
<string name="server_connected">соединено</string>
<string name="server_error">ошибка</string>
<string name="server_connecting">соединяется</string>
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
@@ -33,9 +34,10 @@
<string name="description_via_contact_address_link">через ссылку-контакт</string>
<string name="description_via_one_time_link">через одноразовую ссылку</string>
<!-- SMP Server Information - SimpleXAPI.kt -->
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
<string name="error_setting_network_config">Ошибка при сохранении настроек сети</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="contact_already_exists">Существующий контакт</string>
@@ -63,6 +65,7 @@
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<string name="hide_notification">Скрыть</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
@@ -110,6 +113,7 @@
<string name="this_text_is_available_in_settings">Этот текст можно найти в Настройках</string>
<string name="your_chats">Ваши чаты</string>
<string name="contact_connection_pending">соединяется…</string>
<string name="group_preview_you_are_invited">вы приглашены в группу</string>
<string name="group_connection_pending">соединяется…</string>
<!-- ComposeView.kt, helpers -->
@@ -138,8 +142,8 @@
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Удалить чат?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Чат и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="delete_contact_question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="button_delete_contact">Удалить контакт</string>
<string name="icon_descr_server_status_connected">Соединение с сервером установлено</string>
<string name="icon_descr_server_status_disconnected">Соединение с сервером не установлено</string>
@@ -158,12 +162,15 @@
<string name="add_contact">Добавить контакт</string>
<!-- NewChatSheet -->
<string name="add_contact_or_create_group">Начать новый разговор</string>
<string name="create_one_time_link">Создать одноразовую ссылку</string>
<string name="paste_received_link">Вставить полученную ссылку</string>
<string name="scan_QR_code">Сканировать QR код</string>
<string name="create_group">Создать секретную группу</string>
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
<string name="in_person_or_in_video_call__bracketed">(при встрече или через видеозвонок)</string>
<string name="paste_received_link_from_clipboard">(вставить ссылку из буфера обмена)</string>
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Разрешение не получено!</string>
@@ -265,6 +272,15 @@
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Инфо</string>
<string name="save_servers_button">Сохранить</string>
<string name="network_and_servers">Сеть &amp; серверы</string>
<string name="network_settings">Настройки сети</string>
<string name="network_settings_title">Настройки сети</string>
<string name="network_socks_toggle">Использовать SOCKS прокси (порт 9050)</string>
<string name="network_enable_socks">Использовать SOCKS прокси?</string>
<string name="network_enable_socks_info">Соединяться с серверами через SOCKS прокси через порт 9050? Прокси должен быть запущен до включения этой опции.</string>
<string name="network_disable_socks">Использовать прямое соединение с Интернет?</string>
<string name="network_disable_socks_info">Если вы подтвердите, серверы смогут видеть ваш IP адрес, а провайдер - с какими серверами вы соединяетесь.</string>
<string name="appearance_settings">Интерфейс</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Создать адрес</string>
@@ -310,7 +326,6 @@
<string name="connect_via_link">Соединиться через ссылку</string>
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Open in mobile app</b>.</string>
<string name="add_contact_to_start_new_chat">Добавьте контакт, чтобы начать разговор:</string>
<!-- CICallStatus -->
<string name="callstatus_calling">входящий звонок…</string>
@@ -440,7 +455,10 @@
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
<string name="settings_section_title_device">УСТРОЙСТВО</string>
<string name="settings_section_title_chats">ЧАТЫ</string>
<string name="settings_developer_tools">Инструменты разработчика</string>
<string name="settings_experimental_features">Экспериментальные функции</string>
<string name="settings_section_title_socks">SOCKS ПРОКСИ</string>
<string name="settings_section_title_icon">ИКОНКА</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Данные чата</string>
@@ -496,7 +514,13 @@
<string name="leave_group_button">Выйти</string>
<string name="leave_group_question">Выйти из группы</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Вы перестанете получать сообщения от этой группы. История чата будет сохранена.</string>
<string name="group_left_description">[покинута]</string>
<string name="icon_descr_add_members">Пригласить членов группы</string>
<string name="icon_descr_group_inactive">Группа неактивна</string>
<string name="alert_title_group_invitation_expired">Приглашение истекло!</string>
<string name="alert_message_group_invitation_expired">Приглашение в группу больше не действительно, оно было удалено отправителем.</string>
<string name="alert_title_no_group">Группа не найдена!</string>
<string name="alert_message_no_group">Эта группа больше не существует.</string>
<string name="alert_title_join_group_error">Ошибка приглашения</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
@@ -513,6 +537,93 @@
<string name="rcv_group_event_member_deleted">удалил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">удалил(а) вас из группы</string>
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
<string name="rcv_group_event_updated_group_profile">обновил(а) профиль группы</string>
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">член группы</string>
<string name="group_member_role_admin">админ</string>
<string name="group_member_role_owner">владелец</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">удален(а)</string>
<string name="group_member_status_left">покинул(а)</string>
<string name="group_member_status_group_deleted">группа удалена</string>
<string name="group_member_status_invited">приглашен(а)</string>
<string name="group_member_status_introduced">соединяется (представлен(а))</string>
<string name="group_member_status_intro_invitation">соединяется (приглашение по представлению)</string>
<string name="group_member_status_accepted">соединяется (приглашение принято)</string>
<string name="group_member_status_announced">соединяется (объявлен(а))</string>
<string name="group_member_status_connected">соединен(а)</string>
<string name="group_member_status_complete">соединение завершено</string>
<string name="group_member_status_creator">создатель</string>
<string name="group_member_status_connecting">соединяется</string>
<!-- AddGroupMembersView.kt -->
<string name="no_contacts_to_add">Нет контактов для добавления</string>
<string name="new_member_role">Роль члена группы</string>
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
<string name="invite_to_group_button">Пригласить в группу</string>
<string name="icon_descr_contact_checked">Контакт выбран</string>
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
<string name="no_contacts_selected">Контакты не выбраны</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Пригласить членов группы</string>
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
<string name="group_info_member_you">вы: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Удалить группу</string>
<string name="delete_group_question">Удалить группу?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Группа будет удалена для всех членов - это действие нельзя отменить!</string>
<string name="button_leave_group">Выйти из группы</string>
<string name="button_edit_group_profile">Редактировать профиль группы</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
<string name="info_row_local_name">Локальное имя</string>
<string name="info_row_database_id">ID базы данных</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Удалить члена группы</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Член группы будет удален - это действие нельзя отменить!</string>
<string name="remove_member_confirmation">Удалить</string>
<string name="member_info_section_title_member">ЧЛЕН ГРУППЫ</string>
<string name="info_row_group">Группа</string>
<string name="info_row_connection">Соединение</string>
<string name="conn_level_desc_direct">прямое</string>
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
<string name="receiving_via">Получение через</string>
<string name="sending_via">Отправка через</string>
<string name="network_status">Состояние сети</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Создать скрытую группу</string>
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
<string name="group_display_name_field">Имя группы:</string>
<string name="group_full_name_field">Полное имя:</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
<string name="save_group_profile">Сохранить профиль группы</string>
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_options_reset_to_defaults">Сбросить настройки</string>
<string name="network_option_seconds_label">сек</string>
<string name="network_option_tcp_connection_timeout">Таймаут TCP соединения</string>
<string name="network_option_protocol_timeout">Таймаут протокола</string>
<string name="network_option_ping_interval">Интервал PING</string>
<string name="network_option_enable_tcp_keep_alive">Включить TCP keep-alive</string>
<string name="network_options_revert">Отменить изменения</string>
<string name="network_options_save">Сохранить</string>
<string name="update_network_settings_question">Обновить настройки сети?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
<string name="update_network_settings_confirmation">Обновить</string>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#FFFFFF</color>
</resources>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_dark_blue_background">#030749</color>
</resources>

View File

@@ -10,8 +10,9 @@
<string name="connect_via_link_verb">Connect</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Server connected</string>
<string name="server_connecting">Connecting server…</string>
<string name="server_connected">connected</string>
<string name="server_error">error</string>
<string name="server_connecting">connecting</string>
<string name="connected_to_server_to_receive_messages_from_contact">You are connected to the server used to receive messages from this contact.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Trying to connect to the server used to receive messages from this contact (error: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Trying to connect to the server used to receive messages from this contact.</string>
@@ -33,9 +34,10 @@
<string name="description_via_contact_address_link">via contact address link</string>
<string name="description_via_one_time_link">via one-time link</string>
<!-- SMP Server Information - SimpleXAPI.kt -->
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Error saving SMP servers</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
<string name="error_setting_network_config">Error updating network configuration</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="contact_already_exists">Contact already exists</string>
@@ -63,6 +65,7 @@
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
<string name="simplex_service_notification_text">Receiving messages…</string>
<string name="hide_notification">Hide</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
@@ -110,6 +113,7 @@
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Your chats</string>
<string name="contact_connection_pending">connecting…</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
<string name="group_connection_pending">connecting…</string>
<!-- ComposeView.kt, helpers -->
@@ -138,8 +142,8 @@
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Delete chat?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Chat and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="button_delete_contact">Delete contact</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -158,13 +162,15 @@
<string name="add_contact">Add contact</string>
<!-- NewChatSheet -->
<string name="add_contact_to_start_new_chat">Add contact to start a new chat:</string>
<string name="add_contact_or_create_group">Start new chat</string>
<string name="create_one_time_link">Create link / QR code</string>
<string name="paste_received_link">Connect via received link</string>
<string name="scan_QR_code">Scan QR code</string>
<string name="create_group">Create secret group</string>
<string name="to_share_with_your_contact">(to share with your contact)</string>
<string name="in_person_or_in_video_call__bracketed">(in person or in video call)</string>
<string name="paste_received_link_from_clipboard">(paste link from clipboard)</string>
<string name="only_stored_on_members_devices">(only stored by group members)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Permission Denied!</string>
@@ -271,6 +277,15 @@
<string name="enter_one_SMP_server_per_line">Enter one SMP server per line:</string>
<string name="how_to">How to</string>
<string name="save_servers_button">Save</string>
<string name="network_and_servers">Network &amp; servers</string>
<string name="network_settings">Advanced network settings</string>
<string name="network_settings_title">Network settings</string>
<string name="network_socks_toggle">Use SOCKS proxy (port 9050)</string>
<string name="network_enable_socks">Use SOCKS proxy?</string>
<string name="network_enable_socks_info">Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option.</string>
<string name="network_disable_socks">Use direct Internet connection?</string>
<string name="network_disable_socks_info">If you confirm, the messaging servers will be able to see your IP address, and your provider - which servers you are connecting to.</string>
<string name="appearance_settings">Appearance</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Create address</string>
@@ -442,7 +457,10 @@
<string name="settings_section_title_develop">DEVELOP</string>
<string name="settings_section_title_device">DEVICE</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_developer_tools">Developer tools</string>
<string name="settings_experimental_features">Experimental features</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_icon">APP ICON</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Your chat database</string>
@@ -498,7 +516,13 @@
<string name="leave_group_button">Leave</string>
<string name="leave_group_question">Leave group?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">You will stop receiving messages from this group. Chat history will be preserved.</string>
<string name="group_left_description">[left]</string>
<string name="icon_descr_add_members">Invite members</string>
<string name="icon_descr_group_inactive">Group inactive</string>
<string name="alert_title_group_invitation_expired">Invitation expired!</string>
<string name="alert_message_group_invitation_expired">Group invitation is no longer valid, it was removed by sender.</string>
<string name="alert_title_no_group">Group not found!</string>
<string name="alert_message_no_group">This group no longer exists.</string>
<string name="alert_title_join_group_error">Error joining group</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>
@@ -515,6 +539,93 @@
<string name="rcv_group_event_member_deleted">removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="snd_group_event_member_deleted">you removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">member</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">removed</string>
<string name="group_member_status_left">left</string>
<string name="group_member_status_group_deleted">group deleted</string>
<string name="group_member_status_invited">invited</string>
<string name="group_member_status_introduced">connecting (introduced)</string>
<string name="group_member_status_intro_invitation">connecting (introduction invitation)</string>
<string name="group_member_status_accepted">connecting (accepted)</string>
<string name="group_member_status_announced">connecting (announced)</string>
<string name="group_member_status_connected">connected</string>
<string name="group_member_status_complete">complete</string>
<string name="group_member_status_creator">creator</string>
<string name="group_member_status_connecting">connecting</string>
<!-- AddGroupMembersView.kt -->
<string name="no_contacts_to_add">No contacts to add</string>
<string name="new_member_role">New member role</string>
<string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string>
<string name="icon_descr_contact_checked">Contact checked</string>
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
<string name="no_contacts_selected">No contacts selected</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Invite members</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBERS</string>
<string name="group_info_member_you">you: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Delete group</string>
<string name="delete_group_question">Delete group?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Group will be deleted for all members - this cannot be undone!</string>
<string name="button_leave_group">Leave group</string>
<string name="button_edit_group_profile">Edit group profile</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
<string name="info_row_local_name">Local name</string>
<string name="info_row_database_id">Database ID</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Remove member</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string>
<string name="member_info_section_title_member">MEMBER</string>
<string name="info_row_group">Group</string>
<string name="info_row_connection">Connection</string>
<string name="conn_level_desc_direct">direct</string>
<string name="conn_level_desc_indirect">indirect (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">SERVERS</string>
<string name="receiving_via">Receiving via</string>
<string name="sending_via">Sending via</string>
<string name="network_status">Network status</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>
<string name="group_is_decentralized">The group is fully decentralized it is visible only to the members.</string>
<string name="group_display_name_field">Group display name:</string>
<string name="group_full_name_field">Group full name:</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Group profile is stored on members\' devices, not on the servers.</string>
<string name="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_options_reset_to_defaults">Reset to defaults</string>
<string name="network_option_seconds_label">sec</string>
<string name="network_option_tcp_connection_timeout">TCP connection timeout</string>
<string name="network_option_protocol_timeout">Protocol timeout</string>
<string name="network_option_ping_interval">PING interval</string>
<string name="network_option_enable_tcp_keep_alive">Enable TCP keep-alive</string>
<string name="network_options_revert">Revert</string>
<string name="network_options_save">Save</string>
<string name="update_network_settings_question">Update network settings?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Updating settings will re-connect the client to all servers.</string>
<string name="update_network_settings_confirmation">Update</string>
</resources>

View File

@@ -1,6 +1,6 @@
buildscript {
ext {
compose_version = '1.1.1'
compose_version = '1.2.0-beta02'
}
repositories {
google()
@@ -8,7 +8,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
// NOTE: Do not place your application dependencies here; they belong
@@ -18,8 +18,8 @@ buildscript {
plugins {
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'
}
task clean(type: Delete) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show More