Compare commits
78 Commits
v3.1.0-bet
...
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3d4467c7 | ||
|
|
5345199829 | ||
|
|
481c4c0763 | ||
|
|
bf2b3855b7 | ||
|
|
a254d5f050 | ||
|
|
e8749debec | ||
|
|
afbc7dd2c1 | ||
|
|
7a00a3e324 | ||
|
|
03d9d86aba | ||
|
|
13e7925348 | ||
|
|
46319044f8 | ||
|
|
8a7e320d12 | ||
|
|
152ed96ac0 | ||
|
|
8dc7bea724 | ||
|
|
497cf86eb0 | ||
|
|
9508ea5c97 | ||
|
|
257133db3b | ||
|
|
c4bc88b49b | ||
|
|
80389ffe93 | ||
|
|
e53540f43f | ||
|
|
55adbb4692 | ||
|
|
91baf9f362 | ||
|
|
04b9243d7e | ||
|
|
b3d74933c2 | ||
|
|
90ab6f34bf | ||
|
|
57e7034b4d | ||
|
|
8455cca9c3 | ||
|
|
9fdc2a4631 | ||
|
|
a5cdbc90f8 | ||
|
|
a5972c7de1 | ||
|
|
c74a4fcbca | ||
|
|
4c6ee95eb7 | ||
|
|
9e210256d2 | ||
|
|
d67f86ada5 | ||
|
|
7a03f87822 | ||
|
|
d6a4a245dc | ||
|
|
0fe7e64989 | ||
|
|
e39f9bc251 | ||
|
|
cbd7882ff4 | ||
|
|
4ad1abcbfa | ||
|
|
a36c367b81 | ||
|
|
a14859d8c0 | ||
|
|
9e23150938 | ||
|
|
35eeac194e | ||
|
|
0b4a6cf9eb | ||
|
|
2422f36d61 | ||
|
|
60117d0853 | ||
|
|
95757ed562 | ||
|
|
cc0a74fae4 | ||
|
|
ce91dcde7f | ||
|
|
999923bcf9 | ||
|
|
30c345933b | ||
|
|
1b8c55a0a3 | ||
|
|
4f4935256c | ||
|
|
1dd7520bbd | ||
|
|
de0f231c60 | ||
|
|
0c58adff08 | ||
|
|
e87c78e997 | ||
|
|
ee6f6462cf | ||
|
|
7b9164f95a | ||
|
|
4a931bc145 | ||
|
|
bf4072b365 | ||
|
|
658cc1af56 | ||
|
|
68bc572800 | ||
|
|
2286752fe0 | ||
|
|
9864533dae | ||
|
|
aa7e377bce | ||
|
|
a4aaf36774 | ||
|
|
608030dcaf | ||
|
|
6069108bb9 | ||
|
|
e7f3dc3f41 | ||
|
|
f150932e44 | ||
|
|
7dcde32680 | ||
|
|
552397d938 | ||
|
|
cfa4b44d1f | ||
|
|
9fcd127c48 | ||
|
|
7c01ad7d4f | ||
|
|
13b236f754 |
23
README.md
@@ -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.
|
||||
|
||||

|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 34 KiB |
BIN
apps/android/app/src/main/icon_dark_blue-playstore.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -90,6 +90,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?>() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
BIN
apps/android/app/src/main/res/drawable/logo_light.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
BIN
apps/android/app/src/main/res/mipmap-hdpi/icon_dark_blue.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
apps/android/app/src/main/res/mipmap-mdpi/icon_dark_blue.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
apps/android/app/src/main/res/mipmap-xhdpi/icon_dark_blue.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.4 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxhdpi/icon_dark_blue.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 7.8 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxxhdpi/icon_dark_blue.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
@@ -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">Сеть & серверы</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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="icon_background">#FFFFFF</color>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="icon_dark_blue_background">#030749</color>
|
||||
</resources>
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 788 B |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |