Compare commits

...

64 Commits

Author SHA1 Message Date
Evgeny Poberezkin 0d62d7ddfb Merge branch 'master' into ab/diff-subs 2024-05-28 08:06:25 +01:00
Stanislav Dmitrenko b58ccedf23 android, desktop: enhancements to wallpapers (#4234)
* android, desktop: enhancements to wallpapers

* fixes

* unneeded
2024-05-28 08:02:45 +01:00
Evgeny Poberezkin a78aeb47c5 Merge branch 'master' into ab/diff-subs 2024-05-27 16:00:55 +01:00
spaced4ndy 01cadefde7 core: avoid creating duplicate introductions between group members connecting concurrently (#4235) 2024-05-27 18:42:39 +04:00
Evgeny Poberezkin c70e7223d9 core: delete connection records when connecting (JOIN) fails (#4233)
* core: delete connection records when connecting (JOIN) fails

* show errors in commands
2024-05-27 15:32:09 +01:00
Evgeny Poberezkin a1d4f52185 5.8-beta.3: ios 220, android 214, desktop 49 2024-05-25 20:57:07 +01:00
Evgeny Poberezkin 373070f388 ui: show UI for additional group preferences (SimpleX links and per-role permissions) 2024-05-25 19:54:05 +01:00
Evgeny Poberezkin a03ccd0ba6 ios: update core library 5.8.0.3 2024-05-25 18:55:15 +01:00
Evgeny Poberezkin a7982cccf9 Merge branch 'stable' 2024-05-25 17:22:25 +01:00
Stanislav Dmitrenko bf91666f07 android, desktop: wallpapers (#4109)
* android, desktop: wallpapers

* changes

* removed unused

* changes

* backgrounds moved to themes. can be exported/imported

* changes and refactoring

* scrollable

* change

* reorder

* theme per background

* changes

* don't drop customization in same cases

* changes

* changes

* fix state update

* different defaults

* fix Android

* removed migration

* temporary

* returned theme name as uppercase back

* resilient to wrong preset name

* more

* live color change without button

* fix

* Revert "temporary"

This reverts commit 705c92ee56.

* changes

* changes

* almost candy

* old picker and ability to specify hex color

* different image

* picker

* user theme override, fixes, import/export theme with image

* revert when about to merge: useful layout for theming

* custom image displaying and deletion work correctly

* performance

* moved code up

* changes

* color changes in picker when changing themes

* reset color without closing modal

* unused

* log

* currentTheme instead of MaterialTheme

* different inheritance logic

* fix and move sent/received message colors to top

* remove background

* performance

* fix wrong base

* changes

* fix

* small

* custom image selection made normally

* better color picker

* better picker

* refactor, fixes, optimizations

* working with unknown base theme + tests

* change

* delete wallpaper dir when importing archive

* function rename

* fix bug with state update

* update colors

* fix text colors

* update images

* changes

* remote desktop and theme mode dropdown

* default theme

* additional accent 2 changes

* fix default wallpaper

* removed ChatListAlwaysVisible

* renaming

* update messages

* changes

* default theme

* unused

* member icon background color

* image quality

* fix wallpaper on empty notes screen

* update colors

* change

* update colors

* fix update on android

* fix update on both platforms

* update colors, remove serializable

* fix color

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-25 09:52:16 +01:00
Evgeny Poberezkin ab47a5a27e Revert "ui: comment smp proxy ui (#4204)"
This reverts commit a0d6ae15ab.
2024-05-24 22:59:37 +01:00
Evgeny Poberezkin 23581f59ae core: 5.8.0.3 2024-05-24 21:33:45 +01:00
Evgeny Poberezkin 291df6e9d0 core: logging of chat events (#4216)
* core: update simplexmq (persist server errors)

* fix

* same config

* logging

* logging 2

* log

* log 2

* finally

* catch better

* more logs

* logs

* fix

* more logging, context from PROHIBITED

* warning

* more logs

* logs3

* logs4

* logs in simplexmq

* log locks from simplemq

* log queue size

* log sendMessagesB in simplexmq

* update simplexmq

* logs5

* logs6

* logs7

* logs8

* logs8

* logs9

* logs10

* log11

* log12

* fix test

* more logs

* logging

* clean up

* refactor

* simplify

* tags

* log level

* remove network errors from the log

* rename
2024-05-24 21:09:21 +01:00
Evgeny Poberezkin dae0b63c22 Merge branch 'stable' 2024-05-23 22:56:04 +01:00
Evgeny Poberezkin c59eb06b8a 5.8-beta.2: ios 218, android 210, desktop 47 2024-05-20 21:23:40 +01:00
Evgeny Poberezkin 423fc96638 core: 5.8.0.2 (simplexmq 5.8.0.2) 2024-05-20 18:38:46 +01:00
spaced4ndy ba203faad4 android: check known relays before file reception, support user approval of unknown relays (#4196) 2024-05-20 17:58:30 +04:00
spaced4ndy ec7b35adb9 ios: check known relays before file reception, support user approval of unknown relays (#4044)
* ios: check known relays before file reception, support user approval of unknown relays

* add privacyAskToApproveRelays to AppSettings

* less text

* ui improvements, fixes

* fix ntf

* shorter alert

* simplify settings

* rename

* icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-20 17:49:19 +04:00
spaced4ndy d2d450d1d7 core: check known relays before file reception, support user approval of unknown relays (#4043)
* core: check known relays before file reception, support user approval of unknown relays

* comment

* reset on not approved agent error

* add privacyAskToApproveRelays to AppSettings

* filter distinct servers

* update simplexmq

* remember user_approved_relays

* refactor

* rename

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-20 17:23:29 +04:00
IC Rainbow 94df5d47d1 Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-20 13:11:55 +03:00
Evgeny Poberezkin b4caefb17c 5.8-beta.1: ios 217, android 209, desktop 46 2024-05-18 08:20:38 +01:00
Evgeny Poberezkin fd791f3fb8 ios: update core library 2024-05-17 15:54:51 +01:00
Evgeny Poberezkin 0720292ebf core: 5.8.0.1 (simplexmq: 5.8.0.1, faster reconnection when switching networks) 2024-05-17 15:39:58 +01:00
spaced4ndy a0d6ae15ab ui: comment smp proxy ui (#4204) 2024-05-17 16:36:25 +04:00
Evgeny Poberezkin 3e76a2d49c blog: image 2024-05-16 19:47:24 +01:00
Evgeny Poberezkin f9cba3f8e0 Merge branch 'master' into ab/diff-subs 2024-05-16 19:11:49 +01:00
Evgeny Poberezkin ed3365bb6d website: fix links 2024-05-16 18:16:00 +01:00
Evgeny Poberezkin b2a8797ed4 blog: Redefining Privacy by Making Hard Choices (#4195)
* blog: Redefining Privacy by Making Hard Choices

* update

* update

* revert

* update

* corrections

Co-authored-by: Esra'a Al Shafei <alshafei@users.noreply.github.com>

* update

---------

Co-authored-by: Esra'a Al Shafei <alshafei@users.noreply.github.com>
2024-05-16 18:02:37 +01:00
spaced4ndy d3e522e169 ui: disable send field for disabled connection (based on authErrCounter) (#4193) 2024-05-16 17:08:31 +04:00
spaced4ndy 49e09ca449 core: contact disabled event (#4194) 2024-05-16 17:08:13 +04:00
spaced4ndy d1dde3d0c8 ui: support message forwarding with custom ttl (#4192) 2024-05-16 14:12:58 +04:00
spaced4ndy ef75eceb40 core: support message forwarding with custom ttl (#4191) 2024-05-16 14:11:55 +04:00
spaced4ndy 96ce59f330 multiplatform: SMP proxy configuration buttons (#4188)
* multiplatform: SMP proxy configuration buttons

* fix

* icons

* icon

* icon
2024-05-15 20:09:51 +01:00
Evgeny Poberezkin 3655971aec Merge branch 'master' into ab/diff-subs 2024-05-15 13:57:39 +01:00
spaced4ndy 98a67fa2e4 multiplatform: SMP proxy support (#4179)
* android: SMP proxy support

* android types

* show in meta

* reserve

* remove comment

* texts
2024-05-15 13:56:29 +01:00
spaced4ndy 4b13512950 ios: SMP proxy support (#4178)
* ios: SMP proxy support

* statuses

* group statuses

* error texts

* update

* change icon

* texts

* texts

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-15 13:09:42 +01:00
Alexander Bondarenko dea45e3228 Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-15 14:58:08 +03:00
spaced4ndy 4c0d47bbd4 core: message statuses for sending proxies (#4161)
* core: delivery path

* update simplexmq

* via proxy snd flags

* error statuses

* rework errors

* proxy expired errors

* corrections

* move backwards compatibile parser to new type

* update simplexmq

* names

* refactor, style

* simplexmq

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-15 12:30:05 +01:00
Evgeny Poberezkin 3129602a78 Merge branch 'stable' 2024-05-15 11:44:22 +01:00
Evgeny Poberezkin 93ae1145bc core: update simplexmq (use MVar for better DB connection concurrency) (#4183)
* core: update simplexmq (use MVar for better DB connection concurrency)

* focus failing tests

* add timeouts to test

* fix tests

* more delays

* increase timeouts

* prints

* delay

* delay

* empty

* more delays

* enable all

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-05-15 11:16:38 +01:00
Evgeny Poberezkin d02668386f directory: debug logging (#4104) 2024-05-14 21:32:53 +01:00
M. Sarmad Qadeer bd8c8b5a8c web: fix the links of faq & some other (#4144)
* web: fix the links of faq & some other

* web: fix links

* web: update .eleventy.js to fix links

* web: fix some links and improve the logic

* web: fix links & improve the logic

* remove semicolon

* update links

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-14 19:59:51 +01:00
Alexander Bondarenko 4f2102ac6e use user and diff filters 2024-05-14 16:56:28 +03:00
IC Rainbow 17bcdaf6c4 Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-14 13:41:38 +03:00
Evgeny Poberezkin e359ec02f5 5.8-beta.0: ios 216, android 208, desktop 45 2024-05-13 23:34:14 +01:00
Evgeny Poberezkin a1948d549b ios: update core library 2024-05-13 22:24:10 +01:00
Evgeny Poberezkin 396b73b480 core: 5.8.0.0 2024-05-13 21:13:07 +01:00
Evgeny Poberezkin 331b4633fc core: platform independent encoding for delete command (#4176) 2024-05-13 20:39:13 +01:00
Evgeny Poberezkin 29db2b4e9b core: command to show duplicate message count (#4172)
* core: command to show duplicate message count

* update

* updated

* update

* update

* simplexmq
2024-05-13 19:55:57 +01:00
spaced4ndy f091384aa7 ui: apply model updates only for active user for switch and ratchet sync events (#4175) 2024-05-13 17:25:24 +01:00
IC Rainbow 1a2fad070e Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-13 15:56:29 +03:00
spaced4ndy f40ba6f04d core: api to differentiate contacts and conversations (#4111) 2024-05-13 16:51:54 +04:00
Evgeny Poberezkin 06d61ea73e core: update simplexmq 2024-05-13 08:19:40 +01:00
Evgeny Poberezkin 84d7a77a9f core: update simplexmq (better subscription management) (#4131)
* core: update simplexmq (better subscription management)

* ui: update settings

* update simplexmq

* simplexmq

* simplexmq

* test output

* delay

* disable test
2024-05-13 08:04:12 +01:00
Evgeny Poberezkin 878eea774d core: save app themes as map with any text key (#4159)
* core: save app themes as map with any text key

* remove theme preset name

* list of themes

* theme id

* theme IDs

* moar

* colors

* default to dark

* fix
2024-05-12 23:35:14 +01:00
IC Rainbow 6101be5657 update mq 2024-05-12 21:55:35 +03:00
Narasimha-sc 4575dc6e30 langs: change 1-2 days to several days on fdroid (#4170) 2024-05-12 18:19:27 +01:00
Evgeny Poberezkin a10dd81a1d Merge branch 'master' into ab/diff-subs 2024-05-12 12:07:26 +01:00
Evgeny Poberezkin 289130832a Merge branch 'master' into ab/diff-subs 2024-05-11 23:59:15 +01:00
Evgeny Poberezkin 224cfe9e02 Merge branch 'master' into ab/diff-subs 2024-05-11 13:07:05 +01:00
Alexander Bondarenko 0679d023e2 Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-10 21:01:33 +03:00
Alexander Bondarenko 6d5869e974 Merge remote-tracking branch 'origin/master' into ab/diff-subs 2024-05-08 16:42:54 +03:00
Alexander Bondarenko f0974af03a sync 2024-05-08 16:13:42 +03:00
Alexander Bondarenko da70aa30b6 debug: use client diffs in /get subs 2024-05-08 16:09:05 +03:00
191 changed files with 6008 additions and 1825 deletions
+4 -3
View File
@@ -1,5 +1,6 @@
---
layout: layouts/privacy.html
permalink: /privacy/index.html
---
# SimpleX Chat Privacy Policy and Conditions of Use
@@ -10,7 +11,7 @@ SimpleX Chat communication protocol is the first protocol that has no user profi
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
@@ -78,7 +79,7 @@ When you choose to use instant push notifications in SimpleX iOS app, because th
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off).
#### Another information stored on the servers
@@ -115,7 +116,7 @@ We will update this Privacy Policy as needed so that it is current, accurate, an
Please also read our Conditions of Use of Software and Infrastructure below.
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure
+73 -23
View File
@@ -341,8 +341,8 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r
}
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) async -> ChatItem? {
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId)
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
@@ -927,14 +927,19 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
}
}
func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
auto: auto
) {
await chatItemSimpleUpdate(user, chatItem)
}
}
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r {
@@ -947,19 +952,50 @@ func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: B
}
} else if let networkErrorAlert = networkErrorAlert(r) {
logger.error("apiReceiveFile network error: \(String(describing: r))")
am.showAlert(networkErrorAlert)
if !auto {
am.showAlert(networkErrorAlert)
}
} else {
switch chatError(r) {
case .fileCancelled:
logger.debug("apiReceiveFile ignoring fileCancelled error")
case .fileAlreadyReceiving:
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
case let .fileNotApproved(fileId, unknownServers):
logger.debug("apiReceiveFile fileNotApproved error")
if !auto {
let srvs = unknownServers.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
am.showAlert(Alert(
title: Text("Unknown servers!"),
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
primaryButton: .default(
Text("Download"),
action: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
}
}
}
),
secondaryButton: .cancel()
))
}
default:
logger.error("apiReceiveFile error: \(String(describing: r))")
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
}
}
}
return nil
@@ -1849,21 +1885,35 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case .chatSuspended:
chatSuspended()
case let .contactSwitch(_, contact, switchProgress):
await MainActor.run {
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
case let .contactSwitch(user, contact, switchProgress):
if active(user) {
await MainActor.run {
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
}
}
case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
case let .groupMemberSwitch(user, groupInfo, member, switchProgress):
if active(user) {
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
}
}
case let .contactRatchetSync(_, contact, ratchetSyncProgress):
await MainActor.run {
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
case let .contactRatchetSync(user, contact, ratchetSyncProgress):
if active(user) {
await MainActor.run {
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
}
}
case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
case let .groupMemberRatchetSync(user, groupInfo, member, ratchetSyncProgress):
if active(user) {
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
}
}
case let .contactDisabled(user, contact):
if active(user) {
await MainActor.run {
m.updateContact(contact)
}
}
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible):
await MainActor.run {
@@ -49,7 +49,7 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
}
}
private func serverHost(_ s: String) -> String {
func serverHost(_ s: String) -> String {
if let i = s.range(of: "@")?.lowerBound {
return String(s[i...].dropFirst())
} else {
@@ -60,6 +60,7 @@ struct CIFileView: View {
case .rcvInvitation: return true
case .rcvAccepted: return true
case .rcvTransfer: return false
case .rcvAborted: return true
case .rcvComplete: return true
case .rcvCancelled: return false
case .rcvError: return false
@@ -73,10 +74,10 @@ struct CIFileView: View {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
if fileSizeValid(file) {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
}
@@ -148,6 +149,8 @@ struct CIFileView: View {
} else {
progressView()
}
case .rcvAborted:
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
@@ -21,6 +21,8 @@ struct CIGroupInvitationView: View {
@State private var inProgress = false
@State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) {
@@ -43,7 +45,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
}
@@ -51,7 +53,7 @@ struct CIGroupInvitationView: View {
(
groupInvitationText()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
}
@@ -17,7 +17,6 @@ struct CIImageView: View {
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy?
@State var metaColor: Color
@State private var showFullScreenImage = false
var body: some View {
@@ -38,7 +37,7 @@ struct CIImageView: View {
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
@@ -103,6 +102,7 @@ struct CIImageView: View {
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView()
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
@@ -116,7 +116,7 @@ struct CIImageView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(metaColor)
.foregroundColor(.white)
.padding(padding)
}
@@ -17,6 +17,8 @@ struct CIMetaView: View {
var showStatus = true
var showEdited = true
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
@@ -27,24 +29,24 @@ struct CIMetaView: View {
switch meta.itemStatus {
case let .sndSent(sndProgress):
switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited)
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case .partial:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
}
@@ -64,7 +66,8 @@ func ciMetaText(
transparent: Bool = false,
sent: SentCheckmark? = nil,
showStatus: Bool = true,
showEdited: Bool = true
showEdited: Bool = true,
showViaProxy: Bool
) -> Text {
var r = Text("")
if showEdited, meta.itemEdited {
@@ -78,6 +81,9 @@ func ciMetaText(
}
r = r + Text(" ")
}
if showViaProxy, meta.sentViaProxy == true {
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
}
if showStatus {
if let (icon, statusColor) = meta.statusIcon(color) {
let t = Text(Image(systemName: icon)).font(.caption2)
@@ -19,6 +19,8 @@ struct CIRcvDecryptionError: View {
var chatItem: ChatItem
@State private var alert: CIRcvDecryptionErrorAlert?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
enum CIRcvDecryptionErrorAlert: Identifiable {
case syncAllowedAlert(_ syncConnection: () -> Void)
case syncNotSupportedContactAlert
@@ -119,7 +121,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
)
}
.padding(.horizontal, 12)
@@ -140,7 +142,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red)
.italic()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem)
@@ -69,7 +69,7 @@ struct CIVideoView: View {
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
@@ -95,7 +95,7 @@ struct CIVideoView: View {
}
durationProgress()
}
if let file = file, case .rcvInvitation = file.fileStatus {
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
@@ -105,6 +105,14 @@ struct CIVideoView: View {
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
@@ -280,6 +288,7 @@ struct CIVideoView: View {
} else {
progressView()
}
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
@@ -318,10 +327,10 @@ struct CIVideoView: View {
}
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false)
await receiveFile(user, file.fileId, false, false)
}
}
}
@@ -139,9 +139,10 @@ struct VoiceMessagePlayer: View {
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case .sndError: playbackButton()
case .rcvInvitation: downloadButton(recordingFile)
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
@@ -217,7 +218,7 @@ struct VoiceMessagePlayer: View {
}
}
private func downloadButton(_ recordingFile: CIFile) -> some View {
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Button {
Task {
if let user = chatModel.currentUser {
@@ -225,7 +226,7 @@ struct VoiceMessagePlayer: View {
}
}
} label: {
playPauseIcon("play.fill")
playPauseIcon(icon)
}
}
@@ -87,12 +87,17 @@ struct FramedItemView: View {
.cornerRadius(18)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
switch chatItem.meta.itemStatus {
case .sndErrorAuth:
v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") }
case let .sndError(agentError):
v.onTapGesture { msgDeliveryError("Unexpected error: \(agentError)") }
default: v
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
v.onTapGesture {
AlertManager.shared.showAlert(
Alert(
title: Text(title),
message: Text(text)
)
)
}
} else {
v
}
}
@@ -110,7 +115,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, image):
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -157,13 +162,6 @@ struct FramedItemView: View {
}
}
}
private func msgDeliveryError(_ err: LocalizedStringKey) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
message: err
)
}
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View {
let v = HStack(spacing: 6) {
@@ -35,6 +35,8 @@ struct MsgContentView: View {
@State private var typingIdx = 0
@State private var timer: Timer?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
if meta?.isLive == true {
msgContentView()
@@ -81,7 +83,7 @@ struct MsgContentView: View {
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
}
}
@@ -383,7 +383,7 @@ struct ChatItemInfoView: View {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
memberDeliveryStatusView(memberStatus.0, memberStatus.1)
memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2)
}
} else {
Text("No delivery information")
@@ -392,23 +392,27 @@ struct ChatItemInfoView: View {
}
}
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus)
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
} else {
return nil
}
})
}
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2)
Text(member.chatViewName)
.lineLimit(1)
Spacer()
if sentViaProxy == true {
Image(systemName: "arrow.forward")
.foregroundColor(.secondary).opacity(0.67)
}
let v = Group {
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
switch status {
@@ -712,9 +712,9 @@ struct ComposeView: View {
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation()
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
sent = await forwardItem(ci, fromChatInfo)
sent = await forwardItem(ci, fromChatInfo, ttl)
if !composeState.message.isEmpty {
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: nil)
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
@@ -890,13 +890,14 @@ struct ComposeView: View {
return nil
}
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo) async -> ChatItem? {
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItem = await apiForwardChatItem(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
itemId: forwardedItem.id
itemId: forwardedItem.id,
ttl: ttl
) {
await MainActor.run {
chatModel.addChatItem(chat.chatInfo, chatItem)
@@ -224,8 +224,7 @@ struct SendMessageView: View {
@ViewBuilder private func sendButtonContextMenuItems() -> some View {
if composeState.liveMessage == nil,
!composeState.editing,
!composeState.forwarding {
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
let send = sendLiveMessage,
@@ -34,8 +34,7 @@ struct GroupPreferencesView: View {
featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
featureSection(.files, $preferences.files.enable, $preferences.files.role)
// TODO enable simplexLinks preference in 5.8
// featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.history, $preferences.history.enable)
if groupInfo.canEdit {
@@ -102,8 +101,6 @@ struct GroupPreferencesView: View {
}
}
.frame(height: 36)
// remove in v5.8
.disabled(true)
}
} else {
settingsRow(icon, color: color) {
@@ -240,14 +240,14 @@ struct ChatPreviewView: View {
private func itemStatusMark(_ cItem: ChatItem) -> Text {
switch cItem.meta.itemStatus {
case .sndErrorAuth:
case .sndErrorAuth, .sndError:
return Text(Image(systemName: "multiply"))
.font(.caption)
.foregroundColor(.red) + Text(" ")
case .sndError:
case .sndWarning:
return Text(Image(systemName: "exclamationmark.triangle.fill"))
.font(.caption)
.foregroundColor(.yellow) + Text(" ")
.foregroundColor(.orange) + Text(" ")
default: return Text("")
}
}
@@ -51,10 +51,10 @@ struct AdvancedNetworkSettings: View {
}
.disabled(currentNetCfg == NetCfg.proxyDefaults)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 25_000000, 35_000000, 50_000000], label: secondsLabel)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
intSettingPicker("Receiving concurrency", selection: $netCfg.rcvConcurrency, values: [1, 2, 4, 8, 12, 16, 24], label: "")
// intSettingPicker("Receiving concurrency", selection: $netCfg.rcvConcurrency, values: [1, 2, 4, 8, 12, 16, 24], label: "")
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -23,6 +23,7 @@ extension AppSettings {
setNetCfg(val)
}
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) }
if let val = privacyAcceptImages {
privacyAcceptImagesGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
@@ -50,6 +51,7 @@ extension AppSettings {
var c = AppSettings.defaults
c.networkConfig = getNetCfg()
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
@@ -12,12 +12,16 @@ import SimpleXChat
private enum NetworkAlert: Identifiable {
case updateOnionHosts(hosts: OnionHosts)
case updateSessionMode(mode: TransportSessionMode)
case updateSMPProxyMode(proxyMode: SMPProxyMode)
case updateSMPProxyFallback(proxyFallback: SMPProxyFallback)
case error(err: String)
var id: String {
switch self {
case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)"
case let .updateSessionMode(mode): return "updateSessionMode \(mode)"
case let .updateSMPProxyMode(proxyMode): return "updateSMPProxyMode \(proxyMode)"
case let .updateSMPProxyFallback(proxyFallback): return "updateSMPProxyFallback \(proxyFallback)"
case let .error(err): return "error \(err)"
}
}
@@ -26,11 +30,14 @@ private enum NetworkAlert: Identifiable {
struct NetworkAndServers: View {
@EnvironmentObject var m: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = true
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@State private var onionHosts: OnionHosts = .no
@State private var sessionMode: TransportSessionMode = .user
@State private var proxyMode: SMPProxyMode = .never
@State private var proxyFallback: SMPProxyFallback = .allow
@State private var alert: NetworkAlert?
var body: some View {
@@ -75,6 +82,30 @@ struct NetworkAndServers: View {
Text("Using .onion hosts requires compatible VPN provider.")
}
Section {
Picker("Private routing", selection: $proxyMode) {
ForEach(SMPProxyMode.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
Picker("Allow downgrade", selection: $proxyFallback) {
ForEach(SMPProxyFallback.values, id: \.self) { Text($0.text) }
}
.disabled(proxyMode == .never)
.frame(height: 36)
Toggle("Show message status", isOn: $showSentViaProxy)
} header: {
Text("Private message routing")
} footer: {
VStack(alignment: .leading) {
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
if showSentViaProxy {
Text("Show → on messages sent via private routing.")
}
}
}
Section("Calls") {
NavigationLink {
RTCServers()
@@ -99,14 +130,24 @@ struct NetworkAndServers: View {
currentNetCfg = getNetCfg()
resetNetCfgView()
}
.onChange(of: onionHosts) { _ in
if onionHosts != OnionHosts(netCfg: currentNetCfg) {
alert = .updateOnionHosts(hosts: onionHosts)
.onChange(of: onionHosts) { hosts in
if hosts != OnionHosts(netCfg: currentNetCfg) {
alert = .updateOnionHosts(hosts: hosts)
}
}
.onChange(of: sessionMode) { _ in
if sessionMode != netCfg.sessionMode {
alert = .updateSessionMode(mode: sessionMode)
.onChange(of: sessionMode) { mode in
if mode != netCfg.sessionMode {
alert = .updateSessionMode(mode: mode)
}
}
.onChange(of: proxyMode) { mode in
if mode != netCfg.smpProxyMode {
alert = .updateSMPProxyMode(proxyMode: mode)
}
}
.onChange(of: proxyFallback) { fallbackMode in
if fallbackMode != netCfg.smpProxyFallback {
alert = .updateSMPProxyFallback(proxyFallback: fallbackMode)
}
}
.alert(item: $alert) { a in
@@ -137,6 +178,30 @@ struct NetworkAndServers: View {
resetNetCfgView()
}
)
case let .updateSMPProxyMode(proxyMode):
return Alert(
title: Text("Message routing mode"),
message: Text(proxyModeInfo(proxyMode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
netCfg.smpProxyMode = proxyMode
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .updateSMPProxyFallback(proxyFallback):
return Alert(
title: Text("Message routing fallback"),
message: Text(proxyFallbackInfo(proxyFallback)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
netCfg.smpProxyFallback = proxyFallback
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .error(err):
return Alert(
title: Text("Error updating settings"),
@@ -166,6 +231,8 @@ struct NetworkAndServers: View {
netCfg = currentNetCfg
onionHosts = OnionHosts(netCfg: netCfg)
sessionMode = netCfg.sessionMode
proxyMode = netCfg.smpProxyMode
proxyFallback = netCfg.smpProxyFallback
}
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
@@ -182,6 +249,23 @@ struct NetworkAndServers: View {
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
}
}
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
switch mode {
case .always: return "Always use private routing."
case .unknown: return "Use private routing with unknown servers."
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
case .never: return "Do NOT use private routing."
}
}
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
switch proxyFallback {
case .allow: return "Send messages directly when your or destination server does not support private routing."
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
}
}
}
struct NetworkServersView_Previews: PreviewProvider {
@@ -16,6 +16,7 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -64,18 +65,6 @@ struct PrivacySettings: View {
}
Section {
settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)
}
@@ -108,6 +97,32 @@ struct PrivacySettings: View {
Text("Chats")
}
Section {
settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("network.badge.shield.half.filled") {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
} header: {
Text("Files")
} footer: {
if askToApproveRelays {
Text("The app will ask to confirm downloads from unknown file servers (except .onion).")
} else {
Text("Without Tor or VPN, your IP address will be visible to file servers.")
}
}
Section {
settingsRow("person") {
Toggle("Contacts", isOn: $contactReceipts)
@@ -60,6 +60,7 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
@@ -99,6 +100,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
]
@@ -696,14 +696,16 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
}
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? {
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
logger.error("receiveFile error: \(responseError(r))")
return nil
}
func apiSetFileToReceive(fileId: Int64, encrypted: Bool) {
let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, encrypted: encrypted))
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted))
if case .cmdOk = r { return }
logger.error("setFileToReceive error: \(responseError(r))")
}
+32 -32
View File
@@ -53,11 +53,6 @@
5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; };
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
5C614CFB2C021C1C000E7758 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C614CF62C021C1C000E7758 /* libffi.a */; };
5C614CFC2C021C1C000E7758 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C614CF72C021C1C000E7758 /* libgmpxx.a */; };
5C614CFD2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C614CF82C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a */; };
5C614CFE2C021C1C000E7758 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C614CF92C021C1C000E7758 /* libgmp.a */; };
5C614CFF2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C614CFA2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a */; };
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65DAF829D0CC20003CEE45 /* DeveloperView.swift */; };
5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; };
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; };
@@ -144,6 +139,11 @@
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; };
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; };
5CEE87942C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEE878F2C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a */; };
5CEE87952C024F4F00583B8A /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEE87902C024F4F00583B8A /* libgmp.a */; };
5CEE87962C024F4F00583B8A /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEE87912C024F4F00583B8A /* libgmpxx.a */; };
5CEE87972C024F4F00583B8A /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEE87922C024F4F00583B8A /* libffi.a */; };
5CEE87982C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEE87932C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a */; };
5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; };
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; };
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; };
@@ -316,11 +316,6 @@
5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = "<group>"; };
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = "<group>"; };
5C614CF62C021C1C000E7758 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C614CF72C021C1C000E7758 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C614CF82C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a"; sourceTree = "<group>"; };
5C614CF92C021C1C000E7758 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C614CFA2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a"; sourceTree = "<group>"; };
5C636F662AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = "uk.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C636F672AAB3D2400751C84 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
5C65DAE429C77136003CEE45 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -440,6 +435,11 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; };
5CEE878F2C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a"; sourceTree = "<group>"; };
5CEE87902C024F4F00583B8A /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CEE87912C024F4F00583B8A /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CEE87922C024F4F00583B8A /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CEE87932C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a"; sourceTree = "<group>"; };
5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = "<group>"; };
5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = "<group>"; };
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; };
@@ -529,13 +529,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C614CFD2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a in Frameworks */,
5C614CFF2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a in Frameworks */,
5C614CFC2C021C1C000E7758 /* libgmpxx.a in Frameworks */,
5CEE87942C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a in Frameworks */,
5CEE87972C024F4F00583B8A /* libffi.a in Frameworks */,
5CEE87962C024F4F00583B8A /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C614CFB2C021C1C000E7758 /* libffi.a in Frameworks */,
5C614CFE2C021C1C000E7758 /* libgmp.a in Frameworks */,
5CEE87952C024F4F00583B8A /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CEE87982C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -601,11 +601,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C614CF62C021C1C000E7758 /* libffi.a */,
5C614CF92C021C1C000E7758 /* libgmp.a */,
5C614CF72C021C1C000E7758 /* libgmpxx.a */,
5C614CF82C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7-ghc9.6.3.a */,
5C614CFA2C021C1C000E7758 /* libHSsimplex-chat-5.7.4.1-FMTNcSSNT86KdfUI39X2r7.a */,
5CEE87922C024F4F00583B8A /* libffi.a */,
5CEE87902C024F4F00583B8A /* libgmp.a */,
5CEE87912C024F4F00583B8A /* libgmpxx.a */,
5CEE878F2C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G-ghc9.6.3.a */,
5CEE87932C024F4F00583B8A /* libHSsimplex-chat-5.8.0.3-3OkzEeUKATkEGDdUZR1g6G.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1552,7 +1552,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1577,7 +1577,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1601,7 +1601,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1626,7 +1626,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1687,7 +1687,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -1702,7 +1702,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1724,7 +1724,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -1739,7 +1739,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1761,7 +1761,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1787,7 +1787,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1812,7 +1812,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 219;
CURRENT_PROJECT_VERSION = 220;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1838,7 +1838,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.7.4;
MARKETING_VERSION = 5.8;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
+61 -6
View File
@@ -48,7 +48,7 @@ public enum ChatCommand {
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64)
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64)
case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -123,8 +123,8 @@ public enum ChatCommand {
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, encrypted: Bool?)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
case cancelFile(fileId: Int64)
// remote desktop commands
case setLocalDeviceName(displayName: String)
@@ -192,7 +192,9 @@ public enum ChatCommand {
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId): return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId)"
case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId) ttl=\(ttlStr)"
case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -280,8 +282,8 @@ public enum ChatCommand {
case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
case let .cancelFile(fileId): return "/fcancel \(fileId)"
case let .setLocalDeviceName(displayName): return "/set device name \(displayName)"
case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)"
@@ -639,6 +641,7 @@ public enum ChatResponse: Decodable, Error {
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
case contactDisabled(user: UserRef, contact: Contact)
// remote desktop responses/events
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool)
@@ -796,6 +799,7 @@ public enum ChatResponse: Decodable, Error {
case .ntfMessages: return "ntfMessages"
case .ntfMessage: return "ntfMessage"
case .contactConnectionDeleted: return "contactConnectionDeleted"
case .contactDisabled: return "contactDisabled"
case .remoteCtrlList: return "remoteCtrlList"
case .remoteCtrlFound: return "remoteCtrlFound"
case .remoteCtrlConnecting: return "remoteCtrlConnecting"
@@ -953,6 +957,7 @@ public enum ChatResponse: Decodable, Error {
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .contactDisabled(u, contact): return withUser(u, String(describing: contact))
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)"
case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)"
@@ -1242,9 +1247,12 @@ public struct ServerAddress: Decodable {
public struct NetCfg: Codable, Equatable {
public var socksProxy: String? = nil
var socksMode: SocksMode = .always
public var hostMode: HostMode = .publicHost
public var requiredHostMode = true
public var sessionMode: TransportSessionMode
public var smpProxyMode: SMPProxyMode = .never
public var smpProxyFallback: SMPProxyFallback = .allow
public var tcpConnectTimeout: Int // microseconds
public var tcpTimeout: Int // microseconds
public var tcpTimeoutPerKb: Int // microseconds
@@ -1289,6 +1297,49 @@ public enum HostMode: String, Codable {
case publicHost = "public"
}
public enum SocksMode: String, Codable {
case always = "always"
case onion = "onion"
}
public enum SMPProxyMode: String, Codable {
case always = "always"
case unknown = "unknown"
case unprotected = "unprotected"
case never = "never"
public var text: LocalizedStringKey {
switch self {
case .always: return "always"
case .unknown: return "unknown relays"
case .unprotected: return "unprotected"
case .never: return "never"
}
}
public var id: SMPProxyMode { self }
public static let values: [SMPProxyMode] = [.always, .unknown, .unprotected, .never]
}
public enum SMPProxyFallback: String, Codable {
case allow = "allow"
case allowProtected = "allowProtected"
case prohibit = "prohibit"
public var text: LocalizedStringKey {
switch self {
case .allow: return "yes"
case .allowProtected: return "when IP hidden"
case .prohibit: return "no"
}
}
public var id: SMPProxyFallback { self }
public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit]
}
public enum OnionHosts: String, Identifiable {
case no
case prefer
@@ -1709,6 +1760,7 @@ public enum ChatErrorType: Decodable {
case fileImageType(filePath: String)
case fileImageSize(filePath: String)
case fileNotReceived(fileId: Int64)
case fileNotApproved(fileId: Int64, unknownServers: [String])
// case xFTPRcvFile
// case xFTPSndFile
case fallbackToSMPProhibited(fileId: Int64)
@@ -1987,6 +2039,7 @@ public struct MigrationFileLinkData: Codable {
public struct AppSettings: Codable, Equatable {
public var networkConfig: NetCfg? = nil
public var privacyEncryptLocalFiles: Bool? = nil
public var privacyAskToApproveRelays: Bool? = nil
public var privacyAcceptImages: Bool? = nil
public var privacyLinkPreviews: Bool? = nil
public var privacyShowChatPreviews: Bool? = nil
@@ -2010,6 +2063,7 @@ public struct AppSettings: Codable, Equatable {
let def = AppSettings.defaults
if networkConfig != def.networkConfig { empty.networkConfig = networkConfig }
if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
@@ -2034,6 +2088,7 @@ public struct AppSettings: Codable, Equatable {
AppSettings (
networkConfig: NetCfg.defaults,
privacyEncryptLocalFiles: true,
privacyAskToApproveRelays: true,
privacyAcceptImages: true,
privacyLinkPreviews: true,
privacyShowChatPreviews: true,
+26
View File
@@ -23,9 +23,12 @@ public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles"
public let GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS = "privacyAskToApproveRelays"
let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount"
let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts"
let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode"
let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode"
let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback"
let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout"
let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout"
let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb"
@@ -53,6 +56,8 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false,
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.user.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.never.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allow.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb,
@@ -69,6 +74,7 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
@@ -177,6 +183,8 @@ public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults
public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES)
public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS)
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
@@ -191,6 +199,18 @@ public let networkSessionModeGroupDefault = EnumDefault<TransportSessionMode>(
withDefault: .user
)
public let networkSMPProxyModeGroupDefault = EnumDefault<SMPProxyMode>(
defaults: groupDefaults,
forKey: GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE,
withDefault: .never
)
public let networkSMPProxyFallbackGroupDefault = EnumDefault<SMPProxyFallback>(
defaults: groupDefaults,
forKey: GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK,
withDefault: .allow
)
public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE)
public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE)
@@ -275,6 +295,8 @@ public func getNetCfg() -> NetCfg {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (hostMode, requiredHostMode) = onionHosts.hostMode
let sessionMode = networkSessionModeGroupDefault.get()
let smpProxyMode = networkSMPProxyModeGroupDefault.get()
let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get()
let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB)
@@ -295,6 +317,8 @@ public func getNetCfg() -> NetCfg {
hostMode: hostMode,
requiredHostMode: requiredHostMode,
sessionMode: sessionMode,
smpProxyMode: smpProxyMode,
smpProxyFallback: smpProxyFallback,
tcpConnectTimeout: tcpConnectTimeout,
tcpTimeout: tcpTimeout,
tcpTimeoutPerKb: tcpTimeoutPerKb,
@@ -309,6 +333,8 @@ public func getNetCfg() -> NetCfg {
public func setNetCfg(_ cfg: NetCfg) {
networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg))
networkSessionModeGroupDefault.set(cfg.sessionMode)
networkSMPProxyModeGroupDefault.set(cfg.smpProxyMode)
networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback)
groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB)
+66 -6
View File
@@ -1510,7 +1510,12 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var active: Bool { get { contactStatus == .active } }
public var sendMsgEnabled: Bool { get {
(ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false))
(
ready
&& active
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)
&& !(activeConn?.connDisabled ?? true)
)
|| nextSendGrpInv
} }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
@@ -1601,15 +1606,20 @@ public struct Connection: Decodable {
public var pqEncryption: Bool
public var pqSndEnabled: Bool?
public var pqRcvEnabled: Bool?
public var authErrCounter: Int
public var connectionStats: ConnectionStats? = nil
private enum CodingKeys: String, CodingKey {
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter
}
public var id: ChatId { get { ":\(connId)" } }
public var connDisabled: Bool {
authErrCounter >= 10 // authErrDisableCount in core
}
public var connPQEnabled: Bool {
pqSndEnabled == true && pqRcvEnabled == true
}
@@ -1622,7 +1632,8 @@ public struct Connection: Decodable {
connLevel: 0,
viaGroupLink: false,
pqSupport: false,
pqEncryption: false
pqEncryption: false,
authErrCounter: 0
)
}
@@ -2621,6 +2632,7 @@ public struct CIMeta: Decodable {
public var itemTs: Date
var itemText: String
public var itemStatus: CIStatus
public var sentViaProxy: Bool?
public var createdAt: Date
public var updatedAt: Date
public var itemForwarded: CIForwardedFrom?
@@ -2710,7 +2722,8 @@ public enum CIStatus: Decodable {
case sndSent(sndProgress: SndCIStatusProgress)
case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress)
case sndErrorAuth
case sndError(agentError: String)
case sndError(agentError: SndError)
case sndWarning(agentError: SndError)
case rcvNew
case rcvRead
case invalid(text: String)
@@ -2722,6 +2735,7 @@ public enum CIStatus: Decodable {
case .sndRcvd: return "sndRcvd"
case .sndErrorAuth: return "sndErrorAuth"
case .sndError: return "sndError"
case .sndWarning: return "sndWarning"
case .rcvNew: return "rcvNew"
case .rcvRead: return "rcvRead"
case .invalid: return "invalid"
@@ -2738,7 +2752,8 @@ public enum CIStatus: Decodable {
case .badMsgHash: return ("checkmark", .red)
}
case .sndErrorAuth: return ("multiply", .red)
case .sndError: return ("exclamationmark.triangle.fill", .yellow)
case .sndError: return ("multiply", .red)
case .sndWarning: return ("exclamationmark.triangle.fill", .orange)
case .rcvNew: return ("circlebadge.fill", Color.accentColor)
case .rcvRead: return nil
case .invalid: return ("questionmark", metaColor)
@@ -2756,7 +2771,11 @@ public enum CIStatus: Decodable {
)
case let .sndError(agentError): return (
NSLocalizedString("Message delivery error", comment: "item status text"),
String.localizedStringWithFormat(NSLocalizedString("Unexpected error: %@", comment: "item status description"), agentError)
agentError.errorInfo
)
case let .sndWarning(agentError): return (
NSLocalizedString("Message delivery warning", comment: "item status text"),
agentError.errorInfo
)
case .rcvNew: return nil
case .rcvRead: return nil
@@ -2768,6 +2787,42 @@ public enum CIStatus: Decodable {
}
}
public enum SndError: Decodable {
case auth
case quota
case expired
case relay(srvError: SrvError)
case proxy(proxyServer: String, srvError: SrvError)
case proxyRelay(proxyServer: String, srvError: SrvError)
case other(sndError: String)
public var errorInfo: String {
switch self {
case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text")
case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text")
case .expired: NSLocalizedString("Network issues - message expired after many attempts to send it.", comment: "snd error text")
case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("Destination server error: %@", comment: "snd error text"), srvError.errorInfo)
case let .proxy(proxyServer, srvError): String.localizedStringWithFormat(NSLocalizedString("Forwarding server: %@\nError: %@", comment: "snd error text"), proxyServer, srvError.errorInfo)
case let .proxyRelay(proxyServer, srvError): String.localizedStringWithFormat(NSLocalizedString("Forwarding server: %@\nDestination server error: %@", comment: "snd error text"), proxyServer, srvError.errorInfo)
case let .other(sndError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "snd error text"), sndError)
}
}
}
public enum SrvError: Decodable {
case host
case version
case other(srvError: String)
public var errorInfo: String {
switch self {
case .host: NSLocalizedString("Server address is incompatible with network settings.", comment: "srv error text.")
case .version: NSLocalizedString("Server version is incompatible with network settings.", comment: "srv error text")
case let .other(srvError): srvError
}
}
}
public enum MsgReceiptStatus: String, Decodable {
case ok
case badMsgHash
@@ -3119,6 +3174,7 @@ public struct CIFile: Decodable {
case .rcvInvitation: return false
case .rcvAccepted: return false
case .rcvTransfer: return false
case .rcvAborted: return false
case .rcvCancelled: return false
case .rcvComplete: return true
case .rcvError: return false
@@ -3143,6 +3199,7 @@ public struct CIFile: Decodable {
case .rcvInvitation: return nil
case .rcvAccepted: return rcvCancelAction
case .rcvTransfer: return rcvCancelAction
case .rcvAborted: return nil
case .rcvCancelled: return nil
case .rcvComplete: return nil
case .rcvError: return nil
@@ -3257,6 +3314,7 @@ public enum CIFileStatus: Decodable, Equatable {
case rcvInvitation
case rcvAccepted
case rcvTransfer(rcvProgress: Int64, rcvTotal: Int64)
case rcvAborted
case rcvComplete
case rcvCancelled
case rcvError
@@ -3272,6 +3330,7 @@ public enum CIFileStatus: Decodable, Equatable {
case .rcvInvitation: return "rcvInvitation"
case .rcvAccepted: return "rcvAccepted"
case let .rcvTransfer(rcvProgress, rcvTotal): return "rcvTransfer \(rcvProgress) \(rcvTotal)"
case .rcvAborted: return "rcvAborted"
case .rcvComplete: return "rcvComplete"
case .rcvCancelled: return "rcvCancelled"
case .rcvError: return "rcvError"
@@ -3886,4 +3945,5 @@ public struct ChatItemVersion: Decodable {
public struct MemberDeliveryStatus: Decodable {
public var groupMemberId: Int64
public var memberDeliveryStatus: CIStatus
public var sentViaProxy: Bool?
}
@@ -7,7 +7,7 @@ import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
import chat.simplex.common.model.ChatController
import chat.simplex.common.views.helpers.DBMigrationResult
import chat.simplex.common.platform.chatModel
import chat.simplex.common.platform.initChatControllerAndRunMigrations
import chat.simplex.common.platform.initChatControllerOnStart
import chat.simplex.common.views.helpers.DatabaseUtils
import kotlinx.coroutines.*
import java.util.Date
@@ -60,7 +60,7 @@ class MessagesFetcherWork(
try {
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
initChatControllerAndRunMigrations()
initChatControllerOnStart()
}
withTimeout(durationSeconds * 1000L) {
val chatController = ChatController
@@ -6,7 +6,6 @@ import android.content.Context
import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.media.AudioManager
import android.os.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -66,6 +65,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
context = this
initHaskell()
initMultiplatform()
runMigrations()
tmpDir.deleteRecursively()
tmpDir.mkdir()
@@ -74,7 +74,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
// It's important, otherwise, user may be locked in undefined state
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else if (DatabaseUtils.ksAppPassword.get() == null || DatabaseUtils.ksSelfDestructPassword.get() == null) {
initChatControllerAndRunMigrations()
initChatControllerOnStart()
}
ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
}
@@ -254,7 +254,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM.name) {
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
null
} else {
CurrentColors.value.colors.isLight
@@ -77,7 +77,7 @@ class SimplexService: Service() {
isServiceStarted = true
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
initChatControllerAndRunMigrations()
initChatControllerOnStart()
}
}
}
+4 -1
View File
@@ -37,7 +37,7 @@ kotlin {
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
api("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
api("com.russhwolf:multiplatform-settings:1.1.1")
api("com.charleskorn.kaml:kaml:0.58.0")
api("com.charleskorn.kaml:kaml:0.59.0")
api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}")
implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}")
//Barcode
@@ -53,6 +53,9 @@ kotlin {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
val androidMain by getting {
@@ -15,8 +15,10 @@ actual val dataDir: File = androidAppContext.dataDir
actual val tmpDir: File = androidAppContext.getDir("temp", Application.MODE_PRIVATE)
actual val filesDir: File = File(dataDir.absolutePath + File.separator + "files")
actual val appFilesDir: File = File(filesDir.absolutePath + File.separator + "app_files")
actual val wallpapersDir: File = File(filesDir.absolutePath + File.separator + "assets" + File.separator + "wallpapers").also { it.mkdirs() }
actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files")
actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files"
actual val preferencesDir = File(dataDir.absolutePath + File.separator + "shared_prefs")
actual val chatDatabaseFileName: String = "files_chat.db"
actual val agentDatabaseFileName: String = "files_agent.db"
@@ -7,6 +7,8 @@ import android.content.SharedPreferences
import android.content.res.Configuration
import android.text.BidiFormatter
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
@@ -14,9 +16,11 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.common.model.AppPreferences
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.desc.desc
@@ -51,3 +55,6 @@ actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun desktopExpandWindowToWidth(width: Dp) {}
actual fun isRtl(text: CharSequence): Boolean = BidiFormatter.getInstance().isRtl(text)
actual fun ImageResource.toComposeImageBitmap(): ImageBitmap? =
getDrawable(androidAppContext)?.toBitmap()?.asImageBitmap()
@@ -19,14 +19,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.common.R
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -34,7 +30,6 @@ import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.saveAppLocale
import chat.simplex.common.views.usersettings.AppearanceScope.ColorEditor
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.painterResource
@@ -46,9 +41,8 @@ enum class AppIcon(val image: ImageResource) {
}
@Composable
actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
actual fun AppearanceView(m: ChatModel) {
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
fun setAppIcon(newIcon: AppIcon) {
if (appIcon.value == newIcon) return
val newComponent = ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
@@ -65,18 +59,11 @@ actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatMod
appIcon.value = newIcon
}
AppearanceScope.AppearanceLayout(
appIcon,
m.controller.appPrefs.appLanguage,
m.controller.appPrefs.systemDarkTheme,
changeIcon = ::setAppIcon,
showSettingsModal = showSettingsModal,
editColor = { name, initialColor ->
ModalManager.start.showModalCloseable { close ->
ColorEditor(name, initialColor, close)
}
},
)
}
@@ -86,8 +73,6 @@ fun AppearanceScope.AppearanceLayout(
languagePref: SharedPreference<String?>,
systemDarkTheme: SharedPreference<String?>,
changeIcon: (AppIcon) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
editColor: (ThemeColor, Color) -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
@@ -120,6 +105,13 @@ fun AppearanceScope.AppearanceLayout(
}
// }
}
SectionDividerSpaced(maxTopPadding = true)
ThemesSection(systemDarkTheme)
SectionDividerSpaced(maxTopPadding = true)
ProfileImageSection()
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
@@ -145,11 +137,6 @@ fun AppearanceScope.AppearanceLayout(
}
}
SectionDividerSpaced(maxTopPadding = true)
ProfileImageSection()
SectionDividerSpaced(maxTopPadding = true)
ThemesSection(systemDarkTheme, showSettingsModal, editColor)
SectionBottomSpacer()
}
}
@@ -169,8 +156,6 @@ fun PreviewAppearanceSettings() {
languagePref = SharedPreference({ null }, {}),
systemDarkTheme = SharedPreference({ null }, {}),
changeIcon = {},
showSettingsModal = { {} },
editColor = { _, _ -> },
)
}
}
@@ -4,7 +4,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
@@ -399,6 +399,18 @@ object ChatModel {
currentUser.value = updated
}
fun updateCurrentUserUiThemes(rhId: Long?, uiThemes: ThemeModeOverrides?) {
val current = currentUser.value ?: return
val updated = current.copy(
uiThemes = uiThemes
)
val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId }
if (i != -1) {
users[i] = users[i].copy(user = updated)
}
currentUser.value = updated
}
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withContext(Dispatchers.Main) {
@@ -682,7 +694,8 @@ data class User(
override val showNtfs: Boolean,
val sendRcptsContacts: Boolean,
val sendRcptsSmallGroups: Boolean,
val viewPwdHash: UserPwdHash?
val viewPwdHash: UserPwdHash?,
val uiThemes: ThemeModeOverrides? = null,
): NamedChat, UserLike {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
@@ -709,6 +722,7 @@ data class User(
sendRcptsContacts = true,
sendRcptsSmallGroups = false,
viewPwdHash = null,
uiThemes = null,
)
}
}
@@ -1041,16 +1055,21 @@ data class Contact(
override val updatedAt: Instant,
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean
val contactGrpInvSent: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
override val id get() = "@$contactId"
override val apiId get() = contactId
override val ready get() = activeConn?.connStatus == ConnStatus.Ready
val active get() = contactStatus == ContactStatus.Active
override val sendMsgEnabled get() =
(ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false))
|| nextSendGrpInv
override val sendMsgEnabled get() = (
ready
&& active
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)
&& !(activeConn?.connDisabled ?: true)
)
|| nextSendGrpInv
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = contactConnIncognito
@@ -1109,7 +1128,8 @@ data class Contact(
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false
contactGrpInvSent = false,
uiThemes = null,
)
}
}
@@ -1150,15 +1170,19 @@ data class Connection(
val pqEncryption: Boolean,
val pqSndEnabled: Boolean? = null,
val pqRcvEnabled: Boolean? = null,
val connectionStats: ConnectionStats? = null
val connectionStats: ConnectionStats? = null,
val authErrCounter: Int
) {
val id: ChatId get() = ":$connId"
val connDisabled: Boolean
get() = authErrCounter >= 10 // authErrDisableCount in core
val connPQEnabled: Boolean
get() = pqSndEnabled == true && pqRcvEnabled == true
companion object {
val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false)
val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false, authErrCounter = 0)
}
}
@@ -1245,7 +1269,8 @@ data class GroupInfo (
val chatSettings: ChatSettings,
override val createdAt: Instant,
override val updatedAt: Instant,
val chatTs: Instant?
val chatTs: Instant?,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -1287,7 +1312,8 @@ data class GroupInfo (
chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
chatTs = Clock.System.now()
chatTs = Clock.System.now(),
uiThemes = null,
)
}
}
@@ -1900,18 +1926,20 @@ data class ChatItem (
ts: Instant = Clock.System.now(),
text: String = "hello\nthere",
status: CIStatus = CIStatus.SndNew(),
sentViaProxy: Boolean? = null,
quotedItem: CIQuote? = null,
file: CIFile? = null,
itemForwarded: CIForwardedFrom? = null,
itemDeleted: CIDeleted? = null,
itemEdited: Boolean = false,
itemTimed: CITimed? = null,
itemLive: Boolean = false,
deletable: Boolean = true,
editable: Boolean = true
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status, itemForwarded, itemDeleted, itemEdited, itemTimed, deletable, editable),
meta = CIMeta.getSample(id, ts, text, status, sentViaProxy, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
reactions = listOf(),
@@ -1993,6 +2021,7 @@ data class ChatItem (
itemTs = Clock.System.now(),
itemText = generalGetString(MR.strings.deleted_description),
itemStatus = CIStatus.RcvRead(),
sentViaProxy = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
@@ -2016,6 +2045,7 @@ data class ChatItem (
itemTs = Clock.System.now(),
itemText = "",
itemStatus = CIStatus.RcvRead(),
sentViaProxy = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
@@ -2118,6 +2148,7 @@ data class CIMeta (
val itemTs: Instant,
val itemText: String,
val itemStatus: CIStatus,
val sentViaProxy: Boolean?,
val createdAt: Instant,
val updatedAt: Instant,
val itemForwarded: CIForwardedFrom?,
@@ -2144,7 +2175,7 @@ data class CIMeta (
companion object {
fun getSample(
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), sentViaProxy: Boolean? = null,
itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false,
itemTimed: CITimed? = null, itemLive: Boolean = false, deletable: Boolean = true, editable: Boolean = true
): CIMeta =
@@ -2153,6 +2184,7 @@ data class CIMeta (
itemTs = ts,
itemText = text,
itemStatus = status,
sentViaProxy = sentViaProxy,
createdAt = ts,
updatedAt = ts,
itemForwarded = itemForwarded,
@@ -2171,6 +2203,7 @@ data class CIMeta (
itemTs = Clock.System.now(),
itemText = "invalid JSON",
itemStatus = CIStatus.SndNew(),
sentViaProxy = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
@@ -2227,7 +2260,8 @@ sealed class CIStatus {
@Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus()
@Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus()
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
@Serializable @SerialName("sndError") class CISSndError(val agentError: SndError): CIStatus()
@Serializable @SerialName("sndWarning") class SndWarning(val agentError: SndError): CIStatus()
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
@Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()
@@ -2251,7 +2285,8 @@ sealed class CIStatus {
MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
}
is SndErrorAuth -> MR.images.ic_close to Color.Red
is SndError -> MR.images.ic_warning_filled to WarningYellow
is CISSndError -> MR.images.ic_close to Color.Red
is SndWarning -> MR.images.ic_warning_filled to WarningOrange
is RcvNew -> MR.images.ic_circle_filled to primaryColor
is RcvRead -> null
is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
@@ -2262,13 +2297,48 @@ sealed class CIStatus {
is SndSent -> null
is SndRcvd -> null
is SndErrorAuth -> generalGetString(MR.strings.message_delivery_error_title) to generalGetString(MR.strings.message_delivery_error_desc)
is SndError -> generalGetString(MR.strings.message_delivery_error_title) to (generalGetString(MR.strings.unknown_error) + ": $agentError")
is CISSndError -> generalGetString(MR.strings.message_delivery_error_title) to agentError.errorInfo
is SndWarning -> generalGetString(MR.strings.message_delivery_warning_title) to agentError.errorInfo
is RcvNew -> null
is RcvRead -> null
is Invalid -> "Invalid status" to this.text
}
}
@Serializable
sealed class SndError {
@Serializable @SerialName("auth") class Auth: SndError()
@Serializable @SerialName("quota") class Quota: SndError()
@Serializable @SerialName("expired") class Expired: SndError()
@Serializable @SerialName("relay") class Relay(val srvError: SrvError): SndError()
@Serializable @SerialName("proxy") class Proxy(val proxyServer: String, val srvError: SrvError): SndError()
@Serializable @SerialName("proxyRelay") class ProxyRelay(val proxyServer: String, val srvError: SrvError): SndError()
@Serializable @SerialName("other") class Other(val sndError: String): SndError()
val errorInfo: String get() = when (this) {
is SndError.Auth -> generalGetString(MR.strings.snd_error_auth)
is SndError.Quota -> generalGetString(MR.strings.snd_error_quota)
is SndError.Expired -> generalGetString(MR.strings.snd_error_expired)
is SndError.Relay -> generalGetString(MR.strings.snd_error_relay).format(srvError.errorInfo)
is SndError.Proxy -> generalGetString(MR.strings.snd_error_proxy).format(proxyServer, srvError.errorInfo)
is SndError.ProxyRelay -> generalGetString(MR.strings.snd_error_proxy_relay).format(proxyServer, srvError.errorInfo)
is SndError.Other -> generalGetString(MR.strings.ci_status_other_error).format(sndError)
}
}
@Serializable
sealed class SrvError {
@Serializable @SerialName("host") class Host: SrvError()
@Serializable @SerialName("version") class Version: SrvError()
@Serializable @SerialName("other") class Other(val srvError: String): SrvError()
val errorInfo: String get() = when (this) {
is SrvError.Host -> generalGetString(MR.strings.srv_error_host)
is SrvError.Version -> generalGetString(MR.strings.srv_error_version)
is SrvError.Other -> srvError
}
}
@Serializable
enum class MsgReceiptStatus {
@SerialName("ok") Ok,
@@ -2593,6 +2663,7 @@ data class CIFile(
is CIFileStatus.RcvInvitation -> false
is CIFileStatus.RcvAccepted -> false
is CIFileStatus.RcvTransfer -> false
is CIFileStatus.RcvAborted -> false
is CIFileStatus.RcvCancelled -> false
is CIFileStatus.RcvComplete -> true
is CIFileStatus.RcvError -> false
@@ -2614,6 +2685,7 @@ data class CIFile(
is CIFileStatus.RcvInvitation -> null
is CIFileStatus.RcvAccepted -> rcvCancelAction
is CIFileStatus.RcvTransfer -> rcvCancelAction
is CIFileStatus.RcvAborted -> null
is CIFileStatus.RcvCancelled -> null
is CIFileStatus.RcvComplete -> null
is CIFileStatus.RcvError -> null
@@ -2794,6 +2866,7 @@ sealed class CIFileStatus {
@Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
@Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
@Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
@Serializable @SerialName("rcvAborted") object RcvAborted: CIFileStatus()
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
@@ -2808,6 +2881,7 @@ sealed class CIFileStatus {
is RcvInvitation -> false
is RcvAccepted -> false
is RcvTransfer -> false
is RcvAborted -> false
is RcvComplete -> false
is RcvCancelled -> false
is RcvError -> false
@@ -3354,7 +3428,8 @@ data class ChatItemVersion(
@Serializable
data class MemberDeliveryStatus(
val groupMemberId: Long,
val memberDeliveryStatus: CIStatus
val memberDeliveryStatus: CIStatus,
val sentViaProxy: Boolean?
)
enum class NotificationPreviewMode {
@@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.group.toggleShowMemberMessages
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
@@ -25,8 +26,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import java.util.Date
@@ -106,6 +106,7 @@ class AppPreferences {
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -130,6 +131,8 @@ class AppPreferences {
},
set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) }
)
val networkSMPProxyMode = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, SMPProxyMode.Never.name)
val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, SMPProxyFallback.Allow.name)
val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name)
val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false)
val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
@@ -164,13 +167,20 @@ class AppPreferences {
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name)
val themeOverrides = mkMapPreference(SHARED_PREFS_THEMES, mapOf(), encode = {
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM_THEME_NAME)
val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.themeName)
val currentThemeIds = mkMapPreference(SHARED_PREFS_CURRENT_THEME_IDs, mapOf(), encode = {
json.encodeToString(MapSerializer(String.serializer(), String.serializer()), it)
}, decode = {
json.decodeFromString(MapSerializer(String.serializer(), String.serializer()), it)
})
// Deprecated. Remove key from preferences in 2025
val themeOverridesOld = mkMapPreference(SHARED_PREFS_THEMES_OLD, mapOf(), encode = {
json.encodeToString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
}, decode = {
json.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
jsonCoerceInputValues.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
}, settingsThemes)
val themeOverrides = mkThemeOverridesPreference()
val profileImageCornerRadius = mkFloatPreference(SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS, 22.5f)
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
@@ -185,6 +195,8 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
@@ -263,6 +275,12 @@ class AppPreferences {
set = fun(value) = prefs.putString(prefName, encode(value))
)
private fun mkThemeOverridesPreference(): SharedPreference<List<ThemeOverrides>> =
SharedPreference(
get = fun() = themeOverridesStore ?: (readThemeOverrides()).also { themeOverridesStore = it },
set = fun(value) { if (writeThemeOverrides(value)) { themeOverridesStore = value } }
)
companion object {
const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
internal const val SHARED_PREFS_THEMES_ID = "chat.simplex.app.THEMES"
@@ -288,6 +306,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -306,6 +325,8 @@ class AppPreferences {
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
private const val SHARED_PREFS_NETWORK_SMP_PROXY_MODE = "NetworkSMPProxyMode"
private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback"
private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode"
private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode"
private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
@@ -336,8 +357,10 @@ class AppPreferences {
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
private const val SHARED_PREFS_CURRENT_THEME_IDs = "CurrentThemeIds"
private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme"
private const val SHARED_PREFS_THEMES = "Themes"
private const val SHARED_PREFS_THEMES_OLD = "Themes"
private const val SHARED_PREFS_THEME_OVERRIDES = "ThemeOverrides"
private const val SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS = "ProfileImageCornerRadius"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
@@ -348,9 +371,12 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents"
private var themeOverridesStore: List<ThemeOverrides>? = null
}
}
@@ -435,8 +461,13 @@ object ChatController {
Log.d(TAG, "startChatWithTemporaryDatabase")
val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl)
apiSetNetworkConfig(netCfg, ctrl)
apiSetTempFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
apiSetFilesFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
apiSetAppFilePaths(
getMigrationTempFilesDirectory().absolutePath,
getMigrationTempFilesDirectory().absolutePath,
wallpapersDir.parentFile.absolutePath,
remoteHostsDir.absolutePath,
ctrl
)
apiStartChat(ctrl)
return migrationActiveUser
}
@@ -655,22 +686,10 @@ object ChatController {
}
}
suspend fun apiSetTempFolder(tempFolder: String, ctrl: ChatCtrl? = null) {
val r = sendCmd(null, CC.SetTempFolder(tempFolder), ctrl)
suspend fun apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, remoteHostsFolder: String, ctrl: ChatCtrl? = null) {
val r = sendCmd(null, CC.ApiSetAppFilePaths(filesFolder, tempFolder, assetsFolder, remoteHostsFolder), ctrl)
if (r is CR.CmdOk) return
throw Exception("failed to set temp folder: ${r.responseType} ${r.details}")
}
suspend fun apiSetFilesFolder(filesFolder: String, ctrl: ChatCtrl? = null) {
val r = sendCmd(null, CC.SetFilesFolder(filesFolder), ctrl)
if (r is CR.CmdOk) return
throw Exception("failed to set files folder: ${r.responseType} ${r.details}")
}
suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) {
val r = sendCmd(null, CC.SetRemoteHostsFolder(remoteHostsFolder))
if (r is CR.CmdOk) return
throw Exception("failed to set remote hosts folder: ${r.responseType} ${r.details}")
throw Exception("failed to set app file paths: ${r.responseType} ${r.details}")
}
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable))
@@ -775,8 +794,8 @@ object ChatController {
}
}
suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long): ChatItem? {
val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId)
suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long, ttl: Int?): ChatItem? {
val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl)
return processSendMessageCmd(rh, cmd)?.chatItem
}
@@ -1156,6 +1175,20 @@ object ChatController {
return null
}
suspend fun apiSetUserUIThemes(rh: Long?, userId: Long, themes: ThemeModeOverrides?): Boolean {
val r = sendCmd(rh, CC.ApiSetUserUIThemes(userId, themes))
if (r is CR.CmdOk) return true
Log.e(TAG, "apiSetUserUIThemes bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiSetChatUIThemes(rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?): Boolean {
val r = sendCmd(rh, CC.ApiSetChatUIThemes(chatId, themes))
if (r is CR.CmdOk) return true
Log.e(TAG, "apiSetChatUIThemes bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiCreateUserAddress(rh: Long?): String? {
val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null }
val r = sendCmd(rh, CC.ApiCreateMyAddress(userId))
@@ -1330,9 +1363,9 @@ object ChatController {
}
}
suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
suspend fun apiReceiveFile(rh: Long?, fileId: Long, userApprovedRelays: Boolean, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
// -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected
val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline))
val r = sendCmd(rh, CC.ReceiveFile(fileId, userApprovedRelays = userApprovedRelays, encrypt = encrypted, inline = inline))
return when (r) {
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
@@ -1351,7 +1384,23 @@ object ChatController {
val maybeChatError = chatError(r)
if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) {
Log.d(TAG, "apiReceiveFile ignoring FileCancelled or FileAlreadyReceiving error")
} else {
} else if (maybeChatError is ChatErrorType.FileNotApproved) {
Log.d(TAG, "apiReceiveFile FileNotApproved error")
if (!auto) {
val srvs = maybeChatError.unknownServers.map{ serverHostname(it) }
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.file_not_approved_title),
text = generalGetString(MR.strings.file_not_approved_descr).format(srvs.sorted().joinToString(separator = ", ")),
confirmText = generalGetString(MR.strings.download_file),
onConfirm = {
val user = chatModel.currentUser.value
if (user != null) {
withBGApi { chatModel.controller.receiveFile(rh, user, fileId, userApprovedRelays = true) }
}
},
)
}
} else if (!auto) {
apiErrorAlert("apiReceiveFile", generalGetString(MR.strings.error_receiving_file), r)
}
}
@@ -2031,13 +2080,21 @@ object ChatController {
}
}
is CR.ContactSwitch ->
chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats)
if (active(r.user)) {
chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats)
}
is CR.GroupMemberSwitch ->
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats)
if (active(r.user)) {
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats)
}
is CR.ContactRatchetSync ->
chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats)
if (active(r.user)) {
chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats)
}
is CR.GroupMemberRatchetSync ->
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
if (active(r.user)) {
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
}
is CR.RemoteHostSessionCode -> {
chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode)
}
@@ -2046,6 +2103,11 @@ object ChatController {
chatModel.currentRemoteHost.value = r.remoteHost
switchUIRemoteHost(r.remoteHost.remoteHostId)
}
is CR.ContactDisabled -> {
if (active(r.user)) {
chatModel.updateContact(rhId, r.contact)
}
}
is CR.RemoteHostStopped -> {
val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ }
chatModel.remoteHostPairing.value = null
@@ -2196,9 +2258,14 @@ object ChatController {
}
}
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, auto: Boolean = false) {
val encrypted = appPrefs.privacyEncryptLocalFiles.get()
val chatItem = apiReceiveFile(rhId, fileId, encrypted = encrypted, auto = auto)
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) {
val chatItem = apiReceiveFile(
rhId,
fileId,
userApprovedRelays = userApprovedRelays || !appPrefs.privacyAskToApproveRelays.get(),
encrypted = appPrefs.privacyEncryptLocalFiles.get(),
auto = auto
)
if (chatItem != null) {
chatItemSimpleUpdate(rhId, user, chatItem)
}
@@ -2302,6 +2369,8 @@ object ChatController {
val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!)
val requiredHostMode = appPrefs.networkRequiredHostMode.get()
val sessionMode = appPrefs.networkSessionMode.get()
val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!)
val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!)
val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
val tcpTimeout = appPrefs.networkTCPTimeout.get()
val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get()
@@ -2322,6 +2391,8 @@ object ChatController {
hostMode = hostMode,
requiredHostMode = requiredHostMode,
sessionMode = sessionMode,
smpProxyMode = smpProxyMode,
smpProxyFallback = smpProxyFallback,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpTimeoutPerKb = tcpTimeoutPerKb,
@@ -2340,6 +2411,8 @@ object ChatController {
appPrefs.networkHostMode.set(cfg.hostMode.name)
appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode)
appPrefs.networkSessionMode.set(cfg.sessionMode)
appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name)
appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name)
appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb)
@@ -2387,9 +2460,8 @@ sealed class CC {
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
class StartChat(val mainApp: Boolean): CC()
class ApiStopChat: CC()
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class SetRemoteHostsFolder(val remoteHostsFolder: String): CC()
@Serializable
class ApiSetAppFilePaths(val appFilesFolder: String, val appTempFolder: String, val appAssetsFolder: String, val appRemoteHostsFolder: String): CC()
class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
@@ -2407,7 +2479,7 @@ sealed class CC {
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long): CC()
class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long, val ttl: Int?): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
@@ -2457,6 +2529,8 @@ sealed class CC {
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC()
class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC()
class ApiCreateMyAddress(val userId: Long): CC()
class ApiDeleteMyAddress(val userId: Long): CC()
class ApiShowMyAddress(val userId: Long): CC()
@@ -2475,7 +2549,7 @@ sealed class CC {
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC()
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
// Remote control
class SetLocalDeviceName(val displayName: String): CC()
@@ -2524,9 +2598,7 @@ sealed class CC {
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}"
is StartChat -> "/_start main=${onOff(mainApp)}"
is ApiStopChat -> "/_stop"
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder"
is ApiSetAppFilePaths -> "/set file paths ${json.encodeToString(this)}"
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
@@ -2549,7 +2621,10 @@ sealed class CC {
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiForwardChatItem -> "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId"
is ApiForwardChatItem -> {
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId ttl=${ttlStr}"
}
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
@@ -2603,6 +2678,8 @@ sealed class CC {
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}"
is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}"
is ApiCreateMyAddress -> "/_address $userId"
is ApiDeleteMyAddress -> "/_delete_address $userId"
is ApiShowMyAddress -> "/_show_address $userId"
@@ -2623,6 +2700,7 @@ sealed class CC {
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile ->
"/freceive $fileId" +
(" approved_relays=${onOff(userApprovedRelays)}") +
(if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") +
(if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
@@ -2666,9 +2744,7 @@ sealed class CC {
is ApiDeleteUser -> "apiDeleteUser"
is StartChat -> "startChat"
is ApiStopChat -> "apiStopChat"
is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder"
is SetRemoteHostsFolder -> "setRemoteHostsFolder"
is ApiSetAppFilePaths -> "apiSetAppFilePaths"
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
@@ -2736,6 +2812,8 @@ sealed class CC {
is ApiSetContactPrefs -> "apiSetContactPrefs"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is ApiSetUserUIThemes -> "apiSetUserUIThemes"
is ApiSetChatUIThemes -> "apiSetChatUIThemes"
is ApiCreateMyAddress -> "apiCreateMyAddress"
is ApiDeleteMyAddress -> "apiDeleteMyAddress"
is ApiShowMyAddress -> "apiShowMyAddress"
@@ -3025,9 +3103,12 @@ data class ParsedServerAddress (
@Serializable
data class NetCfg(
val socksProxy: String?,
val socksMode: SocksMode = SocksMode.Always,
val hostMode: HostMode,
val requiredHostMode: Boolean,
val sessionMode: TransportSessionMode,
val smpProxyMode: SMPProxyMode,
val smpProxyFallback: SMPProxyFallback,
val tcpConnectTimeout: Long, // microseconds
val tcpTimeout: Long, // microseconds
val tcpTimeoutPerKb: Long, // microseconds
@@ -3056,6 +3137,8 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
smpProxyMode = SMPProxyMode.Never,
smpProxyFallback = SMPProxyFallback.Allow,
tcpConnectTimeout = 25_000_000,
tcpTimeout = 15_000_000,
tcpTimeoutPerKb = 10_000,
@@ -3071,6 +3154,8 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
smpProxyMode = SMPProxyMode.Never,
smpProxyFallback = SMPProxyFallback.Allow,
tcpConnectTimeout = 35_000_000,
tcpTimeout = 20_000_000,
tcpTimeoutPerKb = 15_000,
@@ -3109,6 +3194,35 @@ enum class HostMode {
@SerialName("public") Public;
}
@Serializable
enum class SocksMode {
@SerialName("always") Always,
@SerialName("onion") Onion;
}
@Serializable
enum class SMPProxyMode {
@SerialName("always") Always,
@SerialName("unknown") Unknown,
@SerialName("unprotected") Unprotected,
@SerialName("never") Never;
companion object {
val default = Never
}
}
@Serializable
enum class SMPProxyFallback {
@SerialName("allow") Allow,
@SerialName("allowProtected") AllowProtected,
@SerialName("prohibit") Prohibit;
companion object {
val default = Allow
}
}
@Serializable
enum class TransportSessionMode {
@SerialName("user") User,
@@ -3971,6 +4085,15 @@ val json = Json {
explicitNulls = false
}
// Can decode unknown enum to default value specified for this field
val jsonCoerceInputValues = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
coerceInputValues = true
}
val jsonShort = Json {
prettyPrint = false
ignoreUnknownKeys = true
@@ -3981,6 +4104,8 @@ val jsonShort = Json {
val yaml = Yaml(configuration = YamlConfiguration(
strictMode = false,
encodeDefaults = false,
/** ~5.5 MB limitation since wallpaper is limited by 5 MB, see [saveWallpaperFile] */
codePointLimit = 5500000,
))
@Serializable
@@ -4197,6 +4322,7 @@ sealed class CR {
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactDisabled") class ContactDisabled(val user: UserRef, val contact: Contact): CR()
// remote events (desktop)
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
@Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR()
@@ -4361,6 +4487,7 @@ sealed class CR {
is CallExtraInfo -> "callExtraInfo"
is CallEnded -> "callEnded"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is ContactDisabled -> "contactDisabled"
is RemoteHostList -> "remoteHostList"
is CurrentRemoteHost -> "currentRemoteHost"
is RemoteHostStarted -> "remoteHostStarted"
@@ -4521,6 +4648,7 @@ sealed class CR {
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}")
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
is ContactDisabled -> withUser(user, json.encodeToString(contact))
// remote events (mobile)
is RemoteHostList -> json.encodeToString(remoteHosts)
is CurrentRemoteHost -> if (remoteHost_ == null) "local" else json.encodeToString(remoteHost_)
@@ -4824,13 +4952,14 @@ sealed class ChatErrorType {
is FileCancel -> "fileCancel"
is FileAlreadyExists -> "fileAlreadyExists"
is FileRead -> "fileRead"
is FileWrite -> "fileWrite"
is FileWrite -> "fileWrite $message"
is FileSend -> "fileSend"
is FileRcvChunk -> "fileRcvChunk"
is FileInternal -> "fileInternal"
is FileImageType -> "fileImageType"
is FileImageSize -> "fileImageSize"
is FileNotReceived -> "fileNotReceived"
is FileNotApproved -> "fileNotApproved"
// is XFTPRcvFile -> "xftpRcvFile"
// is XFTPSndFile -> "xftpSndFile"
is FallbackToSMPProhibited -> "fallbackToSMPProhibited"
@@ -4910,6 +5039,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("fileImageType") class FileImageType(val filePath: String): ChatErrorType()
@Serializable @SerialName("fileImageSize") class FileImageSize(val filePath: String): ChatErrorType()
@Serializable @SerialName("fileNotReceived") class FileNotReceived(val fileId: Long): ChatErrorType()
@Serializable @SerialName("fileNotApproved") class FileNotApproved(val fileId: Long, val unknownServers: List<String>): ChatErrorType()
// @Serializable @SerialName("xFTPRcvFile") object XFTPRcvFile: ChatErrorType()
// @Serializable @SerialName("xFTPSndFile") object XFTPSndFile: ChatErrorType()
@Serializable @SerialName("fallbackToSMPProhibited") class FallbackToSMPProhibited(val fileId: Long): ChatErrorType()
@@ -5408,6 +5538,7 @@ enum class NotificationsMode() {
data class AppSettings(
var networkConfig: NetCfg? = null,
var privacyEncryptLocalFiles: Boolean? = null,
var privacyAskToApproveRelays: Boolean? = null,
var privacyAcceptImages: Boolean? = null,
var privacyLinkPreviews: Boolean? = null,
var privacyShowChatPreviews: Boolean? = null,
@@ -5425,12 +5556,18 @@ data class AppSettings(
var androidCallOnLockScreen: AppSettingsLockScreenCalls? = null,
var iosCallKitEnabled: Boolean? = null,
var iosCallKitCallsInRecents: Boolean? = null,
var uiProfileImageCornerRadius: Float? = null,
var uiColorScheme: String? = null,
var uiDarkColorScheme: String? = null,
var uiCurrentThemeIds: Map<String, String>? = null,
var uiThemes: List<ThemeOverrides>? = null,
) {
fun prepareForExport(): AppSettings {
val empty = AppSettings()
val def = defaults
if (networkConfig != def.networkConfig) { empty.networkConfig = networkConfig }
if (privacyEncryptLocalFiles != def.privacyEncryptLocalFiles) { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages }
if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews }
if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews }
@@ -5448,6 +5585,11 @@ data class AppSettings(
if (androidCallOnLockScreen != def.androidCallOnLockScreen) { empty.androidCallOnLockScreen = androidCallOnLockScreen }
if (iosCallKitEnabled != def.iosCallKitEnabled) { empty.iosCallKitEnabled = iosCallKitEnabled }
if (iosCallKitCallsInRecents != def.iosCallKitCallsInRecents) { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
if (uiProfileImageCornerRadius != def.uiProfileImageCornerRadius) { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius }
if (uiColorScheme != def.uiColorScheme) { empty.uiColorScheme = uiColorScheme }
if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme }
if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds }
if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes }
return empty
}
@@ -5462,6 +5604,7 @@ data class AppSettings(
setNetCfg(net)
}
privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) }
privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) }
privacyAcceptImages?.let { def.privacyAcceptImages.set(it) }
privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) }
privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) }
@@ -5479,6 +5622,11 @@ data class AppSettings(
androidCallOnLockScreen?.let { def.callOnLockScreen.set(it.toCallOnLockScreen()) }
iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) }
iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) }
uiProfileImageCornerRadius?.let { def.profileImageCornerRadius.set(it) }
uiColorScheme?.let { def.currentTheme.set(it) }
uiDarkColorScheme?.let { def.systemDarkTheme.set(it) }
uiCurrentThemeIds?.let { def.currentThemeIds.set(it) }
uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) }
}
companion object {
@@ -5486,6 +5634,7 @@ data class AppSettings(
get() = AppSettings(
networkConfig = NetCfg.defaults,
privacyEncryptLocalFiles = true,
privacyAskToApproveRelays = true,
privacyAcceptImages = true,
privacyLinkPreviews = true,
privacyShowChatPreviews = true,
@@ -5502,7 +5651,12 @@ data class AppSettings(
confirmDBUpgrades = false,
androidCallOnLockScreen = AppSettingsLockScreenCalls.SHOW,
iosCallKitEnabled = true,
iosCallKitCallsInRecents = false
iosCallKitCallsInRecents = false,
uiProfileImageCornerRadius = 22.5f,
uiColorScheme = DefaultTheme.SYSTEM_THEME_NAME,
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = null,
uiThemes = null,
)
val current: AppSettings
@@ -5511,6 +5665,7 @@ data class AppSettings(
return defaults.copy(
networkConfig = getNetCfg(),
privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(),
privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(),
privacyAcceptImages = def.privacyAcceptImages.get(),
privacyLinkPreviews = def.privacyLinkPreviews.get(),
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
@@ -5528,6 +5683,11 @@ data class AppSettings(
androidCallOnLockScreen = AppSettingsLockScreenCalls.from(def.callOnLockScreen.get()),
iosCallKitEnabled = def.iosCallKitEnabled.get(),
iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(),
uiProfileImageCornerRadius = def.profileImageCornerRadius.get(),
uiColorScheme = def.currentTheme.get() ?: DefaultTheme.SYSTEM_THEME_NAME,
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = def.currentThemeIds.get(),
uiThemes = def.themeOverrides.get(),
)
}
}
@@ -2,7 +2,6 @@ package com.sd.lib.compose.wheel_picker
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import chat.simplex.common.ui.theme.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -12,6 +11,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.isInDarkTheme
/**
* The default implementation of focus view in vertical.
@@ -76,7 +76,7 @@ fun FWheelPickerFocusHorizontal(
*/
private val DefaultDividerColor: Color
@Composable
get() = (if (isSystemInDarkTheme()) {
get() = (if (isInDarkTheme()) {
Color.White
} else {
Color.Black
@@ -42,6 +42,12 @@ fun runMigrations() {
ChatController.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
}
lastMigration.set(117)
} else if (lastMigration.get() < 203) {
// Moving to a different key for storing themes as a List
val oldOverrides = ChatController.appPrefs.themeOverridesOld.get().values.toList()
ChatController.appPrefs.themeOverrides.set(oldOverrides)
ChatController.appPrefs.currentThemeIds.set(oldOverrides.associate { it.base.themeName to it.themeId })
lastMigration.set(203)
} else {
lastMigration.set(BuildConfigCommon.ANDROID_VERSION_CODE)
break
@@ -43,14 +43,13 @@ val appPreferences: AppPreferences
val chatController: ChatController = ChatController
fun initChatControllerAndRunMigrations() {
fun initChatControllerOnStart() {
withLongRunningApi {
if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) {
initChatController(startChat = ::showStartChatAfterRestartAlert)
} else {
initChatController()
}
runMigrations()
}
}
@@ -88,11 +87,13 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
return
}
platform.androidRestartNetworkObserver()
controller.apiSetTempFolder(coreTmpDir.absolutePath)
controller.apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
controller.apiSetAppFilePaths(
appFilesDir.absolutePath,
coreTmpDir.absolutePath,
wallpapersDir.parentFile.absolutePath,
remoteHostsDir.absolutePath,
ctrl
)
controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get())
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
@@ -1,10 +1,12 @@
package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import com.charleskorn.kaml.*
import kotlinx.serialization.encodeToString
import java.io.*
import java.net.URI
import java.net.URLDecoder
@@ -14,8 +16,10 @@ expect val dataDir: File
expect val tmpDir: File
expect val filesDir: File
expect val appFilesDir: File
expect val wallpapersDir: File
expect val coreTmpDir: File
expect val dbAbsolutePrefixPath: String
expect val preferencesDir: File
expect val chatDatabaseFileName: String
expect val agentDatabaseFileName: String
@@ -78,6 +82,20 @@ fun getAppFilePath(fileName: String): String {
}
}
fun getWallpaperFilePath(fileName: String): String {
val rh = chatModel.currentRemoteHost.value
val s = File.separator
val path = if (rh == null) {
wallpapersDir.absolutePath + s + fileName
} else {
remoteHostsDir.absolutePath + s + rh.storePath + s + "simplex_v1_assets" + s + "wallpapers" + s + fileName
}
File(path).parentFile.mkdirs()
return path
}
fun getPreferenceFilePath(fileName: String = "themes.yaml"): String = preferencesDir.absolutePath + File.separator + fileName
fun getLoadedFilePath(file: CIFile?): String? {
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
@@ -98,6 +116,42 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? {
}
}
fun readThemeOverrides(): List<ThemeOverrides> {
return try {
val file = File(getPreferenceFilePath("themes.yaml"))
if (!file.exists()) return emptyList()
file.inputStream().use {
val map = yaml.parseToYamlNode(it).yamlMap
val list = map.get<YamlList>("themes")
val res = ArrayList<ThemeOverrides>()
list?.items?.forEach {
try {
res.add(yaml.decodeFromYamlNode(ThemeOverrides.serializer(), it))
} catch (e: Throwable) {
Log.e(TAG, "Error while reading specific theme: ${e.stackTraceToString()}")
}
}
res.skipDuplicates()
}
} catch (e: Throwable) {
Log.e(TAG, "Error while reading themes file: ${e.stackTraceToString()}")
emptyList()
}
}
fun writeThemeOverrides(overrides: List<ThemeOverrides>): Boolean =
try {
File(getPreferenceFilePath("themes.yaml")).outputStream().use {
val string = yaml.encodeToString(ThemesFile(themes = overrides))
it.bufferedWriter().use { it.write(string) }
}
true
} catch (e: Throwable) {
Log.e(TAG, "Error while writing themes file: ${e.stackTraceToString()}")
false
}
private fun fileReady(file: CIFile, filePath: String) =
File(filePath).exists() &&
CIFile.cachedRemoteFileRequests[file.fileSource] != false
@@ -1,11 +1,13 @@
package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import com.russhwolf.settings.Settings
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@Composable
@@ -31,3 +33,5 @@ expect fun windowWidth(): Dp
expect fun desktopExpandWindowToWidth(width: Dp)
expect fun isRtl(text: CharSequence): Boolean
expect fun ImageResource.toComposeImageBitmap(): ImageBitmap?
@@ -31,17 +31,6 @@ val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)
val SentMessageColor = Color(0x1E45B8FF)
val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black
val NoteFolderIconColor: Color @Composable get() = with(CurrentColors.collectAsState().value.appColors.sentMessage) {
// Default color looks too light and better to have it here a little bit brighter
if (alpha == SentMessageColor.alpha) {
copy(min(SentMessageColor.alpha + 0.1f, 1f))
} else {
// Color is non-standard and theme maker can choose color without alpha at all since the theme bound to dark/light variant,
// and it shouldn't be universal
this
}
}
val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2
@@ -6,53 +6,136 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController
import chat.simplex.common.platform.isInNightMode
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex
import chat.simplex.common.ui.theme.ThemeManager.toReadableHex
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import chat.simplex.res.MR
import kotlinx.serialization.Transient
import java.util.UUID
enum class DefaultTheme {
SYSTEM, LIGHT, DARK, SIMPLEX;
LIGHT, DARK, SIMPLEX, BLACK;
companion object {
const val SYSTEM_THEME_NAME: String = "SYSTEM"
}
val themeName: String
get() = name
val mode: DefaultThemeMode get() = if (this == LIGHT) DefaultThemeMode.LIGHT else DefaultThemeMode.DARK
// Call it only with base theme, not SYSTEM
fun hasChangedAnyColor(colors: Colors, appColors: AppColors): Boolean {
val palette = when (this) {
SYSTEM -> return false
LIGHT -> LightColorPalette
DARK -> DarkColorPalette
SIMPLEX -> SimplexColorPalette
}
val appPalette = when (this) {
SYSTEM -> return false
LIGHT -> LightColorPaletteApp
DARK -> DarkColorPaletteApp
SIMPLEX -> SimplexColorPaletteApp
}
return colors.primary != palette.primary ||
colors.primaryVariant != palette.primaryVariant ||
colors.secondary != palette.secondary ||
colors.secondaryVariant != palette.secondaryVariant ||
colors.background != palette.background ||
colors.surface != palette.surface ||
appColors != appPalette
fun hasChangedAnyColor(overrides: ThemeOverrides?): Boolean {
if (overrides == null) return false
return overrides.colors != ThemeColors() ||
overrides.wallpaper != null && (overrides.wallpaper.background != null || overrides.wallpaper.tint != null)
}
}
data class AppColors(
val title: Color,
val sentMessage: Color,
val receivedMessage: Color
)
@Serializable
enum class DefaultThemeMode {
@SerialName("light") LIGHT,
@SerialName("dark") DARK
}
@Stable
class AppColors(
title: Color,
primaryVariant2: Color,
sentMessage: Color,
sentQuote: Color,
receivedMessage: Color,
receivedQuote: Color,
) {
var title by mutableStateOf(title, structuralEqualityPolicy())
internal set
var primaryVariant2 by mutableStateOf(primaryVariant2, structuralEqualityPolicy())
internal set
var sentMessage by mutableStateOf(sentMessage, structuralEqualityPolicy())
internal set
var sentQuote by mutableStateOf(sentQuote, structuralEqualityPolicy())
internal set
var receivedMessage by mutableStateOf(receivedMessage, structuralEqualityPolicy())
internal set
var receivedQuote by mutableStateOf(receivedQuote, structuralEqualityPolicy())
internal set
fun copy(
title: Color = this.title,
primaryVariant2: Color = this.primaryVariant2,
sentMessage: Color = this.sentMessage,
sentQuote: Color = this.sentQuote,
receivedMessage: Color = this.receivedMessage,
receivedQuote: Color = this.receivedQuote,
): AppColors = AppColors(
title,
primaryVariant2,
sentMessage,
sentQuote,
receivedMessage,
receivedQuote,
)
override fun toString(): String {
return buildString {
append("AppColors(")
append("title=$title, ")
append("primaryVariant2=$primaryVariant2, ")
append("sentMessage=$sentMessage, ")
append("sentQuote=$sentQuote, ")
append("receivedMessage=$receivedMessage, ")
append("receivedQuote=$receivedQuote")
append(")")
}
}
}
@Stable
class AppWallpaper(
background: Color? = null,
tint: Color? = null,
type: WallpaperType = WallpaperType.Empty,
) {
var background by mutableStateOf(background, structuralEqualityPolicy())
internal set
var tint by mutableStateOf(tint, structuralEqualityPolicy())
internal set
var type by mutableStateOf(type, structuralEqualityPolicy())
internal set
fun copy(
background: Color? = this.background,
tint: Color? = this.tint,
type: WallpaperType = this.type,
): AppWallpaper = AppWallpaper(
background,
tint,
type,
)
override fun toString(): String {
return buildString {
append("AppWallpaper(")
append("background=$background, ")
append("tint=$tint, ")
append("type=$type")
append(")")
}
}
}
enum class ThemeColor {
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, RECEIVED_MESSAGE;
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT;
fun fromColors(colors: Colors, appColors: AppColors): Color {
fun fromColors(colors: Colors, appColors: AppColors, appWallpaper: AppWallpaper): Color? {
return when (this) {
PRIMARY -> colors.primary
PRIMARY_VARIANT -> colors.primaryVariant
@@ -61,8 +144,13 @@ enum class ThemeColor {
BACKGROUND -> colors.background
SURFACE -> colors.surface
TITLE -> appColors.title
PRIMARY_VARIANT2 -> appColors.primaryVariant2
SENT_MESSAGE -> appColors.sentMessage
SENT_QUOTE -> appColors.sentQuote
RECEIVED_MESSAGE -> appColors.receivedMessage
RECEIVED_QUOTE -> appColors.receivedQuote
WALLPAPER_BACKGROUND -> appWallpaper.background
WALLPAPER_TINT -> appWallpaper.tint
}
}
@@ -75,8 +163,13 @@ enum class ThemeColor {
BACKGROUND -> generalGetString(MR.strings.color_background)
SURFACE -> generalGetString(MR.strings.color_surface)
TITLE -> generalGetString(MR.strings.color_title)
PRIMARY_VARIANT2 -> generalGetString(MR.strings.color_primary_variant2)
SENT_MESSAGE -> generalGetString(MR.strings.color_sent_message)
SENT_QUOTE -> generalGetString(MR.strings.color_sent_quote)
RECEIVED_MESSAGE -> generalGetString(MR.strings.color_received_message)
RECEIVED_QUOTE -> generalGetString(MR.strings.color_received_quote)
WALLPAPER_BACKGROUND -> generalGetString(MR.strings.color_wallpaper_background)
WALLPAPER_TINT -> generalGetString(MR.strings.color_wallpaper_tint)
}
}
@@ -92,45 +185,232 @@ data class ThemeColors(
@SerialName("menus")
val surface: String? = null,
val title: String? = null,
@SerialName("accentVariant2")
val primaryVariant2: String? = null,
val sentMessage: String? = null,
@SerialName("sentReply")
val sentQuote: String? = null,
val receivedMessage: String? = null,
@SerialName("receivedReply")
val receivedQuote: String? = null,
) {
fun toColors(base: DefaultTheme): Colors {
companion object {
fun from(colors: Colors, appColors: AppColors): ThemeColors =
ThemeColors(
primary = colors.primary.toReadableHex(),
primaryVariant = colors.primaryVariant.toReadableHex(),
secondary = colors.secondary.toReadableHex(),
secondaryVariant = colors.secondaryVariant.toReadableHex(),
background = colors.background.toReadableHex(),
surface = colors.surface.toReadableHex(),
title = appColors.title.toReadableHex(),
primaryVariant2 = appColors.primaryVariant2.toReadableHex(),
sentMessage = appColors.sentMessage.toReadableHex(),
sentQuote = appColors.sentQuote.toReadableHex(),
receivedMessage = appColors.receivedMessage.toReadableHex(),
receivedQuote = appColors.receivedQuote.toReadableHex(),
)
}
}
@Serializable
data class ThemeWallpaper (
val preset: String? = null,
val scale: Float? = null,
val scaleType: WallpaperScaleType? = null,
val background: String? = null,
val tint: String? = null,
val image: String? = null,
val imageFile: String? = null,
) {
fun toAppWallpaper(): AppWallpaper {
return AppWallpaper(
background = background?.colorFromReadableHex(),
tint = tint?.colorFromReadableHex(),
type = WallpaperType.from(this) ?: WallpaperType.Empty
)
}
fun withFilledWallpaperBase64(): ThemeWallpaper {
val aw = toAppWallpaper()
val type = aw.type
return ThemeWallpaper(
image = if (type is WallpaperType.Image && type.image != null) resizeImageToStrSize(type.image!!, 5_000_000) else null,
imageFile = null,
preset = if (type is WallpaperType.Preset) type.filename else null,
scale = if (type is WallpaperType.Preset) type.scale else if (type is WallpaperType.Image) type.scale else 1f,
scaleType = if (type is WallpaperType.Image) type.scaleType else null,
background = aw.background?.toReadableHex(),
tint = aw.tint?.toReadableHex(),
)
}
fun withFilledWallpaperPath(): ThemeWallpaper {
val aw = toAppWallpaper()
val type = aw.type
return ThemeWallpaper(
image = null,
imageFile = if (type is WallpaperType.Image) type.filename else null,
preset = if (type is WallpaperType.Preset) type.filename else null,
scale = if (scale == null) null else if (type is WallpaperType.Preset) type.scale else if (type is WallpaperType.Image) scale else null,
scaleType = if (scaleType == null) null else if (type is WallpaperType.Image) type.scaleType else null,
background = aw.background?.toReadableHex(),
tint = aw.tint?.toReadableHex(),
)
}
fun importFromString(): ThemeWallpaper =
if (preset == null && image != null) {
// Need to save image from string and to save its path
try {
val parsed = base64ToBitmap(image)
val filename = saveWallpaperFile(parsed)
copy(image = null, imageFile = filename)
} catch (e: Exception) {
Log.e(TAG, "Error while parsing/copying the image: ${e.stackTraceToString()}")
ThemeWallpaper()
}
} else this
companion object {
fun from(type: WallpaperType, background: String?, tint: String?): ThemeWallpaper {
return ThemeWallpaper(
image = null,
imageFile = if (type is WallpaperType.Image) type.filename else null,
preset = if (type is WallpaperType.Preset) type.filename else null,
scale = if (type is WallpaperType.Preset) type.scale else if (type is WallpaperType.Image) type.scale else null,
scaleType = if (type is WallpaperType.Image) type.scaleType else null,
background = background,
tint = tint,
)
}
}
}
@Serializable
data class ThemesFile(
val themes: List<ThemeOverrides> = emptyList()
)
@Serializable
data class ThemeOverrides (
val themeId: String = UUID.randomUUID().toString(),
val base: DefaultTheme,
val colors: ThemeColors = ThemeColors(),
val wallpaper: ThemeWallpaper? = null,
) {
fun isSame(type: WallpaperType?, themeName: String): Boolean =
(
(wallpaper?.preset != null && type is WallpaperType.Preset && wallpaper.preset == type.filename) ||
(wallpaper?.imageFile != null && type is WallpaperType.Image) ||
(wallpaper?.preset == null && wallpaper?.imageFile == null && (type == WallpaperType.Empty || type == null))
) && base.themeName == themeName
fun withUpdatedColor(name: ThemeColor, color: String?): ThemeOverrides {
return copy(
colors = when (name) {
ThemeColor.PRIMARY -> colors.copy(primary = color)
ThemeColor.PRIMARY_VARIANT -> colors.copy(primaryVariant = color)
ThemeColor.SECONDARY -> colors.copy(secondary = color)
ThemeColor.SECONDARY_VARIANT -> colors.copy(secondaryVariant = color)
ThemeColor.BACKGROUND -> colors.copy(background = color)
ThemeColor.SURFACE -> colors.copy(surface = color)
ThemeColor.TITLE -> colors.copy(title = color)
ThemeColor.PRIMARY_VARIANT2 -> colors.copy(primaryVariant2 = color)
ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color)
ThemeColor.SENT_QUOTE -> colors.copy(sentQuote = color)
ThemeColor.RECEIVED_MESSAGE -> colors.copy(receivedMessage = color)
ThemeColor.RECEIVED_QUOTE -> colors.copy(receivedQuote = color)
ThemeColor.WALLPAPER_BACKGROUND -> colors.copy()
ThemeColor.WALLPAPER_TINT -> colors.copy()
}, wallpaper = when (name) {
ThemeColor.WALLPAPER_BACKGROUND -> wallpaper?.copy(background = color)
ThemeColor.WALLPAPER_TINT -> wallpaper?.copy(tint = color)
else -> wallpaper?.copy()
}
)
}
fun toColors(base: DefaultTheme, perChatTheme: ThemeColors?, perUserTheme: ThemeColors?, presetWallpaperTheme: ThemeColors?): Colors {
val baseColors = when (base) {
DefaultTheme.LIGHT -> LightColorPalette
DefaultTheme.DARK -> DarkColorPalette
DefaultTheme.SIMPLEX -> SimplexColorPalette
// shouldn't be here
DefaultTheme.SYSTEM -> LightColorPalette
DefaultTheme.BLACK -> BlackColorPalette
}
return baseColors.copy(
primary = primary?.colorFromReadableHex() ?: baseColors.primary,
primaryVariant = primaryVariant?.colorFromReadableHex() ?: baseColors.primaryVariant,
secondary = secondary?.colorFromReadableHex() ?: baseColors.secondary,
secondaryVariant = secondaryVariant?.colorFromReadableHex() ?: baseColors.secondaryVariant,
background = background?.colorFromReadableHex() ?: baseColors.background,
surface = surface?.colorFromReadableHex() ?: baseColors.surface,
primary = perChatTheme?.primary?.colorFromReadableHex() ?: perUserTheme?.primary?.colorFromReadableHex() ?: colors.primary?.colorFromReadableHex() ?: presetWallpaperTheme?.primary?.colorFromReadableHex() ?: baseColors.primary,
primaryVariant = perChatTheme?.primaryVariant?.colorFromReadableHex() ?: perUserTheme?.primaryVariant?.colorFromReadableHex() ?: colors.primaryVariant?.colorFromReadableHex() ?: presetWallpaperTheme?.primaryVariant?.colorFromReadableHex() ?: baseColors.primaryVariant,
secondary = perChatTheme?.secondary?.colorFromReadableHex() ?: perUserTheme?.secondary?.colorFromReadableHex() ?: colors.secondary?.colorFromReadableHex() ?: presetWallpaperTheme?.secondary?.colorFromReadableHex() ?: baseColors.secondary,
secondaryVariant = perChatTheme?.secondaryVariant?.colorFromReadableHex() ?: perUserTheme?.secondaryVariant?.colorFromReadableHex() ?: colors.secondaryVariant?.colorFromReadableHex() ?: presetWallpaperTheme?.secondaryVariant?.colorFromReadableHex() ?: baseColors.secondaryVariant,
background = perChatTheme?.background?.colorFromReadableHex() ?: perUserTheme?.background?.colorFromReadableHex() ?: colors.background?.colorFromReadableHex() ?: presetWallpaperTheme?.background?.colorFromReadableHex() ?: baseColors.background,
surface = perChatTheme?.surface?.colorFromReadableHex() ?: perUserTheme?.surface?.colorFromReadableHex() ?: colors.surface?.colorFromReadableHex() ?: presetWallpaperTheme?.surface?.colorFromReadableHex() ?: baseColors.surface,
)
}
fun toAppColors(base: DefaultTheme): AppColors {
fun toAppColors(base: DefaultTheme, perChatTheme: ThemeColors?, perChatWallpaperType: WallpaperType?, perUserTheme: ThemeColors?, perUserWallpaperType: WallpaperType?, presetWallpaperTheme: ThemeColors?): AppColors {
val baseColors = when (base) {
DefaultTheme.LIGHT -> LightColorPaletteApp
DefaultTheme.DARK -> DarkColorPaletteApp
DefaultTheme.SIMPLEX -> SimplexColorPaletteApp
// shouldn't be here
DefaultTheme.SYSTEM -> LightColorPaletteApp
DefaultTheme.BLACK -> BlackColorPaletteApp
}
val sentMessageFallback = colors.sentMessage?.colorFromReadableHex() ?: presetWallpaperTheme?.sentMessage?.colorFromReadableHex() ?: baseColors.sentMessage
val sentQuoteFallback = colors.sentQuote?.colorFromReadableHex() ?: presetWallpaperTheme?.sentQuote?.colorFromReadableHex() ?: baseColors.sentQuote
val receivedMessageFallback = colors.receivedMessage?.colorFromReadableHex() ?: presetWallpaperTheme?.receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage
val receivedQuoteFallback = colors.receivedQuote?.colorFromReadableHex() ?: presetWallpaperTheme?.receivedQuote?.colorFromReadableHex() ?: baseColors.receivedQuote
return baseColors.copy(
title = title?.colorFromReadableHex() ?: baseColors.title,
sentMessage = sentMessage?.colorFromReadableHex() ?: baseColors.sentMessage,
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
title = perChatTheme?.title?.colorFromReadableHex() ?: perUserTheme?.title?.colorFromReadableHex() ?: colors.title?.colorFromReadableHex() ?: presetWallpaperTheme?.title?.colorFromReadableHex() ?: baseColors.title,
primaryVariant2 = perChatTheme?.primaryVariant2?.colorFromReadableHex() ?: perUserTheme?.primaryVariant2?.colorFromReadableHex() ?: colors.primaryVariant2?.colorFromReadableHex() ?: presetWallpaperTheme?.primaryVariant2?.colorFromReadableHex() ?: baseColors.primaryVariant2,
sentMessage = if (perChatTheme?.sentMessage != null) perChatTheme.sentMessage.colorFromReadableHex()
else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.sentMessage?.colorFromReadableHex() ?: sentMessageFallback
else sentMessageFallback,
sentQuote = if (perChatTheme?.sentQuote != null) perChatTheme.sentQuote.colorFromReadableHex()
else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.sentQuote?.colorFromReadableHex() ?: sentQuoteFallback
else sentQuoteFallback,
receivedMessage = if (perChatTheme?.receivedMessage != null) perChatTheme.receivedMessage.colorFromReadableHex()
else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.receivedMessage?.colorFromReadableHex() ?: receivedMessageFallback
else receivedMessageFallback,
receivedQuote = if (perChatTheme?.receivedQuote != null) perChatTheme.receivedQuote.colorFromReadableHex()
else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.receivedQuote?.colorFromReadableHex() ?: receivedQuoteFallback
else receivedQuoteFallback,
)
}
fun withFilledColors(base: DefaultTheme): ThemeColors {
val c = toColors(base)
val ac = toAppColors(base)
fun toAppWallpaper(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverride?, materialBackgroundColor: Color): AppWallpaper {
val mainType = when {
themeOverridesForType != null -> themeOverridesForType
// type can be null if override is empty `"wallpaper": "{}"`, in this case no wallpaper is needed, empty.
// It's not null to override upper level wallpaper
perChatTheme?.wallpaper != null -> perChatTheme.wallpaper.toAppWallpaper().type
perUserTheme?.wallpaper != null -> perUserTheme.wallpaper.toAppWallpaper().type
else -> wallpaper?.toAppWallpaper()?.type ?: return AppWallpaper()
}
val first: ThemeWallpaper? = if (mainType.sameType(perChatTheme?.wallpaper?.toAppWallpaper()?.type)) perChatTheme?.wallpaper else null
val second: ThemeWallpaper? = if (mainType.sameType(perUserTheme?.wallpaper?.toAppWallpaper()?.type)) perUserTheme?.wallpaper else null
val third: ThemeWallpaper? = if (mainType.sameType(this.wallpaper?.toAppWallpaper()?.type)) this.wallpaper else null
return AppWallpaper(type = when (mainType) {
is WallpaperType.Preset -> mainType.copy(
scale = mainType.scale ?: first?.scale ?: second?.scale ?: third?.scale
)
is WallpaperType.Image -> mainType.copy(
scale = if (themeOverridesForType == null) mainType.scale ?: first?.scale ?: second?.scale ?: third?.scale else second?.scale ?: third?.scale ?: mainType.scale,
scaleType = if (themeOverridesForType == null) mainType.scaleType ?: first?.scaleType ?: second?.scaleType ?: third?.scaleType else second?.scaleType ?: third?.scaleType ?: mainType.scaleType,
filename = if (themeOverridesForType == null) mainType.filename else first?.imageFile ?: second?.imageFile ?: third?.imageFile ?: mainType.filename,
)
is WallpaperType.Empty -> mainType
},
background = (first?.background ?: second?.background ?: third?.background)?.colorFromReadableHex() ?: mainType.defaultBackgroundColor(base, materialBackgroundColor),
tint = (first?.tint ?: second?.tint ?: third?.tint)?.colorFromReadableHex() ?: mainType.defaultTintColor(base)
)
}
fun withFilledColors(base: DefaultTheme, perChatTheme: ThemeColors?, perChatWallpaperType: WallpaperType?, perUserTheme: ThemeColors?, perUserWallpaperType: WallpaperType?, presetWallpaperTheme: ThemeColors?): ThemeColors {
val c = toColors(base, perChatTheme, perUserTheme, presetWallpaperTheme)
val ac = toAppColors(base, perChatTheme, perChatWallpaperType, perUserTheme, perUserWallpaperType, presetWallpaperTheme)
return ThemeColors(
primary = c.primary.toReadableHex(),
primaryVariant = c.primaryVariant.toReadableHex(),
@@ -139,23 +419,71 @@ data class ThemeColors(
background = c.background.toReadableHex(),
surface = c.surface.toReadableHex(),
title = ac.title.toReadableHex(),
primaryVariant2 = ac.primaryVariant2.toReadableHex(),
sentMessage = ac.sentMessage.toReadableHex(),
receivedMessage = ac.receivedMessage.toReadableHex()
sentQuote = ac.sentQuote.toReadableHex(),
receivedMessage = ac.receivedMessage.toReadableHex(),
receivedQuote = ac.receivedQuote.toReadableHex(),
)
}
}
private fun String.colorFromReadableHex(): Color =
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
fun List<ThemeOverrides>.getTheme(themeId: String?): ThemeOverrides? =
firstOrNull { it.themeId == themeId }
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
fun List<ThemeOverrides>.getTheme(themeId: String?, type: WallpaperType?, base: DefaultTheme): ThemeOverrides? =
firstOrNull { it.themeId == themeId || it.isSame(type, base.themeName)}
fun List<ThemeOverrides>.replace(theme: ThemeOverrides): List<ThemeOverrides> {
val index = indexOfFirst { it.themeId == theme.themeId ||
// prevent situation when two themes has the same type but different theme id (maybe something was changed in prefs by hand)
it.isSame(WallpaperType.from(theme.wallpaper), theme.base.themeName)
}
return if (index != -1) {
val a = ArrayList(this)
a[index] = theme
a
} else {
this + theme
}
}
fun List<ThemeOverrides>.sameTheme(type: WallpaperType?, themeName: String): ThemeOverrides? = firstOrNull { it.isSame(type, themeName) }
/** See [ThemesTest.testSkipDuplicates] */
fun List<ThemeOverrides>.skipDuplicates(): List<ThemeOverrides> {
val res = ArrayList<ThemeOverrides>()
forEach { theme ->
val themeType = WallpaperType.from(theme.wallpaper)
if (res.none { it.themeId == theme.themeId || it.isSame(themeType, theme.base.themeName) }) {
res.add(theme)
}
}
return res
}
@Serializable
data class ThemeOverrides (
val base: DefaultTheme,
val colors: ThemeColors
data class ThemeModeOverrides (
val light: ThemeModeOverride? = null,
val dark: ThemeModeOverride? = null
) {
fun withUpdatedColor(name: ThemeColor, color: String): ThemeOverrides {
fun preferredMode(darkTheme: Boolean): ThemeModeOverride? = when (darkTheme) {
false -> light
else -> dark
}
}
@Serializable
data class ThemeModeOverride (
val mode: DefaultThemeMode = CurrentColors.value.base.mode,
val colors: ThemeColors = ThemeColors(),
val wallpaper: ThemeWallpaper? = null,
) {
@Transient
val type = WallpaperType.from(wallpaper)
fun withUpdatedColor(name: ThemeColor, color: String?): ThemeModeOverride {
return copy(colors = when (name) {
ThemeColor.PRIMARY -> colors.copy(primary = color)
ThemeColor.PRIMARY_VARIANT -> colors.copy(primaryVariant = color)
@@ -164,9 +492,97 @@ data class ThemeOverrides (
ThemeColor.BACKGROUND -> colors.copy(background = color)
ThemeColor.SURFACE -> colors.copy(surface = color)
ThemeColor.TITLE -> colors.copy(title = color)
ThemeColor.PRIMARY_VARIANT2 -> colors.copy(primaryVariant2 = color)
ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color)
ThemeColor.SENT_QUOTE -> colors.copy(sentQuote = color)
ThemeColor.RECEIVED_MESSAGE -> colors.copy(receivedMessage = color)
})
ThemeColor.RECEIVED_QUOTE -> colors.copy(receivedQuote = color)
ThemeColor.WALLPAPER_BACKGROUND -> colors.copy()
ThemeColor.WALLPAPER_TINT -> colors.copy()
}, wallpaper = when (name) {
ThemeColor.WALLPAPER_BACKGROUND -> wallpaper?.copy(background = color)
ThemeColor.WALLPAPER_TINT -> wallpaper?.copy(tint = color)
else -> wallpaper?.copy()
}
)
}
fun removeSameColors(base: DefaultTheme): ThemeModeOverride {
val c = when (base) {
DefaultTheme.LIGHT -> LightColorPalette
DefaultTheme.DARK -> DarkColorPalette
DefaultTheme.SIMPLEX -> SimplexColorPalette
DefaultTheme.BLACK -> BlackColorPalette
}
val ac = when (base) {
DefaultTheme.LIGHT -> LightColorPaletteApp
DefaultTheme.DARK -> DarkColorPaletteApp
DefaultTheme.SIMPLEX -> SimplexColorPaletteApp
DefaultTheme.BLACK -> BlackColorPaletteApp
}
val w = when (val wallpaperType = WallpaperType.from(wallpaper)) {
is WallpaperType.Preset -> {
val p = PresetWallpaper.from(wallpaperType.filename)
ThemeWallpaper(
preset = wallpaperType.filename,
scale = p?.scale ?: wallpaper?.scale,
scaleType = null,
background = p?.background?.get(base)?.toReadableHex(),
tint = p?.tint?.get(base)?.toReadableHex(),
image = null,
imageFile = null,
)
}
is WallpaperType.Image -> {
ThemeWallpaper(
preset = null,
scale = null,
scaleType = WallpaperScaleType.FILL,
background = Color.Transparent.toReadableHex(),
tint = Color.Transparent.toReadableHex(),
image = null,
imageFile = null,
)
}
else -> {
ThemeWallpaper()
}
}
return copy(
colors = ThemeColors(
primary = if (colors.primary?.colorFromReadableHex() != c.primary) colors.primary else null,
primaryVariant = if (colors.primaryVariant?.colorFromReadableHex() != c.primaryVariant) colors.primaryVariant else null,
secondary = if (colors.secondary?.colorFromReadableHex() != c.secondary) colors.secondary else null,
secondaryVariant = if (colors.secondaryVariant?.colorFromReadableHex() != c.secondaryVariant) colors.secondaryVariant else null,
background = if (colors.background?.colorFromReadableHex() != c.background) colors.background else null,
surface = if (colors.surface?.colorFromReadableHex() != c.surface) colors.surface else null,
title = if (colors.title?.colorFromReadableHex() != ac.title) colors.title else null,
primaryVariant2 = if (colors.primaryVariant2?.colorFromReadableHex() != ac.primaryVariant2) colors.primary else null,
sentMessage = if (colors.sentMessage?.colorFromReadableHex() != ac.sentMessage) colors.sentMessage else null,
sentQuote = if (colors.sentQuote?.colorFromReadableHex() != ac.sentQuote) colors.sentQuote else null,
receivedMessage = if (colors.receivedMessage?.colorFromReadableHex() != ac.receivedMessage) colors.receivedMessage else null,
receivedQuote = if (colors.receivedQuote?.colorFromReadableHex() != ac.receivedQuote) colors.receivedQuote else null,
),
wallpaper = wallpaper?.copy(
preset = wallpaper.preset,
scale = if (wallpaper.scale != w.scale) wallpaper.scale else null,
scaleType = if (wallpaper.scaleType != w.scaleType) wallpaper.scaleType else null,
background = if (wallpaper.background != w.background) wallpaper.background else null,
tint = if (wallpaper.tint != w.tint) wallpaper.tint else null,
image = wallpaper.image,
imageFile = wallpaper.imageFile,
)
)
}
companion object {
fun withFilledAppDefaults(mode: DefaultThemeMode, base: DefaultTheme): ThemeModeOverride =
ThemeModeOverride(
mode = mode,
colors = ThemeOverrides(base = base).withFilledColors(base, null, null, null, null, null),
wallpaper = ThemeWallpaper(preset = PresetWallpaper.SCHOOL.filename)
)
}
}
@@ -204,7 +620,6 @@ val DarkColorPalette = darkColors(
// background = Color.Black,
surface = Color(0xFF222222),
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
error = Color.Red,
onBackground = Color(0xFFFFFBFA),
onSurface = Color(0xFFFFFBFA),
@@ -212,8 +627,11 @@ val DarkColorPalette = darkColors(
)
val DarkColorPaletteApp = AppColors(
title = SimplexBlue,
sentMessage = SentMessageColor,
receivedMessage = Color(0x20B1B0B5)
primaryVariant2 = Color(0xFF18262E),
sentMessage = Color(0xFF18262E),
sentQuote = Color(0xFF1D3847),
receivedMessage = Color(0xff262627),
receivedQuote = Color(0xff373739),
)
val LightColorPalette = lightColors(
@@ -231,8 +649,11 @@ val LightColorPalette = lightColors(
)
val LightColorPaletteApp = AppColors(
title = SimplexBlue,
sentMessage = SentMessageColor,
receivedMessage = Color(0x20B1B0B5)
primaryVariant2 = Color(0xFFE9F7FF),
sentMessage = Color(0xFFE9F7FF),
sentQuote = Color(0xFFD6F0FF),
receivedMessage = Color(0xfff5f5f6),
receivedQuote = Color(0xffececee),
)
val SimplexColorPalette = darkColors(
@@ -251,11 +672,39 @@ val SimplexColorPalette = darkColors(
)
val SimplexColorPaletteApp = AppColors(
title = Color(0xFF267BE5),
sentMessage = SentMessageColor,
receivedMessage = Color(0x20B1B0B5)
primaryVariant2 = Color(0xFF172941),
sentMessage = Color(0xFF172941),
sentQuote = Color(0xFF1C3A57),
receivedMessage = Color(0xff25283a),
receivedQuote = Color(0xff36394a),
)
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
val BlackColorPalette = darkColors(
primary = Color(0xff0077e0), // If this value changes also need to update #0088ff in string resource files
primaryVariant = Color(0xff0077e0),
secondary = HighOrLowlight,
secondaryVariant = DarkGray,
background = Color(0xff070707),
surface = Color(0xff161617),
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
error = Color.Red,
onBackground = Color(0xFFFFFBFA),
onSurface = Color(0xFFFFFBFA),
// onError: Color = Color.Black,
)
val BlackColorPaletteApp = AppColors(
title = Color(0xff0077e0),
primaryVariant2 = Color(0xff243747),
sentMessage = Color(0xFF18262E),
sentQuote = Color(0xFF1D3847),
receivedMessage = Color(0xff1b1b1b),
receivedQuote = Color(0xff29292b),
)
var systemInDarkThemeCurrently: Boolean = isInNightMode()
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()))
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
@@ -263,31 +712,113 @@ fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLi
@Composable
expect fun isSystemInDarkTheme(): Boolean
internal val LocalAppColors = staticCompositionLocalOf { LightColorPaletteApp }
internal val LocalAppWallpaper = staticCompositionLocalOf { AppWallpaper() }
val MaterialTheme.appColors: AppColors
@Composable
@ReadOnlyComposable
get() = LocalAppColors.current
fun AppColors.updateColorsFrom(other: AppColors) {
title = other.title
primaryVariant2 = other.primaryVariant2
sentMessage = other.sentMessage
sentQuote = other.sentQuote
receivedMessage = other.receivedMessage
receivedQuote = other.receivedQuote
}
fun AppWallpaper.updateWallpaperFrom(other: AppWallpaper) {
background = other.background
tint = other.tint
type = other.type
}
val MaterialTheme.wallpaper: AppWallpaper
@Composable
@ReadOnlyComposable
get() = LocalAppWallpaper.current
fun reactOnDarkThemeChanges(isDark: Boolean) {
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == isDark) {
systemInDarkThemeCurrently = isDark
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.value.colors.isLight == isDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, isDark)
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LaunchedEffect(darkTheme) {
// For preview
if (darkTheme != null)
CurrentColors.value = ThemeManager.currentColors(darkTheme)
}
val systemDark = isSystemInDarkTheme()
LaunchedEffect(systemDark) {
reactOnDarkThemeChanges(systemDark)
// TODO: Fix preview working with dark/light theme
// LaunchedEffect(darkTheme) {
// // For preview
// if (darkTheme != null)
// CurrentColors.value = ThemeManager.currentColors(darkTheme, null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())
// }
val systemDark = rememberUpdatedState(isSystemInDarkTheme())
LaunchedEffect(Unit) {
// snapshotFlow vs LaunchedEffect reduce number of recomposes
snapshotFlow { systemDark.value }
.collect {
reactOnDarkThemeChanges(systemDark.value)
}
}
val theme by CurrentColors.collectAsState()
LaunchedEffect(Unit) {
// snapshotFlow vs LaunchedEffect reduce number of recomposes when user is changed or it's themes
snapshotFlow { chatModel.currentUser.value?.uiThemes }
.collect {
ThemeManager.applyTheme(appPrefs.currentTheme.get()!!)
}
}
MaterialTheme(
colors = theme.colors,
typography = Typography,
shapes = Shapes,
content = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colors.onBackground, content = content)
val rememberedAppColors = remember {
// Explicitly creating a new object here so we don't mutate the initial [appColors]
// provided, and overwrite the values set in it.
theme.appColors.copy()
}.apply { updateColorsFrom(theme.appColors) }
val rememberedWallpaper = remember {
// Explicitly creating a new object here so we don't mutate the initial [wallpaper]
// provided, and overwrite the values set in it.
theme.wallpaper.copy()
}.apply { updateWallpaperFrom(theme.wallpaper) }
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colors.onBackground,
LocalAppColors provides rememberedAppColors,
LocalAppWallpaper provides rememberedWallpaper,
content = content)
}
)
}
@Composable
fun SimpleXThemeOverride(theme: ThemeManager.ActiveTheme, content: @Composable () -> Unit) {
MaterialTheme(
colors = theme.colors,
typography = Typography,
shapes = Shapes,
content = {
val rememberedAppColors = remember {
// Explicitly creating a new object here so we don't mutate the initial [appColors]
// provided, and overwrite the values set in it.
theme.appColors.copy()
}.apply { updateColorsFrom(theme.appColors) }
val rememberedWallpaper = remember {
// Explicitly creating a new object here so we don't mutate the initial [wallpaper]
// provided, and overwrite the values set in it.
theme.wallpaper.copy()
}.apply { updateWallpaperFrom(theme.wallpaper) }
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colors.onBackground,
LocalAppColors provides rememberedAppColors,
LocalAppWallpaper provides rememberedWallpaper,
content = content)
}
)
}
@@ -1,14 +1,14 @@
package chat.simplex.common.ui.theme
import androidx.compose.material.Colors
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontFamily
import chat.simplex.res.MR
import chat.simplex.common.model.AppPreferences
import chat.simplex.common.model.ChatController
import chat.simplex.common.platform.platform
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import java.io.File
// https://github.com/rsms/inter
// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt
@@ -18,140 +18,215 @@ expect val EmojiFont: FontFamily
object ThemeManager {
private val appPrefs: AppPreferences = ChatController.appPrefs
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors, val wallpaper: AppWallpaper = AppWallpaper())
private fun systemDarkThemeColors(): Pair<Colors, DefaultTheme> = when (appPrefs.systemDarkTheme.get()) {
DefaultTheme.DARK.name -> DarkColorPalette to DefaultTheme.DARK
DefaultTheme.SIMPLEX.name -> SimplexColorPalette to DefaultTheme.SIMPLEX
DefaultTheme.DARK.themeName -> DarkColorPalette to DefaultTheme.DARK
DefaultTheme.SIMPLEX.themeName -> SimplexColorPalette to DefaultTheme.SIMPLEX
DefaultTheme.BLACK.themeName -> BlackColorPalette to DefaultTheme.BLACK
else -> SimplexColorPalette to DefaultTheme.SIMPLEX
}
fun currentColors(darkForSystemTheme: Boolean): ActiveTheme {
private fun nonSystemThemeName(): String {
val themeName = appPrefs.currentTheme.get()!!
val themeOverrides = appPrefs.themeOverrides.get()
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
return if (themeName != DefaultTheme.SYSTEM_THEME_NAME) {
themeName
} else {
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
if (systemInDarkThemeCurrently) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName
}
val theme = themeOverrides[nonSystemThemeName]
}
fun defaultActiveTheme(appSettingsTheme: List<ThemeOverrides>): ThemeOverrides? {
val nonSystemThemeName = nonSystemThemeName()
val defaultThemeId = appPrefs.currentThemeIds.get()[nonSystemThemeName]
return appSettingsTheme.getTheme(defaultThemeId)
}
fun defaultActiveTheme(perUserTheme: ThemeModeOverrides?, appSettingsTheme: List<ThemeOverrides>): ThemeModeOverride {
val perUserTheme = if (!CurrentColors.value.colors.isLight) perUserTheme?.dark else perUserTheme?.light
if (perUserTheme != null) {
return perUserTheme
}
val defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(colors = defaultTheme?.colors ?: ThemeColors(), wallpaper = defaultTheme?.wallpaper)
}
fun currentColors(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?, appSettingsTheme: List<ThemeOverrides>): ActiveTheme {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = nonSystemThemeName()
val defaultTheme = defaultActiveTheme(appSettingsTheme)
val baseTheme = when (nonSystemThemeName) {
DefaultTheme.LIGHT.name -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
DefaultTheme.DARK.name -> Triple(DefaultTheme.DARK, DarkColorPalette, DarkColorPaletteApp)
DefaultTheme.SIMPLEX.name -> Triple(DefaultTheme.SIMPLEX, SimplexColorPalette, SimplexColorPaletteApp)
else -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
DefaultTheme.LIGHT.themeName -> ActiveTheme(DefaultTheme.LIGHT.themeName, DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp, AppWallpaper(type = PresetWallpaper.SCHOOL.toType(DefaultTheme.LIGHT)))
DefaultTheme.DARK.themeName -> ActiveTheme(DefaultTheme.DARK.themeName, DefaultTheme.DARK, DarkColorPalette, DarkColorPaletteApp, AppWallpaper(type = PresetWallpaper.SCHOOL.toType(DefaultTheme.DARK)))
DefaultTheme.SIMPLEX.themeName -> ActiveTheme(DefaultTheme.SIMPLEX.themeName, DefaultTheme.SIMPLEX, SimplexColorPalette, SimplexColorPaletteApp, AppWallpaper(type = PresetWallpaper.SCHOOL.toType(DefaultTheme.SIMPLEX)))
DefaultTheme.BLACK.themeName -> ActiveTheme(DefaultTheme.BLACK.themeName, DefaultTheme.BLACK, BlackColorPalette, BlackColorPaletteApp, AppWallpaper(type = PresetWallpaper.SCHOOL.toType(DefaultTheme.BLACK)))
else -> ActiveTheme(DefaultTheme.LIGHT.themeName, DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp, AppWallpaper(type = PresetWallpaper.SCHOOL.toType(DefaultTheme.LIGHT)))
}
if (theme == null) {
return ActiveTheme(themeName, baseTheme.first, baseTheme.second, baseTheme.third)
val perUserTheme = if (baseTheme.colors.isLight) perUserTheme?.light else perUserTheme?.dark
val theme = (appSettingsTheme.sameTheme(themeOverridesForType ?: perChatTheme?.type ?: perUserTheme?.type ?: defaultTheme?.wallpaper?.toAppWallpaper()?.type, nonSystemThemeName) ?: defaultTheme)
if (theme == null && perUserTheme == null && perChatTheme == null && themeOverridesForType == null) {
return ActiveTheme(themeName, baseTheme.base, baseTheme.colors, baseTheme.appColors, baseTheme.wallpaper)
}
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
val presetWallpaperTheme = when {
perChatTheme?.wallpaper != null -> if (perChatTheme.wallpaper.preset != null) PresetWallpaper.from(perChatTheme.wallpaper.preset)?.colors?.get(baseTheme.base) else null
perUserTheme?.wallpaper != null -> if (perUserTheme.wallpaper.preset != null) PresetWallpaper.from(perUserTheme.wallpaper.preset)?.colors?.get(baseTheme.base) else null
else -> if (theme?.wallpaper?.preset != null) PresetWallpaper.from(theme.wallpaper.preset)?.colors?.get(baseTheme.base) else null
}
val themeOrEmpty = theme ?: ThemeOverrides(base = baseTheme.base)
val colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
return ActiveTheme(
themeName,
baseTheme.base,
colors,
themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
)
}
fun currentThemeOverridesForExport(darkForSystemTheme: Boolean): ThemeOverrides {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
themeName
} else {
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
}
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val nonFilledTheme = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
return nonFilledTheme.copy(colors = nonFilledTheme.colors.withFilledColors(CurrentColors.value.base))
fun currentThemeOverridesForExport(perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?): ThemeOverrides {
val current = currentColors(null, perChatTheme, perUserTheme, appPrefs.themeOverrides.get())
val wType = current.wallpaper.type
val wBackground = current.wallpaper.background
val wTint = current.wallpaper.tint
return ThemeOverrides(
themeId = "",
base = current.base,
colors = ThemeColors.from(current.colors, current.appColors),
wallpaper = if (wType !is WallpaperType.Empty) ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64() else null
)
}
// colors, default theme enum, localized name of theme
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
allThemes.add(
Triple(
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
DefaultTheme.SYSTEM,
generalGetString(MR.strings.theme_system)
)
)
allThemes.add(
Triple(
LightColorPalette,
DefaultTheme.LIGHT,
generalGetString(MR.strings.theme_light)
)
)
allThemes.add(
Triple(
DarkColorPalette,
DefaultTheme.DARK,
generalGetString(MR.strings.theme_dark)
)
)
allThemes.add(
Triple(
SimplexColorPalette,
DefaultTheme.SIMPLEX,
generalGetString(MR.strings.theme_simplex)
)
)
return allThemes
}
fun applyTheme(theme: String, darkForSystemTheme: Boolean) {
fun applyTheme(theme: String) {
appPrefs.currentTheme.set(theme)
CurrentColors.value = currentColors(darkForSystemTheme)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
platform.androidSetNightModeIfSupported()
}
fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) {
fun changeDarkTheme(theme: String) {
appPrefs.systemDarkTheme.set(theme)
CurrentColors.value = currentColors(darkForSystemTheme)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
fun saveAndApplyThemeColor(name: ThemeColor, color: Color? = null, darkForSystemTheme: Boolean) {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
themeName
} else {
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
fun saveAndApplyThemeColor(baseTheme: DefaultTheme, name: ThemeColor, color: Color? = null, pref: SharedPreference<List<ThemeOverrides>> = appPrefs.themeOverrides) {
val nonSystemThemeName = baseTheme.themeName
val overrides = pref.get()
val themeId = appPrefs.currentThemeIds.get()[nonSystemThemeName]
val prevValue = overrides.getTheme(themeId) ?: ThemeOverrides(base = baseTheme)
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
val themeIds = appPrefs.currentThemeIds.get().toMutableMap()
themeIds[nonSystemThemeName] = prevValue.themeId
appPrefs.currentThemeIds.set(themeIds)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
fun applyThemeColor(name: ThemeColor, color: Color? = null, pref: MutableState<ThemeModeOverride>) {
pref.value = pref.value.withUpdatedColor(name, color?.toReadableHex())
}
fun saveAndApplyWallpaper(baseTheme: DefaultTheme, type: WallpaperType?, pref: SharedPreference<List<ThemeOverrides>> = appPrefs.themeOverrides) {
val nonSystemThemeName = baseTheme.themeName
val overrides = pref.get()
val theme = overrides.sameTheme(type, baseTheme.themeName)
val prevValue = theme ?: ThemeOverrides(base = baseTheme)
pref.set(overrides.replace(prevValue.copy(wallpaper = if (type != null && type !is WallpaperType.Empty) ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint) else null)))
val themeIds = appPrefs.currentThemeIds.get().toMutableMap()
themeIds[nonSystemThemeName] = prevValue.themeId
appPrefs.currentThemeIds.set(themeIds)
CurrentColors.value = currentColors( null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
fun copyFromSameThemeOverrides(type: WallpaperType?, lowerLevelOverride: ThemeModeOverride?, pref: MutableState<ThemeModeOverride>): Boolean {
val overrides = appPrefs.themeOverrides.get()
val sameWallpaper = if (lowerLevelOverride?.type?.sameType(type) == true) lowerLevelOverride.wallpaper else overrides.sameTheme(type, CurrentColors.value.base.themeName)?.wallpaper
if (sameWallpaper == null) {
if (type != null) {
pref.value = ThemeModeOverride(wallpaper = ThemeWallpaper.from(type, null, null).copy(scale = null, scaleType = null))
} else {
// Make an empty wallpaper to override any top level ones
pref.value = ThemeModeOverride(wallpaper = ThemeWallpaper())
}
return true
}
var colorToSet = color
if (colorToSet == null) {
// Setting default color from a base theme
colorToSet = when(nonSystemThemeName) {
DefaultTheme.LIGHT.name -> name.fromColors(LightColorPalette, LightColorPaletteApp)
DefaultTheme.DARK.name -> name.fromColors(DarkColorPalette, DarkColorPaletteApp)
DefaultTheme.SIMPLEX.name -> name.fromColors(SimplexColorPalette, SimplexColorPaletteApp)
// Will not be here
else -> return
var type = sameWallpaper.toAppWallpaper().type
if (type is WallpaperType.Image && sameWallpaper.imageFile == type.filename) {
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
val filename = saveWallpaperFile(File(getWallpaperFilePath(type.filename)).toURI())
if (filename != null) {
type = WallpaperType.Image(filename, type.scale, type.scaleType)
} else {
Log.e(TAG, "Error while copying wallpaper from global overrides to chat overrides")
return false
}
}
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val prevValue = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
overrides[nonSystemThemeName] = prevValue.withUpdatedColor(name, colorToSet.toReadableHex())
appPrefs.themeOverrides.set(overrides)
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
val prevValue = pref.value
pref.value = prevValue.copy(
colors = ThemeColors(),
wallpaper = ThemeWallpaper.from(type, null, null).copy(scale = null, scaleType = null)
)
return true
}
fun saveAndApplyThemeOverrides(theme: ThemeOverrides, darkForSystemTheme: Boolean) {
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val prevValue = overrides[theme.base.name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
overrides[theme.base.name] = prevValue.copy(colors = theme.colors)
appPrefs.themeOverrides.set(overrides)
appPrefs.currentTheme.set(theme.base.name)
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
fun applyWallpaper(type: WallpaperType?, pref: MutableState<ThemeModeOverride>) {
val prevValue = pref.value
pref.value = prevValue.copy(
wallpaper = if (type != null)
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
else null
)
}
fun resetAllThemeColors(darkForSystemTheme: Boolean) {
val themeName = appPrefs.currentTheme.get()!!
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
themeName
} else {
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
fun saveAndApplyThemeOverrides(theme: ThemeOverrides, pref: SharedPreference<List<ThemeOverrides>> = appPrefs.themeOverrides) {
val wallpaper = theme.wallpaper?.importFromString()
val nonSystemThemeName = theme.base.themeName
val overrides = pref.get()
val prevValue = overrides.getTheme(null, wallpaper?.toAppWallpaper()?.type, theme.base) ?: ThemeOverrides(base = theme.base)
if (prevValue.wallpaper?.imageFile != null) {
File(getWallpaperFilePath(prevValue.wallpaper.imageFile)).delete()
}
pref.set(overrides.replace(prevValue.copy(base = theme.base, colors = theme.colors, wallpaper = wallpaper)))
appPrefs.currentTheme.set(nonSystemThemeName)
val currentThemeIds = appPrefs.currentThemeIds.get().toMutableMap()
currentThemeIds[nonSystemThemeName] = prevValue.themeId
appPrefs.currentThemeIds.set(currentThemeIds)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
fun resetAllThemeColors(pref: SharedPreference<List<ThemeOverrides>> = appPrefs.themeOverrides) {
val nonSystemThemeName = nonSystemThemeName()
val themeId = appPrefs.currentThemeIds.get()[nonSystemThemeName] ?: return
val overrides = pref.get()
val prevValue = overrides.getTheme(themeId) ?: return
pref.set(overrides.replace(prevValue.copy(colors = ThemeColors(), wallpaper = prevValue.wallpaper?.copy(background = null, tint = null))))
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
fun resetAllThemeColors(pref: MutableState<ThemeModeOverride>) {
val prevValue = pref.value
pref.value = prevValue.copy(colors = ThemeColors(), wallpaper = prevValue.wallpaper?.copy(background = null, tint = null))
}
fun removeTheme(themeId: String?) {
val themes = ArrayList(appPrefs.themeOverrides.get())
themes.removeAll { it.themeId == themeId }
appPrefs.themeOverrides.set(themes)
}
fun String.colorFromReadableHex(): Color =
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
fun Color.toReadableHex(): String {
val s = Integer.toHexString(toArgb())
return when {
this == Color.Transparent -> "#00ffffff"
s.length == 1 -> "#ff$s$s$s$s$s$s"
s.length == 2 -> "#ff$s$s$s"
s.length == 3 -> "#ff$s$s"
s.length == 6 && this.alpha == 0f -> "#00$s"
s.length == 6 -> "#ff$s"
else -> "#$s"
}
val overrides = appPrefs.themeOverrides.get().toMutableMap()
val prevValue = overrides[nonSystemThemeName] ?: return
overrides[nonSystemThemeName] = prevValue.copy(colors = ThemeColors())
appPrefs.themeOverrides.set(overrides)
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
}
}
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -41,6 +42,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import java.io.File
@Composable
fun ChatInfoView(
@@ -337,6 +339,16 @@ fun ChatInfoLayout(
if (cStats != null && cStats.ratchetSyncAllowed) {
SynchronizeConnectionButton(syncContactConnection)
}
WallpaperButton {
ModalManager.end.showModal {
val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } }
val c = chat.value
if (c != null) {
ChatWallpaperEditorModal(c)
}
}
}
// } else if (developerTools) {
// SynchronizeConnectionButtonForce(syncContactConnectionForce)
// }
@@ -642,6 +654,15 @@ private fun SendReceiptsOption(currentUser: User, state: State<SendReceipts>, on
)
}
@Composable
fun WallpaperButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_image),
stringResource(MR.strings.settings_section_title_chat_theme),
click = onClick
)
}
@Composable
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -675,6 +696,72 @@ fun ShareAddressButton(onClick: () -> Unit) {
)
}
@Composable
fun ModalData.ChatWallpaperEditorModal(chat: Chat) {
val themes = remember(CurrentColors.collectAsState().value.base) {
(chat.chatInfo as? ChatInfo.Direct)?.contact?.uiThemes
?: (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.uiThemes
?: ThemeModeOverrides()
}
val globalThemeUsed = remember { stateGetOrPut("globalThemeUsed") { false } }
val initialTheme = remember(CurrentColors.collectAsState().value.base) {
val preferred = themes.preferredMode(!CurrentColors.value.colors.isLight)
globalThemeUsed.value = preferred == null
preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
ChatWallpaperEditor(
initialTheme,
applyToMode = if (themes.light == themes.dark) null else initialTheme.mode,
globalThemeUsed = globalThemeUsed,
save = { applyToMode, newTheme ->
save(applyToMode, newTheme, chatModel.getChat(chat.id) ?: chat)
})
}
suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, chat: Chat) {
val unchangedThemes: ThemeModeOverrides = ((chat.chatInfo as? ChatInfo.Direct)?.contact?.uiThemes ?: (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.uiThemes) ?: ThemeModeOverrides()
val wallpaperFiles = setOf(unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile)
var changedThemes: ThemeModeOverrides? = unchangedThemes
val changed = newTheme?.copy(wallpaper = newTheme.wallpaper?.withFilledWallpaperPath())
changedThemes = when (applyToMode) {
null -> changedThemes?.copy(light = changed?.copy(mode = DefaultThemeMode.LIGHT), dark = changed?.copy(mode = DefaultThemeMode.DARK))
DefaultThemeMode.LIGHT -> changedThemes?.copy(light = changed?.copy(mode = applyToMode))
DefaultThemeMode.DARK -> changedThemes?.copy(dark = changed?.copy(mode = applyToMode))
}
changedThemes = if (changedThemes?.light != null || changedThemes?.dark != null) {
val light = changedThemes.light
val dark = changedThemes.dark
val currentMode = CurrentColors.value.base.mode
// same image file for both modes, copy image to make them as different files
if (light?.wallpaper?.imageFile != null && dark?.wallpaper?.imageFile != null && light.wallpaper.imageFile == dark.wallpaper.imageFile) {
val imageFile = if (currentMode == DefaultThemeMode.LIGHT) {
dark.wallpaper.imageFile
} else {
light.wallpaper.imageFile
}
val filePath = saveWallpaperFile(File(getWallpaperFilePath(imageFile)).toURI())
changedThemes = if (currentMode == DefaultThemeMode.LIGHT) {
changedThemes.copy(dark = dark.copy(wallpaper = dark.wallpaper.copy(imageFile = filePath)))
} else {
changedThemes.copy(light = light.copy(wallpaper = light.wallpaper.copy(imageFile = filePath)))
}
}
changedThemes
} else {
null
}
val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile
wallpaperFilesToDelete.forEach(::removeWallpaperFile)
if (controller.apiSetChatUIThemes(chat.remoteHostId, chat.id, changedThemes)) {
if (chat.chatInfo is ChatInfo.Direct) {
chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes)))
} else if (chat.chatInfo is ChatInfo.Group) {
chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes)))
}
}
}
private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi {
val chatRh = chat.remoteHostId
chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let {
@@ -42,7 +42,7 @@ sealed class CIInfoTab {
@Composable
fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
val sent = ci.chatDir.sent
val appColors = CurrentColors.collectAsState().value.appColors
val appColors = MaterialTheme.appColors
val uriHandler = LocalUriHandler.current
val selection = remember { mutableStateOf<CIInfoTab>(CIInfoTab.History) }
@@ -304,7 +304,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
@Composable
fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) {
fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus, sentViaProxy: Boolean?) {
SectionItemView(
padding = PaddingValues(horizontal = 0.dp)
) {
@@ -317,6 +317,18 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.fillMaxWidth().weight(1f))
if (sentViaProxy == true) {
Box(
Modifier.size(36.dp),
contentAlignment = Alignment.Center
) {
Icon(
painterResource(MR.images.ic_arrow_forward),
contentDescription = null,
tint = CurrentColors.value.colors.secondary
)
}
}
val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
var modifier = Modifier.size(36.dp).clip(RoundedCornerShape(20.dp))
val info = status.statusInto
@@ -357,8 +369,8 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
if (mss.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
mss.forEach { (member, status) ->
MemberDeliveryStatusView(member, status)
mss.forEach { (member, status, sentViaProxy) ->
MemberDeliveryStatusView(member, status, sentViaProxy)
}
}
} else {
@@ -482,10 +494,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Pair<GroupMember, CIStatus>> {
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Triple<GroupMember, CIStatus, Boolean?>> {
return memberDeliveryStatuses.mapNotNull { mds ->
chatModel.getGroupMember(mds.groupMemberId)?.let { mem ->
mem to mds.memberDeliveryStatus
Triple(mem, mds.memberDeliveryStatus, mds.sentViaProxy)
}
}
}
@@ -5,25 +5,24 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -118,368 +117,377 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
val clipboard = LocalClipboardManager.current
when (chat.chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
ChatLayout(
chat,
unreadCount,
composeState,
composeView = {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (
chat.chatInfo is ChatInfo.Direct
&& !chat.chatInfo.contact.ready
&& chat.chatInfo.contact.active
&& !chat.chatInfo.contact.nextSendGrpInv
val perChatTheme = remember(chat.chatInfo, CurrentColors.value.base) { if (chat.chatInfo is ChatInfo.Direct) chat.chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chat.chatInfo is ChatInfo.Group) chat.chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
ChatLayout(
chat,
unreadCount,
composeState,
composeView = {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
generalGetString(MR.strings.contact_connection_pending),
Modifier.padding(top = 4.dp),
fontSize = 14.sp,
color = MaterialTheme.colors.secondary
if (
chat.chatInfo is ChatInfo.Direct
&& !chat.chatInfo.contact.ready
&& chat.chatInfo.contact.active
&& !chat.chatInfo.contact.nextSendGrpInv
) {
Text(
generalGetString(MR.strings.contact_connection_pending),
Modifier.padding(top = 4.dp),
fontSize = 14.sp,
color = MaterialTheme.colors.secondary
)
}
ComposeView(
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
}
ComposeView(
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
}
},
attachmentOption,
attachmentBottomSheetState,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
back = {
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
chatModel.groupMembers.clear()
},
info = {
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
withBGApi {
// The idea is to preload information before showing a modal because large groups can take time to load all members
var preloadedContactInfo: Pair<ConnectionStats?, Profile?>? = null
var preloadedCode: String? = null
var preloadedLink: Pair<String, GroupMemberRole>? = null
if (chat.chatInfo is ChatInfo.Direct) {
preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel)
preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId)
},
attachmentOption,
attachmentBottomSheetState,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
back = {
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
chatModel.groupMembers.clear()
},
info = {
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
ModalManager.end.showModalCloseable(true) { close ->
val chat = remember { activeChat }.value
if (chat?.chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}
}
}
},
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
val (_, code) = if (member.memberActive) {
val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId)
member to memCode?.second
} else {
member to null
}
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close)
}
}
}
},
loadPrevMessages = {
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
val firstId = chatModel.chatItems.value.firstOrNull()?.id
if (c != null && firstId != null) {
hideKeyboard(view)
withBGApi {
apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
// The idea is to preload information before showing a modal because large groups can take time to load all members
var preloadedContactInfo: Pair<ConnectionStats?, Profile?>? = null
var preloadedCode: String? = null
var preloadedLink: Pair<String, GroupMemberRole>? = null
if (chat.chatInfo is ChatInfo.Direct) {
preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel)
preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId)
}
ModalManager.end.showModalCloseable(true) { close ->
val chat = remember { activeChat }.value
if (chat?.chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}
}
}
}
},
deleteMessage = { itemId, mode ->
withBGApi {
val cInfo = chat.chatInfo
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
val deletedChatItem: ChatItem?
val toChatItem: ChatItem?
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
val r = chatModel.controller.apiDeleteMemberChatItem(
chatRh,
groupId = groupInfo.groupId,
groupMemberId = groupMember.groupMemberId,
itemId = itemId
)
deletedChatItem = r?.first
toChatItem = r?.second
} else {
val r = chatModel.controller.apiDeleteChatItem(
chatRh,
},
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
val (_, code) = if (member.memberActive) {
val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId)
member to memCode?.second
} else {
member to null
}
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close)
}
}
}
},
loadPrevMessages = {
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
val firstId = chatModel.chatItems.value.firstOrNull()?.id
if (c != null && firstId != null) {
withBGApi {
apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
}
}
},
deleteMessage = { itemId, mode ->
withBGApi {
val cInfo = chat.chatInfo
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
val deletedChatItem: ChatItem?
val toChatItem: ChatItem?
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
val r = chatModel.controller.apiDeleteMemberChatItem(
chatRh,
groupId = groupInfo.groupId,
groupMemberId = groupMember.groupMemberId,
itemId = itemId
)
deletedChatItem = r?.first
toChatItem = r?.second
} else {
val r = chatModel.controller.apiDeleteChatItem(
chatRh,
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
mode = mode
)
deletedChatItem = r?.deletedChatItem?.chatItem
toChatItem = r?.toChatItem?.chatItem
}
if (toChatItem == null && deletedChatItem != null) {
chatModel.removeChatItem(chatRh, cInfo, deletedChatItem)
} else if (toChatItem != null) {
chatModel.upsertChatItem(chatRh, cInfo, toChatItem)
}
}
},
deleteMessages = { itemIds ->
if (itemIds.isNotEmpty()) {
val chatInfo = chat.chatInfo
withBGApi {
val deletedItems: ArrayList<ChatItem> = arrayListOf()
for (itemId in itemIds) {
val di = chatModel.controller.apiDeleteChatItem(
chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal
)?.deletedChatItem?.chatItem
if (di != null) {
deletedItems.add(di)
}
}
for (di in deletedItems) {
chatModel.removeChatItem(chatRh, chatInfo, di)
}
}
}
},
receiveFile = { fileId ->
withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) }
},
cancelFile = { fileId ->
withBGApi { chatModel.controller.cancelFile(chatRh, user, fileId) }
},
joinGroup = { groupId, onComplete ->
withBGApi {
chatModel.controller.apiJoinGroup(chatRh, groupId)
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
},
acceptCall = { contact ->
hideKeyboard(view)
withBGApi {
val invitation = chatModel.callInvitations.remove(contact.id)
?: controller.apiGetCallInvitations(chatModel.remoteHostId()).firstOrNull { it.contact.id == contact.id }
if (invitation == null) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
}
},
acceptFeature = { contact, feature, param ->
withBGApi {
chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param)
}
},
openDirectChat = { contactId ->
withBGApi {
openDirectChat(chatRh, contactId, chatModel)
}
},
forwardItem = { cItem, cInfo ->
chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(cInfo, cItem)
},
updateContactStats = { contact ->
withBGApi {
val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
if (r != null) {
val contactStats = r.first
if (contactStats != null)
chatModel.updateContactConnectionStats(chatRh, contact, contactStats)
}
}
},
updateMemberStats = { groupInfo, member ->
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
if (r != null) {
val memStats = r.second
if (memStats != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats)
}
}
}
},
syncContactConnection = { contact ->
withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false)
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
}
}
},
syncMemberConnection = { groupInfo, member ->
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second)
}
}
},
findModelChat = { chatId ->
chatModel.getChat(chatId)
},
findModelMember = { memberId ->
chatModel.groupMembers.find { it.id == memberId }
},
setReaction = { cInfo, cItem, add, reaction ->
withBGApi {
val updatedCI = chatModel.controller.apiChatItemReaction(
rh = chatRh,
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
mode = mode
itemId = cItem.id,
add = add,
reaction = reaction
)
deletedChatItem = r?.deletedChatItem?.chatItem
toChatItem = r?.toChatItem?.chatItem
if (updatedCI != null) {
chatModel.updateChatItem(cInfo, updatedCI)
}
}
if (toChatItem == null && deletedChatItem != null) {
chatModel.removeChatItem(chatRh, cInfo, deletedChatItem)
} else if (toChatItem != null) {
chatModel.upsertChatItem(chatRh, cInfo, toChatItem)
}
}
},
deleteMessages = { itemIds ->
if (itemIds.isNotEmpty()) {
val chatInfo = chat.chatInfo
withBGApi {
val deletedItems: ArrayList<ChatItem> = arrayListOf()
for (itemId in itemIds) {
val di = chatModel.controller.apiDeleteChatItem(
chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal
)?.deletedChatItem?.chatItem
if (di != null) {
deletedItems.add(di)
},
showItemDetails = { cInfo, cItem ->
suspend fun loadChatItemInfo(): ChatItemInfo? {
val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel)
}
}
for (di in deletedItems) {
chatModel.removeChatItem(chatRh, chatInfo, di)
}
return ciInfo
}
}
},
receiveFile = { fileId ->
withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) }
},
cancelFile = { fileId ->
withBGApi { chatModel.controller.cancelFile(chatRh, user, fileId) }
},
joinGroup = { groupId, onComplete ->
withBGApi {
chatModel.controller.apiJoinGroup(chatRh, groupId)
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
},
acceptCall = { contact ->
hideKeyboard(view)
withBGApi {
val invitation = chatModel.callInvitations.remove(contact.id)
?: controller.apiGetCallInvitations(chatModel.remoteHostId()).firstOrNull { it.contact.id == contact.id }
if (invitation == null) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
}
},
acceptFeature = { contact, feature, param ->
withBGApi {
chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param)
}
},
openDirectChat = { contactId ->
withBGApi {
openDirectChat(chatRh, contactId, chatModel)
}
},
updateContactStats = { contact ->
withBGApi {
val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
if (r != null) {
val contactStats = r.first
if (contactStats != null)
chatModel.updateContactConnectionStats(chatRh, contact, contactStats)
}
}
},
updateMemberStats = { groupInfo, member ->
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
if (r != null) {
val memStats = r.second
if (memStats != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats)
}
}
}
},
syncContactConnection = { contact ->
withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false)
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
}
}
},
syncMemberConnection = { groupInfo, member ->
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second)
}
}
},
findModelChat = { chatId ->
chatModel.getChat(chatId)
},
findModelMember = { memberId ->
chatModel.groupMembers.find { it.id == memberId }
},
setReaction = { cInfo, cItem, add, reaction ->
withBGApi {
val updatedCI = chatModel.controller.apiChatItemReaction(
rh = chatRh,
type = cInfo.chatType,
id = cInfo.apiId,
itemId = cItem.id,
add = add,
reaction = reaction
)
if (updatedCI != null) {
chatModel.updateChatItem(cInfo, updatedCI)
}
}
},
showItemDetails = { cInfo, cItem ->
suspend fun loadChatItemInfo(): ChatItemInfo? {
val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel)
}
}
return ciInfo
}
withBGApi {
var initialCiInfo = loadChatItemInfo() ?: return@withBGApi
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(endButtons = {
ShareButton {
clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get()))
}
}) { close ->
var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) }
ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
LaunchedEffect(cItem.id) {
withContext(Dispatchers.Default) {
for (apiResp in controller.messagesChannel) {
val msg = apiResp.resp
if (apiResp.remoteHostId == chatRh &&
msg is CR.ChatItemStatusUpdated &&
msg.chatItem.chatItem.id == cItem.id
) {
ciInfo = loadChatItemInfo() ?: return@withContext
initialCiInfo = ciInfo
withBGApi {
var initialCiInfo = loadChatItemInfo() ?: return@withBGApi
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(endButtons = {
ShareButton {
clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get()))
}
}) { close ->
var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) }
ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
LaunchedEffect(cItem.id) {
withContext(Dispatchers.Default) {
for (apiResp in controller.messagesChannel) {
val msg = apiResp.resp
if (apiResp.remoteHostId == chatRh &&
msg is CR.ChatItemStatusUpdated &&
msg.chatItem.chatItem.id == cItem.id
) {
ciInfo = loadChatItemInfo() ?: return@withContext
initialCiInfo = ciInfo
}
}
}
}
}
KeyChangeEffect(chatModel.chatId.value) {
close()
KeyChangeEffect(chatModel.chatId.value) {
close()
}
}
}
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withBGApi {
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(chatRh, groupInfo, false, chatModel, close)
},
addMembers = { groupInfo ->
hideKeyboard(view)
withBGApi {
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(chatRh, groupInfo, false, chatModel, close)
}
}
}
},
openGroupLink = { groupInfo ->
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
},
openGroupLink = { groupInfo ->
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
chatModel.controller.apiChatRead(
chatRh,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
}
},
changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout
withBGApi {
apiFindMessages(c, chatModel, value)
searchText.value = value
}
},
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
)
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
chatModel.controller.apiChatRead(
chatRh,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
}
},
changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout
withBGApi {
apiFindMessages(c, chatModel, value)
searchText.value = value
}
},
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
)
}
}
is ChatInfo.ContactConnection -> {
val close = { chatModel.chatId.value = null }
@@ -533,6 +541,7 @@ fun ChatLayout(
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit,
forwardItem: (ChatInfo, ChatItem) -> Unit,
updateContactStats: (Contact) -> Unit,
updateMemberStats: (GroupInfo, GroupMember) -> Unit,
syncContactConnection: (Contact) -> Unit,
@@ -548,6 +557,7 @@ fun ChatLayout(
onSearchValueChanged: (String) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
@@ -596,17 +606,27 @@ fun ChatLayout(
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
val wallpaperType = MaterialTheme.wallpaper.type
val backgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, MaterialTheme.colors.background)
val tintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
BoxWithConstraints(Modifier
.fillMaxHeight()
.fillMaxSize()
.background(MaterialTheme.colors.background)
.then(if (wallpaperImage != null)
Modifier.drawBehind { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor) }
else
Modifier)
.padding(contentPadding)
) {
ChatItemsList(
chat, unreadCount, composeState, searchValue,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
)
}
}
@@ -873,6 +893,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit,
forwardItem: (ChatInfo, ChatItem) -> Unit,
updateContactStats: (Contact) -> Unit,
updateMemberStats: (GroupInfo, GroupMember) -> Unit,
syncContactConnection: (Contact) -> Unit,
@@ -885,6 +906,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -975,7 +997,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy)
}
}
@@ -1259,7 +1281,7 @@ val MEMBER_IMAGE_SIZE: Dp = 38.dp
@Composable
fun MemberImage(member: GroupMember) {
ProfileImage(MEMBER_IMAGE_SIZE, member.memberProfile.image)
ProfileImage(MEMBER_IMAGE_SIZE, member.memberProfile.image, backgroundColor = MaterialTheme.colors.background)
}
@Composable
@@ -1528,6 +1550,7 @@ fun PreviewChatLayout() {
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
forwardItem = { _, _ -> },
updateContactStats = { },
updateMemberStats = { _, _ -> },
syncContactConnection = { },
@@ -1543,6 +1566,7 @@ fun PreviewChatLayout() {
onSearchValueChanged = {},
onComposed = {},
developerTools = false,
showViaProxy = false,
)
}
}
@@ -1600,6 +1624,7 @@ fun PreviewGroupChatLayout() {
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
forwardItem = { _, _ -> },
updateContactStats = { },
updateMemberStats = { _, _ -> },
syncContactConnection = { },
@@ -1615,6 +1640,7 @@ fun PreviewGroupChatLayout() {
onSearchValueChanged = {},
onComposed = {},
developerTools = false,
showViaProxy = false,
)
}
}
@@ -16,7 +16,7 @@ import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ComposeContextInvitingContactMemberView() {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val sentColor = MaterialTheme.appColors.sentMessage
Row(
Modifier
.height(60.dp)
@@ -16,7 +16,7 @@ import chat.simplex.res.MR
@Composable
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val sentColor = MaterialTheme.appColors.sentMessage
Row(
Modifier
.height(60.dp)
@@ -20,7 +20,7 @@ import chat.simplex.common.views.helpers.UploadContent
@Composable
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val sentColor = MaterialTheme.appColors.sentMessage
Row(
Modifier
.padding(top = 8.dp)
@@ -408,14 +408,15 @@ fun ComposeView(
composeState.value = composeState.value.copy(inProgress = true)
}
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo): ChatItem? {
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? {
val chatItem = controller.apiForwardChatItem(
rh = rhId,
toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId,
fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId,
itemId = forwardedItem.id
itemId = forwardedItem.id,
ttl = ttl
)
if (chatItem != null) {
chatModel.addChatItem(rhId, chat.chatInfo, chatItem)
@@ -490,9 +491,9 @@ fun ComposeView(
sendMemberContactInvitation()
sent = null
} else if (cs.contextItem is ComposeContextItem.ForwardingItem) {
sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo)
sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo, ttl = ttl)
if (cs.message.isNotEmpty()) {
sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = null)
sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = ttl)
}
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
@@ -775,7 +776,7 @@ fun ComposeView(
@Composable
fun MsgNotAllowedView(reason: String, icon: Painter) {
val color = CurrentColors.collectAsState().value.appColors.receivedMessage
val color = MaterialTheme.appColors.receivedMessage
Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
@@ -861,7 +862,7 @@ fun ComposeView(
}
}
Row(
modifier = Modifier.padding(end = 8.dp),
modifier = Modifier.background(MaterialTheme.colors.background).padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
@@ -973,7 +974,7 @@ fun ComposeView(
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
if (isSystemInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
else MaterialTheme.colors.primary
SendMsgView(
composeState,
@@ -35,7 +35,7 @@ fun ComposeVoiceView(
) {
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val sentColor = MaterialTheme.appColors.sentMessage
Box {
Box(
Modifier
@@ -30,8 +30,8 @@ fun ContextItemView(
cancelContextItem: () -> Unit
) {
val sent = contextItem.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
@Composable
fun MessageText(attachment: ImageResource?, lines: Int) {
@@ -157,7 +157,7 @@ fun SendMsgView(
fun MenuItems(): List<@Composable () -> Unit> {
val menuItems = mutableListOf<@Composable () -> Unit>()
if (cs.liveMessage == null && !cs.editing && !cs.forwarding && !nextSendGrpInv || sendMsgEnabled) {
if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) {
if (
cs.preview !is ComposePreview.VoicePreview &&
cs.contextItem is ComposeContextItem.NoContextItem &&
@@ -9,7 +9,6 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
@@ -233,6 +232,16 @@ fun GroupChatInfoLayout(
} else {
SendReceiptsOptionDisabled()
}
WallpaperButton {
ModalManager.end.showModal {
val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } }
val c = chat.value
if (c != null) {
ChatWallpaperEditorModal(c)
}
}
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
@@ -123,13 +123,12 @@ private fun GroupPreferencesLayout(
applyPrefs(preferences.copy(files = RoleGroupPreference(enable = enable, role)))
}
// TODO enable simplexLinks preference in 5.8
// SectionDividerSpaced(true, maxBottomPadding = false)
// val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) }
// val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) }
// FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
// applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role)))
// }
SectionDividerSpaced(true, maxBottomPadding = false)
val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) }
val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) }
FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) }
@@ -189,8 +188,6 @@ private fun FeatureSection(
generalGetString(MR.strings.feature_enabled_for),
featureRoles,
enableForRole,
// remove in v5.8
enabled = remember { mutableStateOf(false) },
onSelected = { value ->
onSelected(enableFeature.value, value)
}
@@ -47,7 +47,7 @@ fun CICallItemView(
CICallStatus.Error -> {}
}
CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false)
CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false)
}
}
@@ -1,6 +1,5 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -64,7 +63,7 @@ fun CIFileView(
fun fileAction() {
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation -> {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
if (fileSizeValid(file)) {
receiveFile(file.fileId)
} else {
@@ -176,6 +175,8 @@ fun CIFileView(
} else {
progressIndicator()
}
is CIFileStatus.RcvAborted ->
fileIcon(innerIcon = painterResource(MR.images.ic_sync_problem), color = MaterialTheme.colors.primary)
is CIFileStatus.RcvComplete -> fileIcon()
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
@@ -83,8 +83,8 @@ fun CIGroupInvitationView(
}
}
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
modifier = if (action && !inProgress.value) Modifier.clickable(onClick = {
inProgress.value = true
@@ -110,6 +110,7 @@ fun CIGroupInvitationView(
.padding(bottom = 4.dp),
) {
groupInfoView()
val secondaryColor = MaterialTheme.colors.secondary
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
if (action) {
@@ -117,7 +118,7 @@ fun CIGroupInvitationView(
Text(
buildAnnotatedString {
append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join))
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor)) }
},
color = if (inProgress.value)
MaterialTheme.colors.secondary
@@ -128,7 +129,7 @@ fun CIGroupInvitationView(
Text(
buildAnnotatedString {
append(groupInvitationStr())
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor)) }
}
)
}
@@ -144,7 +145,7 @@ fun CIGroupInvitationView(
}
}
CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false)
CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false)
}
}
}
@@ -21,17 +21,12 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import java.io.File
import java.net.URI
@Composable
fun CIImageView(
image: String,
file: CIFile?,
metaColor: Color,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
@@ -51,7 +46,7 @@ fun CIImageView(
icon,
stringResource(stringId),
Modifier.fillMaxSize(),
tint = metaColor
tint = Color.White
)
}
@@ -78,6 +73,7 @@ fun CIImageView(
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive)
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image)
is CIFileStatus.RcvTransfer -> progressIndicator()
is CIFileStatus.RcvAborted -> fileIcon(painterResource(MR.images.ic_sync_problem), MR.strings.icon_descr_file)
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
@@ -206,7 +202,7 @@ fun CIImageView(
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
if (fileSizeValid()) {
receiveFile(file.fileId)
} else {
@@ -12,7 +12,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.res.MR
@@ -35,7 +34,8 @@ fun CIMetaView(
blue = minOf(metaColor.red * 1.33F, 1F))
},
showStatus: Boolean = true,
showEdited: Boolean = true
showEdited: Boolean = true,
showViaProxy: Boolean
) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
@@ -53,7 +53,8 @@ fun CIMetaView(
metaColor,
paleMetaColor,
showStatus = showStatus,
showEdited = showEdited
showEdited = showEdited,
showViaProxy = showViaProxy
)
}
}
@@ -68,7 +69,8 @@ private fun CIMetaText(
color: Color,
paleColor: Color,
showStatus: Boolean = true,
showEdited: Boolean = true
showEdited: Boolean = true,
showViaProxy: Boolean
) {
if (showEdited && meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
@@ -82,6 +84,9 @@ private fun CIMetaText(
}
Spacer(Modifier.width(4.dp))
}
if (showViaProxy && meta.sentViaProxy == true) {
Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp), tint = MaterialTheme.colors.secondary)
}
if (showStatus) {
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor)
if (statusIcon != null) {
@@ -105,7 +110,15 @@ private fun CIMetaText(
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, showStatus: Boolean = true, showEdited: Boolean = true): String {
fun reserveSpaceForMeta(
meta: CIMeta,
chatTTL: Int?,
encrypted: Boolean?,
secondaryColor: Color,
showStatus: Boolean = true,
showEdited: Boolean = true,
showViaProxy: Boolean = false
): String {
val iconSpace = " "
var res = ""
if (showEdited && meta.itemEdited) res += iconSpace
@@ -116,7 +129,10 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, showSt
res += shortTimeText(ttl)
}
}
if (showStatus && (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing)) {
if (showViaProxy && meta.sentViaProxy == true) {
res += iconSpace
}
if (showStatus && (meta.statusIcon(secondaryColor) != null || !meta.disappearing)) {
res += iconSpace
}
if (encrypted != null) {
@@ -137,7 +153,8 @@ fun PreviewCIMetaView() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
null
null,
showViaProxy = false
)
}
@@ -149,7 +166,8 @@ fun PreviewCIMetaViewUnread() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.RcvNew()
),
null
null,
showViaProxy = false
)
}
@@ -159,9 +177,10 @@ fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError("CMD SYNTAX")
status = CIStatus.CISSndError(SndError.Other("CMD SYNTAX"))
),
null
null,
showViaProxy = false
)
}
@@ -172,7 +191,8 @@ fun PreviewCIMetaViewSendNoAuth() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
),
null
null,
showViaProxy = false
)
}
@@ -183,7 +203,8 @@ fun PreviewCIMetaViewSendSent() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete)
),
null
null,
showViaProxy = false
)
}
@@ -195,7 +216,8 @@ fun PreviewCIMetaViewEdited() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true
),
null
null,
showViaProxy = false
)
}
@@ -208,7 +230,8 @@ fun PreviewCIMetaViewEditedUnread() {
itemEdited = true,
status= CIStatus.RcvNew()
),
null
null,
showViaProxy = false
)
}
@@ -221,7 +244,8 @@ fun PreviewCIMetaViewEditedSent() {
itemEdited = true,
status= CIStatus.SndSent(SndCIStatusProgress.Complete)
),
null
null,
showViaProxy = false
)
}
@@ -230,6 +254,7 @@ fun PreviewCIMetaViewEditedSent() {
fun PreviewCIMetaViewDeletedContent() {
CIMetaView(
chatItem = ChatItem.getDeletedContentSampleData(),
null
null,
showViaProxy = false
)
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.appColors
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -137,7 +138,7 @@ fun DecryptionErrorItemFixButton(
onClick: () -> Unit,
syncSupported: Boolean
) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
@@ -164,17 +165,18 @@ fun DecryptionErrorItemFixButton(
tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(2.dp))
val secondaryColor = MaterialTheme.colors.secondary
Text(
buildAnnotatedString {
append(generalGetString(MR.strings.fix_connection))
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor)) }
withStyle(reserveTimestampStyle) { append(" ") } // for icon
},
color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
CIMetaView(ci, timedMessagesTTL = null)
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false)
}
}
}
@@ -184,7 +186,7 @@ fun DecryptionErrorItem(
ci: ChatItem,
onClick: () -> Unit
) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
@@ -195,14 +197,15 @@ fun DecryptionErrorItem(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.BottomEnd,
) {
val secondaryColor = MaterialTheme.colors.secondary
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
CIMetaView(ci, timedMessagesTTL = null)
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false)
}
}
}
@@ -73,7 +73,7 @@ fun CIVideoView(
VideoPreviewImageView(preview, onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
receiveFileIfValidSize(file, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
@@ -102,7 +102,7 @@ fun CIVideoView(
if (file != null) {
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
if (file?.fileStatus is CIFileStatus.RcvInvitation || file?.fileStatus is CIFileStatus.RcvAborted) {
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
}
}
@@ -396,6 +396,7 @@ private fun loadingIndicator(file: CIFile?) {
} else {
progressIndicator()
}
is CIFileStatus.RcvAborted -> fileIcon(painterResource(MR.images.ic_sync_problem), MR.strings.icon_descr_file)
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
@@ -22,6 +22,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlinx.coroutines.flow.*
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@@ -35,6 +36,7 @@ fun CIVoiceView(
hasText: Boolean,
ci: ChatItem,
timedMessagesTTL: Int?,
showViaProxy: Boolean,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
) {
@@ -76,7 +78,7 @@ fun CIVoiceView(
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, play, pause, longClick, receiveFile) {
AudioPlayer.seekTo(it, progress, fileSource.value?.filePath)
}
} else {
@@ -102,6 +104,7 @@ private fun VoiceLayout(
sent: Boolean,
hasText: Boolean,
timedMessagesTTL: Int?,
showViaProxy: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
@@ -151,8 +154,8 @@ private fun VoiceLayout(
}
when {
hasText -> {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -171,7 +174,7 @@ private fun VoiceLayout(
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
}
Box(Modifier.padding(top = 6.dp, end = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
}
}
}
@@ -186,7 +189,7 @@ private fun VoiceLayout(
}
}
Box(Modifier.padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
}
}
}
@@ -218,10 +221,11 @@ private fun PlayPauseButton(
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
longClick: () -> Unit,
icon: ImageResource = MR.images.ic_play_arrow_filled,
) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) sentColor else receivedColor,
@@ -239,7 +243,7 @@ private fun PlayPauseButton(
contentAlignment = Alignment.Center
) {
Icon(
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(icon),
contentDescription = null,
Modifier.size(36.dp),
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
@@ -292,6 +296,8 @@ private fun VoiceMsgIndicator(
) {
ProgressIndicator()
}
} else if (file?.fileStatus is CIFileStatus.RcvAborted) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick, icon = MR.images.ic_sync_problem)
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
}
@@ -59,6 +59,7 @@ fun ChatItemView(
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit,
forwardItem: (ChatInfo, ChatItem) -> Unit,
updateContactStats: (Contact) -> Unit,
updateMemberStats: (GroupInfo, GroupMember) -> Unit,
syncContactConnection: (Contact) -> Unit,
@@ -68,6 +69,8 @@ fun ChatItemView(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean,
preview: Boolean = false,
) {
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
@@ -83,17 +86,15 @@ fun ChatItemView(
.fillMaxWidth(),
contentAlignment = alignment,
) {
val onClick = {
when (cItem.meta.itemStatus) {
is CIStatus.SndErrorAuth -> {
showMsgDeliveryErrorAlert(generalGetString(MR.strings.message_delivery_error_desc))
}
is CIStatus.SndError -> {
showMsgDeliveryErrorAlert(generalGetString(MR.strings.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
}
else -> {}
val info = cItem.meta.itemStatus.statusInto
val onClick = if (info != null) {
{
AlertManager.shared.showAlertMsg(
title = info.first,
text = info.second,
)
}
}
} else { {} }
@Composable
fun ChatItemReactions() {
@@ -130,7 +131,7 @@ fun ChatItemView(
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
@@ -261,8 +262,7 @@ fun ChatItemView(
!cItem.isLiveDummy && !live
) {
ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = {
chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(cItem, cInfo)
forwardItem(cInfo, cItem)
showMenu.value = false
})
}
@@ -273,7 +273,7 @@ fun ChatItemView(
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
if (!(live && cItem.meta.isLive) && !preview) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
@@ -334,14 +334,14 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy)
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile)
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile)
} else {
framedItemView()
}
@@ -353,7 +353,7 @@ fun ChatItemView(
}
@Composable fun LegacyDeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL)
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
@@ -406,7 +406,7 @@ fun ChatItemView(
@Composable
fun DeletedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
@@ -816,50 +816,44 @@ fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteM
)
}
private fun showMsgDeliveryErrorAlert(description: String) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.message_delivery_error_title),
text = description,
)
}
expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager)
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
rhId = null,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
updateContactStats = { },
updateMemberStats = { _, _ -> },
syncContactConnection = { },
syncMemberConnection = { _, _ -> },
findModelChat = { null },
findModelMember = { null },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
developerTools = false,
)
}
fun PreviewChatItemView(
chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello")
) {
ChatItemView(
rhId = null,
ChatInfo.Direct.sampleData,
chatItem,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
forwardItem = { _, _ -> },
updateContactStats = { },
updateMemberStats = { _, _ -> },
syncContactConnection = { },
syncMemberConnection = { _, _ -> },
findModelChat = { null },
findModelMember = { null },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
developerTools = false,
showViaProxy = false,
preview = true,
)
}
@Preview
@@ -884,6 +878,7 @@ fun PreviewChatItemViewDeletedContent() {
scrollToItem = {},
acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> },
forwardItem = { _, _ -> },
updateContactStats = { },
updateMemberStats = { _, _ -> },
syncContactConnection = { },
@@ -893,6 +888,8 @@ fun PreviewChatItemViewDeletedContent() {
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
developerTools = false,
showViaProxy = false,
preview = true,
)
}
}
@@ -16,10 +16,10 @@ import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
@Composable
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) {
val sent = ci.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) sentColor else receivedColor,
@@ -36,7 +36,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
}
}
}
@@ -50,7 +50,8 @@ fun PreviewDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getDeletedContentSampleData(),
null
null,
showViaProxy = false
)
}
}
@@ -17,13 +17,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFo
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem, timedMessagesTTL)
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy)
}
}
@@ -33,6 +33,7 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
linkMode: SimplexLinkMode,
showViaProxy: Boolean,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
@@ -45,9 +46,6 @@ fun FramedItemView(
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
}
@Composable
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
@Composable
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
MarkdownText(
@@ -88,11 +86,11 @@ fun FramedItemView(
@Composable
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
Modifier
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
.background(if (sent) sentColor else receivedColor)
.fillMaxWidth()
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -121,11 +119,11 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
Modifier
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
.background(if (sent) sentColor else receivedColor)
.fillMaxWidth()
.combinedClickable(
onLongClick = { showMenu.value = true },
@@ -180,17 +178,24 @@ fun FramedItemView(
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, showMenu, receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy)
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
when {
transparentBackground -> Color.Transparent
sent -> MaterialTheme.colors.background
else -> MaterialTheme.colors.background
}
)
.background(
when {
transparentBackground -> Color.Transparent
@@ -240,11 +245,11 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
}
}
is MsgContent.MCVideo -> {
@@ -252,35 +257,35 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
}
}
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, chatTTL, metaColor)
CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy)
}
}
}
@@ -292,14 +297,15 @@ fun CIMarkdownText(
chatTTL: Int?,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy
)
}
}
@@ -17,8 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MsgErrorType
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -51,7 +50,7 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTT
@Composable
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
@@ -69,7 +68,7 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci, timedMessagesTTL, showViaProxy = false)
}
}
}
@@ -20,9 +20,9 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean) {
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
shape = RoundedCornerShape(18.dp),
color = if (ci.chatDir.sent) sentColor else receivedColor,
@@ -35,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl
Box(Modifier.weight(1f, false)) {
MergedMarkedDeletedText(ci, revealed)
}
CIMetaView(ci, timedMessagesTTL)
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
}
}
}
@@ -112,7 +112,8 @@ fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())),
null
null,
showViaProxy = false
)
}
}
@@ -69,7 +69,8 @@ fun MarkdownText (
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
onLinkLongClick: (link: String) -> Unit = {}
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean = false
) {
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
@@ -77,7 +78,7 @@ fun MarkdownText (
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL, null) // LALAL
reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy)
} else {
" "
}
@@ -486,6 +486,8 @@ fun deleteChatDatabaseFilesAndState() {
tmpDir.deleteRecursively()
getMigrationTempFilesDirectory().deleteRecursively()
tmpDir.mkdir()
wallpapersDir.deleteRecursively()
wallpapersDir.mkdirs()
DatabaseUtils.ksDatabasePassword.remove()
controller.appPrefs.storeDBPassphrase.set(true)
controller.ctrl = null
@@ -2,12 +2,14 @@ package chat.simplex.common.views.helpers
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
@@ -51,7 +53,8 @@ fun ProfileImage(
size: Dp,
image: String? = null,
icon: ImageResource = MR.images.ic_account_circle_filled,
color: Color = MaterialTheme.colors.secondaryVariant
color: Color = MaterialTheme.colors.secondaryVariant,
backgroundColor: Color? = null
) {
Box(Modifier.size(size)) {
if (image == null) {
@@ -61,6 +64,9 @@ fun ProfileImage(
else -> null
}
if (iconToReplace != null) {
if (backgroundColor != null) {
Box(Modifier.size(size * 0.7f).align(Alignment.Center).background(backgroundColor, CircleShape))
}
Icon(
iconToReplace,
contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder),
@@ -0,0 +1,418 @@
package chat.simplex.common.views.helpers
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File
import kotlin.math.*
enum class PresetWallpaper(
val res: ImageResource,
val filename: String,
val text: StringResource,
val scale: Float,
val background: Map<DefaultTheme, Color>,
val tint: Map<DefaultTheme, Color>,
val colors: Map<DefaultTheme, ThemeColors>,
) {
CATS(MR.images.wallpaper_cats, "cats", MR.strings.wallpaper_cats, 0.63f,
wallpaperBackgrounds(light = "#ffF8F6EA"),
tint = mapOf(
DefaultTheme.LIGHT to "#ffefdca6".colorFromReadableHex(),
DefaultTheme.DARK to "#ff4b3b0e".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff51400f".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff4b3b0e".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#fffffaed",
sentQuote = "#fffaf0d6",
receivedMessage = "#ffF8F7F4",
receivedQuote = "#ffefede9",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff2f2919",
sentQuote = "#ff473a1d",
receivedMessage = "#ff272624",
receivedQuote = "#ff373633",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff41371b",
sentQuote = "#ff654f1c",
receivedMessage = "#ff272624",
receivedQuote = "#ff373633",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff41371b",
sentQuote = "#ff654f1c",
receivedMessage = "#ff1f1e1b",
receivedQuote = "#ff2f2d27",
),
)
),
FLOWERS(MR.images.wallpaper_flowers, "flowers", MR.strings.wallpaper_flowers, 0.53f,
wallpaperBackgrounds(light = "#ffE2FFE4"),
tint = mapOf(
DefaultTheme.LIGHT to "#ff9CEA59".colorFromReadableHex(),
DefaultTheme.DARK to "#ff31560D".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff36600f".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff31560D".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#fff1ffe5",
sentQuote = "#ffdcf9c4",
receivedMessage = "#ffF4F8F2",
receivedQuote = "#ffe7ece7",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff163521",
sentQuote = "#ff1B5330",
receivedMessage = "#ff242523",
receivedQuote = "#ff353733",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff184739",
sentQuote = "#ff1F6F4B",
receivedMessage = "#ff242523",
receivedQuote = "#ff353733",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff184739",
sentQuote = "#ff1F6F4B",
receivedMessage = "#ff1c1f1a",
receivedQuote = "#ff282b25",
),
)
),
HEARTS(MR.images.wallpaper_hearts, "hearts", MR.strings.wallpaper_hearts, 0.59f,
wallpaperBackgrounds(light = "#ffFDECEC"),
tint = mapOf(
DefaultTheme.LIGHT to "#fffde0e0".colorFromReadableHex(),
DefaultTheme.DARK to "#ff3c0f0f".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff411010".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff3C0F0F".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#fffff4f4",
sentQuote = "#ffffdfdf",
receivedMessage = "#fff8f6f6",
receivedQuote = "#ffefebeb",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff301515",
sentQuote = "#ff4C1818",
receivedMessage = "#ff242121",
receivedQuote = "#ff3b3535",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff491A28",
sentQuote = "#ff761F29",
receivedMessage = "#ff242121",
receivedQuote = "#ff3b3535",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff491A28",
sentQuote = "#ff761F29",
receivedMessage = "#ff1f1b1b",
receivedQuote = "#ff2e2626",
),
)
),
KIDS(MR.images.wallpaper_kids, "kids", MR.strings.wallpaper_kids, 0.53f,
wallpaperBackgrounds(light = "#ffdbfdfb"),
tint = mapOf(
DefaultTheme.LIGHT to "#ffadeffc".colorFromReadableHex(),
DefaultTheme.DARK to "#ff16404B".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff184753".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff16404B".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#ffeafeff",
sentQuote = "#ffcbf4f7",
receivedMessage = "#fff3fafa",
receivedQuote = "#ffe4efef",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff16302F",
sentQuote = "#ff1a4a49",
receivedMessage = "#ff252626",
receivedQuote = "#ff373A39",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff1a4745",
sentQuote = "#ff1d6b69",
receivedMessage = "#ff252626",
receivedQuote = "#ff373a39",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff1a4745",
sentQuote = "#ff1d6b69",
receivedMessage = "#ff1e1f1f",
receivedQuote = "#ff262b29",
),
)
),
SCHOOL(MR.images.wallpaper_school, "school", MR.strings.wallpaper_school, 0.53f,
wallpaperBackgrounds(light = "#ffE7F5FF"),
tint = mapOf(
DefaultTheme.LIGHT to "#ffCEEBFF".colorFromReadableHex(),
DefaultTheme.DARK to "#ff0F293B".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff112f43".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff0F293B".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#ffeef9ff",
sentQuote = "#ffD6EDFA",
receivedMessage = "#ffF3F5F9",
receivedQuote = "#ffe4e8ee",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff172833",
sentQuote = "#ff1C3E4F",
receivedMessage = "#ff26282c",
receivedQuote = "#ff393c40",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff1A3C5D",
sentQuote = "#ff235b80",
receivedMessage = "#ff26282c",
receivedQuote = "#ff393c40",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff1A3C5D",
sentQuote = "#ff235b80",
receivedMessage = "#ff1d1e22",
receivedQuote = "#ff292b2f",
),
)
),
TRAVEL(MR.images.wallpaper_travel, "travel", MR.strings.wallpaper_travel, 0.68f,
wallpaperBackgrounds(light = "#fff9eeff"),
tint = mapOf(
DefaultTheme.LIGHT to "#ffeedbfe".colorFromReadableHex(),
DefaultTheme.DARK to "#ff311E48".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff35204e".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff311E48".colorFromReadableHex()
),
mapOf(
DefaultTheme.LIGHT to ThemeColors(
sentMessage = "#fffcf6ff",
sentQuote = "#fff2e0fc",
receivedMessage = "#ffF6F4F7",
receivedQuote = "#ffede9ee",
),
DefaultTheme.DARK to ThemeColors(
sentMessage = "#ff33263B",
sentQuote = "#ff53385E",
receivedMessage = "#ff272528",
receivedQuote = "#ff3B373E",
),
DefaultTheme.SIMPLEX to ThemeColors(
sentMessage = "#ff3C255D",
sentQuote = "#ff623485",
receivedMessage = "#ff26273B",
receivedQuote = "#ff3A394F",
),
DefaultTheme.BLACK to ThemeColors(
sentMessage = "#ff3C255D",
sentQuote = "#ff623485",
receivedMessage = "#ff231f23",
receivedQuote = "#ff2c2931",
),
)
);
fun toType(base: DefaultTheme, scale: Float? = null): WallpaperType =
WallpaperType.Preset(
filename,
scale ?: appPrefs.themeOverrides.get().firstOrNull { it.wallpaper != null && it.wallpaper.preset == filename && it.base == base }?.wallpaper?.scale ?: 1f
)
companion object {
fun from(filename: String): PresetWallpaper? =
entries.firstOrNull { it.filename == filename }
}
}
fun wallpaperBackgrounds(light: String): Map<DefaultTheme, Color> =
mapOf(
DefaultTheme.LIGHT to light.colorFromReadableHex(),
DefaultTheme.DARK to "#ff121212".colorFromReadableHex(),
DefaultTheme.SIMPLEX to "#ff111528".colorFromReadableHex(),
DefaultTheme.BLACK to "#ff070707".colorFromReadableHex()
)
@Serializable
enum class WallpaperScaleType(val contentScale: ContentScale, val text: StringResource) {
@SerialName("fill") FILL(ContentScale.Crop, MR.strings.wallpaper_scale_fill),
@SerialName("fit") FIT(ContentScale.Fit, MR.strings.wallpaper_scale_fit),
@SerialName("repeat") REPEAT(ContentScale.Fit, MR.strings.wallpaper_scale_repeat),
}
sealed class WallpaperType {
abstract val scale: Float?
val image by lazy {
val filename = when (this) {
is Preset -> filename
is Image -> filename
else -> return@lazy null
}
if (filename == "") return@lazy null
if (cachedImages[filename] != null) {
cachedImages[filename]
} else {
val res = if (this is Preset) {
(PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).res.toComposeImageBitmap()!!
} else {
try {
// In case of unintentional image deletion don't crash the app
File(getWallpaperFilePath(filename)).inputStream().use { loadImageBitmap(it) }
} catch (e: Exception) {
Log.e(TAG, "Error while loading wallpaper file: ${e.stackTraceToString()}")
null
}
}
res?.prepareToDraw()
cachedImages[filename] = res ?: return@lazy null
res
}
}
fun sameType(other: WallpaperType?): Boolean =
if (this is Preset && other is Preset) this.filename == other.filename
else this.javaClass == other?.javaClass
fun samePreset(other: PresetWallpaper?): Boolean = this is Preset && filename == other?.filename
data class Preset(
val filename: String,
override val scale: Float?,
): WallpaperType() {
val predefinedImageScale = PresetWallpaper.from(filename)?.scale ?: 1f
}
data class Image(
val filename: String,
override val scale: Float?,
val scaleType: WallpaperScaleType?,
): WallpaperType()
object Empty: WallpaperType() {
override val scale: Float?
get() = null
}
fun defaultBackgroundColor(theme: DefaultTheme, materialBackground: Color): Color =
if (this is Preset) {
(PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).background[theme]!!
} else {
materialBackground
}
fun defaultTintColor(theme: DefaultTheme): Color =
if (this is Preset) {
(PresetWallpaper.from(filename) ?: PresetWallpaper.CATS).tint[theme]!!
} else if (this is Image && scaleType == WallpaperScaleType.REPEAT) {
Color.Transparent
} else {
Color.Transparent
}
companion object {
var cachedImages: MutableMap<String, ImageBitmap> = mutableMapOf()
fun from(wallpaper: ThemeWallpaper?): WallpaperType? {
return if (wallpaper == null) {
null
} else if (wallpaper.preset != null) {
Preset(wallpaper.preset, wallpaper.scale)
} else if (wallpaper.imageFile != null) {
Image(wallpaper.imageFile, wallpaper.scale, wallpaper.scaleType)
} else {
Empty
}
}
}
}
fun DrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperType, background: Color, tint: Color) = clipRect {
val quality = FilterQuality.High
fun repeat(imageScale: Float) {
val scale = imageScale * density
for (h in 0..(size.height / image.height / scale).roundToInt()) {
for (w in 0..(size.width / image.width / scale).roundToInt()) {
drawImage(
image,
dstOffset = IntOffset(x = (w * image.width * scale).roundToInt(), y = (h * image.height * scale).roundToInt()),
dstSize = IntSize((image.width * scale).roundToInt(), (image.height * scale).roundToInt()),
colorFilter = ColorFilter.tint(tint, BlendMode.SrcIn),
filterQuality = quality
)
}
}
}
drawRect(background)
when (imageType) {
is WallpaperType.Preset -> repeat((imageType.scale ?: 1f) * imageType.predefinedImageScale)
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
WallpaperScaleType.REPEAT -> repeat(imageType.scale ?: 1f)
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
val scaledWidth = (image.width * scale.scaleX).roundToInt()
val scaledHeight = (image.height * scale.scaleY).roundToInt()
// Large image will cause freeze
if (image.width > 4320 || image.height > 4320) return@clipRect
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
if (scaleType == WallpaperScaleType.FIT) {
if (scaledWidth < size.width) {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while (x > 0) {
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while (x < size.width) {
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while (y > 0) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while (y < size.height) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y += scaledHeight
}
}
}
drawRect(tint)
}
}
is WallpaperType.Empty -> {}
}
}
@@ -51,7 +51,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) {
val theme = CurrentColors.collectAsState()
val titleColor = CurrentColors.collectAsState().value.appColors.title
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
else // color is not updated when changing themes if I pass null here
@@ -76,7 +76,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
@Composable
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancelEnabled: Boolean) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val sentColor = MaterialTheme.appColors.sentMessage
Row(
Modifier.fillMaxWidth().padding(top = 8.dp).background(sentColor),
verticalAlignment = Alignment.CenterVertically
@@ -41,9 +41,12 @@ enum class ModalPlacement {
}
class ModalData {
private val state = mutableMapOf<String, MutableState<Any>>()
private val state = mutableMapOf<String, MutableState<Any?>>()
fun <T> stateGetOrPut (key: String, default: () -> T): MutableState<T> =
state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState<T>
fun <T> stateGetOrPutNullable (key: String, default: () -> T?): MutableState<T?> =
state.getOrPut(key) { mutableStateOf(default() as Any?) } as MutableState<T?>
}
class ModalManager(private val placement: ModalPlacement? = null) {
@@ -0,0 +1,520 @@
package chat.simplex.common.views.helpers
import SectionBottomSpacer
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.yaml
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector
import chat.simplex.common.views.usersettings.AppearanceScope.editColor
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.serialization.encodeToString
import java.net.URI
@Composable
fun ModalData.UserWallpaperEditor(
theme: ThemeModeOverride,
applyToMode: DefaultThemeMode?,
globalThemeUsed: MutableState<Boolean>,
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
) {
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
var showMore by remember { stateGetOrPut("showMore") { false } }
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
val currentTheme by CurrentColors.collectAsState()
AppBarTitle(stringResource(MR.strings.settings_section_title_user_theme))
val wallpaperImage = MaterialTheme.wallpaper.type.image
val wallpaperType = MaterialTheme.wallpaper.type
val onTypeCopyFromSameTheme = { type: WallpaperType? ->
if (type is WallpaperType.Image && chatModel.remoteHostId() != null) {
false
} else {
ThemeManager.copyFromSameThemeOverrides(type, null, themeModeOverride)
withBGApi { save(applyToMode.value, themeModeOverride.value) }
globalThemeUsed.value = false
true
}
}
val preApplyGlobalIfNeeded = { type: WallpaperType? ->
if (globalThemeUsed.value) {
onTypeCopyFromSameTheme(type)
}
}
val onTypeChange: (WallpaperType?) -> Unit = { type: WallpaperType? ->
if (globalThemeUsed.value) {
preApplyGlobalIfNeeded(type)
// Saves copied static image instead of original from global theme
ThemeManager.applyWallpaper(themeModeOverride.value.type, themeModeOverride)
} else {
ThemeManager.applyWallpaper(type, themeModeOverride)
}
withBGApi { save(applyToMode.value, themeModeOverride.value) }
}
val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
val filename = saveWallpaperFile(to)
if (filename != null) {
onTypeChange(WallpaperType.Image(filename, 1f, WallpaperScaleType.FILL))
}
}
}
val currentColors = { type: WallpaperType? ->
// If applying for :
// - all themes: no overrides needed
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null
ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get())
}
val onChooseType: (WallpaperType?) -> Unit = { type: WallpaperType? ->
when {
// don't have image in parent or already selected wallpaper with custom image
type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ }
type is WallpaperType.Image && (wallpaperType is WallpaperType.Image || currentColors(type).wallpaper.type.image == null) -> withLongRunningApi { importWallpaperLauncher.launch("image/*") }
type is WallpaperType.Image -> onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)
themeModeOverride.value.type != type || currentTheme.wallpaper.type != type -> onTypeCopyFromSameTheme(type)
else -> onTypeChange(type)
}
}
val editColor = { name: ThemeColor ->
editColor(
name,
wallpaperType,
wallpaperImage,
onColorChange = { color ->
preApplyGlobalIfNeeded(themeModeOverride.value.type)
ThemeManager.applyThemeColor(name, color, themeModeOverride)
withBGApi { save(applyToMode.value, themeModeOverride.value) }
}
)
}
WallpaperPresetSelector(
selectedWallpaper = wallpaperType,
baseTheme = currentTheme.base,
currentColors = { type ->
// If applying for :
// - all themes: no overrides needed
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null
ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get())
},
onChooseType = onChooseType
)
WallpaperSetupView(
themeModeOverride.value.type,
CurrentColors.collectAsState().value.base,
currentTheme.wallpaper,
currentTheme.appColors.sentMessage,
currentTheme.appColors.sentQuote,
currentTheme.appColors.receivedMessage,
currentTheme.appColors.receivedQuote,
editColor = { name -> editColor(name) },
onTypeChange = onTypeChange,
)
SectionSpacer()
if (!globalThemeUsed.value) {
ResetToGlobalThemeButton(true) {
themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
globalThemeUsed.value = true
withBGApi { save(applyToMode.value, null) }
}
}
SetDefaultThemeButton {
globalThemeUsed.value = false
val lightBase = DefaultTheme.LIGHT
val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX
val mode = themeModeOverride.value.mode
withBGApi {
// Saving for both modes in one place by changing mode once per save
if (applyToMode.value == null) {
val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase))
}
themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase)
save(themeModeOverride.value.mode, themeModeOverride.value)
}
}
KeyChangeEffect(theme.mode) {
themeModeOverride.value = theme
if (applyToMode.value != null) {
applyToMode.value = theme.mode
}
}
// Applies updated global theme if current one tracks global theme
KeyChangeEffect(CurrentColors.collectAsState().value) {
if (globalThemeUsed.value) {
themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
globalThemeUsed.value = true
}
}
SectionSpacer()
if (showMore) {
val values by remember { mutableStateOf(
listOf(
null to generalGetString(MR.strings.chat_theme_apply_to_all_modes),
DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode),
DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode),
)
)
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.chat_theme_apply_to_mode),
values,
applyToMode,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = {
applyToMode.value = it
if (it != null && it != CurrentColors.value.base.mode) {
val lightBase = DefaultTheme.LIGHT
val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX
ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName)
}
}
)
SectionSpacer()
AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor)
SectionSpacer()
ImportExportThemeSection(null, remember { chatModel.currentUser }.value?.uiThemes) {
withBGApi {
themeModeOverride.value = it
save(applyToMode.value, it)
}
}
} else {
AdvancedSettingsButton { showMore = true }
}
SectionBottomSpacer()
}
}
@Composable
fun ModalData.ChatWallpaperEditor(
theme: ThemeModeOverride,
applyToMode: DefaultThemeMode?,
globalThemeUsed: MutableState<Boolean>,
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
) {
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
var showMore by remember { stateGetOrPut("showMore") { false } }
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
val currentTheme by remember(themeModeOverride.value, CurrentColors.collectAsState().value) {
mutableStateOf(
ThemeManager.currentColors(null, if (globalThemeUsed.value) null else themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())
)
}
AppBarTitle(stringResource(MR.strings.settings_section_title_chat_theme))
val onTypeCopyFromSameTheme: (WallpaperType?) -> Boolean = { type ->
if (type is WallpaperType.Image && chatModel.remoteHostId() != null) {
false
} else {
val success = ThemeManager.copyFromSameThemeOverrides(type, chatModel.currentUser.value?.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight), themeModeOverride)
if (success) {
withBGApi { save(applyToMode.value, themeModeOverride.value) }
globalThemeUsed.value = false
}
success
}
}
val preApplyGlobalIfNeeded = { type: WallpaperType? ->
if (globalThemeUsed.value) {
onTypeCopyFromSameTheme(type)
}
}
val onTypeChange: (WallpaperType?) -> Unit = { type ->
if (globalThemeUsed.value) {
preApplyGlobalIfNeeded(type)
// Saves copied static image instead of original from global theme
ThemeManager.applyWallpaper(themeModeOverride.value.type, themeModeOverride)
} else {
ThemeManager.applyWallpaper(type, themeModeOverride)
}
withBGApi { save(applyToMode.value, themeModeOverride.value) }
}
val editColor: (ThemeColor) -> Unit = { name: ThemeColor ->
ModalManager.end.showModal {
val currentTheme by remember(themeModeOverride.value, CurrentColors.collectAsState().value) {
mutableStateOf(
ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())
)
}
val initialColor: Color = when (name) {
ThemeColor.WALLPAPER_BACKGROUND -> currentTheme.wallpaper.background ?: Color.Transparent
ThemeColor.WALLPAPER_TINT -> currentTheme.wallpaper.tint ?: Color.Transparent
ThemeColor.PRIMARY -> currentTheme.colors.primary
ThemeColor.PRIMARY_VARIANT -> currentTheme.colors.primaryVariant
ThemeColor.SECONDARY -> currentTheme.colors.secondary
ThemeColor.SECONDARY_VARIANT -> currentTheme.colors.secondaryVariant
ThemeColor.BACKGROUND -> currentTheme.colors.background
ThemeColor.SURFACE -> currentTheme.colors.surface
ThemeColor.TITLE -> currentTheme.appColors.title
ThemeColor.PRIMARY_VARIANT2 -> currentTheme.appColors.primaryVariant2
ThemeColor.SENT_MESSAGE -> currentTheme.appColors.sentMessage
ThemeColor.SENT_QUOTE -> currentTheme.appColors.sentQuote
ThemeColor.RECEIVED_MESSAGE -> currentTheme.appColors.receivedMessage
ThemeColor.RECEIVED_QUOTE -> currentTheme.appColors.receivedQuote
}
AppearanceScope.ColorEditor(
name,
initialColor,
CurrentColors.collectAsState().value.base,
themeModeOverride.value.type,
themeModeOverride.value.type?.image,
currentTheme.wallpaper.background,
currentTheme.wallpaper.tint,
currentColors = {
ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())
},
onColorChange = { color ->
preApplyGlobalIfNeeded(themeModeOverride.value.type)
ThemeManager.applyThemeColor(name, color, themeModeOverride)
withBGApi { save(applyToMode.value, themeModeOverride.value) }
}
)
}
}
val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
val filename = saveWallpaperFile(to)
if (filename != null) {
// Delete only non-user image
if (!globalThemeUsed.value) {
removeWallpaperFile((themeModeOverride.value.type as? WallpaperType.Image)?.filename)
}
globalThemeUsed.value = false
onTypeChange(WallpaperType.Image(filename, 1f, WallpaperScaleType.FILL))
}
}
}
val currentColors = { type: WallpaperType? ->
ThemeManager.currentColors(type, if (type?.sameType(themeModeOverride.value.type) == true) themeModeOverride.value else null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
}
WallpaperPresetSelector(
selectedWallpaper = currentTheme.wallpaper.type,
activeBackgroundColor = currentTheme.wallpaper.background,
activeTintColor = currentTheme.wallpaper.tint,
baseTheme = CurrentColors.collectAsState().value.base,
currentColors = { type -> currentColors(type) },
onChooseType = { type ->
when {
type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ }
type is WallpaperType.Image && ((themeModeOverride.value.type is WallpaperType.Image && !globalThemeUsed.value) || currentColors(type).wallpaper.type.image == null) -> {
withLongRunningApi { importWallpaperLauncher.launch("image/*") }
}
type is WallpaperType.Image -> {
if (!onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)) {
withLongRunningApi { importWallpaperLauncher.launch("image/*") }
}
}
globalThemeUsed.value || themeModeOverride.value.type != type -> {
onTypeCopyFromSameTheme(type)
}
else -> {
onTypeChange(type)
}
}
},
)
WallpaperSetupView(
themeModeOverride.value.type,
CurrentColors.collectAsState().value.base,
currentTheme.wallpaper,
currentTheme.appColors.sentMessage,
currentTheme.appColors.sentQuote,
currentTheme.appColors.receivedMessage,
currentTheme.appColors.receivedQuote,
editColor = editColor,
onTypeChange = onTypeChange,
)
SectionSpacer()
if (!globalThemeUsed.value) {
ResetToGlobalThemeButton(remember { chatModel.currentUser }.value?.uiThemes?.preferredMode(isInDarkTheme()) == null) {
themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
globalThemeUsed.value = true
withBGApi { save(applyToMode.value, null) }
}
}
SetDefaultThemeButton {
globalThemeUsed.value = false
val lightBase = DefaultTheme.LIGHT
val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX
val mode = themeModeOverride.value.mode
withBGApi {
// Saving for both modes in one place by changing mode once per save
if (applyToMode.value == null) {
val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase))
}
themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase)
save(themeModeOverride.value.mode, themeModeOverride.value)
}
}
KeyChangeEffect(theme.mode) {
themeModeOverride.value = theme
if (applyToMode.value != null) {
applyToMode.value = theme.mode
}
}
// Applies updated global theme if current one tracks global theme
KeyChangeEffect(CurrentColors.collectAsState()) {
if (globalThemeUsed.value) {
themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
globalThemeUsed.value = true
}
}
SectionSpacer()
if (showMore) {
val values by remember { mutableStateOf(
listOf(
null to generalGetString(MR.strings.chat_theme_apply_to_all_modes),
DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode),
DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode),
)
)
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.chat_theme_apply_to_mode),
values,
applyToMode,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = {
applyToMode.value = it
if (it != null && it != CurrentColors.value.base.mode) {
val lightBase = DefaultTheme.LIGHT
val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX
ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName)
}
}
)
SectionSpacer()
AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor)
SectionSpacer()
ImportExportThemeSection(themeModeOverride.value, remember { chatModel.currentUser }.value?.uiThemes) {
withBGApi {
themeModeOverride.value = it
save(applyToMode.value, it)
}
}
} else {
AdvancedSettingsButton { showMore = true }
}
SectionBottomSpacer()
}
}
@Composable
private fun ImportExportThemeSection(perChat: ThemeModeOverride?, perUser: ThemeModeOverrides?, save: (ThemeModeOverride) -> Unit) {
SectionView {
val theme = remember { mutableStateOf(null as String?) }
val exportThemeLauncher = rememberFileChooserLauncher(false) { to: URI? ->
val themeValue = theme.value
if (themeValue != null && to != null) {
copyBytesToFile(themeValue.byteInputStream(), to) {
theme.value = null
}
}
}
SectionItemView({
val overrides = ThemeManager.currentThemeOverridesForExport(perChat, perUser)
val lines = yaml.encodeToString<ThemeOverrides>(overrides).lines()
// Removing theme id without using custom serializer or data class
theme.value = lines.subList(1, lines.size).joinToString("\n")
withLongRunningApi { exportThemeLauncher.launch("simplex.theme") }
}) {
Text(generalGetString(MR.strings.export_theme), color = colors.primary)
}
val importThemeLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
val theme = getThemeFromUri(to)
if (theme != null) {
val res = ThemeModeOverride(mode = theme.base.mode, colors = theme.colors, wallpaper = theme.wallpaper?.importFromString()).removeSameColors(theme.base)
save(res)
}
}
}
// Can not limit to YAML mime type since it's unsupported by Android
SectionItemView({ withLongRunningApi { importThemeLauncher.launch("*/*") } }) {
Text(generalGetString(MR.strings.import_theme), color = colors.primary)
}
}
}
@Composable
private fun ResetToGlobalThemeButton(app: Boolean, onClick: () -> Unit) {
SectionItemView(onClick) {
Text(stringResource(if (app) MR.strings.chat_theme_reset_to_app_theme else MR.strings.chat_theme_reset_to_user_theme), color = MaterialTheme.colors.primary)
}
}
@Composable
private fun SetDefaultThemeButton(onClick: () -> Unit) {
SectionItemView(onClick) {
Text(stringResource(MR.strings.chat_theme_set_default_theme), color = MaterialTheme.colors.primary)
}
}
@Composable
private fun AdvancedSettingsButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_arrow_downward),
stringResource(MR.strings.wallpaper_advanced_settings),
click = onClick
)
}
@@ -19,6 +19,7 @@ import kotlinx.serialization.encodeToString
import java.io.*
import java.net.URI
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.Executors
@@ -145,6 +146,7 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
runCatching {
return yaml.decodeFromStream<ThemeOverrides>(it!!)
}.onFailure {
Log.e(TAG, "Error while decoding theme: ${it.stackTraceToString()}")
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.import_theme_error),
@@ -280,6 +282,38 @@ fun saveFileFromUri(uri: URI, withAlertOnException: Boolean = true): CryptoFile?
}
}
fun saveWallpaperFile(uri: URI): String? {
val destFileName = generateNewFileName("wallpaper", "jpg", File(getWallpaperFilePath("")))
val destFile = File(getWallpaperFilePath(destFileName))
try {
val inputStream = uri.inputStream()
Files.copy(inputStream!!, destFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
Log.e(TAG, "Error saving wallpaper file: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString())
return null
}
return destFile.name
}
fun saveWallpaperFile(image: ImageBitmap): String {
val destFileName = generateNewFileName("wallpaper", "jpg", File(getWallpaperFilePath("")))
val destFile = File(getWallpaperFilePath(destFileName))
val dataResized = resizeImageToDataSize(image, false, maxDataSize = 5_000_000)
val output = FileOutputStream(destFile)
dataResized.use {
it.writeTo(output)
}
return destFile.name
}
fun removeWallpaperFile(fileName: String? = null) {
File(getWallpaperFilePath("_")).parentFile.listFiles()?.forEach {
if (it.name == fileName) it.delete()
}
WallpaperType.cachedImages.remove(fileName)
}
fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T {
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
tmpFile.deleteOnExit()
@@ -550,10 +584,33 @@ fun KeyChangeEffect(
val initialKey = remember { key1 }
val initialKey2 = remember { key2 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
LaunchedEffect(key1, key2) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
}
}
}
/**
* Runs the [block] only after initial value of the [key1], or [key2], or [key3] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun KeyChangeEffect(
key1: Any?,
key2: Any?,
key3: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val initialKey = remember { key1 }
val initialKey2 = remember { key2 }
val initialKey3 = remember { key3 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2, key3) {
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
block()
anyChange = true
}
}
}
@@ -34,7 +34,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
} else {
val r: LAResult = if (passcode.value == authRequest.password) {
if (authRequest.selfDestruct && sdPassword != null && controller.ctrl == -1L) {
initChatControllerAndRunMigrations()
initChatControllerOnStart()
}
LAResult.Success
} else {
@@ -669,7 +669,7 @@ private suspend fun MutableState<MigrationToState?>.cleanUpOnBack(chatReceiver:
if (state is MigrationToState.ArchiveImportFailed) {
// Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state
deleteChatDatabaseFilesAndState()
initChatControllerAndRunMigrations()
initChatControllerOnStart()
} else if (state is MigrationToState.DownloadProgress && state.ctrl != null) {
stopArchiveDownloading(state.fileId, state.ctrl)
}
@@ -66,6 +66,8 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
hostMode = currentCfg.value.hostMode,
requiredHostMode = currentCfg.value.requiredHostMode,
sessionMode = currentCfg.value.sessionMode,
smpProxyMode = currentCfg.value.smpProxyMode,
smpProxyFallback = currentCfg.value.smpProxyFallback,
tcpConnectTimeout = networkTCPConnectTimeout.value,
tcpTimeout = networkTCPTimeout.value,
tcpTimeoutPerKb = networkTCPTimeoutPerKb.value,
@@ -159,7 +161,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
SectionItemView {
TimeoutSettingRow(
stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout,
listOf(10_000000, 15_000000, 20_000000, 25_000000, 35_000000, 50_000000), secondsLabel
listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel
)
}
SectionItemView {
@@ -175,12 +177,12 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel
)
}
SectionItemView {
IntSettingRow(
stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency,
listOf(1, 2, 4, 8, 12, 16, 24), ""
)
}
// SectionItemView {
// IntSettingRow(
// stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency,
// listOf(1, 2, 4, 8, 12, 16, 24), ""
// )
// }
SectionItemView {
TimeoutSettingRow(
stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval,
@@ -43,6 +43,8 @@ fun NetworkAndServersView() {
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
val smpProxyMode = remember { mutableStateOf(netCfg.smpProxyMode) }
val smpProxyFallback = remember { mutableStateOf(netCfg.smpProxyFallback) }
val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }
NetworkAndServersLayout(
@@ -51,6 +53,8 @@ fun NetworkAndServersView() {
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = onionHosts,
sessionMode = sessionMode,
smpProxyMode = smpProxyMode,
smpProxyFallback = smpProxyFallback,
proxyPort = proxyPort,
toggleSocksProxy = { enable ->
if (enable) {
@@ -137,6 +141,59 @@ fun NetworkAndServersView() {
}
}
}
},
updateSMPProxyMode = {
if (smpProxyMode.value == it) return@NetworkAndServersLayout
val prevValue = smpProxyMode.value
smpProxyMode.value = it
val startsWith = when (it) {
SMPProxyMode.Always -> generalGetString(MR.strings.network_smp_proxy_mode_always_description)
SMPProxyMode.Unknown -> generalGetString(MR.strings.network_smp_proxy_mode_unknown_description)
SMPProxyMode.Unprotected -> generalGetString(MR.strings.network_smp_proxy_mode_unprotected_description)
SMPProxyMode.Never -> generalGetString(MR.strings.network_smp_proxy_mode_never_description)
}
showUpdateNetworkSettingsDialog(
title = generalGetString(MR.strings.update_network_smp_proxy_mode_question),
startsWith,
onDismiss = { smpProxyMode.value = prevValue }
) {
withBGApi {
val newCfg = chatModel.controller.getNetCfg().copy(smpProxyMode = it)
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
if (res) {
chatModel.controller.setNetCfg(newCfg)
smpProxyMode.value = it
} else {
smpProxyMode.value = prevValue
}
}
}
},
updateSMPProxyFallback = {
if (smpProxyFallback.value == it) return@NetworkAndServersLayout
val prevValue = smpProxyFallback.value
smpProxyFallback.value = it
val startsWith = when (it) {
SMPProxyFallback.Allow -> generalGetString(MR.strings.network_smp_proxy_fallback_allow_description)
SMPProxyFallback.AllowProtected -> generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description)
SMPProxyFallback.Prohibit -> generalGetString(MR.strings.network_smp_proxy_fallback_prohibit_description)
}
showUpdateNetworkSettingsDialog(
title = generalGetString(MR.strings.update_network_smp_proxy_fallback_question),
startsWith,
onDismiss = { smpProxyFallback.value = prevValue }
) {
withBGApi {
val newCfg = chatModel.controller.getNetCfg().copy(smpProxyFallback = it)
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
if (res) {
chatModel.controller.setNetCfg(newCfg)
smpProxyFallback.value = it
} else {
smpProxyFallback.value = prevValue
}
}
}
}
)
}
@@ -147,16 +204,22 @@ fun NetworkAndServersView() {
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
smpProxyMode: MutableState<SMPProxyMode>,
smpProxyFallback: MutableState<SMPProxyFallback>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
updateSMPProxyMode: (SMPProxyMode) -> Unit,
updateSMPProxyFallback: (SMPProxyFallback) -> Unit,
) {
val m = chatModel
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
AppBarTitle(stringResource(MR.strings.network_and_servers))
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
@@ -165,7 +228,6 @@ fun NetworkAndServersView() {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (currentRemoteHost == null) {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false)
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
if (developerTools) {
@@ -188,6 +250,18 @@ fun NetworkAndServersView() {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
}
if (currentRemoteHost == null) {
SectionView(generalGetString(MR.strings.settings_section_title_private_message_routing)) {
SMPProxyModePicker(smpProxyMode, showModal, updateSMPProxyMode)
SMPProxyFallbackPicker(smpProxyFallback, showModal, updateSMPProxyFallback, enabled = remember { mutableStateOf(smpProxyMode.value != SMPProxyMode.Never) })
SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy)
}
SectionCustomFooter {
Text(stringResource(MR.strings.private_routing_explanation))
}
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
}
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
}
@@ -452,6 +526,79 @@ private fun SessionModePicker(
)
}
@Composable
private fun SMPProxyModePicker(
smpProxyMode: MutableState<SMPProxyMode>,
showModal: (@Composable ModalData.() -> Unit) -> Unit,
updateSMPProxyMode: (SMPProxyMode) -> Unit,
) {
val density = LocalDensity.current
val values = remember {
SMPProxyMode.values().map {
when (it) {
SMPProxyMode.Always -> ValueTitleDesc(SMPProxyMode.Always, generalGetString(MR.strings.network_smp_proxy_mode_always), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_always_description), density))
SMPProxyMode.Unknown -> ValueTitleDesc(SMPProxyMode.Unknown, generalGetString(MR.strings.network_smp_proxy_mode_unknown), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unknown_description), density))
SMPProxyMode.Unprotected -> ValueTitleDesc(SMPProxyMode.Unprotected, generalGetString(MR.strings.network_smp_proxy_mode_unprotected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unprotected_description), density))
SMPProxyMode.Never -> ValueTitleDesc(SMPProxyMode.Never, generalGetString(MR.strings.network_smp_proxy_mode_never), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_never_description), density))
}
}
}
SectionItemWithValue(
generalGetString(MR.strings.network_smp_proxy_mode_private_routing),
smpProxyMode,
values,
icon = painterResource(MR.images.ic_settings_ethernet),
onSelected = {
showModal {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_smp_proxy_mode_private_routing))
SectionViewSelectable(null, smpProxyMode, values, updateSMPProxyMode)
}
}
}
)
}
@Composable
private fun SMPProxyFallbackPicker(
smpProxyFallback: MutableState<SMPProxyFallback>,
showModal: (@Composable ModalData.() -> Unit) -> Unit,
updateSMPProxyFallback: (SMPProxyFallback) -> Unit,
enabled: State<Boolean>,
) {
val density = LocalDensity.current
val values = remember {
SMPProxyFallback.values().map {
when (it) {
SMPProxyFallback.Allow -> ValueTitleDesc(SMPProxyFallback.Allow, generalGetString(MR.strings.network_smp_proxy_fallback_allow), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_description), density))
SMPProxyFallback.AllowProtected -> ValueTitleDesc(SMPProxyFallback.AllowProtected, generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description), density))
SMPProxyFallback.Prohibit -> ValueTitleDesc(SMPProxyFallback.Prohibit, generalGetString(MR.strings.network_smp_proxy_fallback_prohibit), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_prohibit_description), density))
}
}
}
SectionItemWithValue(
generalGetString(MR.strings.network_smp_proxy_fallback_allow_downgrade),
smpProxyFallback,
values,
icon = painterResource(MR.images.ic_arrows_left_right),
enabled = enabled,
onSelected = {
showModal {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_smp_proxy_fallback_allow_downgrade))
SectionViewSelectable(null, smpProxyFallback, values, updateSMPProxyFallback)
}
}
}
)
}
@Composable
private fun NetworkSectionFooter(revert: () -> Unit, save: () -> Unit, revertDisabled: Boolean, saveDisabled: Boolean) {
Row(
@@ -506,8 +653,12 @@ fun PreviewNetworkAndServersLayout() {
toggleSocksProxy = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) },
smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) },
useOnion = {},
updateSessionMode = {},
updateSMPProxyMode = {},
updateSMPProxyFallback = {},
)
}
}
@@ -1,6 +1,7 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
@@ -63,10 +64,6 @@ fun PrivacySettingsView(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable ->
withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) }
})
SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SettingsPreferenceItem(
painterResource(MR.images.ic_chat_bubble),
@@ -91,6 +88,22 @@ fun PrivacySettingsView(
chatModel.simplexLinkMode.value = it
})
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_files)) {
SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable ->
withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) }
})
SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays)
}
SectionCustomFooter {
if (chatModel.controller.appPrefs.privacyAskToApproveRelays.state.value) {
Text(stringResource(MR.strings.app_will_ask_to_confirm_unknown_file_servers))
} else {
Text(stringResource(MR.strings.without_tor_or_vpn_ip_address_will_be_visible_to_file_servers))
}
}
val currentUser = chatModel.currentUser.value
if (currentUser != null) {
@@ -141,7 +154,7 @@ fun PrivacySettingsView(
}
if (!chatModel.desktopNoUserNoRemote) {
SectionDividerSpaced()
SectionDividerSpaced(maxTopPadding = true)
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
@@ -144,7 +144,7 @@ fun SettingsLayout(
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }, extraPadding = true)
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
@@ -847,7 +847,6 @@
<string name="auth_open_chat_profiles">افتح ملفات تعريف الدردشة</string>
<string name="revoke_file__confirm">اسحب الوصول</string>
<string name="save_archive">حفظ الأرشيف</string>
<string name="save_color">حفظ اللون</string>
<string name="reveal_verb">كشف</string>
<string name="stop_rcv_file__message">سيتم إيقاف استلام الملف.</string>
<string name="reject_contact_button">رفض</string>
@@ -119,6 +119,8 @@
<string name="error_joining_group">Error joining group</string>
<string name="cannot_receive_file">Cannot receive file</string>
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
<string name="file_not_approved_title">Unknown servers!</string>
<string name="file_not_approved_descr">Without Tor or VPN, your IP address will be visible to these XFTP relays:\n%1$s.</string>
<string name="error_receiving_file">Error receiving file</string>
<string name="error_creating_address">Error creating address</string>
<string name="contact_already_exists">Contact already exists</string>
@@ -255,8 +257,20 @@
<!-- Chat Alerts - ChatItemView.kt -->
<string name="message_delivery_error_title">Message delivery error</string>
<string name="message_delivery_warning_title">Message delivery warning</string>
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
<!-- CIStatus errors -->
<string name="ci_status_other_error">Error: %1$s</string>
<string name="snd_error_auth">Wrong key or unknown connection - most likely this connection is deleted.</string>
<string name="snd_error_quota">Capacity exceeded - recipient did not receive previously sent messages.</string>
<string name="snd_error_expired">Network issues - message expired after many attempts to send it.</string>
<string name="snd_error_relay">Destination server error: %1$s</string>
<string name="snd_error_proxy">Forwarding server: %1$s\nError: %2$s</string>
<string name="snd_error_proxy_relay">Forwarding server: %1$s\nDestination server error: %2$s</string>
<string name="srv_error_host">Server address is incompatible with network settings.</string>
<string name="srv_error_version">Server version is incompatible with network settings.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
@@ -710,9 +724,29 @@
<string name="update_network_session_mode_question">Update transport isolation mode?</string>
<string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string>
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]></string>
<string name="network_smp_proxy_mode_private_routing">Private routing</string>
<string name="network_smp_proxy_mode_always">Always</string>
<string name="network_smp_proxy_mode_unknown">Unknown relays</string>
<string name="network_smp_proxy_mode_unprotected">Unprotected</string>
<string name="network_smp_proxy_mode_never">Never</string>
<string name="network_smp_proxy_mode_always_description">Always use private routing.</string>
<string name="network_smp_proxy_mode_unknown_description">Use private routing with unknown servers.</string>
<string name="network_smp_proxy_mode_unprotected_description">Use private routing with unknown servers when IP address is not protected.</string>
<string name="network_smp_proxy_mode_never_description">Do NOT use private routing.</string>
<string name="update_network_smp_proxy_mode_question">Message routing mode</string>
<string name="network_smp_proxy_fallback_allow_downgrade">Allow downgrade</string>
<string name="network_smp_proxy_fallback_allow">Yes</string>
<string name="network_smp_proxy_fallback_allow_protected">When IP hidden</string>
<string name="network_smp_proxy_fallback_prohibit">No</string>
<string name="network_smp_proxy_fallback_allow_description">Send messages directly when your or destination server does not support private routing.</string>
<string name="network_smp_proxy_fallback_allow_protected_description">Send messages directly when IP address is protected and your or destination server does not support private routing.</string>
<string name="network_smp_proxy_fallback_prohibit_description">Do NOT send messages directly, even if your or destination server does not support private routing.</string>
<string name="update_network_smp_proxy_fallback_question">Message routing fallback</string>
<string name="private_routing_show_message_status">Show message status</string>
<string name="private_routing_explanation">To protect your IP address, private routing uses your SMP servers to deliver messages.</string>
<string name="appearance_settings">Appearance</string>
<string name="customize_theme_title">Customize theme</string>
<string name="theme_colors_section_title">THEME COLORS</string>
<string name="theme_colors_section_title">INTERFACE COLORS</string>
<string name="app_version_title">App version</string>
<string name="app_version_name">App version: v%s</string>
<string name="app_version_code">App build: %s</string>
@@ -960,6 +994,9 @@
<string name="protect_app_screen">Protect app screen</string>
<string name="encrypt_local_files">Encrypt local files</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="protect_ip_address">Protect IP address</string>
<string name="app_will_ask_to_confirm_unknown_file_servers">The app will ask to confirm downloads from unknown file servers (except .onion or when SOCKS proxy is enabled).</string>
<string name="without_tor_or_vpn_ip_address_will_be_visible_to_file_servers">Without Tor or VPN, your IP address will be visible to file servers.</string>
<string name="send_link_previews">Send link previews</string>
<string name="privacy_show_last_messages">Show last messages</string>
<string name="privacy_message_draft">Message draft</string>
@@ -1024,6 +1061,7 @@
<string name="settings_section_title_app">APP</string>
<string name="settings_section_title_device">DEVICE</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_section_title_files">FILES</string>
<string name="settings_section_title_delivery_receipts">SEND DELIVERY RECEIPTS TO</string>
<string name="settings_restart_app">Restart</string>
<string name="settings_shutdown">Shutdown</string>
@@ -1034,7 +1072,11 @@
<string name="settings_section_title_icon">APP ICON</string>
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_profile_images">Profile images</string>
<string name="settings_section_title_chat_theme">Chat theme</string>
<string name="settings_section_title_user_theme">Profile theme</string>
<string name="settings_section_title_chat_colors">Chat colors</string>
<string name="settings_section_title_messages">MESSAGES AND FILES</string>
<string name="settings_section_title_private_message_routing">PRIVATE MESSAGE ROUTING</string>
<string name="settings_section_title_calls">CALLS</string>
<string name="settings_section_title_network_connection">Network connection</string>
<string name="settings_section_title_incognito">Incognito mode</string>
@@ -1162,6 +1204,7 @@
<string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="chat_list_always_visible">Show chat list in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
<string name="upgrade_and_open_chat">Upgrade and open chat</string>
<string name="downgrade_and_open_chat">Downgrade and open chat</string>
@@ -1504,23 +1547,30 @@
<string name="incognito_info_share">When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</string>
<!-- Default themes -->
<string name="color_mode_system">System</string>
<string name="color_mode_light">Light</string>
<string name="color_mode_dark">Dark</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_simplex">SimpleX</string>
<string name="theme_black">Black</string>
<!-- Languages -->
<string name="language_system">System</string>
<!-- Appearance.kt -->
<string name="theme">Theme</string>
<string name="color_mode">Color mode</string>
<string name="dark_theme">Dark theme</string>
<string name="save_color">Save color</string>
<string name="dark_mode_colors">Dark mode colors</string>
<string name="import_theme">Import theme</string>
<string name="import_theme_error">Import theme error</string>
<string name="import_theme_error_desc">Make sure the file has correct YAML syntax. Export theme to have an example of the theme file structure.</string>
<string name="export_theme">Export theme</string>
<string name="reset_color">Reset colors</string>
<string name="reset_single_color">Reset color</string>
<string name="theme_destination_app_theme">App theme</string>
<string name="color_primary">Accent</string>
<string name="color_primary_variant">Additional accent</string>
<string name="color_secondary">Secondary</string>
@@ -1528,8 +1578,37 @@
<string name="color_background">Background</string>
<string name="color_surface">Menus &amp; alerts</string>
<string name="color_title">Title</string>
<string name="color_primary_variant2">Additional accent 2</string>
<string name="color_sent_message">Sent message</string>
<string name="color_sent_quote">Sent reply</string>
<string name="color_received_message">Received message</string>
<string name="color_received_quote">Received reply</string>
<string name="color_wallpaper_background">Wallpaper background</string>
<string name="color_wallpaper_tint">Wallpaper accent</string>
<string name="theme_remove_image">Remove image</string>
<!-- Backgrounds -->
<string name="wallpaper_cats">Cats</string>
<string name="wallpaper_flowers">Flowers</string>
<string name="wallpaper_hearts">Hearts</string>
<string name="wallpaper_kids">Kids</string>
<string name="wallpaper_school">School</string>
<string name="wallpaper_travel">Travel</string>
<string name="wallpaper_preview_hello_alice">Good afternoon!</string>
<string name="wallpaper_preview_hello_bob">Good morning!</string>
<string name="wallpaper_scale">Scale</string>
<string name="wallpaper_scale_repeat">Repeat</string>
<string name="wallpaper_scale_fill">Fill</string>
<string name="wallpaper_scale_fit">Fit</string>
<string name="wallpaper_advanced_settings">Advanced settings</string>
<string name="chat_theme_reset_to_app_theme">Reset to app theme</string>
<string name="chat_theme_reset_to_user_theme">Reset to user theme</string>
<string name="chat_theme_set_default_theme">Set default theme</string>
<string name="chat_theme_apply_to_mode">Apply to</string>
<string name="chat_theme_apply_to_all_modes">All color modes</string>
<string name="chat_theme_apply_to_light_mode">Light mode</string>
<string name="chat_theme_apply_to_dark_mode">Dark mode</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">You allow</string>
@@ -1150,7 +1150,6 @@
<string name="network_options_revert">Отмени промените</string>
<string name="network_options_save">Запази</string>
<string name="reset_color">Нулирай цветовете</string>
<string name="save_color">Запази цвета</string>
<string name="color_secondary">Вторичен</string>
<string name="custom_time_picker_select">Избери</string>
<string name="to_start_a_new_chat_help_header">За да започнете нов чат</string>
@@ -55,7 +55,6 @@
<string name="update_network_settings_question">Aktualizovat nastavení sítě\?</string>
<string name="incognito">Inkognito</string>
<string name="incognito_random_profile">Váš náhodný profil</string>
<string name="save_color">Uložit barvu</string>
<string name="reset_color">Obnovit barvu</string>
<string name="color_primary">Zbarvení</string>
<string name="chat_preferences_you_allow">Povolujete</string>
@@ -842,7 +842,6 @@
<string name="theme_dark">Dunkel</string>
<!-- Appearance.kt -->
<string name="theme">Design</string>
<string name="save_color">Farbe speichern</string>
<string name="reset_color">Farben zurücksetzen</string>
<string name="color_primary">Akzent</string>
<!-- Preferences.kt -->
@@ -607,7 +607,6 @@
<string name="secret_text">secreto</string>
<string name="open_simplex_chat_to_accept_call">Abrir SimpleX Chat para aceptar llamada</string>
<string name="network_options_reset_to_defaults">Restablecer valores por defecto</string>
<string name="save_color">Guardar color</string>
<string name="icon_descr_server_status_pending">Pendiente</string>
<string name="periodic_notifications">Notificaciones periódicas</string>
<string name="store_passphrase_securely">Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes.</string>
@@ -873,7 +873,6 @@
<string name="v4_6_chinese_spanish_interface_descr">Kiitos käyttäjille osallistu Weblaten kautta!</string>
<string name="profile_password">Profiilin salasana</string>
<string name="reset_color">Oletusvärit</string>
<string name="save_color">Tallenna väri</string>
<string name="prohibit_sending_voice_messages">Estä ääniviestien lähettäminen.</string>
<string name="v4_4_disappearing_messages_desc">Lähetetyt viestit poistetaan asetetun ajan kuluttua.</string>
<string name="add_contact_or_create_group">Aloita uusi keskustelu</string>
@@ -783,7 +783,6 @@
<string name="theme_light">Clair</string>
<string name="theme_dark">Sombre</string>
<string name="theme">Thème</string>
<string name="save_color">Enregistrer la couleur</string>
<string name="reset_color">Réinitialisation des couleurs</string>
<string name="color_primary">Principale</string>
<string name="chat_preferences_you_allow">Vous autorisez</string>
@@ -69,7 +69,6 @@
<string name="personal_welcome">स्वागत %1$s!</string>
<string name="callstate_starting">शुरुआत</string>
<string name="send_verb">भेजना</string>
<string name="save_color">रंग बचाओ</string>
<string name="share_verb">साझा करना</string>
<string name="reject_contact_button">अस्वीकार</string>
<string name="network_use_onion_hosts_required">आवश्यक</string>
@@ -1196,7 +1196,6 @@
<string name="scan_code">Kód beolvasása</string>
<string name="open_port_in_firewall_title">Port megnyitása a tűzfalon</string>
<string name="callstate_starting">indítás…</string>
<string name="save_color">Szín mentése</string>
<string name="settings_shutdown">Leállítás</string>
<string name="icon_descr_sent_msg_status_sent">elküldve</string>
<string name="network_socks_toggle_use_socks_proxy">SOCKS proxy használata</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#5f6368"><path d="M629-446.5H235.48q-13.79 0-23.64-9.79-9.84-9.79-9.84-23.5t9.84-23.71q9.85-10 23.64-10H629L455.79-686.71Q445.5-697 445.25-710.5t10.25-24.48q10.5-10.52 24-10.27t23.81 10.57L734.1-503.59q4.9 4.91 7.65 10.97 2.75 6.06 2.75 12.78 0 6.71-2.75 12.78Q739-461 734.5-456.5l-231 231q-11 11-23.75 10.5t-23.25-11.02Q446-237 446-250.42q0-13.41 10.5-23.58L629-446.5Z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

@@ -0,0 +1,4 @@
<svg height="24" viewBox="0 -960 960 960" width="24" fill="#000000" xmlns="http://www.w3.org/2000/svg">
<path d="M 831.5 -480 L 657 -656.5 C 651.667 -662.167 648.917 -668.833 648.75 -676.5 C 648.583 -684.167 651.333 -690.833 657 -696.5 C 662.667 -702.5 669.333 -705.5 677 -705.5 C 684.667 -705.5 691.5 -702.667 697.5 -697 L 894 -500.5 C 897 -497.167 899.25 -493.917 900.75 -490.75 C 902.25 -487.583 903 -484 903 -480 C 903 -476 902.25 -472.417 900.75 -469.25 C 899.25 -466.083 897 -463 894 -460 L 697.5 -263.5 C 691.5 -257.5 684.667 -254.583 677 -254.75 C 669.333 -254.917 662.667 -257.833 657 -263.5 C 651 -269.5 648.167 -276.25 648.5 -283.75 C 648.833 -291.25 651.667 -297.833 657 -303.5 L 831.5 -480 Z M 128.5 -480 L 303 -303.5 C 308.333 -297.833 311.083 -291.167 311.25 -283.5 C 311.417 -275.833 308.667 -269.167 303 -263.5 C 297.333 -257.5 290.667 -254.5 283 -254.5 C 275.333 -254.5 268.667 -257.5 263 -263.5 L 66.5 -460 C 63.167 -463 60.833 -466.083 59.5 -469.25 C 58.167 -472.417 57.5 -476 57.5 -480 C 57.5 -484 58.167 -487.583 59.5 -490.75 C 60.833 -493.917 63.167 -497.167 66.5 -500.5 L 263 -697 C 268.667 -702.667 275.333 -705.417 283 -705.25 C 290.667 -705.083 297.5 -702.167 303.5 -696.5 C 309.167 -690.5 311.833 -683.75 311.5 -676.25 C 311.167 -668.75 308.333 -662.167 303 -656.5 L 128.5 -480 Z" transform="matrix(0.9999999999999999, 0, 0, 0.9999999999999999, 0, 0)"/>
<rect x="123" y="-514" width="711.266" height="68" style="stroke: rgb(0, 0, 0);" transform="matrix(0.9999999999999999, 0, 0, 0.9999999999999999, 0, 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

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