mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36f97b2ea9 | |||
| bf390dabd4 | |||
| 06cb564eae | |||
| b1c732f3cc | |||
| b58c880d4c | |||
| ba9a6f3ab6 | |||
| 7ad173c5dc | |||
| fdad58b0ee | |||
| 2b7de2a7a6 | |||
| 2ae3748489 | |||
| 36aae92c55 | |||
| e290309cd1 | |||
| d3dd6e5d1c | |||
| 51ea478acb | |||
| 8966a0e22f | |||
| 6eb76315eb | |||
| eb35d81aba | |||
| fb54841d76 | |||
| 95f518a582 | |||
| 6b89eb872b | |||
| ab6301c3e9 | |||
| 36dc66d5d5 | |||
| 111acb0813 | |||
| 65fae747c3 | |||
| 935d5bfdd6 | |||
| f7a27ff91b | |||
| ab848e8c13 | |||
| 8c307c4675 | |||
| c323d6c61f | |||
| 03ab4612a5 | |||
| 687e3be9ac | |||
| 2c121b5731 | |||
| c619092464 | |||
| e4c6d210c6 | |||
| 60642317d0 | |||
| 01b9c16f1a | |||
| 15a17f3c13 | |||
| 3450420b80 | |||
| 29c6d51e6a | |||
| a8ba4ede82 | |||
| f7f3f82090 | |||
| a8a0f2db03 | |||
| e68cc23828 | |||
| 1e63eb3752 | |||
| 96866c7a5d | |||
| 815981487b | |||
| b2c455c301 | |||
| ca366d0b47 | |||
| 904945a67d | |||
| c8a85f651d | |||
| 13603f009b | |||
| 043005d186 | |||
| 785fab1653 | |||
| 7226e5d37a | |||
| e8c9f6d5ab | |||
| 54126eba6b | |||
| 41c9742b0d | |||
| 6d25991417 | |||
| 463f644bce | |||
| 5367ffe418 | |||
| 313bc65457 | |||
| 4e979aee7e | |||
| 6a2f2a512f | |||
| 4d9e446489 | |||
| 8d93f228b3 | |||
| af7d7e8303 | |||
| 976b1c919f | |||
| 29eafa9a74 | |||
| 7723e4ca7a | |||
| 051726702b | |||
| 7df8c23c81 | |||
| a362fc734e | |||
| 4b263510ee | |||
| 291096d87f | |||
| c5c65f813b | |||
| 2e34ae3b1c | |||
| 8432399458 | |||
| 59ad220d93 | |||
| 65369aa47b | |||
| 60e9ed9476 | |||
| 4bf5125c51 | |||
| b2a523c3fe | |||
| 2ae621792e | |||
| c62d99ab97 | |||
| 1f1ed3f3dd | |||
| 6f195c4167 | |||
| 235fb8dc0c | |||
| 44a9ea2102 | |||
| 121541759b | |||
| 716a941dc6 | |||
| 16bd9ccc4f | |||
| 33e702d453 | |||
| 883bf768af | |||
| f341e54128 | |||
| 99bd3f6133 | |||
| 7590502f29 | |||
| f770a8396e | |||
| b0f3d59cb0 | |||
| cab5bc2daf | |||
| 935c3987b3 | |||
| 084d1d09a5 | |||
| 47ec486201 | |||
| 72103949a7 | |||
| 3b708105a4 | |||
| 800efb3a34 | |||
| 949fb17406 | |||
| b435c0145f | |||
| 87c0c9de91 | |||
| cd1af400bb | |||
| e1e161539d | |||
| 4db7e88ed8 | |||
| 82a4a8c6f8 | |||
| 15ddefe86b | |||
| fa844c48e9 | |||
| 7e96da95f9 | |||
| 0bb5774ff1 | |||
| 866d84e7ac | |||
| d6262bc2a4 | |||
| e5909d4e12 | |||
| 23b75f11fe | |||
| 71fa2bfec0 | |||
| 29e2c00811 | |||
| 7c1d573a17 | |||
| 89908ef5dc | |||
| c3c712aa02 | |||
| b56ad77502 | |||
| 5e476516cb | |||
| ce2f3c0371 | |||
| da13e6614b | |||
| 79d9e90ab7 | |||
| 387aec8593 | |||
| a403f2051a | |||
| 9e83b54b85 | |||
| 2696086faa | |||
| 546ad01fcb | |||
| 247e7f1ea7 | |||
| 0290a687af | |||
| 97cab8b542 | |||
| 4a42797d83 | |||
| 3051732622 | |||
| 3fe005e252 | |||
| 79dcada757 | |||
| f7eeb4d1e3 | |||
| d572cfbc09 | |||
| cb95c51fe1 | |||
| e057f9e407 | |||
| 6333a60103 | |||
| 3a539aec5b | |||
| 40be12430e | |||
| 53d66be910 | |||
| d6699ffb03 | |||
| 6ad2eeec89 | |||
| d971e7c31f | |||
| d2d8498258 | |||
| c1f67c08f7 | |||
| 70f78e7984 | |||
| 7f84057b86 | |||
| 718c8f826a | |||
| e13cb6e2fd | |||
| cb529e3202 | |||
| b3e67efba0 | |||
| 3f2ca8f902 | |||
| 9c9f6d8443 | |||
| d50ebbd061 | |||
| 6cc4323571 | |||
| 5cddf8e2d3 | |||
| 2c2ab98105 | |||
| 5fd75ee286 | |||
| 0a134e2ded | |||
| e4a66c767c | |||
| e4b1ff5e0f | |||
| 5f67c450b1 | |||
| dc418923ac | |||
| 106dceabfc | |||
| 295cec7c53 | |||
| 84bf815e5c | |||
| 82445ec8d5 | |||
| 36ef6df9fb | |||
| c000a1b924 | |||
| 9bf7821444 | |||
| 03eaa94324 | |||
| 7ad7f4f91a | |||
| 8d53c569c7 | |||
| b7860ad0e8 | |||
| 9f5ea49676 | |||
| c1eed47463 | |||
| 91a0283a36 | |||
| da793bbf4f | |||
| e958e45652 | |||
| 66284a954b | |||
| 6aebb93f7f | |||
| 53e330dac9 | |||
| e174c43bec | |||
| 18c3f49f96 | |||
| aa9c2f3228 | |||
| 1c5e6f52ec | |||
| 1a653649ec | |||
| 50f06a3c55 | |||
| 43e560c901 |
@@ -42,3 +42,4 @@ stack.yaml.lock
|
|||||||
|
|
||||||
# Temporary test files
|
# Temporary test files
|
||||||
tests/tmp
|
tests/tmp
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -24,7 +24,24 @@
|
|||||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
||||||
|
|
||||||
## Why privacy of communications matter
|
## Contents
|
||||||
|
|
||||||
|
- [Why privacy matters](#why-privacy-matters)
|
||||||
|
- [SimpleX approach to privacy and security](#simplex-approach-to-privacy-and-security)
|
||||||
|
- [Complete privacy](#complete-privacy-of-your-identity-profile-contacts-and-metadata)
|
||||||
|
- [Protection against spam and abuse](#the-best-protection-against-spam-and-abuse)
|
||||||
|
- [Ownership and security of your data](#complete-ownership-control-and-security-of-your-data)
|
||||||
|
- [Users own SimpleX network](#users-own-simplex-network)
|
||||||
|
- [Frequently asked questions](#frequently-asked-questions)
|
||||||
|
- [News and updates](#news-and-updates)
|
||||||
|
- [Make a private connection](#make-a-private-connection)
|
||||||
|
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||||
|
- [SimpleX Platform design](#simplex-platform-design)
|
||||||
|
- [For developers](#for-developers)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [Disclaimer, License](#disclaimer)
|
||||||
|
|
||||||
|
## Why privacy matters
|
||||||
|
|
||||||
Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide.
|
Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide.
|
||||||
|
|
||||||
@@ -32,9 +49,9 @@ One of the most shocking stories is the experience of [Mohamedou Ould Salahi](ht
|
|||||||
|
|
||||||
It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with.
|
It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with.
|
||||||
|
|
||||||
## SimpleX unique approach to privacy and security
|
## SimpleX approach to privacy and security
|
||||||
|
|
||||||
### Full privacy of your identity, profile, contacts and metadata
|
### Complete privacy of your identity, profile, contacts and metadata
|
||||||
|
|
||||||
**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata).
|
**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata).
|
||||||
|
|
||||||
@@ -50,24 +67,23 @@ SimpleX stores all user data on client devices, the messages are only held tempo
|
|||||||
|
|
||||||
You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network).
|
You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network).
|
||||||
|
|
||||||
## For developers
|
## Frequently asked questions
|
||||||
|
|
||||||
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
|
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||||
|
|
||||||
You already can:
|
2. _Why should I not just use Signal?_ This [post](https://github.com/dessalines/essays/blob/master/why_not_signal.md) shows why Signal cannot be considered a private messenger. Signal is a centralised platform that uses phone numbers to identify its users and their contacts.
|
||||||
|
|
||||||
- use SimpleX Chat library to integrate chat functionality into your apps.
|
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||||
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
|
|
||||||
|
|
||||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
|
||||||
|
|
||||||
## News and updates
|
## News and updates
|
||||||
|
|
||||||
[Apr 04, 2022. Instant notifications for SimpleX Chat mobile apps](./blog/20220404-simplex-chat-instant-notifications.md). We would really appreciate any feedback on the design we are implementing.
|
[Jun 4, 2022. v2.2: the new Privacy and Security settings](./blog/20220604-simplex-chat-new-privacy-security-settings.md)
|
||||||
|
|
||||||
|
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
|
||||||
|
|
||||||
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
|
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
|
||||||
|
|
||||||
[Feb 14, 2022. SimpleX Chat: join our public beta for iOS](./blog/20220214-simplex-chat-ios-public-beta.md)
|
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
|
||||||
|
|
||||||
[All updates](./blog)
|
[All updates](./blog)
|
||||||
|
|
||||||
@@ -103,6 +119,17 @@ Only the client devices have information about users, their contacts and groups.
|
|||||||
|
|
||||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||||
|
|
||||||
|
## For developers
|
||||||
|
|
||||||
|
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
|
||||||
|
|
||||||
|
You already can:
|
||||||
|
|
||||||
|
- use SimpleX Chat library to integrate chat functionality into your apps.
|
||||||
|
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
|
||||||
|
|
||||||
|
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||||
@@ -113,12 +140,13 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/p
|
|||||||
- ✅ Private instant notifications for Android using background service.
|
- ✅ Private instant notifications for Android using background service.
|
||||||
- ✅ Haskell chat bot templates.
|
- ✅ Haskell chat bot templates.
|
||||||
- ✅ v2.0 - supporting images and files in mobile apps.
|
- ✅ v2.0 - supporting images and files in mobile apps.
|
||||||
- 🏗 End-to-end encrypted audio and video calls via the mobile apps.
|
- ✅ Manual chat history deletion.
|
||||||
- 🏗 Automatic chat history deletion.
|
- 🚀 End-to-end encrypted audio and video calls via the mobile apps (enable via Experimental Features).
|
||||||
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
|
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
|
||||||
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
||||||
|
- 🏗 Chat database portability and encryption.
|
||||||
- Groups support for mobile apps.
|
- Groups support for mobile apps.
|
||||||
- Chat database portability and encryption.
|
- Disappearing messages, with mutual agreement.
|
||||||
- Web widgets for custom interactivity in the chats.
|
- Web widgets for custom interactivity in the chats.
|
||||||
- SMP protocol improvements:
|
- SMP protocol improvements:
|
||||||
- SMP queue redundancy and rotation.
|
- SMP queue redundancy and rotation.
|
||||||
@@ -139,3 +167,13 @@ You are likely to discover some bugs - we would really appreciate if you use it
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL v3](./LICENSE)
|
[AGPL v3](./LICENSE)
|
||||||
|
|
||||||
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||||
|
|
||||||
|
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||||
|
|
||||||
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
|
||||||
|
|
||||||
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||||
|
|
||||||
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||||
|
|||||||
+5
@@ -1,5 +1,8 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
|
<ComposeCustomCodeStyleSettings>
|
||||||
|
<option name="USE_CUSTOM_FORMATTING_FOR_MODIFIERS" value="false" />
|
||||||
|
</ComposeCustomCodeStyleSettings>
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
|
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
|
||||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
|
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
|
||||||
@@ -123,9 +126,11 @@
|
|||||||
<codeStyleSettings language="kotlin">
|
<codeStyleSettings language="kotlin">
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
<option name="RIGHT_MARGIN" value="140" />
|
<option name="RIGHT_MARGIN" value="140" />
|
||||||
|
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
<option name="CALL_PARAMETERS_WRAP" value="0" />
|
<option name="CALL_PARAMETERS_WRAP" value="0" />
|
||||||
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
||||||
<option name="EXTENDS_LIST_WRAP" value="0" />
|
<option name="EXTENDS_LIST_WRAP" value="0" />
|
||||||
|
|||||||
Generated
-1
@@ -13,7 +13,6 @@
|
|||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "chat.simplex.app"
|
applicationId "chat.simplex.app"
|
||||||
minSdk 29
|
minSdk 29
|
||||||
targetSdk 32
|
targetSdk 32
|
||||||
versionCode 30
|
versionCode 41
|
||||||
versionName "2.0"
|
versionName "3.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ndk {
|
ndk {
|
||||||
@@ -76,6 +76,7 @@ dependencies {
|
|||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||||
|
implementation 'androidx.fragment:fragment:1.4.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||||
@@ -103,6 +104,9 @@ dependencies {
|
|||||||
// Link Previews
|
// Link Previews
|
||||||
implementation 'org.jsoup:jsoup:1.13.1'
|
implementation 'org.jsoup:jsoup:1.13.1'
|
||||||
|
|
||||||
|
// Biometric authentication
|
||||||
|
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="chat.simplex.app">
|
package="chat.simplex.app">
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" />
|
<uses-feature android:name="android.hardware.camera" />
|
||||||
@@ -15,7 +16,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="SimplexApp"
|
android:name="SimplexApp"
|
||||||
@@ -61,6 +65,9 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".views.call.IncomingCallActivity"
|
||||||
|
android:showOnLockScreen="true"/>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="chat.simplex.app.provider"
|
android:authorities="chat.simplex.app.provider"
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link href="./style.css" rel="stylesheet" />
|
<link href="./style.css" rel="stylesheet" />
|
||||||
|
<script src="./lz-string.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<video id="remote-video-stream" autoplay playsinline></video>
|
<video
|
||||||
<video id="local-video-stream" muted autoplay playsinline></video>
|
id="remote-video-stream"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
|
></video>
|
||||||
|
<video
|
||||||
|
id="local-video-stream"
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||||
|
></video>
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
<footer>
|
||||||
<script src="./call.js"></script>
|
<script src="./call.js"></script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
video::-webkit-media-controls {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#remote-video-stream {
|
#remote-video-stream {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -21,6 +19,23 @@ body {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
bottom: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*::-webkit-media-controls {
|
||||||
|
display: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-media-controls-panel {
|
||||||
|
display: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-media-controls-play-button {
|
||||||
|
display: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-media-controls-start-playback-button {
|
||||||
|
display: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
|||||||
// from simplex-chat
|
// from simplex-chat
|
||||||
typedef void* chat_ctrl;
|
typedef void* chat_ctrl;
|
||||||
|
|
||||||
extern chat_ctrl chat_init(const char * path);
|
extern chat_ctrl chat_init(const char *path);
|
||||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||||
extern char *chat_recv_msg(chat_ctrl ctrl);
|
extern char *chat_recv_msg(chat_ctrl ctrl);
|
||||||
|
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||||
|
extern char *chat_parse_markdown(const char *str);
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
JNIEXPORT jlong JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
|
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
|
||||||
@@ -48,3 +50,16 @@ JNIEXPORT jstring JNICALL
|
|||||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||||
|
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||||
|
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,23 +4,31 @@ import android.app.Application
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.SystemClock.elapsedRealtime
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Replay
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.*
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
import chat.simplex.app.model.NtfManager
|
import chat.simplex.app.model.NtfManager
|
||||||
|
import chat.simplex.app.ui.theme.SimpleButton
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.SplashView
|
import chat.simplex.app.views.SplashView
|
||||||
|
import chat.simplex.app.views.call.ActiveCallView
|
||||||
|
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||||
import chat.simplex.app.views.chat.ChatView
|
import chat.simplex.app.views.chat.ChatView
|
||||||
import chat.simplex.app.views.chatlist.ChatListView
|
import chat.simplex.app.views.chatlist.ChatListView
|
||||||
import chat.simplex.app.views.chatlist.openChat
|
import chat.simplex.app.views.chatlist.openChat
|
||||||
@@ -28,17 +36,22 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.newchat.connectViaUri
|
import chat.simplex.app.views.newchat.connectViaUri
|
||||||
import chat.simplex.app.views.newchat.withUriAction
|
import chat.simplex.app.views.newchat.withUriAction
|
||||||
import chat.simplex.app.views.onboarding.*
|
import chat.simplex.app.views.onboarding.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
//import kotlinx.serialization.decodeFromString
|
class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||||
|
|
||||||
class MainActivity: ComponentActivity() {
|
|
||||||
private val vm by viewModels<SimplexViewModel>()
|
private val vm by viewModels<SimplexViewModel>()
|
||||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||||
|
private val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||||
|
private val enteredBackground = mutableStateOf<Long?>(null)
|
||||||
|
private val laFailed = mutableStateOf(false)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// testJson()
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
|
// testJson()
|
||||||
|
val m = vm.chatModel
|
||||||
|
processNotificationIntent(intent, m)
|
||||||
setContent {
|
setContent {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -46,7 +59,14 @@ class MainActivity: ComponentActivity() {
|
|||||||
.background(MaterialTheme.colors.background)
|
.background(MaterialTheme.colors.background)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
MainPage(vm.chatModel)
|
MainPage(
|
||||||
|
m,
|
||||||
|
userAuthorized,
|
||||||
|
laFailed,
|
||||||
|
::runAuthenticate,
|
||||||
|
::setPerformLA,
|
||||||
|
showLANotice = { m.controller.showLANotice(this) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,14 +78,66 @@ class MainActivity: ComponentActivity() {
|
|||||||
processIntent(intent, vm.chatModel)
|
processIntent(intent, vm.chatModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
|
withApi {
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_STOP -> {
|
||||||
|
enteredBackground.value = elapsedRealtime()
|
||||||
|
}
|
||||||
|
Lifecycle.Event.ON_START -> {
|
||||||
|
val enteredBackgroundVal = enteredBackground.value
|
||||||
|
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
|
||||||
|
runAuthenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runAuthenticate() {
|
||||||
|
val m = vm.chatModel
|
||||||
|
if (!m.controller.appPrefs.performLA.get()) {
|
||||||
|
userAuthorized.value = true
|
||||||
|
} else {
|
||||||
|
userAuthorized.value = false
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
|
authenticate(
|
||||||
|
generalGetString(R.string.auth_unlock),
|
||||||
|
generalGetString(R.string.auth_log_in_using_credential),
|
||||||
|
this@MainActivity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
userAuthorized.value = true
|
||||||
|
}
|
||||||
|
is LAResult.Error -> {
|
||||||
|
laFailed.value = true
|
||||||
|
laErrorToast(applicationContext, laResult.errString)
|
||||||
|
}
|
||||||
|
LAResult.Failed -> {
|
||||||
|
laFailed.value = true
|
||||||
|
laFailedToast(applicationContext)
|
||||||
|
}
|
||||||
|
LAResult.Unavailable -> {
|
||||||
|
userAuthorized.value = true
|
||||||
|
m.performLA.value = false
|
||||||
|
m.controller.appPrefs.performLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun schedulePeriodicServiceRestartWorker() {
|
private fun schedulePeriodicServiceRestartWorker() {
|
||||||
val workerVersion = chatController.getAutoRestartWorkerVersion()
|
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||||
ExistingPeriodicWorkPolicy.KEEP
|
ExistingPeriodicWorkPolicy.KEEP
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||||
chatController.setAutoRestartWorkerVersion(SimplexService.SERVICE_START_WORKER_VERSION)
|
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||||
ExistingPeriodicWorkPolicy.REPLACE
|
ExistingPeriodicWorkPolicy.REPLACE
|
||||||
}
|
}
|
||||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||||
@@ -75,6 +147,82 @@ class MainActivity: ComponentActivity() {
|
|||||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setPerformLA(on: Boolean) {
|
||||||
|
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||||
|
if (on) {
|
||||||
|
enableLA()
|
||||||
|
} else {
|
||||||
|
disableLA()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableLA() {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock),
|
||||||
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
|
this@MainActivity,
|
||||||
|
completed = { laResult ->
|
||||||
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
prefPerformLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laErrorToast(applicationContext, laResult.errString)
|
||||||
|
}
|
||||||
|
LAResult.Failed -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laFailedToast(applicationContext)
|
||||||
|
}
|
||||||
|
LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laUnavailableInstructionAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableLA() {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
generalGetString(R.string.auth_disable_simplex_lock),
|
||||||
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
|
this@MainActivity,
|
||||||
|
completed = { laResult ->
|
||||||
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
}
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
prefPerformLA.set(true)
|
||||||
|
laErrorToast(applicationContext, laResult.errString)
|
||||||
|
}
|
||||||
|
LAResult.Failed -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
prefPerformLA.set(true)
|
||||||
|
laFailedToast(applicationContext)
|
||||||
|
}
|
||||||
|
LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||||
@@ -83,15 +231,86 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainPage(chatModel: ChatModel) {
|
fun MainPage(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
userAuthorized: MutableState<Boolean?>,
|
||||||
|
laFailed: MutableState<Boolean>,
|
||||||
|
runAuthenticate: () -> Unit,
|
||||||
|
setPerformLA: (Boolean) -> Unit,
|
||||||
|
showLANotice: () -> Unit
|
||||||
|
) {
|
||||||
|
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||||
|
var chatsAccessAuthorized by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(userAuthorized.value) {
|
||||||
|
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||||
|
delay(500L)
|
||||||
|
}
|
||||||
|
chatsAccessAuthorized = userAuthorized.value == true
|
||||||
|
}
|
||||||
|
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(showAdvertiseLAAlert) {
|
||||||
|
if (
|
||||||
|
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||||
|
&& showAdvertiseLAAlert
|
||||||
|
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||||
|
&& chatModel.chats.isNotEmpty()
|
||||||
|
&& chatModel.activeCallInvitation.value == null
|
||||||
|
) {
|
||||||
|
showLANotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||||
|
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||||
|
laUnavailableInstructionAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||||
|
if (chatModel.clearOverlays.value) {
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
|
chatModel.clearOverlays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun retryAuthView() {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
SimpleButton(
|
||||||
|
stringResource(R.string.auth_retry),
|
||||||
|
icon = Icons.Outlined.Replay,
|
||||||
|
click = {
|
||||||
|
laFailed.value = false
|
||||||
|
runAuthenticate()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
val onboarding = chatModel.onboardingStage.value
|
val onboarding = chatModel.onboardingStage.value
|
||||||
val userCreated = chatModel.userCreated.value
|
val userCreated = chatModel.userCreated.value
|
||||||
when {
|
when {
|
||||||
onboarding == null || userCreated == null -> SplashView()
|
onboarding == null || userCreated == null -> SplashView()
|
||||||
onboarding == OnboardingStage.OnboardingComplete && userCreated ->
|
!chatsAccessAuthorized -> {
|
||||||
if (chatModel.chatId.value == null) ChatListView(chatModel)
|
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||||
else ChatView(chatModel)
|
retryAuthView()
|
||||||
|
} else {
|
||||||
|
SplashView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||||
|
Box {
|
||||||
|
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||||
|
else {
|
||||||
|
showAdvertiseLAAlert = true
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
|
||||||
|
else ChatView(chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
onboarding == OnboardingStage.Step1_SimpleXInfo ->
|
onboarding == OnboardingStage.Step1_SimpleXInfo ->
|
||||||
Box(Modifier.padding(horizontal = 20.dp)) {
|
Box(Modifier.padding(horizontal = 20.dp)) {
|
||||||
SimpleXInfo(chatModel, onboarding = true)
|
SimpleXInfo(chatModel, onboarding = true)
|
||||||
@@ -99,25 +318,45 @@ fun MainPage(chatModel: ChatModel) {
|
|||||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||||
}
|
}
|
||||||
ModalManager.shared.showInView()
|
ModalManager.shared.showInView()
|
||||||
|
val invitation = chatModel.activeCallInvitation.value
|
||||||
|
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||||
AlertManager.shared.showInView()
|
AlertManager.shared.showInView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
|
when (intent?.action) {
|
||||||
|
NtfManager.OpenChatAction -> {
|
||||||
|
val chatId = intent.getStringExtra("chatId")
|
||||||
|
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||||
|
if (chatId != null) {
|
||||||
|
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NtfManager.ShowChatsAction -> {
|
||||||
|
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
}
|
||||||
|
NtfManager.AcceptCallAction -> {
|
||||||
|
val chatId = intent.getStringExtra("chatId")
|
||||||
|
if (chatId == null || chatId == "") return
|
||||||
|
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
val invitation = chatModel.callInvitations[chatId]
|
||||||
|
if (invitation == null) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||||
|
} else {
|
||||||
|
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
NtfManager.OpenChatAction -> {
|
|
||||||
val chatId = intent.getStringExtra("chatId")
|
|
||||||
Log.d(TAG, "processIntent: OpenChatAction $chatId")
|
|
||||||
if (chatId != null) {
|
|
||||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
|
||||||
chatModel.clearOverlays.value = true
|
|
||||||
if (cInfo != null) withApi { openChat(chatModel, cInfo) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NtfManager.ShowChatsAction -> {
|
|
||||||
Log.d(TAG, "processIntent: ShowChatsAction")
|
|
||||||
chatModel.clearOverlays.value = true
|
|
||||||
}
|
|
||||||
"android.intent.action.VIEW" -> {
|
"android.intent.action.VIEW" -> {
|
||||||
val uri = intent.data
|
val uri = intent.data
|
||||||
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||||
@@ -154,7 +393,6 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//fun testJson() {
|
//fun testJson() {
|
||||||
// val str: String = """
|
// val str: String = """
|
||||||
// """.trimIndent()
|
// """.trimIndent()
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
|||||||
// SimpleX API
|
// SimpleX API
|
||||||
typealias ChatCtrl = Long
|
typealias ChatCtrl = Long
|
||||||
external fun chatInit(path: String): ChatCtrl
|
external fun chatInit(path: String): ChatCtrl
|
||||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
|
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||||
external fun chatRecvMsg(ctrl: ChatCtrl) : String
|
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||||
|
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||||
|
external fun chatParseMarkdown(str: String): String
|
||||||
|
|
||||||
class SimplexApp: Application(), LifecycleEventObserver {
|
class SimplexApp: Application(), LifecycleEventObserver {
|
||||||
val chatController: ChatController by lazy {
|
val chatController: ChatController by lazy {
|
||||||
val ctrl = chatInit(getFilesDirectory(applicationContext))
|
val ctrl = chatInit(getFilesDirectory(applicationContext))
|
||||||
ChatController(ctrl, ntfManager, applicationContext)
|
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
val chatModel: ChatModel by lazy {
|
val chatModel: ChatModel by lazy {
|
||||||
@@ -38,7 +40,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val ntfManager: NtfManager by lazy {
|
private val ntfManager: NtfManager by lazy {
|
||||||
NtfManager(applicationContext)
|
NtfManager(applicationContext, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appPreferences: AppPreferences by lazy {
|
||||||
|
AppPreferences(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -51,8 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||||
} else {
|
} else {
|
||||||
chatController.startChat(user)
|
chatController.startChat(user)
|
||||||
SimplexService.start(applicationContext)
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
chatController.showBackgroundServiceNotice()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,9 +67,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
withApi {
|
withApi {
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_STOP ->
|
Lifecycle.Event.ON_STOP ->
|
||||||
if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
|
if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
|
||||||
Lifecycle.Event.ON_START ->
|
Lifecycle.Event.ON_START ->
|
||||||
SimplexService.start(applicationContext)
|
SimplexService.stop(applicationContext)
|
||||||
|
Lifecycle.Event.ON_RESUME ->
|
||||||
|
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||||
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.util.Log
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.app.views.helpers.withApi
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -20,6 +21,7 @@ class SimplexService: Service() {
|
|||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
private var isServiceStarted = false
|
||||||
private var isStartingService = false
|
private var isStartingService = false
|
||||||
|
private var isStoppingService = false
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||||
@@ -47,7 +49,6 @@ class SimplexService: Service() {
|
|||||||
val text = getString(R.string.simplex_service_notification_text)
|
val text = getString(R.string.simplex_service_notification_text)
|
||||||
notificationManager = createNotificationChannel()
|
notificationManager = createNotificationChannel()
|
||||||
serviceNotification = createNotification(title, text)
|
serviceNotification = createNotification(title, text)
|
||||||
|
|
||||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,6 @@ class SimplexService: Service() {
|
|||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Starting foreground service")
|
Log.w(TAG, "Starting foreground service")
|
||||||
chatController.startChat(user)
|
chatController.startChat(user)
|
||||||
chatController.startReceiver()
|
|
||||||
isServiceStarted = true
|
isServiceStarted = true
|
||||||
saveServiceState(self, ServiceState.STARTED)
|
saveServiceState(self, ServiceState.STARTED)
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
@@ -88,6 +88,8 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun stopService() {
|
private fun stopService() {
|
||||||
Log.d(TAG, "Stopping foreground service")
|
Log.d(TAG, "Stopping foreground service")
|
||||||
|
if (isStoppingService) return
|
||||||
|
isStoppingService = true
|
||||||
try {
|
try {
|
||||||
wakeLock?.let {
|
wakeLock?.let {
|
||||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||||
@@ -98,7 +100,7 @@ class SimplexService: Service() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||||
}
|
}
|
||||||
|
isStoppingService = false
|
||||||
isServiceStarted = false
|
isServiceStarted = false
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ class SimplexService: Service() {
|
|||||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
}
|
}
|
||||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ntf_icon)
|
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
@@ -213,6 +215,7 @@ class SimplexService: Service() {
|
|||||||
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
||||||
|
|
||||||
private suspend fun serviceAction(context: Context, action: Action) {
|
private suspend fun serviceAction(context: Context, action: Action) {
|
||||||
|
if (!AppPreferences(context).runServiceInBackground.get()) { return }
|
||||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Intent(context, SimplexService::class.java).also {
|
Intent(context, SimplexService::class.java).also {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import androidx.compose.ui.text.SpanStyle
|
|||||||
import androidx.compose.ui.text.font.*
|
import androidx.compose.ui.text.font.*
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.SecretColor
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimplexBlue
|
|
||||||
import chat.simplex.app.views.call.*
|
import chat.simplex.app.views.call.*
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
@@ -24,6 +23,8 @@ class ChatModel(val controller: ChatController) {
|
|||||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||||
val currentUser = mutableStateOf<User?>(null)
|
val currentUser = mutableStateOf<User?>(null)
|
||||||
val userCreated = mutableStateOf<Boolean?>(null)
|
val userCreated = mutableStateOf<Boolean?>(null)
|
||||||
|
val chatRunning = mutableStateOf<Boolean?>(null)
|
||||||
|
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||||
val chats = mutableStateListOf<Chat>()
|
val chats = mutableStateListOf<Chat>()
|
||||||
val chatId = mutableStateOf<String?>(null)
|
val chatId = mutableStateOf<String?>(null)
|
||||||
val chatItems = mutableStateListOf<ChatItem>()
|
val chatItems = mutableStateListOf<ChatItem>()
|
||||||
@@ -38,13 +39,20 @@ class ChatModel(val controller: ChatController) {
|
|||||||
|
|
||||||
// set when app is opened via contact or invitation URI
|
// set when app is opened via contact or invitation URI
|
||||||
val appOpenUrl = mutableStateOf<Uri?>(null)
|
val appOpenUrl = mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// preferences
|
||||||
val runServiceInBackground = mutableStateOf(true)
|
val runServiceInBackground = mutableStateOf(true)
|
||||||
|
val performLA = mutableStateOf(false)
|
||||||
|
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||||
|
|
||||||
// current WebRTC call
|
// current WebRTC call
|
||||||
val callInvitations = mutableStateMapOf<String, CallInvitation>()
|
val callManager = CallManager(this)
|
||||||
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
|
val callInvitations = mutableStateMapOf<String, RcvCallInvitation>()
|
||||||
|
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
|
||||||
val activeCall = mutableStateOf<Call?>(null)
|
val activeCall = mutableStateOf<Call?>(null)
|
||||||
val callCommand = mutableStateOf<WCallCommand?>(null)
|
val callCommand = mutableStateOf<WCallCommand?>(null)
|
||||||
|
val showCallView = mutableStateOf(false)
|
||||||
|
val switchingCall = mutableStateOf(false)
|
||||||
|
|
||||||
fun updateUserProfile(profile: Profile) {
|
fun updateUserProfile(profile: Profile) {
|
||||||
val user = currentUser.value
|
val user = currentUser.value
|
||||||
@@ -75,6 +83,20 @@ class ChatModel(val controller: ChatController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateChats(newChats: List<Chat>) {
|
||||||
|
val mergedChats = arrayListOf<Chat>()
|
||||||
|
for (newChat in newChats) {
|
||||||
|
val i = getChatIndex(newChat.chatInfo.id)
|
||||||
|
if (i >= 0) {
|
||||||
|
mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo))
|
||||||
|
} else {
|
||||||
|
mergedChats.add(newChat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chats.clear()
|
||||||
|
chats.addAll(mergedChats)
|
||||||
|
}
|
||||||
|
|
||||||
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
|
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
|
||||||
val i = getChatIndex(id)
|
val i = getChatIndex(id)
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
@@ -172,8 +194,28 @@ class ChatModel(val controller: ChatController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearChat(cInfo: ChatInfo) {
|
||||||
|
// clear preview
|
||||||
|
val i = getChatIndex(cInfo.id)
|
||||||
|
if (i >= 0) {
|
||||||
|
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
|
||||||
|
}
|
||||||
|
// clear current chat
|
||||||
|
if (chatId.value == cInfo.id) {
|
||||||
|
chatItems.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun markChatItemsRead(cInfo: ChatInfo) {
|
fun markChatItemsRead(cInfo: ChatInfo) {
|
||||||
|
// update preview
|
||||||
val chatIdx = getChatIndex(cInfo.id)
|
val chatIdx = getChatIndex(cInfo.id)
|
||||||
|
if (chatIdx >= 0) {
|
||||||
|
val chat = chats[chatIdx]
|
||||||
|
val lastId = chat.chatItems.lastOrNull()?.id
|
||||||
|
if (lastId != null) {
|
||||||
|
chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if (chatId.value == cInfo.id) {
|
if (chatId.value == cInfo.id) {
|
||||||
var i = 0
|
var i = 0
|
||||||
@@ -184,12 +226,6 @@ class ChatModel(val controller: ChatController) {
|
|||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
val chat = chats[chatIdx]
|
|
||||||
val pItem = chat.chatItems.lastOrNull()
|
|
||||||
chats[chatIdx] = chat.copy(
|
|
||||||
chatItems = if (pItem == null) arrayListOf() else arrayListOf(pItem.withStatus(CIStatus.RcvRead())),
|
|
||||||
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +292,7 @@ interface SomeChat {
|
|||||||
val apiId: Long
|
val apiId: Long
|
||||||
val ready: Boolean
|
val ready: Boolean
|
||||||
val createdAt: Instant
|
val createdAt: Instant
|
||||||
|
val updatedAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -307,6 +344,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contact.apiId
|
override val apiId get() = contact.apiId
|
||||||
override val ready get() = contact.ready
|
override val ready get() = contact.ready
|
||||||
override val createdAt get() = contact.createdAt
|
override val createdAt get() = contact.createdAt
|
||||||
|
override val updatedAt get() = contact.updatedAt
|
||||||
override val displayName get() = contact.displayName
|
override val displayName get() = contact.displayName
|
||||||
override val fullName get() = contact.fullName
|
override val fullName get() = contact.fullName
|
||||||
override val image get() = contact.image
|
override val image get() = contact.image
|
||||||
@@ -324,6 +362,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = groupInfo.apiId
|
override val apiId get() = groupInfo.apiId
|
||||||
override val ready get() = groupInfo.ready
|
override val ready get() = groupInfo.ready
|
||||||
override val createdAt get() = groupInfo.createdAt
|
override val createdAt get() = groupInfo.createdAt
|
||||||
|
override val updatedAt get() = groupInfo.updatedAt
|
||||||
override val displayName get() = groupInfo.displayName
|
override val displayName get() = groupInfo.displayName
|
||||||
override val fullName get() = groupInfo.fullName
|
override val fullName get() = groupInfo.fullName
|
||||||
override val image get() = groupInfo.image
|
override val image get() = groupInfo.image
|
||||||
@@ -341,6 +380,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contactRequest.apiId
|
override val apiId get() = contactRequest.apiId
|
||||||
override val ready get() = contactRequest.ready
|
override val ready get() = contactRequest.ready
|
||||||
override val createdAt get() = contactRequest.createdAt
|
override val createdAt get() = contactRequest.createdAt
|
||||||
|
override val updatedAt get() = contactRequest.updatedAt
|
||||||
override val displayName get() = contactRequest.displayName
|
override val displayName get() = contactRequest.displayName
|
||||||
override val fullName get() = contactRequest.fullName
|
override val fullName get() = contactRequest.fullName
|
||||||
override val image get() = contactRequest.image
|
override val image get() = contactRequest.image
|
||||||
@@ -358,6 +398,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contactConnection.apiId
|
override val apiId get() = contactConnection.apiId
|
||||||
override val ready get() = contactConnection.ready
|
override val ready get() = contactConnection.ready
|
||||||
override val createdAt get() = contactConnection.createdAt
|
override val createdAt get() = contactConnection.createdAt
|
||||||
|
override val updatedAt get() = contactConnection.updatedAt
|
||||||
override val displayName get() = contactConnection.displayName
|
override val displayName get() = contactConnection.displayName
|
||||||
override val fullName get() = contactConnection.fullName
|
override val fullName get() = contactConnection.fullName
|
||||||
override val image get() = contactConnection.image
|
override val image get() = contactConnection.image
|
||||||
@@ -376,7 +417,8 @@ class Contact(
|
|||||||
val profile: Profile,
|
val profile: Profile,
|
||||||
val activeConn: Connection,
|
val activeConn: Connection,
|
||||||
val viaGroup: Long? = null,
|
val viaGroup: Long? = null,
|
||||||
override val createdAt: Instant
|
override val createdAt: Instant,
|
||||||
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
override val chatType get() = ChatType.Direct
|
override val chatType get() = ChatType.Direct
|
||||||
override val id get() = "@$contactId"
|
override val id get() = "@$contactId"
|
||||||
@@ -392,7 +434,8 @@ class Contact(
|
|||||||
localDisplayName = "alice",
|
localDisplayName = "alice",
|
||||||
profile = Profile.sampleData,
|
profile = Profile.sampleData,
|
||||||
activeConn = Connection.sampleData,
|
activeConn = Connection.sampleData,
|
||||||
createdAt = Clock.System.now()
|
createdAt = Clock.System.now(),
|
||||||
|
updatedAt = Clock.System.now()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,10 +464,10 @@ class Connection(val connId: Long, val connStatus: ConnStatus) {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Profile(
|
class Profile(
|
||||||
val displayName: String,
|
override val displayName: String,
|
||||||
val fullName: String,
|
override val fullName: String,
|
||||||
val image: String? = null
|
override val image: String? = null
|
||||||
) {
|
): NamedChat {
|
||||||
companion object {
|
companion object {
|
||||||
val sampleData = Profile(
|
val sampleData = Profile(
|
||||||
displayName = "alice",
|
displayName = "alice",
|
||||||
@@ -438,7 +481,8 @@ class GroupInfo (
|
|||||||
val groupId: Long,
|
val groupId: Long,
|
||||||
override val localDisplayName: String,
|
override val localDisplayName: String,
|
||||||
val groupProfile: GroupProfile,
|
val groupProfile: GroupProfile,
|
||||||
override val createdAt: Instant
|
override val createdAt: Instant,
|
||||||
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
override val chatType get() = ChatType.Group
|
override val chatType get() = ChatType.Group
|
||||||
override val id get() = "#$groupId"
|
override val id get() = "#$groupId"
|
||||||
@@ -453,7 +497,8 @@ class GroupInfo (
|
|||||||
groupId = 1,
|
groupId = 1,
|
||||||
localDisplayName = "team",
|
localDisplayName = "team",
|
||||||
groupProfile = GroupProfile.sampleData,
|
groupProfile = GroupProfile.sampleData,
|
||||||
createdAt = Clock.System.now()
|
createdAt = Clock.System.now(),
|
||||||
|
updatedAt = Clock.System.now()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +570,7 @@ class UserContactRequest (
|
|||||||
override val localDisplayName: String,
|
override val localDisplayName: String,
|
||||||
val profile: Profile,
|
val profile: Profile,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
val updatedAt: Instant
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
override val chatType get() = ChatType.ContactRequest
|
override val chatType get() = ChatType.ContactRequest
|
||||||
override val id get() = "<@$contactRequestId"
|
override val id get() = "<@$contactRequestId"
|
||||||
@@ -553,7 +598,7 @@ class PendingContactConnection(
|
|||||||
val pccConnStatus: ConnStatus,
|
val pccConnStatus: ConnStatus,
|
||||||
val viaContactUri: Boolean,
|
val viaContactUri: Boolean,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
val updatedAt: Instant
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
override val chatType get() = ChatType.ContactConnection
|
override val chatType get() = ChatType.ContactConnection
|
||||||
override val id get () = ":$pccConnId"
|
override val id get () = ":$pccConnId"
|
||||||
@@ -649,13 +694,6 @@ data class ChatItem (
|
|||||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||||
else null
|
else null
|
||||||
|
|
||||||
val isMsgContent: Boolean get() =
|
|
||||||
when (content) {
|
|
||||||
is CIContent.SndMsgContent -> true
|
|
||||||
is CIContent.RcvMsgContent -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDeletedContent: Boolean get() =
|
val isDeletedContent: Boolean get() =
|
||||||
when (content) {
|
when (content) {
|
||||||
is CIContent.SndDeleted -> true
|
is CIContent.SndDeleted -> true
|
||||||
@@ -807,10 +845,11 @@ sealed class CIContent: ItemContent {
|
|||||||
|
|
||||||
@Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
|
@Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
|
||||||
@Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
|
@Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
|
||||||
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
|
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
|
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
|
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
|
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
|
||||||
override val text: String get() = when(this) {
|
override val text: String get() = when(this) {
|
||||||
is SndMsgContent -> msgContent.text
|
is SndMsgContent -> msgContent.text
|
||||||
@@ -819,6 +858,7 @@ sealed class CIContent: ItemContent {
|
|||||||
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
||||||
is SndCall -> status.text(duration)
|
is SndCall -> status.text(duration)
|
||||||
is RcvCall -> status.text(duration)
|
is RcvCall -> status.text(duration)
|
||||||
|
is RcvIntegrityError -> msgError.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,7 +1082,7 @@ enum class FormatColor(val color: String) {
|
|||||||
|
|
||||||
val uiColor: Color @Composable get() = when (this) {
|
val uiColor: Color @Composable get() = when (this) {
|
||||||
red -> Color.Red
|
red -> Color.Red
|
||||||
green -> Color.Green
|
green -> SimplexGreen
|
||||||
blue -> SimplexBlue
|
blue -> SimplexBlue
|
||||||
yellow -> Color.Yellow
|
yellow -> Color.Yellow
|
||||||
cyan -> Color.Cyan
|
cyan -> Color.Cyan
|
||||||
@@ -1082,3 +1122,18 @@ enum class CICallStatus {
|
|||||||
|
|
||||||
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class MsgErrorType() {
|
||||||
|
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
|
||||||
|
@Serializable @SerialName("msgBadId") class MsgBadId(val msgId: Long): MsgErrorType()
|
||||||
|
@Serializable @SerialName("msgBadHash") class MsgBadHash(): MsgErrorType()
|
||||||
|
@Serializable @SerialName("msgDuplicate") class MsgDuplicate(): MsgErrorType()
|
||||||
|
|
||||||
|
val text: String get() = when (this) {
|
||||||
|
is MsgSkipped -> String.format(generalGetString(R.string.integrity_msg_skipped), toMsgId - fromMsgId + 1)
|
||||||
|
is MsgBadHash -> generalGetString(R.string.integrity_msg_bad_hash) // not used now
|
||||||
|
is MsgBadId -> generalGetString(R.string.integrity_msg_bad_id) // not used now
|
||||||
|
is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package chat.simplex.app.model
|
package chat.simplex.app.model
|
||||||
|
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.*
|
||||||
import android.content.Intent
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.views.call.*
|
||||||
|
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
class NtfManager(val context: Context) {
|
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||||
|
|
||||||
|
// DO NOT change notification channel settings / names
|
||||||
|
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
||||||
|
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||||
|
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||||
|
const val CallNotificationId: Int = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -22,11 +33,22 @@ class NtfManager(val context: Context) {
|
|||||||
private val msgNtfTimeoutMs = 30000L
|
private val msgNtfTimeoutMs = 30000L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
manager.createNotificationChannel(NotificationChannel(
|
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
|
||||||
MessageChannel,
|
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
|
||||||
"SimpleX Chat messages",
|
manager.createNotificationChannel(callNotificationChannel())
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
}
|
||||||
))
|
|
||||||
|
private fun callNotificationChannel(): NotificationChannel {
|
||||||
|
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
|
||||||
|
val attrs = AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.build()
|
||||||
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
|
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||||
|
callChannel.setSound(soundUri, attrs)
|
||||||
|
callChannel.enableVibration(true)
|
||||||
|
return callChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelNotificationsForChat(chatId: String) {
|
fun cancelNotificationsForChat(chatId: String) {
|
||||||
@@ -42,21 +64,25 @@ class NtfManager(val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||||
Log.d(TAG, "notifyMessageReceived ${cInfo.id}")
|
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
|
||||||
|
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val recentNotification = (now - prevNtfTime.getOrDefault(cInfo.id, 0) < msgNtfTimeoutMs)
|
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||||
prevNtfTime[cInfo.id] = now
|
prevNtfTime[chatId] = now
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, MessageChannel)
|
val notification = NotificationCompat.Builder(context, MessageChannel)
|
||||||
.setContentTitle(cInfo.displayName)
|
.setContentTitle(displayName)
|
||||||
.setContentText(hideSecrets(cItem))
|
.setContentText(msgText)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setGroup(MessageGroup)
|
.setGroup(MessageGroup)
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||||
.setSmallIcon(R.drawable.ntf_icon)
|
.setSmallIcon(R.drawable.ntf_icon)
|
||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(getMsgPendingIntent(cInfo))
|
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||||
.setSilent(recentNotification)
|
.setSilent(recentNotification)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -66,16 +92,62 @@ class NtfManager(val context: Context) {
|
|||||||
.setGroup(MessageGroup)
|
.setGroup(MessageGroup)
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||||
.setGroupSummary(true)
|
.setGroupSummary(true)
|
||||||
.setContentIntent(getSummaryNtfIntent())
|
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||||
notify(cInfo.id.hashCode(), notification)
|
notify(chatId.hashCode(), notification)
|
||||||
notify(0, summary)
|
notify(0, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||||
|
if (isAppOnForeground(context)) return
|
||||||
|
val contactId = invitation.contact.id
|
||||||
|
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||||
|
val keyguardManager = getKeyguardManager(context)
|
||||||
|
val image = invitation.contact.image
|
||||||
|
var ntfBuilder =
|
||||||
|
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||||
|
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||||
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
NotificationCompat.Builder(context, LockScreenCallChannel)
|
||||||
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setSilent(true)
|
||||||
|
} else {
|
||||||
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
|
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||||
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||||
|
.setSound(soundUri)
|
||||||
|
}
|
||||||
|
val text = generalGetString(
|
||||||
|
if (invitation.callType.media == CallMediaType.Video) {
|
||||||
|
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||||
|
} else {
|
||||||
|
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ntfBuilder = ntfBuilder
|
||||||
|
.setContentTitle(invitation.contact.displayName)
|
||||||
|
.setContentText(text)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setSmallIcon(R.drawable.ntf_icon)
|
||||||
|
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
|
||||||
|
.setColor(0x88FFFF)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
with(NotificationManagerCompat.from(context)) {
|
||||||
|
notify(CallNotificationId, ntfBuilder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelCallNotification() {
|
||||||
|
manager.cancel(CallNotificationId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun hideSecrets(cItem: ChatItem) : String {
|
private fun hideSecrets(cItem: ChatItem) : String {
|
||||||
val md = cItem.formattedText
|
val md = cItem.formattedText
|
||||||
return if (md == null) {
|
return if (md == null) {
|
||||||
@@ -93,25 +165,13 @@ class NtfManager(val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
|
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
||||||
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
|
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
var intent = Intent(context, MainActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
.putExtra("chatId", cInfo.id)
|
.setAction(intentAction)
|
||||||
.setAction(OpenChatAction)
|
if (chatId != null) intent = intent.putExtra("chatId", chatId)
|
||||||
return TaskStackBuilder.create(context).run {
|
|
||||||
addNextIntentWithParentStack(intent)
|
|
||||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSummaryNtfIntent() : PendingIntent{
|
|
||||||
Log.d(TAG, "getSummaryNtfIntent")
|
|
||||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
.setAction(ShowChatsAction)
|
|
||||||
return TaskStackBuilder.create(context).run {
|
return TaskStackBuilder.create(context).run {
|
||||||
addNextIntentWithParentStack(intent)
|
addNextIntentWithParentStack(intent)
|
||||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package chat.simplex.app.model
|
package chat.simplex.app.model
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.app.ActivityManager.RunningAppProcessInfo
|
import android.app.ActivityManager.RunningAppProcessInfo
|
||||||
import android.content.Context
|
import android.app.Application
|
||||||
import android.content.SharedPreferences
|
import android.content.*
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -13,13 +18,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.views.call.*
|
import chat.simplex.app.views.call.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
@@ -29,57 +34,149 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
typealias ChatCtrl = Long
|
typealias ChatCtrl = Long
|
||||||
|
|
||||||
open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: NtfManager, val appContext: Context) {
|
fun isAppOnForeground(context: Context): Boolean {
|
||||||
var chatModel = ChatModel(this)
|
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||||
|
val packageName = context.packageName
|
||||||
|
for (appProcess in appProcesses) {
|
||||||
|
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CallOnLockScreen {
|
||||||
|
DISABLE,
|
||||||
|
SHOW,
|
||||||
|
ACCEPT;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val default = SHOW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPreferences(val context: Context) {
|
||||||
|
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
||||||
|
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||||
|
val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
|
||||||
|
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||||
|
val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
|
||||||
|
private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
|
||||||
|
val callOnLockScreen: Preference<CallOnLockScreen> = Preference(
|
||||||
|
get = fun(): CallOnLockScreen {
|
||||||
|
val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
|
||||||
|
return try {
|
||||||
|
CallOnLockScreen.valueOf(value)
|
||||||
|
} catch (e: Error) {
|
||||||
|
CallOnLockScreen.default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) }
|
||||||
|
)
|
||||||
|
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
|
||||||
|
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
|
||||||
|
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
|
||||||
|
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
|
||||||
|
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
|
||||||
|
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
|
||||||
|
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||||
|
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
|
||||||
|
|
||||||
|
private fun mkIntPreference(prefName: String, default: Int) =
|
||||||
|
Preference(
|
||||||
|
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||||
|
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mkBoolPreference(prefName: String, default: Boolean) =
|
||||||
|
Preference(
|
||||||
|
get = fun() = sharedPreferences.getBoolean(prefName, default),
|
||||||
|
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mkStrPreference(prefName: String, default: String?): Preference<String?> =
|
||||||
|
Preference(
|
||||||
|
get = fun() = sharedPreferences.getString(prefName, default),
|
||||||
|
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mkDatePreference(prefName: String, default: Instant?): Preference<Instant?> =
|
||||||
|
Preference(
|
||||||
|
get = {
|
||||||
|
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
|
||||||
|
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
|
||||||
|
},
|
||||||
|
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply()
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||||
|
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||||
|
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||||
|
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
||||||
|
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
|
||||||
|
private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay"
|
||||||
|
private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen"
|
||||||
|
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
|
||||||
|
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
|
||||||
|
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
|
||||||
|
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||||
|
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||||
|
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
|
||||||
|
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
|
||||||
|
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MESSAGE_TIMEOUT: Int = 15_000_000
|
||||||
|
|
||||||
|
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
|
||||||
|
val chatModel = ChatModel(this)
|
||||||
|
private var receiverStarted = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
chatModel.runServiceInBackground.value = getRunServiceInBackground()
|
chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get()
|
||||||
|
chatModel.performLA.value = appPrefs.performLA.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun startChat(user: User) {
|
suspend fun startChat(user: User) {
|
||||||
Log.d(TAG, "user: $user")
|
Log.d(TAG, "user: $user")
|
||||||
try {
|
try {
|
||||||
apiStartChat()
|
val justStarted = apiStartChat()
|
||||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||||
chatModel.userAddress.value = apiGetUserAddress()
|
chatModel.userAddress.value = apiGetUserAddress()
|
||||||
chatModel.userSMPServers.value = getUserSMPServers()
|
chatModel.userSMPServers.value = getUserSMPServers()
|
||||||
val chats = apiGetChats()
|
val chats = apiGetChats()
|
||||||
chatModel.chats.clear()
|
if (justStarted) {
|
||||||
chatModel.chats.addAll(chats)
|
chatModel.chats.clear()
|
||||||
|
chatModel.chats.addAll(chats)
|
||||||
|
} else {
|
||||||
|
chatModel.updateChats(chats)
|
||||||
|
}
|
||||||
chatModel.currentUser.value = user
|
chatModel.currentUser.value = user
|
||||||
chatModel.userCreated.value = true
|
chatModel.userCreated.value = true
|
||||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||||
Log.d(TAG, "started chat")
|
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||||
} catch(e: Error) {
|
chatModel.chatRunning.value = true
|
||||||
|
startReceiver()
|
||||||
|
Log.d(TAG, "chat started")
|
||||||
|
} catch (e: Error) {
|
||||||
Log.e(TAG, "failed starting chat $e")
|
Log.e(TAG, "failed starting chat $e")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startReceiver() {
|
private fun startReceiver() {
|
||||||
Log.d(TAG, "ChatController startReceiver")
|
Log.d(TAG, "ChatController startReceiver")
|
||||||
|
if (receiverStarted) return
|
||||||
thread(name="receiver") {
|
thread(name="receiver") {
|
||||||
withApi { recvMspLoop() }
|
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun isAppOnForeground(context: Context): Boolean {
|
|
||||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
|
||||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
|
||||||
val packageName = context.packageName
|
|
||||||
for (appProcess in appProcesses) {
|
|
||||||
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelNotificationsForChat(chatId: String) {
|
|
||||||
ntfManager.cancelNotificationsForChat(chatId)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendCmd(cmd: CC): CR {
|
suspend fun sendCmd(cmd: CC): CR {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val c = cmd.cmdString
|
val c = cmd.cmdString
|
||||||
@@ -100,18 +197,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun recvMsg(): CR {
|
private suspend fun recvMsg(): CR? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val json = chatRecvMsg(ctrl)
|
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
|
||||||
val r = APIResponse.decodeStr(json).resp
|
if (json == "") {
|
||||||
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
null
|
||||||
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
} else {
|
||||||
r
|
val r = APIResponse.decodeStr(json).resp
|
||||||
|
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
||||||
|
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
||||||
|
r
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun recvMspLoop() {
|
private suspend fun recvMspLoop() {
|
||||||
processReceivedMsg(recvMsg())
|
val msg = recvMsg()
|
||||||
|
if (msg != null) processReceivedMsg(msg)
|
||||||
recvMspLoop()
|
recvMspLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,19 +232,48 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
throw Error("user not created ${r.responseType} ${r.details}")
|
throw Error("user not created ${r.responseType} ${r.details}")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiStartChat() {
|
suspend fun apiStartChat(): Boolean {
|
||||||
val r = sendCmd(CC.StartChat())
|
val r = sendCmd(CC.StartChat())
|
||||||
if (r is CR.ChatStarted || r is CR.ChatRunning) return
|
when (r) {
|
||||||
throw Error("failed starting chat: ${r.responseType} ${r.details}")
|
is CR.ChatStarted -> return true
|
||||||
|
is CR.ChatRunning -> return false
|
||||||
|
else -> throw Error("failed starting chat: ${r.responseType} ${r.details}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiSetFilesFolder(filesFolder: String) {
|
suspend fun apiStopChat(): Boolean {
|
||||||
|
val r = sendCmd(CC.ApiStopChat())
|
||||||
|
when (r) {
|
||||||
|
is CR.ChatStopped -> return true
|
||||||
|
else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun apiSetFilesFolder(filesFolder: String) {
|
||||||
val r = sendCmd(CC.SetFilesFolder(filesFolder))
|
val r = sendCmd(CC.SetFilesFolder(filesFolder))
|
||||||
if (r is CR.CmdOk) return
|
if (r is CR.CmdOk) return
|
||||||
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
|
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiGetChats(): List<Chat> {
|
suspend fun apiExportArchive(config: ArchiveConfig) {
|
||||||
|
val r = sendCmd(CC.ApiExportArchive(config))
|
||||||
|
if (r is CR.CmdOk) return
|
||||||
|
throw Error("failed to export archive: ${r.responseType} ${r.details}")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun apiImportArchive(config: ArchiveConfig) {
|
||||||
|
val r = sendCmd(CC.ApiImportArchive(config))
|
||||||
|
if (r is CR.CmdOk) return
|
||||||
|
throw Error("failed to import archive: ${r.responseType} ${r.details}")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun apiDeleteStorage() {
|
||||||
|
val r = sendCmd(CC.ApiDeleteStorage())
|
||||||
|
if (r is CR.CmdOk) return
|
||||||
|
throw Error("failed to delete storage: ${r.responseType} ${r.details}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun apiGetChats(): List<Chat> {
|
||||||
val r = sendCmd(CC.ApiGetChats())
|
val r = sendCmd(CC.ApiGetChats())
|
||||||
if (r is CR.ApiChats ) return r.chats
|
if (r is CR.ApiChats ) return r.chats
|
||||||
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
|
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||||
@@ -177,7 +308,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getUserSMPServers(): List<String>? {
|
private suspend fun getUserSMPServers(): List<String>? {
|
||||||
val r = sendCmd(CC.GetUserSMPServers())
|
val r = sendCmd(CC.GetUserSMPServers())
|
||||||
if (r is CR.UserSMPServers) return r.smpServers
|
if (r is CR.UserSMPServers) return r.smpServers
|
||||||
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
|
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||||
@@ -268,6 +399,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? {
|
||||||
|
val r = sendCmd(CC.ApiClearChat(type, id))
|
||||||
|
if (r is CR.ChatCleared) return r.chatInfo
|
||||||
|
Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun apiUpdateProfile(profile: Profile): Profile? {
|
suspend fun apiUpdateProfile(profile: Profile): Profile? {
|
||||||
val r = sendCmd(CC.ApiUpdateProfile(profile))
|
val r = sendCmd(CC.ApiUpdateProfile(profile))
|
||||||
if (r is CR.UserProfileNoChange) return profile
|
if (r is CR.UserProfileNoChange) return profile
|
||||||
@@ -297,7 +435,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiGetUserAddress(): String? {
|
private suspend fun apiGetUserAddress(): String? {
|
||||||
val r = sendCmd(CC.ShowMyAddress())
|
val r = sendCmd(CC.ShowMyAddress())
|
||||||
if (r is CR.UserContactLink) return r.connReqContact
|
if (r is CR.UserContactLink) return r.connReqContact
|
||||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||||
@@ -332,20 +470,20 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
return r is CR.CmdOk
|
return r is CR.CmdOk
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List<String>, media: CallMediaType, capabilities: CallCapabilities): Boolean {
|
suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean {
|
||||||
val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates)
|
val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates)
|
||||||
val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession)
|
val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession)
|
||||||
val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer))
|
val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer))
|
||||||
return r is CR.CmdOk
|
return r is CR.CmdOk
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List<String>): Boolean {
|
suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean {
|
||||||
val answer = WebRTCSession(rtcSession, rtcIceCandidates)
|
val answer = WebRTCSession(rtcSession, rtcIceCandidates)
|
||||||
val r = sendCmd(CC.ApiSendCallAnswer(contact, answer))
|
val r = sendCmd(CC.ApiSendCallAnswer(contact, answer))
|
||||||
return r is CR.CmdOk
|
return r is CR.CmdOk
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List<String>): Boolean {
|
suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean {
|
||||||
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
|
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
|
||||||
val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo))
|
val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo))
|
||||||
return r is CR.CmdOk
|
return r is CR.CmdOk
|
||||||
@@ -356,15 +494,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
return r is CR.CmdOk
|
return r is CR.CmdOk
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
|
suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
|
||||||
try {
|
val r = sendCmd(CC.ApiCallStatus(contact, status))
|
||||||
val callStatus = WebRTCCallStatus.valueOf(status)
|
return r is CR.CmdOk
|
||||||
val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
|
|
||||||
return r is CR.CmdOk
|
|
||||||
} catch (e: Error) {
|
|
||||||
Log.d(TAG,"apiCallStatus: call status $status not used")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
|
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
|
||||||
@@ -437,15 +569,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
val cItem = r.chatItem.chatItem
|
val cItem = r.chatItem.chatItem
|
||||||
chatModel.addChatItem(cInfo, cItem)
|
chatModel.addChatItem(cInfo, cItem)
|
||||||
val file = cItem.file
|
val file = cItem.file
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) {
|
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) {
|
||||||
withApi {
|
withApi { receiveFile(file.fileId) }
|
||||||
val chatItem = apiReceiveFile(file.fileId)
|
|
||||||
if (chatItem != null) {
|
|
||||||
chatItemSimpleUpdate(chatItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
|
if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,11 +618,62 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
removeFile(appContext, fileName)
|
removeFile(appContext, fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is CR.CallInvitation ->
|
||||||
|
chatModel.callManager.reportNewIncomingCall(r.callInvitation)
|
||||||
|
is CR.CallOffer -> {
|
||||||
|
// TODO askConfirmation?
|
||||||
|
// TODO check encryption is compatible
|
||||||
|
withCall(r, r.contact) { call ->
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
|
||||||
|
val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
|
||||||
|
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, relay = useRelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CR.CallAnswer -> {
|
||||||
|
withCall(r, r.contact) { call ->
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
|
||||||
|
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CR.CallExtraInfo -> {
|
||||||
|
withCall(r, r.contact) { _ ->
|
||||||
|
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CR.CallEnded -> {
|
||||||
|
val invitation = chatModel.callInvitations.remove(r.contact.id)
|
||||||
|
if (invitation != null) {
|
||||||
|
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||||
|
}
|
||||||
|
withCall(r, r.contact) { _ ->
|
||||||
|
chatModel.callCommand.value = WCallCommand.End
|
||||||
|
withApi {
|
||||||
|
chatModel.activeCall.value = null
|
||||||
|
chatModel.showCallView.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else ->
|
else ->
|
||||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
|
if (call != null && call.contact.apiId == contact.apiId) {
|
||||||
|
perform(call)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun receiveFile(fileId: Long) {
|
||||||
|
val chatItem = apiReceiveFile(fileId)
|
||||||
|
if (chatItem != null) {
|
||||||
|
chatItemSimpleUpdate(chatItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
|
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
|
||||||
val cInfo = aChatItem.chatInfo
|
val cInfo = aChatItem.chatInfo
|
||||||
val cItem = aChatItem.chatItem
|
val cItem = aChatItem.chatItem
|
||||||
@@ -526,74 +704,194 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
|||||||
chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err))
|
chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showBackgroundServiceNotice() {
|
fun showBackgroundServiceNoticeIfNeeded() {
|
||||||
if (!getBackgroundServiceNoticeShown()) {
|
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||||
AlertManager.shared.showAlert {
|
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||||
AlertDialog(
|
// the branch for the new users who has never seen service notice
|
||||||
onDismissRequest = AlertManager.shared::hideAlert,
|
if (isIgnoringBatteryOptimizations(appContext)) {
|
||||||
title = {
|
showBGServiceNotice()
|
||||||
Row {
|
} else {
|
||||||
Icon(
|
showBGServiceNoticeIgnoreOptimization()
|
||||||
Icons.Outlined.Bolt,
|
}
|
||||||
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
|
// set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
|
||||||
)
|
appPrefs.backgroundServiceNoticeShown.set(true)
|
||||||
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
|
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||||
}
|
} else if (!isIgnoringBatteryOptimizations(appContext) && appPrefs.runServiceInBackground.get()) {
|
||||||
},
|
// the branch for users who have app installed, and have seen the service notice,
|
||||||
text = {
|
// but the battery optimization for the app is on (Android 12) AND the service is running
|
||||||
Column {
|
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||||
Text(
|
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
|
||||||
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
|
showDisablingServiceNotice()
|
||||||
Modifier.padding(bottom = 8.dp)
|
appPrefs.runServiceInBackground.set(false)
|
||||||
)
|
chatModel.runServiceInBackground.value = false
|
||||||
Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown))
|
} else {
|
||||||
}
|
// show battery optimization notice
|
||||||
},
|
showBGServiceNoticeIgnoreOptimization()
|
||||||
confirmButton = {
|
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||||
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
setBackgroundServiceNoticeShown()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
private fun showBGServiceNotice() = AlertManager.shared.showAlert {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = AlertManager.shared::hideAlert,
|
||||||
|
title = {
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Bolt,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
|
||||||
|
Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setAutoRestartWorkerVersion(version: Int) =
|
private fun showBGServiceNoticeIgnoreOptimization() = AlertManager.shared.showAlert {
|
||||||
sharedPreferences.edit()
|
val ignoreOptimization = {
|
||||||
.putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
|
AlertManager.shared.hideAlert()
|
||||||
.apply()
|
askAboutIgnoringBatteryOptimization(appContext)
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = ignoreOptimization,
|
||||||
|
title = {
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Bolt,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
|
||||||
|
Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Text(annotatedStringResource(R.string.turn_off_battery_optimization))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getRunServiceInBackground(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
private fun showDisablingServiceNotice() = AlertManager.shared.showAlert {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = AlertManager.shared::hideAlert,
|
||||||
|
title = {
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Bolt,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.private_instant_notifications_disabled), fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
annotatedStringResource(R.string.turning_off_background_service),
|
||||||
|
Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setRunServiceInBackground(runService: Boolean) =
|
fun showLANotice(activity: FragmentActivity) {
|
||||||
sharedPreferences.edit()
|
Log.d(TAG, "showLANotice")
|
||||||
.putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService)
|
if (!appPrefs.laNoticeShown.get()) {
|
||||||
.apply()
|
appPrefs.laNoticeShown.set(true)
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||||
|
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||||
|
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||||
|
onConfirm = {
|
||||||
|
authenticate(
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock),
|
||||||
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
chatModel.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Error -> {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laErrorToast(appContext, laResult.errString)
|
||||||
|
}
|
||||||
|
LAResult.Failed -> {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laFailedToast(appContext)
|
||||||
|
}
|
||||||
|
LAResult.Unavailable -> {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
chatModel.showAdvertiseLAUnavailableAlert.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||||
|
val powerManager = context.getSystemService(Application.POWER_SERVICE) as PowerManager
|
||||||
|
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
fun setBackgroundServiceNoticeShown() =
|
private fun askAboutIgnoringBatteryOptimization(context: Context) {
|
||||||
sharedPreferences.edit()
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||||
.putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, true)
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
companion object {
|
Intent().apply {
|
||||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
@SuppressLint("BatteryLife")
|
||||||
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
data = Uri.parse("package:${context.packageName}")
|
||||||
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
// This flag is needed when you start a new activity from non-Activity context
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
|
||||||
|
|
||||||
// ChatCommand
|
// ChatCommand
|
||||||
sealed class CC {
|
sealed class CC {
|
||||||
class Console(val cmd: String): CC()
|
class Console(val cmd: String): CC()
|
||||||
class ShowActiveUser: CC()
|
class ShowActiveUser: CC()
|
||||||
class CreateActiveUser(val profile: Profile): CC()
|
class CreateActiveUser(val profile: Profile): CC()
|
||||||
class StartChat: CC()
|
class StartChat: CC()
|
||||||
|
class ApiStopChat: CC()
|
||||||
class SetFilesFolder(val filesFolder: String): CC()
|
class SetFilesFolder(val filesFolder: String): CC()
|
||||||
|
class ApiExportArchive(val config: ArchiveConfig): CC()
|
||||||
|
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||||
|
class ApiDeleteStorage: CC()
|
||||||
class ApiGetChats: CC()
|
class ApiGetChats: CC()
|
||||||
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
||||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
||||||
@@ -604,6 +902,7 @@ sealed class CC {
|
|||||||
class AddContact: CC()
|
class AddContact: CC()
|
||||||
class Connect(val connReq: String): CC()
|
class Connect(val connReq: String): CC()
|
||||||
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
||||||
|
class ApiClearChat(val type: ChatType, val id: Long): CC()
|
||||||
class ApiUpdateProfile(val profile: Profile): CC()
|
class ApiUpdateProfile(val profile: Profile): CC()
|
||||||
class ApiParseMarkdown(val text: String): CC()
|
class ApiParseMarkdown(val text: String): CC()
|
||||||
class CreateMyAddress: CC()
|
class CreateMyAddress: CC()
|
||||||
@@ -626,7 +925,11 @@ sealed class CC {
|
|||||||
is ShowActiveUser -> "/u"
|
is ShowActiveUser -> "/u"
|
||||||
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
|
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
|
||||||
is StartChat -> "/_start"
|
is StartChat -> "/_start"
|
||||||
|
is ApiStopChat -> "/_stop"
|
||||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||||
|
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
|
||||||
|
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||||
|
is ApiDeleteStorage -> "/_db delete"
|
||||||
is ApiGetChats -> "/_get chats pcc=on"
|
is ApiGetChats -> "/_get chats pcc=on"
|
||||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||||
@@ -637,6 +940,7 @@ sealed class CC {
|
|||||||
is AddContact -> "/connect"
|
is AddContact -> "/connect"
|
||||||
is Connect -> "/connect $connReq"
|
is Connect -> "/connect $connReq"
|
||||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
||||||
|
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
|
||||||
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
|
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
|
||||||
is ApiParseMarkdown -> "/_parse $text"
|
is ApiParseMarkdown -> "/_parse $text"
|
||||||
is CreateMyAddress -> "/address"
|
is CreateMyAddress -> "/address"
|
||||||
@@ -650,7 +954,7 @@ sealed class CC {
|
|||||||
is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
|
is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
|
||||||
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
|
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
|
||||||
is ApiEndCall -> "/_call end @${contact.apiId}"
|
is ApiEndCall -> "/_call end @${contact.apiId}"
|
||||||
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}"
|
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
|
||||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||||
is ReceiveFile -> "/freceive $fileId"
|
is ReceiveFile -> "/freceive $fileId"
|
||||||
}
|
}
|
||||||
@@ -660,7 +964,11 @@ sealed class CC {
|
|||||||
is ShowActiveUser -> "showActiveUser"
|
is ShowActiveUser -> "showActiveUser"
|
||||||
is CreateActiveUser -> "createActiveUser"
|
is CreateActiveUser -> "createActiveUser"
|
||||||
is StartChat -> "startChat"
|
is StartChat -> "startChat"
|
||||||
|
is ApiStopChat -> "apiStopChat"
|
||||||
is SetFilesFolder -> "setFilesFolder"
|
is SetFilesFolder -> "setFilesFolder"
|
||||||
|
is ApiExportArchive -> "apiExportArchive"
|
||||||
|
is ApiImportArchive -> "apiImportArchive"
|
||||||
|
is ApiDeleteStorage -> "apiDeleteStorage"
|
||||||
is ApiGetChats -> "apiGetChats"
|
is ApiGetChats -> "apiGetChats"
|
||||||
is ApiGetChat -> "apiGetChat"
|
is ApiGetChat -> "apiGetChat"
|
||||||
is ApiSendMessage -> "apiSendMessage"
|
is ApiSendMessage -> "apiSendMessage"
|
||||||
@@ -671,6 +979,7 @@ sealed class CC {
|
|||||||
is AddContact -> "addContact"
|
is AddContact -> "addContact"
|
||||||
is Connect -> "connect"
|
is Connect -> "connect"
|
||||||
is ApiDeleteChat -> "apiDeleteChat"
|
is ApiDeleteChat -> "apiDeleteChat"
|
||||||
|
is ApiClearChat -> "apiClearChat"
|
||||||
is ApiUpdateProfile -> "updateProfile"
|
is ApiUpdateProfile -> "updateProfile"
|
||||||
is ApiParseMarkdown -> "apiParseMarkdown"
|
is ApiParseMarkdown -> "apiParseMarkdown"
|
||||||
is CreateMyAddress -> "createMyAddress"
|
is CreateMyAddress -> "createMyAddress"
|
||||||
@@ -701,6 +1010,9 @@ sealed class CC {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
|
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
|
||||||
|
|
||||||
val json = Json {
|
val json = Json {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -734,6 +1046,7 @@ sealed class CR {
|
|||||||
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
|
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
|
||||||
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
|
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
|
||||||
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
||||||
|
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||||
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
|
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
|
||||||
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
||||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
|
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
|
||||||
@@ -742,6 +1055,7 @@ sealed class CR {
|
|||||||
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
|
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
|
||||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
|
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
|
||||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
|
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
|
||||||
|
@Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR()
|
||||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
|
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
|
||||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
|
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
|
||||||
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
|
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
|
||||||
@@ -774,8 +1088,8 @@ sealed class CR {
|
|||||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||||
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||||
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
|
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
|
||||||
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
|
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
|
||||||
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
|
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
|
||||||
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
|
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
|
||||||
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
|
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
|
||||||
@@ -791,6 +1105,7 @@ sealed class CR {
|
|||||||
is ActiveUser -> "activeUser"
|
is ActiveUser -> "activeUser"
|
||||||
is ChatStarted -> "chatStarted"
|
is ChatStarted -> "chatStarted"
|
||||||
is ChatRunning -> "chatRunning"
|
is ChatRunning -> "chatRunning"
|
||||||
|
is ChatStopped -> "chatStopped"
|
||||||
is ApiChats -> "apiChats"
|
is ApiChats -> "apiChats"
|
||||||
is ApiChat -> "apiChat"
|
is ApiChat -> "apiChat"
|
||||||
is UserSMPServers -> "userSMPServers"
|
is UserSMPServers -> "userSMPServers"
|
||||||
@@ -799,6 +1114,7 @@ sealed class CR {
|
|||||||
is SentInvitation -> "sentInvitation"
|
is SentInvitation -> "sentInvitation"
|
||||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||||
is ContactDeleted -> "contactDeleted"
|
is ContactDeleted -> "contactDeleted"
|
||||||
|
is ChatCleared -> "chatCleared"
|
||||||
is UserProfileNoChange -> "userProfileNoChange"
|
is UserProfileNoChange -> "userProfileNoChange"
|
||||||
is UserProfileUpdated -> "userProfileUpdated"
|
is UserProfileUpdated -> "userProfileUpdated"
|
||||||
is ParsedMarkdown -> "apiParsedMarkdown"
|
is ParsedMarkdown -> "apiParsedMarkdown"
|
||||||
@@ -849,6 +1165,7 @@ sealed class CR {
|
|||||||
is ActiveUser -> json.encodeToString(user)
|
is ActiveUser -> json.encodeToString(user)
|
||||||
is ChatStarted -> noDetails()
|
is ChatStarted -> noDetails()
|
||||||
is ChatRunning -> noDetails()
|
is ChatRunning -> noDetails()
|
||||||
|
is ChatStopped -> noDetails()
|
||||||
is ApiChats -> json.encodeToString(chats)
|
is ApiChats -> json.encodeToString(chats)
|
||||||
is ApiChat -> json.encodeToString(chat)
|
is ApiChat -> json.encodeToString(chat)
|
||||||
is UserSMPServers -> json.encodeToString(smpServers)
|
is UserSMPServers -> json.encodeToString(smpServers)
|
||||||
@@ -857,6 +1174,7 @@ sealed class CR {
|
|||||||
is SentInvitation -> noDetails()
|
is SentInvitation -> noDetails()
|
||||||
is ContactAlreadyExists -> json.encodeToString(contact)
|
is ContactAlreadyExists -> json.encodeToString(contact)
|
||||||
is ContactDeleted -> json.encodeToString(contact)
|
is ContactDeleted -> json.encodeToString(contact)
|
||||||
|
is ChatCleared -> json.encodeToString(chatInfo)
|
||||||
is UserProfileNoChange -> noDetails()
|
is UserProfileNoChange -> noDetails()
|
||||||
is UserProfileUpdated -> json.encodeToString(toProfile)
|
is UserProfileUpdated -> json.encodeToString(toProfile)
|
||||||
is ParsedMarkdown -> json.encodeToString(formattedText)
|
is ParsedMarkdown -> json.encodeToString(formattedText)
|
||||||
@@ -889,7 +1207,7 @@ sealed class CR {
|
|||||||
is SndFileRcvCancelled -> json.encodeToString(chatItem)
|
is SndFileRcvCancelled -> json.encodeToString(chatItem)
|
||||||
is SndFileStart -> json.encodeToString(chatItem)
|
is SndFileStart -> json.encodeToString(chatItem)
|
||||||
is SndGroupFileCancelled -> json.encodeToString(chatItem)
|
is SndGroupFileCancelled -> json.encodeToString(chatItem)
|
||||||
is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}"
|
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
|
||||||
is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}"
|
is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}"
|
||||||
is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}"
|
is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}"
|
||||||
is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}"
|
is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}"
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ val Purple700 = Color(0xFF3700B3)
|
|||||||
val Teal200 = Color(0xFF03DAC5)
|
val Teal200 = Color(0xFF03DAC5)
|
||||||
val Gray = Color(0x22222222)
|
val Gray = Color(0x22222222)
|
||||||
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
||||||
val SimplexGreen = Color(98, 196, 103, 255)
|
val SimplexGreen = Color(77, 218, 103, 255)
|
||||||
val SecretColor = Color(0x40808080)
|
val SecretColor = Color(0x40808080)
|
||||||
val LightGray = Color(241, 242, 246, 255)
|
val LightGray = Color(241, 242, 246, 255)
|
||||||
val DarkGray = Color(43, 44, 46, 255)
|
val DarkGray = Color(43, 44, 46, 255)
|
||||||
val HighOrLowlight = Color(134, 135, 139, 255)
|
val HighOrLowlight = Color(139, 135, 134, 255)
|
||||||
|
val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||||
|
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||||
val ToolbarLight = Color(220, 220, 220, 20)
|
val ToolbarLight = Color(220, 220, 220, 20)
|
||||||
val ToolbarDark = Color(80, 80, 80, 20)
|
val ToolbarDark = Color(80, 80, 80, 20)
|
||||||
val WarningOrange = Color(255, 149, 0, 255)
|
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||||
|
val GroupDark = Color(80, 80, 80, 60)
|
||||||
|
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||||
|
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||||
|
val WarningOrange = Color(255, 127, 0, 255)
|
||||||
val FileLight = Color(183, 190, 199, 255)
|
val FileLight = Color(183, 190, 199, 255)
|
||||||
val FileDark = Color(101, 101, 106, 255)
|
val FileDark = Color(101, 101, 106, 255)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
private val DarkColorPalette = darkColors(
|
private val DarkColorPalette = darkColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
@@ -13,10 +14,8 @@ private val DarkColorPalette = darkColors(
|
|||||||
// background = Color(0xFF121212),
|
// background = Color(0xFF121212),
|
||||||
// surface = Color(0xFF121212),
|
// surface = Color(0xFF121212),
|
||||||
// error = Color(0xFFCF6679),
|
// error = Color(0xFFCF6679),
|
||||||
// onPrimary = Color.Black,
|
onBackground = Color(0xFFFFFBFA),
|
||||||
// onSecondary = Color.Black,
|
onSurface = Color(0xFFFFFBFA),
|
||||||
// onBackground = Color.White,
|
|
||||||
// onSurface = Color.White,
|
|
||||||
// onError: Color = Color.Black,
|
// onError: Color = Color.Black,
|
||||||
)
|
)
|
||||||
private val LightColorPalette = lightColors(
|
private val LightColorPalette = lightColors(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ val Typography = Typography(
|
|||||||
h3 = TextStyle(
|
h3 = TextStyle(
|
||||||
fontFamily = Inter,
|
fontFamily = Inter,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 19.sp
|
fontSize = 18.5.sp
|
||||||
),
|
),
|
||||||
body1 = TextStyle(
|
body1 = TextStyle(
|
||||||
fontFamily = Inter,
|
fontFamily = Inter,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.*
|
|||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -17,8 +18,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.chat.ComposeState
|
import chat.simplex.app.views.chat.*
|
||||||
import chat.simplex.app.views.chat.SendMsgView
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import com.google.accompanist.insets.ProvideWindowInsets
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||||
val composeState = remember { mutableStateOf(ComposeState()) }
|
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
TerminalLayout(
|
TerminalLayout(
|
||||||
chatModel.terminalItems,
|
chatModel.terminalItems,
|
||||||
@@ -35,6 +35,7 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
withApi {
|
withApi {
|
||||||
// show "in progress"
|
// show "in progress"
|
||||||
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
|
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
|
||||||
|
composeState.value = ComposeState(useLinkPreviews = false)
|
||||||
// hide "in progress"
|
// hide "in progress"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,6 +82,10 @@ fun TerminalLayout(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TerminalLog(terminalItems: List<TerminalItem>) {
|
fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val keyboardState by getKeyboardState()
|
||||||
|
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||||
|
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
|
||||||
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LazyColumn(state = listState) {
|
LazyColumn(state = listState) {
|
||||||
items(terminalItems) { item ->
|
items(terminalItems) { item ->
|
||||||
@@ -100,8 +105,9 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val len = terminalItems.count()
|
val len = terminalItems.count()
|
||||||
if (len > 1) {
|
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
ciListState.value = CIListState(true, len, keyboardState)
|
||||||
listState.animateScrollToItem(len - 1)
|
listState.animateScrollToItem(len - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +125,7 @@ fun PreviewTerminalLayout() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
TerminalLayout(
|
TerminalLayout(
|
||||||
terminalItems = TerminalItem.sampleData,
|
terminalItems = TerminalItem.sampleData,
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
|
||||||
sendCommand = {},
|
sendCommand = {},
|
||||||
close = {}
|
close = {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
|||||||
val createModifier: Modifier
|
val createModifier: Modifier
|
||||||
val createColor: Color
|
val createColor: Color
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) }
|
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
||||||
createColor = MaterialTheme.colors.primary
|
createColor = MaterialTheme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
createModifier = Modifier.padding(8.dp)
|
createModifier = Modifier.padding(8.dp)
|
||||||
@@ -114,9 +114,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
|||||||
Profile(displayName, fullName, null)
|
Profile(displayName, fullName, null)
|
||||||
)
|
)
|
||||||
chatModel.controller.startChat(user)
|
chatModel.controller.startChat(user)
|
||||||
SimplexService.start(chatModel.controller.appContext)
|
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||||
// TODO show it later?
|
|
||||||
chatModel.controller.showBackgroundServiceNotice()
|
|
||||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.views.helpers.ModalManager
|
||||||
|
import chat.simplex.app.views.helpers.withApi
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
class CallManager(val chatModel: ChatModel) {
|
||||||
|
fun reportNewIncomingCall(invitation: RcvCallInvitation) {
|
||||||
|
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||||
|
with (chatModel) {
|
||||||
|
callInvitations[invitation.contact.id] = invitation
|
||||||
|
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||||
|
activeCallInvitation.value = invitation
|
||||||
|
controller.ntfManager.notifyCallInvitation(invitation)
|
||||||
|
} else {
|
||||||
|
val contact = invitation.contact
|
||||||
|
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
|
if (call == null) {
|
||||||
|
justAcceptIncomingCall(invitation = invitation)
|
||||||
|
} else {
|
||||||
|
withApi {
|
||||||
|
chatModel.switchingCall.value = true
|
||||||
|
try {
|
||||||
|
endCall(call = call)
|
||||||
|
justAcceptIncomingCall(invitation = invitation)
|
||||||
|
} finally {
|
||||||
|
withApi { chatModel.switchingCall.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
|
||||||
|
with (chatModel) {
|
||||||
|
activeCall.value = Call(
|
||||||
|
contact = invitation.contact,
|
||||||
|
callState = CallState.InvitationAccepted,
|
||||||
|
localMedia = invitation.callType.media,
|
||||||
|
sharedKey = invitation.sharedKey
|
||||||
|
)
|
||||||
|
showCallView.value = true
|
||||||
|
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||||
|
callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay)
|
||||||
|
callInvitations.remove(invitation.contact.id)
|
||||||
|
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||||
|
activeCallInvitation.value = null
|
||||||
|
controller.ntfManager.cancelCallNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endCall(call: Call) {
|
||||||
|
with (chatModel) {
|
||||||
|
if (call.callState == CallState.Ended) {
|
||||||
|
Log.d(TAG, "CallManager.endCall: call ended")
|
||||||
|
activeCall.value = null
|
||||||
|
showCallView.value = false
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||||
|
callCommand.value = WCallCommand.End
|
||||||
|
showCallView.value = false
|
||||||
|
controller.apiEndCall(call.contact)
|
||||||
|
activeCall.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endCall(invitation: RcvCallInvitation) {
|
||||||
|
with (chatModel) {
|
||||||
|
callInvitations.remove(invitation.contact.id)
|
||||||
|
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||||
|
activeCallInvitation.value = null
|
||||||
|
controller.ntfManager.cancelCallNotification()
|
||||||
|
}
|
||||||
|
withApi {
|
||||||
|
if (!controller.apiRejectCall(invitation.contact)) {
|
||||||
|
Log.e(TAG, "apiRejectCall error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||||
|
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
||||||
|
chatModel.activeCallInvitation.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,318 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.content.Context
|
||||||
import android.content.ClipData
|
import android.media.AudioManager
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.*
|
import android.webkit.*
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.webkit.WebViewAssetLoader
|
import androidx.webkit.WebViewAssetLoader
|
||||||
import androidx.webkit.WebViewClientCompat
|
import androidx.webkit.WebViewClientCompat
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.views.helpers.TextEditor
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoCallView(close: () -> Unit) {
|
fun ActiveCallView(chatModel: ChatModel) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = {
|
||||||
lateinit var wv: WebView
|
val call = chatModel.activeCall.value
|
||||||
val context = LocalContext.current
|
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||||
val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java)
|
})
|
||||||
|
val cxt = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||||
|
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
|
if (call != null) {
|
||||||
|
Log.d(TAG, "has active call $call")
|
||||||
|
when (val r = apiMsg.resp) {
|
||||||
|
is WCallResponse.Capabilities -> withApi {
|
||||||
|
val callType = CallType(call.localMedia, r.capabilities)
|
||||||
|
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||||
|
}
|
||||||
|
is WCallResponse.Offer -> withApi {
|
||||||
|
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||||
|
}
|
||||||
|
is WCallResponse.Answer -> withApi {
|
||||||
|
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||||
|
}
|
||||||
|
is WCallResponse.Ice -> withApi {
|
||||||
|
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||||
|
}
|
||||||
|
is WCallResponse.Connection ->
|
||||||
|
try {
|
||||||
|
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||||
|
if (callStatus == WebRTCCallStatus.Connected) {
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||||
|
}
|
||||||
|
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||||
|
} catch (e: Error) {
|
||||||
|
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||||
|
}
|
||||||
|
is WCallResponse.Connected -> {
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||||
|
scope.launch {
|
||||||
|
delay(2000L)
|
||||||
|
setCallSound(cxt, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is WCallResponse.Ended -> {
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||||
|
withApi { chatModel.callManager.endCall(call) }
|
||||||
|
chatModel.showCallView.value = false
|
||||||
|
}
|
||||||
|
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||||
|
is WCallCommand.Answer ->
|
||||||
|
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||||
|
is WCallCommand.Media -> {
|
||||||
|
when (cmd.media) {
|
||||||
|
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||||
|
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is WCallCommand.Camera -> {
|
||||||
|
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||||
|
if (!call.audioEnabled) {
|
||||||
|
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is WCallCommand.End ->
|
||||||
|
chatModel.showCallView.value = false
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
is WCallResponse.Error -> {
|
||||||
|
Log.e(TAG, "ActiveCallView: command error ${r.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val call = chatModel.activeCall.value
|
||||||
|
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||||
|
var cxt = LocalContext.current
|
||||||
|
ActiveCallOverlayLayout(
|
||||||
|
call = call,
|
||||||
|
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||||
|
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||||
|
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||||
|
toggleSound = {
|
||||||
|
var call = chatModel.activeCall.value
|
||||||
|
if (call != null) {
|
||||||
|
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
||||||
|
chatModel.activeCall.value = call
|
||||||
|
setCallSound(cxt, call)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCallSound(cxt: Context, call: Call) {
|
||||||
|
Log.d(TAG, "setCallSound: set audio mode")
|
||||||
|
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
if (call.soundSpeaker) {
|
||||||
|
am.mode = AudioManager.MODE_NORMAL
|
||||||
|
am.isSpeakerphoneOn = true
|
||||||
|
} else {
|
||||||
|
am.mode = AudioManager.MODE_IN_CALL
|
||||||
|
am.isSpeakerphoneOn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveCallOverlayLayout(
|
||||||
|
call: Call,
|
||||||
|
dismiss: () -> Unit,
|
||||||
|
toggleAudio: () -> Unit,
|
||||||
|
toggleVideo: () -> Unit,
|
||||||
|
toggleSound: () -> Unit,
|
||||||
|
flipCamera: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
when (call.peerMedia ?: call.localMedia) {
|
||||||
|
CallMediaType.Video -> {
|
||||||
|
CallInfoView(call, alignment = Alignment.Start)
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ToggleAudioButton(call, toggleAudio)
|
||||||
|
Spacer(Modifier.size(40.dp))
|
||||||
|
IconButton(onClick = dismiss) {
|
||||||
|
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
|
}
|
||||||
|
if (call.videoEnabled) {
|
||||||
|
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
||||||
|
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.size(48.dp))
|
||||||
|
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallMediaType.Audio -> {
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||||
|
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
IconButton(onClick = dismiss) {
|
||||||
|
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.padding(start = 32.dp)) {
|
||||||
|
ToggleAudioButton(call, toggleAudio)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
Box(Modifier.padding(end = 32.dp)) {
|
||||||
|
ToggleSoundButton(call, toggleSound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
||||||
|
if (call.hasMedia) {
|
||||||
|
IconButton(onClick = action) {
|
||||||
|
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||||
|
if (call.audioEnabled) {
|
||||||
|
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
|
||||||
|
} else {
|
||||||
|
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
|
||||||
|
if (call.soundSpeaker) {
|
||||||
|
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
|
||||||
|
} else {
|
||||||
|
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||||
|
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
||||||
|
Text(text, color = Color(0xFFFFFFD8), style = style)
|
||||||
|
Column(horizontalAlignment = alignment) {
|
||||||
|
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
|
InfoText(call.callState.text)
|
||||||
|
|
||||||
|
val connInfo =
|
||||||
|
if (call.connectionInfo == null) ""
|
||||||
|
else " (${call.connectionInfo.text})"
|
||||||
|
InfoText(call.encryptionStatus + connInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//@Composable
|
||||||
|
//fun CallViewDebug(close: () -> Unit) {
|
||||||
|
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
||||||
|
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
|
||||||
|
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
|
||||||
|
//
|
||||||
|
// BackHandler(onBack = close)
|
||||||
|
// Column(
|
||||||
|
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .background(MaterialTheme.colors.background)
|
||||||
|
// .fillMaxSize()
|
||||||
|
// ) {
|
||||||
|
// WebRTCView(callCommand) { apiMsg ->
|
||||||
|
// // for debugging
|
||||||
|
// // commandText.value = apiMsg
|
||||||
|
// commandText.value = json.encodeToString(apiMsg)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TextEditor(Modifier.height(180.dp), text = commandText)
|
||||||
|
//
|
||||||
|
// Row(
|
||||||
|
// Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .padding(bottom = 6.dp),
|
||||||
|
// horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
// ) {
|
||||||
|
// Button(onClick = {
|
||||||
|
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
|
||||||
|
// clipboard?.setPrimaryClip(clip)
|
||||||
|
// }) { Text("Copy") }
|
||||||
|
// Button(onClick = {
|
||||||
|
// try {
|
||||||
|
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
|
||||||
|
// commandText.value = ""
|
||||||
|
// println("sending: ${commandText.value}")
|
||||||
|
// callCommand.value = apiCall.command
|
||||||
|
// } catch(e: Error) {
|
||||||
|
// println("error parsing command: ${commandText.value}")
|
||||||
|
// println(e)
|
||||||
|
// }
|
||||||
|
// }) { Text("Send") }
|
||||||
|
// Button(onClick = {
|
||||||
|
// commandText.value = ""
|
||||||
|
// }) { Text("Clear") }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
Manifest.permission.CAMERA,
|
Manifest.permission.CAMERA,
|
||||||
@@ -42,6 +321,10 @@ fun VideoCallView(close: () -> Unit) {
|
|||||||
Manifest.permission.INTERNET
|
Manifest.permission.INTERNET
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
fun processCommand(wv: WebView, cmd: WCallCommand) {
|
||||||
|
val apiCall = WVAPICall(command = cmd)
|
||||||
|
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
|
||||||
|
}
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -50,98 +333,83 @@ fun VideoCallView(close: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
wv.evaluateJavascript("endCall()", null)
|
val wv = webView.value
|
||||||
|
if (wv != null) processCommand(wv, WCallCommand.End)
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val localContext = LocalContext.current
|
LaunchedEffect(callCommand.value, webView.value) {
|
||||||
val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") }
|
val cmd = callCommand.value
|
||||||
|
val wv = webView.value
|
||||||
|
if (cmd != null && wv != null) {
|
||||||
|
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||||
|
processCommand(wv, cmd)
|
||||||
|
callCommand.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
val assetLoader = WebViewAssetLoader.Builder()
|
val assetLoader = WebViewAssetLoader.Builder()
|
||||||
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext))
|
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
Column(
|
if (permissionsState.allPermissionsGranted) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Box(Modifier.fillMaxSize()) {
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
AndroidView(
|
||||||
modifier = Modifier
|
factory = { AndroidViewContext ->
|
||||||
.background(MaterialTheme.colors.background)
|
WebView(AndroidViewContext).apply {
|
||||||
.fillMaxSize()
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
) {
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
if (permissionsState.allPermissionsGranted) {
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
Box(
|
)
|
||||||
Modifier
|
this.webChromeClient = object: WebChromeClient() {
|
||||||
.fillMaxWidth()
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
.aspectRatio(ratio = 1F)
|
if (request.origin.toString().startsWith("file:/")) {
|
||||||
) {
|
request.grant(request.resources)
|
||||||
AndroidView(
|
} else {
|
||||||
factory = { AndroidViewContext ->
|
Log.d(TAG, "Permission request from webview denied.")
|
||||||
WebView(AndroidViewContext).apply {
|
request.deny()
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
)
|
|
||||||
this.webChromeClient = object: WebChromeClient() {
|
|
||||||
override fun onPermissionRequest(request: PermissionRequest) {
|
|
||||||
if (request.origin.toString().startsWith("file:/")) {
|
|
||||||
request.grant(request.resources)
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Permission request from webview denied.")
|
|
||||||
request.deny()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
|
||||||
val rtnValue = super.onConsoleMessage(consoleMessage)
|
|
||||||
val msg = consoleMessage?.message() as String
|
|
||||||
if (msg.startsWith("{")) {
|
|
||||||
commandToShow.value = "processCommand($msg)"
|
|
||||||
}
|
|
||||||
return rtnValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
|
||||||
this.clearHistory()
|
|
||||||
this.clearCache(true)
|
|
||||||
// this.addJavascriptInterface(JavascriptInterface(), "Android")
|
|
||||||
val webViewSettings = this.settings
|
|
||||||
webViewSettings.allowFileAccess = true
|
|
||||||
webViewSettings.allowContentAccess = true
|
|
||||||
webViewSettings.javaScriptEnabled = true
|
|
||||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
|
||||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
|
||||||
this.loadUrl("file:android_asset/www/call.html")
|
|
||||||
}
|
}
|
||||||
|
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||||
|
this.clearHistory()
|
||||||
|
this.clearCache(true)
|
||||||
|
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
||||||
|
val webViewSettings = this.settings
|
||||||
|
webViewSettings.allowFileAccess = true
|
||||||
|
webViewSettings.allowContentAccess = true
|
||||||
|
webViewSettings.javaScriptEnabled = true
|
||||||
|
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||||
|
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
|
this.loadUrl("file:android_asset/www/call.html")
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
wv = it
|
) { wv ->
|
||||||
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
|
// for debugging
|
||||||
|
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
|
withApi {
|
||||||
|
delay(2000L)
|
||||||
|
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
|
webView.value = wv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text("NEED PERMISSIONS")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextEditor(Modifier.height(180.dp), text = commandToShow)
|
// for debugging
|
||||||
|
// class WebRTCInterface(private val onResponse: (String) -> Unit) {
|
||||||
Row(
|
class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
||||||
Modifier
|
@JavascriptInterface
|
||||||
.fillMaxWidth()
|
fun postMessage(message: String) {
|
||||||
.padding(bottom = 6.dp),
|
Log.d(TAG, "WebRTCInterface.postMessage")
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
try {
|
||||||
) {
|
// for debugging
|
||||||
Button( onClick = {
|
// onResponse(message)
|
||||||
val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value)
|
onResponse(json.decodeFromString(message))
|
||||||
clipboard?.setPrimaryClip(clip)
|
} catch (e: Error) {
|
||||||
}) {Text("Copy")}
|
Log.e(TAG, "failed parsing WebView message: $message")
|
||||||
Button( onClick = {
|
|
||||||
println("sending: ${commandToShow.value}")
|
|
||||||
wv.evaluateJavascript(commandToShow.value, null)
|
|
||||||
commandToShow.value = ""
|
|
||||||
}) {Text("Send")}
|
|
||||||
Button( onClick = {
|
|
||||||
commandToShow.value = ""
|
|
||||||
}) {Text("Clear")}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,3 +422,45 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa
|
|||||||
return assetLoader.shouldInterceptRequest(request.url)
|
return assetLoader.shouldInterceptRequest(request.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewActiveCallOverlayVideo() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ActiveCallOverlayLayout(
|
||||||
|
call = Call(
|
||||||
|
contact = Contact.sampleData,
|
||||||
|
callState = CallState.Negotiated,
|
||||||
|
localMedia = CallMediaType.Video,
|
||||||
|
peerMedia = CallMediaType.Video,
|
||||||
|
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||||
|
),
|
||||||
|
dismiss = {},
|
||||||
|
toggleAudio = {},
|
||||||
|
toggleVideo = {},
|
||||||
|
toggleSound = {},
|
||||||
|
flipCamera = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewActiveCallOverlayAudio() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ActiveCallOverlayLayout(
|
||||||
|
call = Call(
|
||||||
|
contact = Contact.sampleData,
|
||||||
|
callState = CallState.Negotiated,
|
||||||
|
localMedia = CallMediaType.Audio,
|
||||||
|
peerMedia = CallMediaType.Audio,
|
||||||
|
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||||
|
),
|
||||||
|
dismiss = {},
|
||||||
|
toggleAudio = {},
|
||||||
|
toggleVideo = {},
|
||||||
|
toggleSound = {},
|
||||||
|
flipCamera = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
|
import chat.simplex.app.views.onboarding.SimpleXLogo
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
class IncomingCallActivity: ComponentActivity() {
|
||||||
|
private val vm by viewModels<SimplexViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val activity = this
|
||||||
|
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
||||||
|
unlockForIncomingCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
lockAfterIncomingCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unlockForIncomingCall() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
} else {
|
||||||
|
window.addFlags(activityFlags)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getKeyguardManager(this).requestDismissKeyguard(this, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lockAfterIncomingCall() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(false)
|
||||||
|
setTurnScreenOn(false)
|
||||||
|
} else {
|
||||||
|
window.clearFlags(activityFlags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getKeyguardManager(context: Context): KeyguardManager =
|
||||||
|
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||||
|
val switchingCall = m.switchingCall.value
|
||||||
|
val invitation = m.activeCallInvitation.value
|
||||||
|
val call = m.activeCall.value
|
||||||
|
val showCallView = m.showCallView.value
|
||||||
|
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||||
|
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||||
|
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||||
|
activity.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SimpleXTheme {
|
||||||
|
Surface(
|
||||||
|
Modifier
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.fillMaxSize()) {
|
||||||
|
if (showCallView) {
|
||||||
|
Box {
|
||||||
|
ActiveCallView(m)
|
||||||
|
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||||
|
}
|
||||||
|
} else if (invitation != null) {
|
||||||
|
IncomingCallLockScreenAlert(invitation, m, activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
||||||
|
val cm = chatModel.callManager
|
||||||
|
val cxt = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||||
|
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
||||||
|
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||||
|
IncomingCallLockScreenAlertLayout(
|
||||||
|
invitation,
|
||||||
|
callOnLockScreen,
|
||||||
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
|
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||||
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||||
|
openApp = {
|
||||||
|
SoundPlayer.shared.stop()
|
||||||
|
var intent = Intent(activity, MainActivity::class.java)
|
||||||
|
.setAction(OpenChatAction)
|
||||||
|
.putExtra("chatId", invitation.contact.id)
|
||||||
|
activity.startActivity(intent)
|
||||||
|
activity.finish()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallLockScreenAlertLayout(
|
||||||
|
invitation: RcvCallInvitation,
|
||||||
|
callOnLockScreen: CallOnLockScreen?,
|
||||||
|
rejectCall: () -> Unit,
|
||||||
|
ignoreCall: () -> Unit,
|
||||||
|
acceptCall: () -> Unit,
|
||||||
|
openApp: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(30.dp)
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
IncomingCallInfo(invitation)
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||||
|
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||||
|
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
Row {
|
||||||
|
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||||
|
Spacer(Modifier.size(48.dp))
|
||||||
|
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||||
|
Spacer(Modifier.size(48.dp))
|
||||||
|
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||||
|
}
|
||||||
|
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||||
|
SimpleXLogo()
|
||||||
|
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||||
|
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = Color.Transparent
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 50.dp)
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
IconButton(action) {
|
||||||
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewIncomingCallLockScreenAlert() {
|
||||||
|
SimpleXTheme(true) {
|
||||||
|
Surface(
|
||||||
|
Modifier
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.fillMaxSize()) {
|
||||||
|
IncomingCallLockScreenAlertLayout(
|
||||||
|
invitation = RcvCallInvitation(
|
||||||
|
contact = Contact.sampleData,
|
||||||
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
|
sharedKey = null,
|
||||||
|
callTs = Clock.System.now()
|
||||||
|
),
|
||||||
|
callOnLockScreen = null,
|
||||||
|
rejectCall = {},
|
||||||
|
ignoreCall = {},
|
||||||
|
acceptCall = {},
|
||||||
|
openApp = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.model.Contact
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
|
val cm = chatModel.callManager
|
||||||
|
val cxt = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
|
||||||
|
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||||
|
IncomingCallAlertLayout(
|
||||||
|
invitation,
|
||||||
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
|
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||||
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallAlertLayout(
|
||||||
|
invitation: RcvCallInvitation,
|
||||||
|
rejectCall: () -> Unit,
|
||||||
|
ignoreCall: () -> Unit,
|
||||||
|
acceptCall: () -> Unit
|
||||||
|
) {
|
||||||
|
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||||
|
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||||
|
IncomingCallInfo(invitation)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||||
|
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||||
|
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallInfo(invitation: RcvCallInvitation) {
|
||||||
|
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||||
|
Row {
|
||||||
|
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
||||||
|
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(invitation.callTypeText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = Color.Transparent
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = action)
|
||||||
|
.defaultMinSize(minWidth = 50.dp)
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||||
|
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewIncomingCallAlertLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
IncomingCallAlertLayout(
|
||||||
|
invitation = RcvCallInvitation(
|
||||||
|
contact = Contact.sampleData,
|
||||||
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
|
sharedKey = null,
|
||||||
|
callTs = Clock.System.now()
|
||||||
|
),
|
||||||
|
rejectCall = {},
|
||||||
|
ignoreCall = {},
|
||||||
|
acceptCall = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.helpers.withScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
class SoundPlayer {
|
||||||
|
var player: MediaPlayer? = null
|
||||||
|
var playing = false
|
||||||
|
|
||||||
|
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||||
|
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
|
||||||
|
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||||
|
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||||
|
playing = true
|
||||||
|
withScope(scope) {
|
||||||
|
while (playing) {
|
||||||
|
if (sound) player?.start()
|
||||||
|
vibrator?.vibrate(effect)
|
||||||
|
delay(3500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
playing = false
|
||||||
|
player?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val shared = SoundPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.Contact
|
import chat.simplex.app.model.Contact
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
class Call(
|
data class Call(
|
||||||
val contact: Contact,
|
val contact: Contact,
|
||||||
val callState: CallState,
|
val callState: CallState,
|
||||||
val localMedia: CallMediaType,
|
val localMedia: CallMediaType,
|
||||||
@@ -14,77 +17,134 @@ class Call(
|
|||||||
val peerMedia: CallMediaType? = null,
|
val peerMedia: CallMediaType? = null,
|
||||||
val sharedKey: String? = null,
|
val sharedKey: String? = null,
|
||||||
val audioEnabled: Boolean = true,
|
val audioEnabled: Boolean = true,
|
||||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video
|
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||||
|
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||||
|
var localCamera: VideoCamera = VideoCamera.User,
|
||||||
|
val connectionInfo: ConnectionInfo? = null
|
||||||
) {
|
) {
|
||||||
val encrypted: Boolean get() = (localCapabilities?.encryption ?: false) && sharedKey != null
|
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||||
|
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||||
|
|
||||||
|
val encryptionStatus: String @Composable get() = when(callState) {
|
||||||
|
CallState.WaitCapabilities -> ""
|
||||||
|
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
|
||||||
|
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||||
|
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class CallState {
|
enum class CallState {
|
||||||
WaitCapabilities,
|
WaitCapabilities,
|
||||||
InvitationSent,
|
InvitationSent,
|
||||||
InvitationReceived,
|
InvitationAccepted,
|
||||||
OfferSent,
|
OfferSent,
|
||||||
OfferReceived,
|
OfferReceived,
|
||||||
|
AnswerReceived,
|
||||||
Negotiated,
|
Negotiated,
|
||||||
Connected;
|
Connected,
|
||||||
|
Ended;
|
||||||
|
|
||||||
val text: String get() = when(this) {
|
val text: String @Composable get() = when(this) {
|
||||||
WaitCapabilities -> generalGetString(R.string.callstate_starting)
|
WaitCapabilities -> stringResource(R.string.callstate_starting)
|
||||||
InvitationSent -> generalGetString(R.string.callstate_waiting_for_answer)
|
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
|
||||||
InvitationReceived -> generalGetString(R.string.callstate_starting)
|
InvitationAccepted -> stringResource(R.string.callstate_starting)
|
||||||
OfferSent -> generalGetString(R.string.callstate_waiting_for_confirmation)
|
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
|
||||||
OfferReceived -> generalGetString(R.string.callstate_received_answer)
|
OfferReceived -> stringResource(R.string.callstate_received_answer)
|
||||||
Negotiated -> generalGetString(R.string.callstate_connecting)
|
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
|
||||||
Connected -> generalGetString(R.string.callstate_connected)
|
Negotiated -> stringResource(R.string.callstate_connecting)
|
||||||
|
Connected -> stringResource(R.string.callstate_connected)
|
||||||
|
Ended -> stringResource(R.string.callstate_ended)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||||
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?)
|
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class WCallCommand {
|
sealed class WCallCommand {
|
||||||
@Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
|
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||||
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand()
|
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List<String>, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
|
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List<String>): WCallCommand()
|
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: List<String>): WCallCommand()
|
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||||
@Serializable @SerialName("end") class End(): WCallCommand()
|
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
|
||||||
|
@Serializable @SerialName("end") object End: WCallCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class WCallResponse {
|
sealed class WCallResponse {
|
||||||
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List<String>): WCallResponse()
|
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||||
// TODO remove accept, it is needed for debugging
|
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||||
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List<String>, val media: CallMediaType, val aesKey: String? = null): WCallResponse()
|
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
|
||||||
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List<String>): WCallResponse()
|
|
||||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: List<String>): WCallResponse()
|
|
||||||
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
||||||
@Serializable @SerialName("ended") class Ended(): WCallResponse()
|
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||||
@Serializable @SerialName("ok") class Ok(): WCallResponse()
|
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||||
|
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||||
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
||||||
@Serializable class Invalid(val str: String): WCallResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List<String>)
|
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List<String>)
|
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||||
@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?)
|
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
|
||||||
|
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||||
|
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||||
|
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||||
|
})
|
||||||
|
val callTitle: String get() = generalGetString(when(callType.media) {
|
||||||
|
CallMediaType.Video -> R.string.incoming_video_call
|
||||||
|
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||||
|
})
|
||||||
|
}
|
||||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||||
|
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||||
|
val text: String @Composable get() = when {
|
||||||
|
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||||
|
stringResource(R.string.call_connection_peer_to_peer)
|
||||||
|
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
||||||
|
stringResource(R.string.call_connection_via_relay)
|
||||||
|
else ->
|
||||||
|
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||||
|
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||||
|
@Serializable class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||||
|
|
||||||
enum class WebRTCCallStatus(val status: String) {
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
|
||||||
Connected("connected"),
|
@Serializable
|
||||||
Disconnected("disconnected"),
|
enum class RTCIceCandidateType(val value: String) {
|
||||||
Failed("failed")
|
@SerialName("host") Host("host"),
|
||||||
|
@SerialName("srflx") ServerReflexive("srflx"),
|
||||||
|
@SerialName("prflx") PeerReflexive("prflx"),
|
||||||
|
@SerialName("relay") Relay("relay")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class CallMediaType(val media: String) {
|
enum class WebRTCCallStatus(val value: String) {
|
||||||
Video("video"),
|
@SerialName("connected") Connected("connected"),
|
||||||
Audio("audio")
|
@SerialName("connecting") Connecting("connecting"),
|
||||||
|
@SerialName("disconnected") Disconnected("disconnected"),
|
||||||
|
@SerialName("failed") Failed("failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class CallMediaType {
|
||||||
|
@SerialName("video") Video,
|
||||||
|
@SerialName("audio") Audio
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class VideoCamera {
|
||||||
|
@SerialName("user") User,
|
||||||
|
@SerialName("environment") Environment;
|
||||||
|
val flipped: VideoCamera get() = if (this == User) Environment else User
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.Circle
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -27,36 +26,64 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
ChatInfoLayout(chat,
|
ChatInfoLayout(
|
||||||
|
chat,
|
||||||
close = close,
|
close = close,
|
||||||
deleteContact = {
|
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||||
AlertManager.shared.showAlertMsg(
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
|
||||||
title = generalGetString(R.string.delete_contact__question),
|
|
||||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
|
||||||
onConfirm = {
|
|
||||||
val cInfo = chat.chatInfo
|
|
||||||
withApi {
|
|
||||||
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
|
|
||||||
if (r) {
|
|
||||||
chatModel.removeChat(cInfo.id)
|
|
||||||
chatModel.chatId.value = null
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.delete_contact__question),
|
||||||
|
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (r) {
|
||||||
|
chatModel.removeChat(chatInfo.id)
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.clear_chat_question),
|
||||||
|
text = generalGetString(R.string.clear_chat_warning),
|
||||||
|
confirmText = generalGetString(R.string.clear_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (updatedChatInfo != null) {
|
||||||
|
chatModel.clearChat(updatedChatInfo)
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
|
fun ChatInfoLayout(
|
||||||
Column(Modifier
|
chat: Chat,
|
||||||
.fillMaxSize()
|
close: () -> Unit,
|
||||||
.background(MaterialTheme.colors.background)
|
deleteContact: () -> Unit,
|
||||||
.padding(horizontal = 8.dp),
|
clearChat: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
CloseSheetBar(close)
|
CloseSheetBar(close)
|
||||||
@@ -66,7 +93,9 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
|
modifier = Modifier
|
||||||
|
.padding(top = 32.dp)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||||
@@ -90,13 +119,27 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
|
modifier = Modifier
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1F))
|
Spacer(Modifier.weight(1F))
|
||||||
|
|
||||||
Box(Modifier.padding(48.dp)) {
|
Box(Modifier.padding(4.dp)) {
|
||||||
|
SimpleButton(
|
||||||
|
stringResource(R.string.clear_chat_button),
|
||||||
|
icon = Icons.Outlined.Restore,
|
||||||
|
color = WarningOrange,
|
||||||
|
click = clearChat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.button_delete_contact),
|
stringResource(R.string.button_delete_contact),
|
||||||
icon = Icons.Outlined.Delete,
|
icon = Icons.Outlined.Delete,
|
||||||
@@ -104,22 +147,35 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
|
|||||||
click = deleteContact
|
click = deleteContact
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (cInfo is ChatInfo.Group) {
|
||||||
|
Spacer(Modifier.weight(1F))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
SimpleButton(
|
||||||
|
stringResource(R.string.clear_chat_button),
|
||||||
|
icon = Icons.Outlined.Restore,
|
||||||
|
color = WarningOrange,
|
||||||
|
click = clearChat
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(chat: Chat) {
|
fun ServerImage(chat: Chat) {
|
||||||
val status = chat.serverInfo.networkStatus
|
when (chat.serverInfo.networkStatus) {
|
||||||
when {
|
is Chat.NetworkStatus.Connected ->
|
||||||
status is Chat.NetworkStatus.Connected ->
|
|
||||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||||
status is Chat.NetworkStatus.Disconnected ->
|
is Chat.NetworkStatus.Disconnected ->
|
||||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||||
status is Chat.NetworkStatus.Error ->
|
is Chat.NetworkStatus.Error ->
|
||||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||||
else ->
|
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||||
Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +189,7 @@ fun PreviewChatInfoLayout() {
|
|||||||
chatItems = arrayListOf(),
|
chatItems = arrayListOf(),
|
||||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||||
),
|
),
|
||||||
close = {}, deleteContact = {}
|
close = {}, deleteContact = {}, clearChat = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
@@ -12,7 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.ArrowBackIos
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.mapSaver
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -20,6 +19,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -28,10 +28,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp.Companion.context
|
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.call.*
|
||||||
import chat.simplex.app.views.chat.item.ChatItemView
|
import chat.simplex.app.views.chat.item.ChatItemView
|
||||||
import chat.simplex.app.views.chatlist.openChat
|
import chat.simplex.app.views.chatlist.openChat
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
@@ -44,11 +44,11 @@ import kotlinx.datetime.Clock
|
|||||||
fun ChatView(chatModel: ChatModel) {
|
fun ChatView(chatModel: ChatModel) {
|
||||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||||
val user = chatModel.currentUser.value
|
val user = chatModel.currentUser.value
|
||||||
val composeState = remember { mutableStateOf(ComposeState()) }
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
|
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||||
|
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
|
||||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
|
||||||
|
|
||||||
if (chat == null || user == null) {
|
if (chat == null || user == null) {
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
@@ -57,10 +57,10 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
// TODO a more advanced version would mark as read only if in view
|
// TODO a more advanced version would mark as read only if in view
|
||||||
LaunchedEffect(chat.chatItems) {
|
LaunchedEffect(chat.chatItems) {
|
||||||
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
||||||
delay(1000L)
|
delay(750L)
|
||||||
if (chat.chatItems.count() > 0) {
|
if (chat.chatItems.isNotEmpty()) {
|
||||||
chatModel.markChatItemsRead(chat.chatInfo)
|
chatModel.markChatItemsRead(chat.chatInfo)
|
||||||
chatModel.controller.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
withApi {
|
withApi {
|
||||||
chatModel.controller.apiChatRead(
|
chatModel.controller.apiChatRead(
|
||||||
chat.chatInfo.chatType,
|
chat.chatInfo.chatType,
|
||||||
@@ -76,25 +76,22 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
composeState,
|
composeState,
|
||||||
composeView = {
|
composeView = {
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chatModel,
|
chatModel, chat, composeState, attachmentOption,
|
||||||
chat,
|
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
|
||||||
composeState,
|
)
|
||||||
chosenImage,
|
|
||||||
chosenFile,
|
|
||||||
showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
|
|
||||||
},
|
},
|
||||||
chosenImage,
|
attachmentOption,
|
||||||
chosenFile,
|
|
||||||
scope,
|
scope,
|
||||||
attachmentBottomSheetState,
|
attachmentBottomSheetState,
|
||||||
chatModel.chatItems,
|
chatModel.chatItems,
|
||||||
|
useLinkPreviews = useLinkPreviews,
|
||||||
back = { chatModel.chatId.value = null },
|
back = { chatModel.chatId.value = null },
|
||||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||||
openDirectChat = { contactId ->
|
openDirectChat = { contactId ->
|
||||||
val c = chatModel.chats.firstOrNull {
|
val c = chatModel.chats.firstOrNull {
|
||||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||||
}
|
}
|
||||||
if (c != null) withApi { openChat(chatModel, c.chatInfo) }
|
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
|
||||||
},
|
},
|
||||||
deleteMessage = { itemId, mode ->
|
deleteMessage = { itemId, mode ->
|
||||||
withApi {
|
withApi {
|
||||||
@@ -109,13 +106,22 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveFile = { fileId ->
|
receiveFile = { fileId ->
|
||||||
withApi {
|
withApi { chatModel.controller.receiveFile(fileId) }
|
||||||
val chatItem = chatModel.controller.apiReceiveFile(fileId)
|
},
|
||||||
if (chatItem != null) {
|
startCall = { media ->
|
||||||
val cInfo = chatItem.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
val cItem = chatItem.chatItem
|
if (cInfo is ChatInfo.Direct) {
|
||||||
chatModel.upsertChatItem(cInfo, cItem)
|
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
|
||||||
}
|
chatModel.showCallView.value = true
|
||||||
|
chatModel.callCommand.value = WCallCommand.Capabilities
|
||||||
|
}
|
||||||
|
},
|
||||||
|
acceptCall = { contact ->
|
||||||
|
val invitation = chatModel.callInvitations.remove(contact.id)
|
||||||
|
if (invitation == null) {
|
||||||
|
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||||
|
} else {
|
||||||
|
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -128,28 +134,19 @@ fun ChatLayout(
|
|||||||
chat: Chat,
|
chat: Chat,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
composeView: (@Composable () -> Unit),
|
composeView: (@Composable () -> Unit),
|
||||||
chosenImage: MutableState<Bitmap?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
chosenFile: MutableState<Uri?>,
|
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
attachmentBottomSheetState: ModalBottomSheetState,
|
attachmentBottomSheetState: ModalBottomSheetState,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
|
useLinkPreviews: Boolean,
|
||||||
back: () -> Unit,
|
back: () -> Unit,
|
||||||
info: () -> Unit,
|
info: () -> Unit,
|
||||||
openDirectChat: (Long) -> Unit,
|
openDirectChat: (Long) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit
|
receiveFile: (Long) -> Unit,
|
||||||
|
startCall: (CallMediaType) -> Unit,
|
||||||
|
acceptCall: (Contact) -> Unit
|
||||||
) {
|
) {
|
||||||
fun onImageChange(bitmap: Bitmap) {
|
|
||||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
|
||||||
}
|
|
||||||
fun onFileChange(uri: Uri) {
|
|
||||||
val fileName = getFileName(context, uri)
|
|
||||||
if (fileName != null) {
|
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -160,25 +157,21 @@ fun ChatLayout(
|
|||||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||||
modifier = Modifier.navigationBarsWithImePadding(),
|
modifier = Modifier.navigationBarsWithImePadding(),
|
||||||
sheetContent = {
|
sheetContent = {
|
||||||
GetImageBottomSheet(
|
ChooseAttachmentView(
|
||||||
chosenImage,
|
attachmentOption,
|
||||||
::onImageChange,
|
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||||
chosenFile,
|
)
|
||||||
::onFileChange,
|
|
||||||
hideBottomSheet = {
|
|
||||||
scope.launch { attachmentBottomSheetState.hide() }
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
sheetState = attachmentBottomSheetState,
|
sheetState = attachmentBottomSheetState,
|
||||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { ChatInfoToolbar(chat, back, info) },
|
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
|
||||||
bottomBar = composeView,
|
bottomBar = composeView,
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding()
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
Box(Modifier.padding(contentPadding)) {
|
Box(Modifier.padding(contentPadding)) {
|
||||||
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile)
|
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,33 +180,43 @@ fun ChatLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
|
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||||
|
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||||
|
IconButton(onClick, modifier = modifier) {
|
||||||
|
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
Column {
|
Column {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp)
|
.height(52.dp)
|
||||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 4.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = back) {
|
val cInfo = chat.chatInfo
|
||||||
Icon(
|
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||||
Icons.Outlined.ArrowBackIos,
|
if (cInfo is ChatInfo.Direct) {
|
||||||
stringResource(R.string.back),
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||||
tint = MaterialTheme.colors.primary,
|
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
modifier = Modifier.padding(10.dp)
|
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||||
)
|
startCall(CallMediaType.Audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||||
|
startCall(CallMediaType.Video)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 68.dp)
|
.padding(horizontal = 80.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = info),
|
.clickable(onClick = info),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
val cInfo = chat.chatInfo
|
|
||||||
ChatInfoImage(cInfo, size = 40.dp)
|
ChatInfoImage(cInfo, size = 40.dp)
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(start = 8.dp),
|
Modifier.padding(start = 8.dp),
|
||||||
@@ -254,9 +257,11 @@ fun ChatItemsList(
|
|||||||
chat: Chat,
|
chat: Chat,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
|
useLinkPreviews: Boolean,
|
||||||
openDirectChat: (Long) -> Unit,
|
openDirectChat: (Long) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit
|
receiveFile: (Long) -> Unit,
|
||||||
|
acceptCall: (Contact) -> Unit
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
|
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
|
||||||
val keyboardState by getKeyboardState()
|
val keyboardState by getKeyboardState()
|
||||||
@@ -294,11 +299,11 @@ fun ChatItemsList(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(42.dp))
|
Spacer(Modifier.size(42.dp))
|
||||||
}
|
}
|
||||||
ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile)
|
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||||
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
|
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // direct message
|
} else { // direct message
|
||||||
@@ -309,7 +314,7 @@ fun ChatItemsList(
|
|||||||
end = if (sent) 12.dp else 76.dp,
|
end = if (sent) 12.dp else 76.dp,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
|
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,18 +372,20 @@ fun PreviewChatLayout() {
|
|||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
chosenFile = remember { mutableStateOf(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
chosenImage = remember { mutableStateOf(null) },
|
|
||||||
scope = rememberCoroutineScope(),
|
scope = rememberCoroutineScope(),
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
|
useLinkPreviews = true,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
openDirectChat = {},
|
openDirectChat = {},
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {}
|
receiveFile = {},
|
||||||
|
startCall = {},
|
||||||
|
acceptCall = { _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,18 +419,20 @@ fun PreviewGroupChatLayout() {
|
|||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
chosenImage = remember { mutableStateOf(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
chosenFile = remember { mutableStateOf(null) },
|
|
||||||
scope = rememberCoroutineScope(),
|
scope = rememberCoroutineScope(),
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
|
useLinkPreviews = true,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
openDirectChat = {},
|
openDirectChat = {},
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {}
|
receiveFile = {},
|
||||||
|
startCall = {},
|
||||||
|
acceptCall = { _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ package chat.simplex.app.views.chat
|
|||||||
|
|
||||||
import ComposeFileView
|
import ComposeFileView
|
||||||
import ComposeImageView
|
import ComposeImageView
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -21,16 +33,20 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
object NoPreview: ComposePreview()
|
object NoPreview: ComposePreview()
|
||||||
class CLinkPreview(val linkPreview: LinkPreview): ComposePreview()
|
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
class ImagePreview(val image: String): ComposePreview()
|
class ImagePreview(val image: String): ComposePreview()
|
||||||
class FilePreview(val fileName: String): ComposePreview()
|
class FilePreview(val fileName: String): ComposePreview()
|
||||||
}
|
}
|
||||||
@@ -45,12 +61,14 @@ data class ComposeState(
|
|||||||
val message: String = "",
|
val message: String = "",
|
||||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||||
val inProgress: Boolean = false
|
val inProgress: Boolean = false,
|
||||||
|
val useLinkPreviews: Boolean
|
||||||
) {
|
) {
|
||||||
constructor(editingItem: ChatItem): this(
|
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
|
||||||
editingItem.content.text,
|
editingItem.content.text,
|
||||||
chatItemPreview(editingItem),
|
chatItemPreview(editingItem),
|
||||||
ComposeContextItem.EditingItem(editingItem)
|
ComposeContextItem.EditingItem(editingItem),
|
||||||
|
useLinkPreviews = useLinkPreviews
|
||||||
)
|
)
|
||||||
|
|
||||||
val editing: Boolean
|
val editing: Boolean
|
||||||
@@ -73,7 +91,7 @@ data class ComposeState(
|
|||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.ImagePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> true
|
else -> useLinkPreviews
|
||||||
}
|
}
|
||||||
val linkPreview: LinkPreview?
|
val linkPreview: LinkPreview?
|
||||||
get() =
|
get() =
|
||||||
@@ -101,17 +119,118 @@ fun ComposeView(
|
|||||||
chatModel: ChatModel,
|
chatModel: ChatModel,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
chosenImage: MutableState<Bitmap?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
chosenFile: MutableState<Uri?>,
|
showChooseAttachment: () -> Unit
|
||||||
showAttachmentBottomSheet: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val linkUrl = remember { mutableStateOf<String?>(null) }
|
val linkUrl = remember { mutableStateOf<String?>(null) }
|
||||||
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
|
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||||
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
|
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||||
val cancelledLinks = remember { mutableSetOf<String>() }
|
val cancelledLinks = remember { mutableSetOf<String>() }
|
||||||
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
|
// attachments
|
||||||
|
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
||||||
|
val photoUri = remember { mutableStateOf<Uri?>(null) }
|
||||||
|
val photoTmpFile = remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
|
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||||
|
@CallSuper
|
||||||
|
override fun createIntent(context: Context, input: Void?): Intent {
|
||||||
|
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
|
||||||
|
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
|
||||||
|
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||||
|
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: Void?
|
||||||
|
): SynchronousResult<Bitmap?>? = null
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||||
|
val photoUriVal = photoUri.value
|
||||||
|
val photoTmpFileVal = photoTmpFile.value
|
||||||
|
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
|
||||||
|
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
|
||||||
|
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||||
|
photoTmpFileVal.delete()
|
||||||
|
bitmap
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||||
|
photoTmpFile.value?.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
|
||||||
|
if (bitmap != null) {
|
||||||
|
chosenImage.value = bitmap
|
||||||
|
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
|
if (isGranted) {
|
||||||
|
cameraLauncher.launch(null)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||||
|
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||||
|
chosenImage.value = bitmap
|
||||||
|
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
val fileSize = getFileSize(context, uri)
|
||||||
|
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||||
|
val fileName = getFileName(SimplexApp.context, uri)
|
||||||
|
if (fileName != null) {
|
||||||
|
chosenFile.value = uri
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.large_file),
|
||||||
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(attachmentOption.value) {
|
||||||
|
when (attachmentOption.value) {
|
||||||
|
AttachmentOption.TakePhoto -> {
|
||||||
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
|
cameraLauncher.launch(null)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.PickImage -> {
|
||||||
|
galleryLauncher.launch("image/*")
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.PickFile -> {
|
||||||
|
filesLauncher.launch("*/*")
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isSimplexLink(link: String): Boolean =
|
fun isSimplexLink(link: String): Boolean =
|
||||||
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
||||||
@@ -124,6 +243,7 @@ fun ComposeView(
|
|||||||
|
|
||||||
fun loadLinkPreview(url: String, wait: Long? = null) {
|
fun loadLinkPreview(url: String, wait: Long? = null) {
|
||||||
if (pendingLinkUrl.value == url) {
|
if (pendingLinkUrl.value == url) {
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
|
||||||
withApi {
|
withApi {
|
||||||
if (wait != null) delay(wait)
|
if (wait != null) delay(wait)
|
||||||
val lp = getLinkPreview(url)
|
val lp = getLinkPreview(url)
|
||||||
@@ -162,7 +282,7 @@ fun ComposeView(
|
|||||||
is ComposePreview.CLinkPreview -> {
|
is ComposePreview.CLinkPreview -> {
|
||||||
val url = parseMessage(cs.message)
|
val url = parseMessage(cs.message)
|
||||||
val lp = composePreview.linkPreview
|
val lp = composePreview.linkPreview
|
||||||
if (url == lp.uri) {
|
if (lp != null && url == lp.uri) {
|
||||||
MsgContent.MCLink(cs.message, preview = lp)
|
MsgContent.MCLink(cs.message, preview = lp)
|
||||||
} else {
|
} else {
|
||||||
MsgContent.MCText(cs.message)
|
MsgContent.MCText(cs.message)
|
||||||
@@ -184,7 +304,8 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearState() {
|
fun clearState() {
|
||||||
composeState.value = ComposeState()
|
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
|
textStyle.value = smallFont
|
||||||
chosenImage.value = null
|
chosenImage.value = null
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
linkUrl.value = null
|
linkUrl.value = null
|
||||||
@@ -194,15 +315,15 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
withApi {
|
composeState.value = composeState.value.copy(inProgress = true)
|
||||||
composeState.value = composeState.value.copy(inProgress = true)
|
val cInfo = chat.chatInfo
|
||||||
val cInfo = chat.chatInfo
|
val cs = composeState.value
|
||||||
val cs = composeState.value
|
when (val contextItem = cs.contextItem) {
|
||||||
when (val contextItem = cs.contextItem) {
|
is ComposeContextItem.EditingItem -> {
|
||||||
is ComposeContextItem.EditingItem -> {
|
val ei = contextItem.chatItem
|
||||||
val ei = contextItem.chatItem
|
val oldMsgContent = ei.content.msgContent
|
||||||
val oldMsgContent = ei.content.msgContent
|
if (oldMsgContent != null) {
|
||||||
if (oldMsgContent != null) {
|
withApi {
|
||||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||||
type = cInfo.chatType,
|
type = cInfo.chatType,
|
||||||
id = cInfo.apiId,
|
id = cInfo.apiId,
|
||||||
@@ -210,39 +331,42 @@ fun ComposeView(
|
|||||||
mc = updateMsgContent(oldMsgContent)
|
mc = updateMsgContent(oldMsgContent)
|
||||||
)
|
)
|
||||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||||
|
clearState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
}
|
||||||
var mc: MsgContent? = null
|
else -> {
|
||||||
var file: String? = null
|
var mc: MsgContent? = null
|
||||||
when (val preview = cs.preview) {
|
var file: String? = null
|
||||||
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
|
when (val preview = cs.preview) {
|
||||||
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
|
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
|
||||||
is ComposePreview.ImagePreview -> {
|
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
|
||||||
val chosenImageVal = chosenImage.value
|
is ComposePreview.ImagePreview -> {
|
||||||
if (chosenImageVal != null) {
|
val chosenImageVal = chosenImage.value
|
||||||
file = saveImage(context, chosenImageVal)
|
if (chosenImageVal != null) {
|
||||||
if (file != null) {
|
file = saveImage(context, chosenImageVal)
|
||||||
mc = MsgContent.MCImage(cs.message, preview.image)
|
if (file != null) {
|
||||||
}
|
mc = MsgContent.MCImage(cs.message, preview.image)
|
||||||
}
|
|
||||||
}
|
|
||||||
is ComposePreview.FilePreview -> {
|
|
||||||
val chosenFileVal = chosenFile.value
|
|
||||||
if (chosenFileVal != null) {
|
|
||||||
file = saveFileFromUri(context, chosenFileVal)
|
|
||||||
if (file != null) {
|
|
||||||
mc = MsgContent.MCFile(cs.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val quotedItemId: Long? = when (contextItem) {
|
is ComposePreview.FilePreview -> {
|
||||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
val chosenFileVal = chosenFile.value
|
||||||
else -> null
|
if (chosenFileVal != null) {
|
||||||
|
file = saveFileFromUri(context, chosenFileVal)
|
||||||
|
if (file != null) {
|
||||||
|
mc = MsgContent.MCFile(cs.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val quotedItemId: Long? = when (contextItem) {
|
||||||
|
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
if (mc != null) {
|
if (mc != null) {
|
||||||
|
withApi {
|
||||||
val aChatItem = chatModel.controller.apiSendMessage(
|
val aChatItem = chatModel.controller.apiSendMessage(
|
||||||
type = cInfo.chatType,
|
type = cInfo.chatType,
|
||||||
id = cInfo.apiId,
|
id = cInfo.apiId,
|
||||||
@@ -251,10 +375,12 @@ fun ComposeView(
|
|||||||
mc = mc
|
mc = mc
|
||||||
)
|
)
|
||||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||||
|
clearState()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
clearState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +402,7 @@ fun ComposeView(
|
|||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
cancelledLinks.add(uri)
|
cancelledLinks.add(uri)
|
||||||
}
|
}
|
||||||
|
pendingLinkUrl.value = null
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +469,7 @@ fun ComposeView(
|
|||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
.clickable {
|
||||||
if (attachEnabled) {
|
if (attachEnabled) {
|
||||||
showAttachmentBottomSheet()
|
showChooseAttachment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
@@ -52,7 +50,10 @@ fun ContextItemView(
|
|||||||
contentDescription = stringResource(R.string.icon_descr_context),
|
contentDescription = stringResource(R.string.icon_descr_context),
|
||||||
tint = HighOrLowlight,
|
tint = HighOrLowlight,
|
||||||
)
|
)
|
||||||
ContextItemText(contextItem)
|
MarkdownText(
|
||||||
|
contextItem.text, contextItem.formattedText,
|
||||||
|
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = cancelContextItem) {
|
IconButton(onClick = cancelContextItem) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -65,20 +66,6 @@ fun ContextItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContextItemText(cxtItem: ChatItem) {
|
|
||||||
val member = cxtItem.memberDisplayName
|
|
||||||
if (member == null) {
|
|
||||||
Text(cxtItem.text, maxLines = 3)
|
|
||||||
} else {
|
|
||||||
val annotatedText = buildAnnotatedString {
|
|
||||||
withStyle(boldFont) { append(member) }
|
|
||||||
append(": ${cxtItem.text}")
|
|
||||||
}
|
|
||||||
Text(annotatedText, maxLines = 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewContextItemView() {
|
fun PreviewContextItemView() {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ fun PreviewSendMsgView() {
|
|||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
@@ -129,7 +129,7 @@ fun PreviewSendMsgView() {
|
|||||||
fun PreviewSendMsgViewEditing() {
|
fun PreviewSendMsgViewEditing() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData())
|
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||||
@@ -150,7 +150,7 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
fun PreviewSendMsgViewInProgress() {
|
fun PreviewSendMsgViewInProgress() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true)
|
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||||
|
val sent = cItem.chatDir.sent
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
|
||||||
|
when (status) {
|
||||||
|
CICallStatus.Pending -> if (sent) {
|
||||||
|
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
||||||
|
} else {
|
||||||
|
AcceptCallButton(cInfo, acceptCall)
|
||||||
|
}
|
||||||
|
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||||
|
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||||
|
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||||
|
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||||
|
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||||
|
CICallStatus.Ended -> Row {
|
||||||
|
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||||
|
Text(status.duration(duration), color = HighOrLowlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
cItem.timestampText,
|
||||||
|
color = HighOrLowlight,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||||
|
if (cInfo is ChatInfo.Direct) {
|
||||||
|
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
|
||||||
|
}
|
||||||
|
// if case let .direct(contact) = chatInfo {
|
||||||
|
// Button {
|
||||||
|
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||||
|
// m.activeCallInvitation = nil
|
||||||
|
// m.activeCall = Call(
|
||||||
|
// contact: contact,
|
||||||
|
// callState: .invitationReceived,
|
||||||
|
// localMedia: invitation.peerMedia,
|
||||||
|
// sharedKey: invitation.sharedKey
|
||||||
|
// )
|
||||||
|
// m.showCallView = true
|
||||||
|
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||||
|
// } else {
|
||||||
|
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||||
|
// }
|
||||||
|
// } label: {
|
||||||
|
// Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct CICallItemView: View {
|
||||||
|
// @EnvironmentObject var m: ChatModel
|
||||||
|
// var chatInfo: ChatInfo
|
||||||
|
// var chatItem: ChatItem
|
||||||
|
// var status: CICallStatus
|
||||||
|
// var duration: Int
|
||||||
|
//
|
||||||
|
// var body: some View {
|
||||||
|
// switch status {
|
||||||
|
// case .pending:
|
||||||
|
// if sent {
|
||||||
|
// Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
||||||
|
// } else {
|
||||||
|
// acceptCallButton()
|
||||||
|
// }
|
||||||
|
// case .missed: missedCallIcon(sent).foregroundColor(.red)
|
||||||
|
// case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
|
||||||
|
// case .accepted: connectingCallIcon()
|
||||||
|
// case .negotiated: connectingCallIcon()
|
||||||
|
// case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
|
||||||
|
// case .ended: endedCallIcon(sent)
|
||||||
|
// case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// chatItem.timestampText
|
||||||
|
// .font(.caption)
|
||||||
|
// .foregroundColor(.secondary)
|
||||||
|
// .padding(.bottom, 8)
|
||||||
|
// .padding(.horizontal, 12)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func missedCallIcon(_ sent: Bool) -> some View {
|
||||||
|
// Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func connectingCallIcon() -> some View {
|
||||||
|
// Image(systemName: "phone.connection").foregroundColor(.green)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
|
// HStack {
|
||||||
|
// Image(systemName: "phone.down")
|
||||||
|
// Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// @ViewBuilder private func acceptCallButton() -> some View {
|
||||||
|
// if case let .direct(contact) = chatInfo {
|
||||||
|
// Button {
|
||||||
|
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||||
|
// m.activeCallInvitation = nil
|
||||||
|
// m.activeCall = Call(
|
||||||
|
// contact: contact,
|
||||||
|
// callState: .invitationReceived,
|
||||||
|
// localMedia: invitation.peerMedia,
|
||||||
|
// sharedKey: invitation.sharedKey
|
||||||
|
// )
|
||||||
|
// m.showCallView = true
|
||||||
|
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||||
|
// } else {
|
||||||
|
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||||
|
// }
|
||||||
|
// } label: {
|
||||||
|
// Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -203,7 +203,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import chat.simplex.app.views.helpers.*
|
|||||||
fun CIImageView(
|
fun CIImageView(
|
||||||
image: String,
|
image: String,
|
||||||
file: CIFile?,
|
file: CIFile?,
|
||||||
showMenu: MutableState<Boolean>
|
showMenu: MutableState<Boolean>,
|
||||||
|
receiveFile: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
@Composable
|
@Composable
|
||||||
fun loadingIndicator() {
|
fun loadingIndicator() {
|
||||||
@@ -98,11 +99,21 @@ fun CIImageView(
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
imageView(base64ToBitmap(image), onClick = {
|
imageView(base64ToBitmap(image), onClick = {
|
||||||
if (file != null && file.fileStatus == CIFileStatus.RcvAccepted)
|
if (file != null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
when (file.fileStatus) {
|
||||||
generalGetString(R.string.waiting_for_image),
|
CIFileStatus.RcvInvitation ->
|
||||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
receiveFile(file.fileId)
|
||||||
)
|
CIFileStatus.RcvAccepted ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_image),
|
||||||
|
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
CIFileStatus.RcvTransfer -> {} // ?
|
||||||
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
loadingIndicator()
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ import kotlinx.datetime.Clock
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatItemView(
|
fun ChatItemView(
|
||||||
user: User,
|
user: User,
|
||||||
|
cInfo: ChatInfo,
|
||||||
cItem: ChatItem,
|
cItem: ChatItem,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
cxt: Context,
|
cxt: Context,
|
||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
|
useLinkPreviews: Boolean,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit
|
receiveFile: (Long) -> Unit,
|
||||||
|
acceptCall: (Contact) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sent = cItem.chatDir.sent
|
val sent = cItem.chatDir.sent
|
||||||
@@ -54,18 +57,12 @@ fun ChatItemView(
|
|||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||||
) {
|
) {
|
||||||
if (cItem.isMsgContent) {
|
@Composable fun ContentItem() {
|
||||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||||
EmojiItemView(cItem)
|
EmojiItemView(cItem)
|
||||||
} else {
|
} else {
|
||||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
|
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
|
||||||
}
|
}
|
||||||
} else if (cItem.isDeletedContent) {
|
|
||||||
DeletedItemView(cItem, showMember = showMember)
|
|
||||||
} else if (cItem.isCall) {
|
|
||||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
|
|
||||||
}
|
|
||||||
if (cItem.isMsgContent) {
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu.value,
|
expanded = showMenu.value,
|
||||||
onDismissRequest = { showMenu.value = false },
|
onDismissRequest = { showMenu.value = false },
|
||||||
@@ -73,7 +70,7 @@ fun ChatItemView(
|
|||||||
) {
|
) {
|
||||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||||
if (composeState.value.editing) {
|
if (composeState.value.editing) {
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem))
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
} else {
|
} else {
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
}
|
}
|
||||||
@@ -100,9 +97,9 @@ fun ChatItemView(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cItem.chatDir.sent && cItem.meta.editable) {
|
if (cItem.meta.editable) {
|
||||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -117,6 +114,39 @@ fun ChatItemView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable fun DeletedItem() {
|
||||||
|
DeletedItemView(cItem, showMember = showMember)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu.value,
|
||||||
|
onDismissRequest = { showMenu.value = false },
|
||||||
|
Modifier.width(220.dp)
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable fun CallItem(status: CICallStatus, duration: Int) {
|
||||||
|
CICallItemView(cInfo, cItem, status, duration, acceptCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val c = cItem.content) {
|
||||||
|
is CIContent.SndMsgContent -> ContentItem()
|
||||||
|
is CIContent.RcvMsgContent -> ContentItem()
|
||||||
|
is CIContent.SndDeleted -> DeletedItem()
|
||||||
|
is CIContent.RcvDeleted -> DeletedItem()
|
||||||
|
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||||
|
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||||
|
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +200,16 @@ fun PreviewChatItemView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
User.sampleData,
|
||||||
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
),
|
),
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
useLinkPreviews = true,
|
||||||
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
cxt = LocalContext.current,
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {}
|
receiveFile = {},
|
||||||
|
acceptCall = { _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,11 +220,14 @@ fun PreviewChatItemViewDeletedContent() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
User.sampleData,
|
||||||
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getDeletedContentSampleData(),
|
ChatItem.getDeletedContentSampleData(),
|
||||||
composeState = remember { mutableStateOf(ComposeState()) },
|
useLinkPreviews = true,
|
||||||
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
cxt = LocalContext.current,
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {}
|
receiveFile = {},
|
||||||
|
acceptCall = { _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ fun FramedItemView(
|
|||||||
contentAlignment = Alignment.TopStart
|
contentAlignment = Alignment.TopStart
|
||||||
) {
|
) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3,
|
qi.text, qi.formattedText, sender = qi.sender(user), senderBold = true, maxLines = 3,
|
||||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ fun FramedItemView(
|
|||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
when (val mc = ci.content.msgContent) {
|
when (val mc = ci.content.msgContent) {
|
||||||
is MsgContent.MCImage -> {
|
is MsgContent.MCImage -> {
|
||||||
CIImageView(image = mc.image, file = ci.file, showMenu)
|
CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile)
|
||||||
if (mc.text == "") {
|
if (mc.text == "") {
|
||||||
metaColor = Color.White
|
metaColor = Color.White
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+29
-4
@@ -1,12 +1,13 @@
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
@@ -20,11 +21,35 @@ fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
|
|||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.clickable(onClick = close)
|
.clickable(onClick = close)
|
||||||
) {
|
) {
|
||||||
|
var scale by remember { mutableStateOf(1f) }
|
||||||
|
var translationX by remember { mutableStateOf(0f) }
|
||||||
|
var translationY by remember { mutableStateOf(0f) }
|
||||||
Image(
|
Image(
|
||||||
imageBitmap.asImageBitmap(),
|
imageBitmap.asImageBitmap(),
|
||||||
contentDescription = stringResource(R.string.image_descr),
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = translationX,
|
||||||
|
translationY = translationY,
|
||||||
|
)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures(
|
||||||
|
onGesture = { _, pan, gestureZoom, _ ->
|
||||||
|
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||||
|
if (scale > 1) {
|
||||||
|
translationX += pan.x * scale
|
||||||
|
translationY += pan.y * scale
|
||||||
|
} else {
|
||||||
|
translationX = 0f
|
||||||
|
translationY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||||
|
Surface(
|
||||||
|
Modifier.clickable(onClick = {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||||
|
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = ReceivedColorLight,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
CIMetaView(ci)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun IntegrityErrorItemViewView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
IntegrityErrorItemView(
|
||||||
|
ChatItem.getDeletedContentSampleData()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+245
-114
@@ -1,11 +1,12 @@
|
|||||||
package chat.simplex.app.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -16,52 +17,176 @@ import androidx.compose.ui.unit.dp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.ui.theme.WarningOrange
|
||||||
|
import chat.simplex.app.views.chat.clearChatDialog
|
||||||
|
import chat.simplex.app.views.chat.deleteContactDialog
|
||||||
import chat.simplex.app.views.chat.item.ItemAction
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||||
ChatListNavLinkLayout(
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
chat = chat,
|
var showMarkRead by remember { mutableStateOf(false) }
|
||||||
click = {
|
val stopped = chatModel.chatRunning.value == false
|
||||||
when (chat.chatInfo) {
|
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
|
||||||
is ChatInfo.ContactRequest -> contactRequestAlertDialog(chat.chatInfo, chatModel)
|
showMenu.value = false
|
||||||
is ChatInfo.ContactConnection -> contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel)
|
delay(500L)
|
||||||
else ->
|
showMarkRead = chat.chatStats.unreadCount > 0
|
||||||
if (chat.chatInfo.ready) {
|
}
|
||||||
withApi { openChat(chatModel, chat.chatInfo) }
|
when (chat.chatInfo) {
|
||||||
} else {
|
is ChatInfo.Direct ->
|
||||||
pendingContactAlertDialog(chat.chatInfo, chatModel)
|
ChatListNavLinkLayout(
|
||||||
}
|
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||||
}
|
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||||
},
|
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||||
deleteContact = {
|
showMenu,
|
||||||
AlertManager.shared.showAlertMsg(
|
stopped
|
||||||
title = generalGetString(R.string.delete_contact__question),
|
|
||||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
|
||||||
onConfirm = {
|
|
||||||
val cInfo = chat.chatInfo
|
|
||||||
withApi {
|
|
||||||
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
|
|
||||||
if (r) {
|
|
||||||
chatModel.removeChat(cInfo.id)
|
|
||||||
chatModel.chatId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
is ChatInfo.Group ->
|
||||||
)
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||||
|
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||||
|
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
is ChatInfo.ContactRequest ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
|
||||||
|
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||||
|
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
is ChatInfo.ContactConnection ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
|
||||||
|
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
|
||||||
|
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
|
fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||||
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
|
if (chatInfo.ready) {
|
||||||
|
withApi { openChat(chatInfo, chatModel) }
|
||||||
|
} else {
|
||||||
|
pendingContactAlertDialog(chatInfo, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||||
|
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
chatModel.chatItems.clear()
|
chatModel.chatItems.clear()
|
||||||
chatModel.chatItems.addAll(chat.chatItems)
|
chatModel.chatItems.addAll(chat.chatItems)
|
||||||
chatModel.chatId.value = cInfo.id
|
chatModel.chatId.value = chatInfo.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||||
|
if (showMarkRead) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.mark_read),
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
onClick = {
|
||||||
|
markChatRead(chat, chatModel)
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.clear_verb),
|
||||||
|
Icons.Outlined.Restore,
|
||||||
|
onClick = {
|
||||||
|
clearChatDialog(chat.chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = WarningOrange
|
||||||
|
)
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
onClick = {
|
||||||
|
deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||||
|
if (showMarkRead) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.mark_read),
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
onClick = {
|
||||||
|
markChatRead(chat, chatModel)
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.clear_verb),
|
||||||
|
Icons.Outlined.Restore,
|
||||||
|
onClick = {
|
||||||
|
clearChatDialog(chat.chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = WarningOrange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.accept_contact_button),
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
onClick = {
|
||||||
|
acceptContactRequest(chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.reject_contact_button),
|
||||||
|
Icons.Outlined.Close,
|
||||||
|
onClick = {
|
||||||
|
rejectContactRequest(chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
onClick = {
|
||||||
|
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markChatRead(chat: Chat, chatModel: ChatModel) {
|
||||||
|
chatModel.markChatItemsRead(chat.chatInfo)
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.apiChatRead(
|
||||||
|
chat.chatInfo.chatType,
|
||||||
|
chat.chatInfo.apiId,
|
||||||
|
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,25 +195,29 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
|
|||||||
title = generalGetString(R.string.accept_connection_request__question),
|
title = generalGetString(R.string.accept_connection_request__question),
|
||||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||||
confirmText = generalGetString(R.string.accept_contact_button),
|
confirmText = generalGetString(R.string.accept_contact_button),
|
||||||
onConfirm = {
|
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
||||||
withApi {
|
|
||||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
|
||||||
if (contact != null) {
|
|
||||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
|
||||||
chatModel.replaceChat(contactRequest.id, chat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissText = generalGetString(R.string.reject_contact_button),
|
dismissText = generalGetString(R.string.reject_contact_button),
|
||||||
onDismiss = {
|
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||||
withApi {
|
|
||||||
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
|
||||||
chatModel.removeChat(contactRequest.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||||
|
if (contact != null) {
|
||||||
|
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||||
|
chatModel.replaceChat(contactRequest.id, chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
||||||
|
chatModel.removeChat(contactRequest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||||
AlertManager.shared.showAlertDialogButtons(
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
title = generalGetString(
|
title = generalGetString(
|
||||||
@@ -159,17 +288,16 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Unit) {
|
fun ChatListNavLinkLayout(
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
chatLinkPreview: @Composable () -> Unit,
|
||||||
Surface(
|
click: () -> Unit,
|
||||||
modifier = Modifier
|
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||||
.fillMaxWidth()
|
showMenu: MutableState<Boolean>,
|
||||||
.combinedClickable(
|
stopped: Boolean
|
||||||
onClick = click,
|
) {
|
||||||
onLongClick = { if (chat.chatInfo is ChatInfo.Direct) showMenu.value = true }
|
var modifier = Modifier.fillMaxWidth().height(88.dp)
|
||||||
)
|
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||||
.height(88.dp)
|
Surface(modifier) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -178,28 +306,17 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Un
|
|||||||
.padding(end = 12.dp),
|
.padding(end = 12.dp),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
when (chat.chatInfo) {
|
chatLinkPreview()
|
||||||
is ChatInfo.Direct -> ChatPreviewView(chat)
|
|
||||||
is ChatInfo.Group -> ChatPreviewView(chat)
|
|
||||||
is ChatInfo.ContactRequest -> ContactRequestView(chat.chatInfo)
|
|
||||||
is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (chat.chatInfo is ChatInfo.Direct) {
|
if (dropdownMenuItems != null) {
|
||||||
DropdownMenu(
|
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||||
expanded = showMenu.value,
|
DropdownMenu(
|
||||||
onDismissRequest = { showMenu.value = false },
|
expanded = showMenu.value,
|
||||||
Modifier.width(220.dp)
|
onDismissRequest = { showMenu.value = false },
|
||||||
) {
|
Modifier.width(220.dp)
|
||||||
ItemAction(
|
) {
|
||||||
stringResource(R.string.delete_verb),
|
dropdownMenuItems()
|
||||||
Icons.Outlined.Delete,
|
}
|
||||||
onClick = {
|
|
||||||
deleteContact()
|
|
||||||
showMenu.value = false
|
|
||||||
},
|
|
||||||
color = Color.Red
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,20 +333,27 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Un
|
|||||||
fun PreviewChatListNavLinkDirect() {
|
fun PreviewChatListNavLinkDirect() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chat = Chat(
|
chatLinkPreview = {
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
ChatPreviewView(
|
||||||
chatItems = listOf(
|
chat = Chat(
|
||||||
ChatItem.getSampleData(
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
1,
|
chatItems = listOf(
|
||||||
CIDirection.DirectSnd(),
|
ChatItem.getSampleData(
|
||||||
Clock.System.now(),
|
1,
|
||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
CIDirection.DirectSnd(),
|
||||||
)
|
Clock.System.now(),
|
||||||
),
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||||
chatStats = Chat.ChatStats()
|
)
|
||||||
),
|
),
|
||||||
|
chatStats = Chat.ChatStats()
|
||||||
|
),
|
||||||
|
stopped = false
|
||||||
|
)
|
||||||
|
},
|
||||||
click = {},
|
click = {},
|
||||||
deleteContact = {}
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,20 +368,27 @@ fun PreviewChatListNavLinkDirect() {
|
|||||||
fun PreviewChatListNavLinkGroup() {
|
fun PreviewChatListNavLinkGroup() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chat = Chat(
|
chatLinkPreview = {
|
||||||
chatInfo = ChatInfo.Group.sampleData,
|
ChatPreviewView(
|
||||||
chatItems = listOf(
|
Chat(
|
||||||
ChatItem.getSampleData(
|
chatInfo = ChatInfo.Group.sampleData,
|
||||||
1,
|
chatItems = listOf(
|
||||||
CIDirection.DirectSnd(),
|
ChatItem.getSampleData(
|
||||||
Clock.System.now(),
|
1,
|
||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
CIDirection.DirectSnd(),
|
||||||
)
|
Clock.System.now(),
|
||||||
),
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||||
chatStats = Chat.ChatStats()
|
)
|
||||||
),
|
),
|
||||||
|
chatStats = Chat.ChatStats()
|
||||||
|
),
|
||||||
|
stopped = false
|
||||||
|
)
|
||||||
|
},
|
||||||
click = {},
|
click = {},
|
||||||
deleteContact = {}
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,13 +403,13 @@ fun PreviewChatListNavLinkGroup() {
|
|||||||
fun PreviewChatListNavLinkContactRequest() {
|
fun PreviewChatListNavLinkContactRequest() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chat = Chat(
|
chatLinkPreview = {
|
||||||
chatInfo = ChatInfo.ContactRequest.sampleData,
|
ContactRequestView(ChatInfo.ContactRequest.sampleData)
|
||||||
chatItems = listOf(),
|
},
|
||||||
chatStats = Chat.ChatStats()
|
|
||||||
),
|
|
||||||
click = {},
|
click = {},
|
||||||
deleteContact = {}
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.material.icons.outlined.Menu
|
||||||
|
import androidx.compose.material.icons.outlined.PersonAdd
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -19,7 +21,8 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
import chat.simplex.app.ui.theme.ToolbarDark
|
import chat.simplex.app.ui.theme.ToolbarDark
|
||||||
import chat.simplex.app.ui.theme.ToolbarLight
|
import chat.simplex.app.ui.theme.ToolbarLight
|
||||||
import chat.simplex.app.views.helpers.ModalManager
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import chat.simplex.app.views.newchat.NewChatSheet
|
import chat.simplex.app.views.newchat.NewChatSheet
|
||||||
import chat.simplex.app.views.onboarding.MakeConnection
|
import chat.simplex.app.views.onboarding.MakeConnection
|
||||||
import chat.simplex.app.views.usersettings.SettingsView
|
import chat.simplex.app.views.usersettings.SettingsView
|
||||||
@@ -64,16 +67,14 @@ fun scaffoldController(): ScaffoldController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListView(chatModel: ChatModel) {
|
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||||
val scaffoldCtrl = scaffoldController()
|
val scaffoldCtrl = scaffoldController()
|
||||||
if (chatModel.clearOverlays.value) {
|
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||||
scaffoldCtrl.collapse()
|
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||||
ModalManager.shared.closeModal()
|
|
||||||
chatModel.clearOverlays.value = false
|
|
||||||
}
|
}
|
||||||
BottomSheetScaffold(
|
BottomSheetScaffold(
|
||||||
scaffoldState = scaffoldCtrl.state,
|
scaffoldState = scaffoldCtrl.state,
|
||||||
drawerContent = { SettingsView(chatModel) },
|
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||||
sheetPeekHeight = 0.dp,
|
sheetPeekHeight = 0.dp,
|
||||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
||||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||||
@@ -84,7 +85,7 @@ fun ChatListView(chatModel: ChatModel) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
.background(MaterialTheme.colors.background)
|
||||||
) {
|
) {
|
||||||
ChatListToolbar(scaffoldCtrl)
|
ChatListToolbar(scaffoldCtrl, stopped)
|
||||||
Divider()
|
Divider()
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ChatList(chatModel)
|
ChatList(chatModel)
|
||||||
@@ -105,44 +106,7 @@ fun ChatListView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
|
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
val welcomeMsg = if (displayName != null) {
|
|
||||||
String.format(stringResource(R.string.personal_welcome), displayName)
|
|
||||||
} else stringResource(R.string.welcome)
|
|
||||||
Text(
|
|
||||||
text = welcomeMsg,
|
|
||||||
Modifier.padding(bottom = 24.dp),
|
|
||||||
style = MaterialTheme.typography.h1,
|
|
||||||
color = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
ChatHelpView { scaffoldCtrl.toggleSheet() }
|
|
||||||
Row(
|
|
||||||
Modifier.padding(top = 30.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.this_text_is_available_in_settings),
|
|
||||||
color = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Settings,
|
|
||||||
stringResource(R.string.icon_descr_settings),
|
|
||||||
tint = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -166,13 +130,24 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(5.dp)
|
modifier = Modifier.padding(5.dp)
|
||||||
)
|
)
|
||||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
if (!stopped) {
|
||||||
Icon(
|
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||||
Icons.Outlined.PersonAdd,
|
Icon(
|
||||||
stringResource(R.string.add_contact),
|
Icons.Outlined.PersonAdd,
|
||||||
tint = MaterialTheme.colors.primary,
|
stringResource(R.string.add_contact),
|
||||||
modifier = Modifier.padding(10.dp)
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Report,
|
||||||
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package chat.simplex.app.views.chatlist
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Circle
|
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||||
import androidx.compose.material.icons.filled.MoreHoriz
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -20,22 +20,16 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.chat.item.MarkdownText
|
import chat.simplex.app.views.chat.item.MarkdownText
|
||||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||||
import chat.simplex.app.views.helpers.badgeLayout
|
import chat.simplex.app.views.helpers.badgeLayout
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPreviewView(chat: Chat) {
|
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||||
Row {
|
Row {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
Box(contentAlignment = Alignment.BottomStart) {
|
ChatInfoImage(cInfo, size = 72.dp)
|
||||||
ChatInfoImage(cInfo, size = 72.dp)
|
|
||||||
if (cInfo is ChatInfo.Direct) {
|
|
||||||
ChatStatusImage(chat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = 8.dp)
|
||||||
@@ -56,17 +50,18 @@ fun ChatPreviewView(chat: Chat) {
|
|||||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||||
metaText = ci.timestampText,
|
metaText = ci.timestampText,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
|
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||||
Column(
|
|
||||||
Modifier.fillMaxHeight(),
|
Box(
|
||||||
verticalArrangement = Arrangement.Top
|
contentAlignment = Alignment.TopEnd
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
@@ -76,17 +71,29 @@ fun ChatPreviewView(chat: Chat) {
|
|||||||
)
|
)
|
||||||
val n = chat.chatStats.unreadCount
|
val n = chat.chatStats.unreadCount
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
Text(
|
Box(
|
||||||
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
Modifier.padding(top = 24.dp),
|
||||||
color = MaterialTheme.colors.onPrimary,
|
contentAlignment = Alignment.Center
|
||||||
fontSize = 14.sp,
|
) {
|
||||||
modifier = Modifier
|
Text(
|
||||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
||||||
.align(Alignment.End)
|
color = MaterialTheme.colors.onPrimary,
|
||||||
.badgeLayout()
|
fontSize = 11.sp,
|
||||||
.padding(horizontal = 3.dp)
|
modifier = Modifier
|
||||||
.padding(vertical = 1.dp)
|
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
||||||
)
|
.badgeLayout()
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cInfo is ChatInfo.Direct) {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(top = 52.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ChatStatusImage(chat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,17 +105,19 @@ fun ChatStatusImage(chat: Chat) {
|
|||||||
val descr = s.statusString
|
val descr = s.statusString
|
||||||
if (s is Chat.NetworkStatus.Error) {
|
if (s is Chat.NetworkStatus.Error) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Circle,
|
Icons.Outlined.ErrorOutline,
|
||||||
contentDescription = descr,
|
contentDescription = descr,
|
||||||
tint = HighOrLowlight,
|
tint = HighOrLowlight,
|
||||||
modifier = Modifier.padding(start = 6.dp).padding(bottom = 4.dp).size(6.dp)
|
modifier = Modifier
|
||||||
|
.size(19.dp)
|
||||||
)
|
)
|
||||||
} else if (!(s is Chat.NetworkStatus.Connected)) {
|
} else if (s !is Chat.NetworkStatus.Connected) {
|
||||||
Icon(
|
CircularProgressIndicator(
|
||||||
Icons.Filled.MoreHoriz,
|
Modifier
|
||||||
contentDescription = descr,
|
.padding(horizontal = 2.dp)
|
||||||
tint = HighOrLowlight,
|
.size(15.dp),
|
||||||
modifier = Modifier.width(19.dp).padding(start = 6.dp)
|
color = HighOrLowlight,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +131,6 @@ fun ChatStatusImage(chat: Chat) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreviewChatPreviewView() {
|
fun PreviewChatPreviewView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatPreviewView(Chat.sampleData)
|
ChatPreviewView(Chat.sampleData, stopped = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@@ -14,7 +15,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.model.PendingContactConnection
|
import chat.simplex.app.model.PendingContactConnection
|
||||||
import chat.simplex.app.model.getTimestampText
|
import chat.simplex.app.model.getTimestampText
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,7 +37,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = HighOrLowlight
|
color = HighOrLowlight
|
||||||
)
|
)
|
||||||
Text(contactConnection.description, maxLines = 2, color = HighOrLowlight)
|
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
}
|
}
|
||||||
val ts = getTimestampText(contactConnection.updatedAt)
|
val ts = getTimestampText(contactConnection.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@@ -12,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatInfo
|
import chat.simplex.app.model.ChatInfo
|
||||||
import chat.simplex.app.model.getTimestampText
|
import chat.simplex.app.model.getTimestampText
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -32,7 +33,7 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colors.primary
|
color = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2)
|
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
}
|
}
|
||||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsSectionView
|
||||||
|
import kotlinx.datetime.*
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val archivePath = "${getFilesDirectory(context)}/$archiveName"
|
||||||
|
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath)
|
||||||
|
ChatArchiveLayout(
|
||||||
|
title,
|
||||||
|
archiveTime,
|
||||||
|
saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
|
||||||
|
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatArchiveLayout(
|
||||||
|
title: String,
|
||||||
|
archiveTime: Instant,
|
||||||
|
saveArchive: () -> Unit,
|
||||||
|
deleteArchiveAlert: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||||
|
style = MaterialTheme.typography.h1
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsSectionView(stringResource(R.string.chat_database_section)) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.IosShare,
|
||||||
|
stringResource(R.string.save_archive),
|
||||||
|
saveArchive,
|
||||||
|
textColor = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
divider()
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
stringResource(R.string.delete_archive),
|
||||||
|
deleteArchiveAlert,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||||
|
SettingsSectionFooter(
|
||||||
|
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher<String, Uri?> =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument(),
|
||||||
|
onResult = { destination ->
|
||||||
|
try {
|
||||||
|
destination?.let {
|
||||||
|
val contentResolver = cxt.contentResolver
|
||||||
|
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||||
|
val outputStream = BufferedOutputStream(stream)
|
||||||
|
val file = File(chatArchivePath)
|
||||||
|
outputStream.write(file.readBytes())
|
||||||
|
outputStream.close()
|
||||||
|
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Error) {
|
||||||
|
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||||
|
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_chat_archive_question),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = {
|
||||||
|
val fileDeleted = File(archivePath).delete()
|
||||||
|
if (fileDeleted) {
|
||||||
|
m.controller.appPrefs.chatArchiveName.set(null)
|
||||||
|
m.controller.appPrefs.chatArchiveTime.set(null)
|
||||||
|
ModalManager.shared.closeModal()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "deleteArchiveAlert delete() error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatArchiveLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatArchiveLayout(
|
||||||
|
title = "New database archive",
|
||||||
|
archiveTime = Clock.System.now(),
|
||||||
|
saveArchive = {},
|
||||||
|
deleteArchiveAlert = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.FileUtils
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
import kotlinx.datetime.*
|
||||||
|
import java.io.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DatabaseView(
|
||||||
|
m: ChatModel,
|
||||||
|
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val progressIndicator = remember { mutableStateOf(false) }
|
||||||
|
val runChat = remember { mutableStateOf(false) }
|
||||||
|
val prefs = m.controller.appPrefs
|
||||||
|
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
||||||
|
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
|
||||||
|
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
|
||||||
|
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
|
||||||
|
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
|
||||||
|
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
importArchiveAlert(m, context, uri, progressIndicator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(m.chatRunning) {
|
||||||
|
runChat.value = m.chatRunning.value ?: true
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
DatabaseLayout(
|
||||||
|
progressIndicator.value,
|
||||||
|
runChat.value,
|
||||||
|
m.chatDbChanged.value,
|
||||||
|
importArchiveLauncher,
|
||||||
|
chatArchiveName,
|
||||||
|
chatArchiveTime,
|
||||||
|
chatLastStart,
|
||||||
|
startChat = { startChat(m, runChat) },
|
||||||
|
stopChatAlert = { stopChatAlert(m, runChat) },
|
||||||
|
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||||
|
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
|
||||||
|
showSettingsModal
|
||||||
|
)
|
||||||
|
if (progressIndicator.value) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(30.dp),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DatabaseLayout(
|
||||||
|
progressIndicator: Boolean,
|
||||||
|
runChat: Boolean,
|
||||||
|
chatDbChanged: Boolean,
|
||||||
|
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||||
|
chatArchiveName: MutableState<String?>,
|
||||||
|
chatArchiveTime: MutableState<Instant?>,
|
||||||
|
chatLastStart: MutableState<Instant?>,
|
||||||
|
startChat: () -> Unit,
|
||||||
|
stopChatAlert: () -> Unit,
|
||||||
|
exportArchive: () -> Unit,
|
||||||
|
deleteChatAlert: () -> Unit,
|
||||||
|
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||||
|
) {
|
||||||
|
val stopped = !runChat
|
||||||
|
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.your_chat_database),
|
||||||
|
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||||
|
style = MaterialTheme.typography.h1
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsSectionView(stringResource(R.string.run_chat_section)) {
|
||||||
|
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(30.dp))
|
||||||
|
|
||||||
|
SettingsSectionView(stringResource(R.string.chat_database_section)) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.IosShare,
|
||||||
|
stringResource(R.string.export_database),
|
||||||
|
exportArchive,
|
||||||
|
textColor = MaterialTheme.colors.primary,
|
||||||
|
disabled = operationsDisabled
|
||||||
|
)
|
||||||
|
divider()
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.FileDownload,
|
||||||
|
stringResource(R.string.import_database),
|
||||||
|
{ importArchiveLauncher.launch("application/zip") },
|
||||||
|
textColor = Color.Red,
|
||||||
|
disabled = operationsDisabled
|
||||||
|
)
|
||||||
|
divider()
|
||||||
|
val chatArchiveNameVal = chatArchiveName.value
|
||||||
|
val chatArchiveTimeVal = chatArchiveTime.value
|
||||||
|
val chatLastStartVal = chatLastStart.value
|
||||||
|
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||||
|
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.Inventory2,
|
||||||
|
title,
|
||||||
|
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||||
|
disabled = operationsDisabled
|
||||||
|
)
|
||||||
|
divider()
|
||||||
|
}
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.DeleteForever,
|
||||||
|
stringResource(R.string.delete_database),
|
||||||
|
deleteChatAlert,
|
||||||
|
textColor = Color.Red,
|
||||||
|
disabled = operationsDisabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsSectionFooter(
|
||||||
|
if (chatDbChanged) {
|
||||||
|
stringResource(R.string.restart_the_app_to_use_new_chat_database)
|
||||||
|
} else {
|
||||||
|
if (stopped) {
|
||||||
|
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RunChatSetting(
|
||||||
|
runChat: Boolean,
|
||||||
|
stopped: Boolean,
|
||||||
|
chatDbChanged: Boolean,
|
||||||
|
startChat: () -> Unit,
|
||||||
|
stopChatAlert: () -> Unit
|
||||||
|
) {
|
||||||
|
SettingsItemView() {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
||||||
|
Icon(
|
||||||
|
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
|
||||||
|
chatRunningText,
|
||||||
|
tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(
|
||||||
|
chatRunningText,
|
||||||
|
Modifier.padding(end = 24.dp),
|
||||||
|
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
|
||||||
|
)
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = runChat,
|
||||||
|
onCheckedChange = { runChatSwitch ->
|
||||||
|
if (runChatSwitch) {
|
||||||
|
startChat()
|
||||||
|
} else {
|
||||||
|
stopChatAlert()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = HighOrLowlight
|
||||||
|
),
|
||||||
|
enabled = !chatDbChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
||||||
|
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSectionFooter(text: String) {
|
||||||
|
Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>) {
|
||||||
|
withApi {
|
||||||
|
try {
|
||||||
|
m.controller.apiStartChat()
|
||||||
|
runChat.value = true
|
||||||
|
m.chatRunning.value = true
|
||||||
|
m.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||||
|
} catch (e: Error) {
|
||||||
|
runChat.value = false
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.stop_chat_question),
|
||||||
|
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||||
|
confirmText = generalGetString(R.string.stop_chat_confirmation),
|
||||||
|
onConfirm = { stopChat(m, runChat) },
|
||||||
|
onDismiss = { runChat.value = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>) {
|
||||||
|
withApi {
|
||||||
|
try {
|
||||||
|
m.controller.apiStopChat()
|
||||||
|
runChat.value = false
|
||||||
|
m.chatRunning.value = false
|
||||||
|
} catch (e: Error) {
|
||||||
|
runChat.value = true
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportArchive(
|
||||||
|
context: Context,
|
||||||
|
m: ChatModel,
|
||||||
|
progressIndicator: MutableState<Boolean>,
|
||||||
|
chatArchiveName: MutableState<String?>,
|
||||||
|
chatArchiveTime: MutableState<Instant?>,
|
||||||
|
chatArchiveFile: MutableState<String?>,
|
||||||
|
saveArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>
|
||||||
|
) {
|
||||||
|
progressIndicator.value = true
|
||||||
|
withApi {
|
||||||
|
try {
|
||||||
|
val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile)
|
||||||
|
chatArchiveFile.value = archiveFile
|
||||||
|
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
|
||||||
|
progressIndicator.value = false
|
||||||
|
} catch (e: Error) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString())
|
||||||
|
progressIndicator.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun exportChatArchive(
|
||||||
|
m: ChatModel,
|
||||||
|
context: Context,
|
||||||
|
chatArchiveName: MutableState<String?>,
|
||||||
|
chatArchiveTime: MutableState<Instant?>,
|
||||||
|
chatArchiveFile: MutableState<String?>
|
||||||
|
): String {
|
||||||
|
val archiveTime = Clock.System.now()
|
||||||
|
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||||
|
val archiveName = "simplex-chat.$ts.zip"
|
||||||
|
val archivePath = "${getFilesDirectory(context)}/$archiveName"
|
||||||
|
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||||
|
m.controller.apiExportArchive(config)
|
||||||
|
deleteOldArchive(m, context)
|
||||||
|
m.controller.appPrefs.chatArchiveName.set(archiveName)
|
||||||
|
chatArchiveName.value = archiveName
|
||||||
|
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
|
||||||
|
chatArchiveTime.value = archiveTime
|
||||||
|
chatArchiveFile.value = archivePath
|
||||||
|
return archivePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteOldArchive(m: ChatModel, context: Context) {
|
||||||
|
val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
|
||||||
|
if (chatArchiveName != null) {
|
||||||
|
val file = File("${getFilesDirectory(context)}/$chatArchiveName")
|
||||||
|
val fileDeleted = file.delete()
|
||||||
|
if (fileDeleted) {
|
||||||
|
m.controller.appPrefs.chatArchiveName.set(null)
|
||||||
|
m.controller.appPrefs.chatArchiveTime.set(null)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "deleteOldArchive file.delete() error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument(),
|
||||||
|
onResult = { destination ->
|
||||||
|
try {
|
||||||
|
destination?.let {
|
||||||
|
val filePath = chatArchiveFile.value
|
||||||
|
if (filePath != null) {
|
||||||
|
val contentResolver = cxt.contentResolver
|
||||||
|
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||||
|
val outputStream = BufferedOutputStream(stream)
|
||||||
|
val file = File(filePath)
|
||||||
|
outputStream.write(file.readBytes())
|
||||||
|
outputStream.close()
|
||||||
|
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Error) {
|
||||||
|
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||||
|
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
|
||||||
|
} finally {
|
||||||
|
chatArchiveFile.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.import_database_question),
|
||||||
|
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||||
|
confirmText = generalGetString(R.string.import_database_confirmation),
|
||||||
|
onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
|
||||||
|
progressIndicator.value = true
|
||||||
|
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
|
||||||
|
if (archivePath != null) {
|
||||||
|
withApi {
|
||||||
|
try {
|
||||||
|
m.controller.apiDeleteStorage()
|
||||||
|
try {
|
||||||
|
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||||
|
m.controller.apiImportArchive(config)
|
||||||
|
operationEnded(m, progressIndicator) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
||||||
|
}
|
||||||
|
} catch (e: Error) {
|
||||||
|
operationEnded(m, progressIndicator) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Error) {
|
||||||
|
operationEnded(m, progressIndicator) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
File(archivePath).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(importedArchiveUri)
|
||||||
|
val archiveName = getFileName(context, importedArchiveUri)
|
||||||
|
if (inputStream != null && archiveName != null) {
|
||||||
|
val archivePath = "${context.cacheDir}/$archiveName"
|
||||||
|
val destFile = File(archivePath)
|
||||||
|
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||||
|
archivePath
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_chat_profile_question),
|
||||||
|
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = { deleteChat(m, progressIndicator) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||||
|
progressIndicator.value = true
|
||||||
|
withApi {
|
||||||
|
try {
|
||||||
|
m.controller.apiDeleteStorage()
|
||||||
|
operationEnded(m, progressIndicator) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||||
|
}
|
||||||
|
} catch (e: Error) {
|
||||||
|
operationEnded(m, progressIndicator) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
|
||||||
|
m.chatDbChanged.value = true
|
||||||
|
progressIndicator.value = false
|
||||||
|
alert.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewDatabaseLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
DatabaseLayout(
|
||||||
|
progressIndicator = false,
|
||||||
|
runChat = true,
|
||||||
|
chatDbChanged = false,
|
||||||
|
importArchiveLauncher = rememberGetContentLauncher {},
|
||||||
|
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||||
|
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||||
|
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||||
|
startChat = {},
|
||||||
|
stopChatAlert = {},
|
||||||
|
exportArchive = {},
|
||||||
|
deleteChatAlert = {},
|
||||||
|
showSettingsModal = { {} }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.SupervisedUserCircle
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -34,14 +35,15 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
|
|||||||
fun ProfileImage(
|
fun ProfileImage(
|
||||||
size: Dp,
|
size: Dp,
|
||||||
image: String? = null,
|
image: String? = null,
|
||||||
icon: ImageVector = Icons.Filled.AccountCircle
|
icon: ImageVector = Icons.Filled.AccountCircle,
|
||||||
|
color: Color = MaterialTheme.colors.secondary
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(size)) {
|
Box(Modifier.size(size)) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||||
tint = MaterialTheme.colors.secondary,
|
tint = color,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.newchat.ActionButton
|
||||||
|
|
||||||
|
sealed class AttachmentOption {
|
||||||
|
object TakePhoto: AttachmentOption()
|
||||||
|
object PickImage: AttachmentOption()
|
||||||
|
object PickFile: AttachmentOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChooseAttachmentView(
|
||||||
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
|
hide: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
if (!focusState.hasFocus) hide()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||||
|
attachmentOption.value = AttachmentOption.TakePhoto
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||||
|
attachmentOption.value = AttachmentOption.PickImage
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||||
|
attachmentOption.value = AttachmentOption.PickFile
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.Collections
|
||||||
|
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -127,18 +128,15 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//class GetGalleryContent: ActivityResultContracts.GetContent() {
|
//class GetGalleryContent: ActivityResultContracts.GetContent() {
|
||||||
// override fun createIntent(context: Context, input: String): Intent {
|
// override fun createIntent(context: Context, input: String): Intent {
|
||||||
// super.createIntent(context, input)
|
// super.createIntent(context, input)
|
||||||
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
|
|
||||||
//@Composable
|
//@Composable
|
||||||
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||||
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
|
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
|
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
|
||||||
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
|
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
|
||||||
@@ -155,8 +153,6 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
|||||||
fun GetImageBottomSheet(
|
fun GetImageBottomSheet(
|
||||||
imageBitmap: MutableState<Bitmap?>,
|
imageBitmap: MutableState<Bitmap?>,
|
||||||
onImageChange: (Bitmap) -> Unit,
|
onImageChange: (Bitmap) -> Unit,
|
||||||
fileUri: MutableState<Uri?>? = null,
|
|
||||||
onFileChange: ((Uri) -> Unit)? = null,
|
|
||||||
hideBottomSheet: () -> Unit
|
hideBottomSheet: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -182,20 +178,6 @@ fun GetImageBottomSheet(
|
|||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
|
|
||||||
if (uri != null && fileUri != null && onFileChange != null) {
|
|
||||||
val fileSize = getFileSize(context, uri)
|
|
||||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
|
||||||
fileUri.value = uri
|
|
||||||
onFileChange(uri)
|
|
||||||
} else {
|
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
generalGetString(R.string.large_file),
|
|
||||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -226,12 +208,6 @@ fun GetImageBottomSheet(
|
|||||||
galleryLauncher.launch("image/*")
|
galleryLauncher.launch("image/*")
|
||||||
hideBottomSheet()
|
hideBottomSheet()
|
||||||
}
|
}
|
||||||
if (fileUri != null && onFileChange != null) {
|
|
||||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
|
||||||
filesLauncher.launch("*/*")
|
|
||||||
hideBottomSheet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,23 +66,36 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) {
|
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
|
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
|
if (linkPreview == null) {
|
||||||
Image(
|
Box(
|
||||||
imageBitmap,
|
Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp),
|
||||||
stringResource(R.string.image_descr_link_preview),
|
contentAlignment = Alignment.CenterStart
|
||||||
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
|
) {
|
||||||
)
|
CircularProgressIndicator(
|
||||||
Column(Modifier.fillMaxWidth().weight(1F)) {
|
Modifier.size(16.dp),
|
||||||
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
color = HighOrLowlight,
|
||||||
Text(
|
strokeWidth = 2.dp
|
||||||
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
)
|
||||||
style = MaterialTheme.typography.body2
|
}
|
||||||
|
} else {
|
||||||
|
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
|
||||||
|
Image(
|
||||||
|
imageBitmap,
|
||||||
|
stringResource(R.string.image_descr_link_preview),
|
||||||
|
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
|
||||||
)
|
)
|
||||||
|
Column(Modifier.fillMaxWidth().weight(1F)) {
|
||||||
|
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.body2
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -140,3 +153,11 @@ fun PreviewComposeLinkView() {
|
|||||||
ComposeLinkView(LinkPreview.sampleData) { -> }
|
ComposeLinkView(LinkPreview.sampleData) { -> }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun PreviewComposeLinkViewLoading() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ComposeLinkView(null) { -> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build.VERSION.SDK_INT
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricManager.Authenticators.*
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import chat.simplex.app.R
|
||||||
|
|
||||||
|
sealed class LAResult {
|
||||||
|
object Success: LAResult()
|
||||||
|
class Error(val errString: CharSequence): LAResult()
|
||||||
|
object Failed: LAResult()
|
||||||
|
object Unavailable: LAResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticate(
|
||||||
|
promptTitle: String,
|
||||||
|
promptSubtitle: String,
|
||||||
|
activity: FragmentActivity,
|
||||||
|
completed: (LAResult) -> Unit
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
SDK_INT in 28..29 ->
|
||||||
|
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||||
|
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||||
|
SDK_INT > 29 ->
|
||||||
|
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||||
|
else ->
|
||||||
|
completed(LAResult.Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateWithBiometricManager(
|
||||||
|
promptTitle: String,
|
||||||
|
promptSubtitle: String,
|
||||||
|
activity: FragmentActivity,
|
||||||
|
completed: (LAResult) -> Unit,
|
||||||
|
authenticators: Int
|
||||||
|
) {
|
||||||
|
val biometricManager = BiometricManager.from(activity)
|
||||||
|
when (biometricManager.canAuthenticate(authenticators)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||||
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
val biometricPrompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
executor,
|
||||||
|
object: BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
errorCode: Int,
|
||||||
|
errString: CharSequence
|
||||||
|
) {
|
||||||
|
super.onAuthenticationError(errorCode, errString)
|
||||||
|
completed(LAResult.Error(errString))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult
|
||||||
|
) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
completed(LAResult.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
super.onAuthenticationFailed()
|
||||||
|
completed(LAResult.Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(promptTitle)
|
||||||
|
.setSubtitle(promptSubtitle)
|
||||||
|
.setAllowedAuthenticators(authenticators)
|
||||||
|
.setConfirmationRequired(false)
|
||||||
|
.build()
|
||||||
|
biometricPrompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
completed(LAResult.Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.auth_simplex_lock_turned_on),
|
||||||
|
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
|
||||||
|
context,
|
||||||
|
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
|
||||||
|
fun laFailedToast(context: Context) = Toast.makeText(
|
||||||
|
context,
|
||||||
|
generalGetString(R.string.auth_failed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
|
||||||
|
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.auth_unavailable),
|
||||||
|
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.auth_unavailable),
|
||||||
|
generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
|
||||||
|
)
|
||||||
@@ -9,20 +9,22 @@ import androidx.compose.material.Surface
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ModalView(close: () -> Unit, content: @Composable () -> Unit) {
|
fun ModalView(
|
||||||
|
close: () -> Unit,
|
||||||
|
background: Color = MaterialTheme.colors.background,
|
||||||
|
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
Surface(
|
Surface(Modifier.fillMaxSize()) {
|
||||||
Modifier
|
Column(Modifier.background(background)) {
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
.fillMaxSize()
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
CloseSheetBar(close)
|
CloseSheetBar(close)
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { content() }
|
Box(modifier) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ class ModalManager {
|
|||||||
private val modalCount = mutableStateOf(0)
|
private val modalCount = mutableStateOf(0)
|
||||||
|
|
||||||
fun showModal(content: @Composable () -> Unit) {
|
fun showModal(content: @Composable () -> Unit) {
|
||||||
showCustomModal { close -> ModalView(close, content) }
|
showCustomModal { close -> ModalView(close, content = content) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||||
@@ -48,6 +50,10 @@ class ModalManager {
|
|||||||
modalCount.value = modalViews.count()
|
modalCount.value = modalViews.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closeModals() {
|
||||||
|
while (modalViews.isNotEmpty()) closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun showInView() {
|
fun showInView() {
|
||||||
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ import java.util.*
|
|||||||
import kotlin.math.log2
|
import kotlin.math.log2
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
|
||||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
|
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
|
||||||
GlobalScope.launch { withContext(Dispatchers.Main, action) }
|
|
||||||
|
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||||
|
scope.launch { withContext(Dispatchers.Main, action) }
|
||||||
|
|
||||||
enum class KeyboardState {
|
enum class KeyboardState {
|
||||||
Opened, Closed
|
Opened, Closed
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ fun SimpleXInfoLayout(
|
|||||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||||
Image(
|
SimpleXLogo()
|
||||||
painter = painterResource(R.drawable.logo),
|
|
||||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(vertical = 20.dp)
|
|
||||||
.fillMaxWidth(0.80f)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
|
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
|
||||||
@@ -79,6 +73,17 @@ fun SimpleXInfoLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimpleXLogo() {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.logo),
|
||||||
|
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 20.dp)
|
||||||
|
.fillMaxWidth(0.80f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
|
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||||
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallSettingsView(m: ChatModel) {
|
||||||
|
CallSettingsLayout(
|
||||||
|
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
|
||||||
|
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallSettingsLayout(
|
||||||
|
webrtcPolicyRelay: Preference<Boolean>,
|
||||||
|
callOnLockScreen: Preference<CallOnLockScreen>,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.your_calls),
|
||||||
|
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||||
|
style = MaterialTheme.typography.h1
|
||||||
|
)
|
||||||
|
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||||
|
Box(Modifier.padding(start = 10.dp)) {
|
||||||
|
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||||
|
}
|
||||||
|
divider()
|
||||||
|
|
||||||
|
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.call_on_lock_screen))
|
||||||
|
Row {
|
||||||
|
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedPreferenceToggle(
|
||||||
|
text: String,
|
||||||
|
preference: Preference<Boolean>,
|
||||||
|
preferenceState: MutableState<Boolean>? = null
|
||||||
|
) {
|
||||||
|
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text, Modifier.padding(end = 24.dp))
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = prefState.value,
|
||||||
|
onCheckedChange = {
|
||||||
|
preference.set(it)
|
||||||
|
prefState.value = it
|
||||||
|
},
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = HighOrLowlight
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(end = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text)
|
||||||
|
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
||||||
|
RadioButton(selected = prefState.value == value, colors = colors, onClick = {
|
||||||
|
preference.set(value)
|
||||||
|
prefState.value = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Videocam
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_experimental_features),
|
||||||
|
style = MaterialTheme.typography.h1,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||||
|
)
|
||||||
|
SettingsSectionView("") {
|
||||||
|
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Image
|
||||||
|
import androidx.compose.material.icons.outlined.TravelExplore
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||||
|
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.your_privacy),
|
||||||
|
style = MaterialTheme.typography.h1,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||||
|
)
|
||||||
|
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
|
||||||
|
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(30.dp))
|
||||||
|
|
||||||
|
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||||
|
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||||
|
divider()
|
||||||
|
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) {
|
|||||||
if (userSMPServers != null) {
|
if (userSMPServers != null) {
|
||||||
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
||||||
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
||||||
var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
||||||
fun saveSMPServers(smpServers: List<String>) {
|
fun saveSMPServers(smpServers: List<String>) {
|
||||||
withApi {
|
withApi {
|
||||||
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
||||||
|
|||||||
+258
-151
@@ -1,48 +1,64 @@
|
|||||||
package chat.simplex.app.views.usersettings
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Report
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.UriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import chat.simplex.app.BuildConfig
|
import chat.simplex.app.BuildConfig
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.Profile
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.TerminalView
|
import chat.simplex.app.views.TerminalView
|
||||||
import chat.simplex.app.views.call.VideoCallView
|
import chat.simplex.app.views.database.DatabaseView
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsView(chatModel: ChatModel) {
|
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||||
val user = chatModel.currentUser.value
|
val user = chatModel.currentUser.value
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
|
||||||
|
fun setRunServiceInBackground(on: Boolean) {
|
||||||
|
chatModel.controller.appPrefs.runServiceInBackground.set(on)
|
||||||
|
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||||
|
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||||
|
}
|
||||||
|
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||||
|
chatModel.runServiceInBackground.value = on
|
||||||
|
}
|
||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
SettingsLayout(
|
SettingsLayout(
|
||||||
profile = user.profile,
|
profile = user.profile,
|
||||||
|
stopped,
|
||||||
runServiceInBackground = chatModel.runServiceInBackground,
|
runServiceInBackground = chatModel.runServiceInBackground,
|
||||||
setRunServiceInBackground = { on ->
|
setRunServiceInBackground = ::setRunServiceInBackground,
|
||||||
chatModel.controller.setRunServiceInBackground(on)
|
setPerformLA = setPerformLA,
|
||||||
chatModel.runServiceInBackground.value = on
|
|
||||||
},
|
|
||||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||||
|
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
|
||||||
|
ModalView(close = close, modifier = Modifier,
|
||||||
|
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||||
|
modalView(chatModel)
|
||||||
|
}
|
||||||
|
} } },
|
||||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
|
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
|
||||||
showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
|
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,179 +69,267 @@ val simplexTeamUri =
|
|||||||
@Composable
|
@Composable
|
||||||
fun SettingsLayout(
|
fun SettingsLayout(
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
|
stopped: Boolean,
|
||||||
runServiceInBackground: MutableState<Boolean>,
|
runServiceInBackground: MutableState<Boolean>,
|
||||||
setRunServiceInBackground: (Boolean) -> Unit,
|
setRunServiceInBackground: (Boolean) -> Unit,
|
||||||
|
setPerformLA: (Boolean) -> Unit,
|
||||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||||
|
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||||
showTerminal: () -> Unit,
|
showTerminal: () -> Unit,
|
||||||
showVideoChatPrototype: () -> Unit
|
// showVideoChatPrototype: () -> Unit
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
Surface(
|
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||||
Modifier
|
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
.fillMaxSize()
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||||
.padding(8.dp)
|
|
||||||
.padding(top = 16.dp)
|
.padding(top = 16.dp)
|
||||||
) {
|
) {
|
||||||
|
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.your_settings),
|
stringResource(R.string.your_settings),
|
||||||
style = MaterialTheme.typography.h1,
|
style = MaterialTheme.typography.h1,
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(30.dp))
|
Spacer(Modifier.height(30.dp))
|
||||||
|
|
||||||
SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
|
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
|
||||||
ProfileImage(size = 60.dp, profile.image)
|
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
ProfilePreview(profile, stopped = stopped)
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
profile.displayName,
|
|
||||||
style = MaterialTheme.typography.caption,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(profile.fullName)
|
|
||||||
}
|
}
|
||||||
|
divider()
|
||||||
|
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
|
||||||
|
divider()
|
||||||
|
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
spacer()
|
||||||
SettingsSectionView(showModal { UserAddressView(it) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.QrCode,
|
|
||||||
contentDescription = stringResource(R.string.icon_descr_address),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(stringResource(R.string.your_simplex_contact_address))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
SettingsSectionView(showModal { HelpView(it) }) {
|
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||||
Icon(
|
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
|
||||||
Icons.Outlined.HelpOutline,
|
divider()
|
||||||
contentDescription = stringResource(R.string.icon_descr_help),
|
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||||
)
|
divider()
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
|
||||||
Text(stringResource(R.string.how_to_use_simplex_chat))
|
divider()
|
||||||
|
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
spacer()
|
||||||
SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Info,
|
|
||||||
contentDescription = stringResource(R.string.icon_descr_help),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(stringResource(R.string.about_simplex_chat))
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
SettingsSectionView(showModal { MarkdownHelpView() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.TextFormat,
|
|
||||||
contentDescription = stringResource(R.string.markdown_help),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(stringResource(R.string.markdown_in_messages))
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Tag,
|
|
||||||
contentDescription = stringResource(R.string.icon_descr_simplex_team),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.chat_with_the_founder),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Email,
|
|
||||||
contentDescription = stringResource(R.string.icon_descr_email),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.send_us_an_email),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
SettingsSectionView(showModal { SMPServersView(it) }) {
|
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
|
||||||
Icon(
|
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
|
||||||
Icons.Outlined.Dns,
|
divider()
|
||||||
contentDescription = stringResource(R.string.smp_servers),
|
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||||
)
|
divider()
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||||
Text(stringResource(R.string.smp_servers))
|
divider()
|
||||||
|
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||||
|
divider()
|
||||||
|
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
spacer()
|
||||||
SettingsSectionView() {
|
|
||||||
Icon(
|
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||||
Icons.Outlined.Bolt,
|
ChatConsoleItem(showTerminal, stopped)
|
||||||
contentDescription = stringResource(R.string.private_notifications),
|
divider()
|
||||||
)
|
InstallTerminalAppItem(uriHandler)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
divider()
|
||||||
Text(
|
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||||
stringResource(R.string.private_notifications), Modifier
|
// divider()
|
||||||
.padding(end = 24.dp)
|
AppVersionItem()
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1F))
|
|
||||||
Switch(
|
|
||||||
checked = runServiceInBackground.value,
|
|
||||||
onCheckedChange = { setRunServiceInBackground(it) },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
SettingsSectionView(showTerminal) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
|
||||||
contentDescription = stringResource(R.string.chat_console),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(stringResource(R.string.chat_console))
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_github),
|
|
||||||
contentDescription = "GitHub",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
|
||||||
}
|
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
|
||||||
// SettingsSectionView(showVideoChatPrototype) {
|
|
||||||
SettingsSectionView() {
|
|
||||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||||
|
Column(Modifier.padding(horizontal = 6.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
|
||||||
|
SettingsItemView(openDatabaseView) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Archive,
|
||||||
|
contentDescription = stringResource(R.string.database_export_and_import),
|
||||||
|
tint = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(stringResource(R.string.database_export_and_import))
|
||||||
|
}
|
||||||
|
if (stopped) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Report,
|
||||||
|
contentDescription = stringResource(R.string.chat_is_stopped),
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.padding(end = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun PrivateNotificationsItem(
|
||||||
|
runServiceInBackground: MutableState<Boolean>,
|
||||||
|
setRunServiceInBackground: (Boolean) -> Unit,
|
||||||
|
stopped: Boolean
|
||||||
|
) {
|
||||||
|
SettingsItemView(disabled = stopped) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Bolt,
|
||||||
|
contentDescription = stringResource(R.string.private_notifications),
|
||||||
|
tint = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.private_notifications),
|
||||||
|
Modifier
|
||||||
|
.padding(end = 24.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
color = if (stopped) HighOrLowlight else Color.Unspecified
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = runServiceInBackground.value,
|
||||||
|
onCheckedChange = { setRunServiceInBackground(it) },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = HighOrLowlight
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(end = 6.dp),
|
||||||
|
enabled = !stopped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||||
|
SettingsItemView() {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Lock,
|
||||||
|
contentDescription = stringResource(R.string.chat_lock),
|
||||||
|
tint = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_lock), Modifier
|
||||||
|
.padding(end = 24.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F)
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = performLA.value,
|
||||||
|
onCheckedChange = { setPerformLA(it) },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = HighOrLowlight
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(end = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
|
||||||
|
SettingsItemView(showTerminal, disabled = stopped) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||||
|
contentDescription = stringResource(R.string.chat_console),
|
||||||
|
tint = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_console),
|
||||||
|
color = if (stopped) HighOrLowlight else Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||||
|
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_github),
|
||||||
|
contentDescription = "GitHub",
|
||||||
|
tint = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun AppVersionItem() {
|
||||||
|
SettingsItemView() {
|
||||||
|
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
|
||||||
|
ProfileImage(size = size, image = profileOf.image, color = color)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
profileOf.displayName,
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (stopped) HighOrLowlight else Color.Unspecified
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
profileOf.fullName,
|
||||||
|
color = if (stopped) HighOrLowlight else Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
|
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.padding(start = 8.dp)
|
.padding(start = 8.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height)
|
.height(height)
|
||||||
Row(
|
Row(
|
||||||
if (click == null) modifier else modifier.clickable(onClick = click),
|
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
|
||||||
|
SettingsItemView(click, disabled = disabled) {
|
||||||
|
Icon(icon, text, tint = HighOrLowlight)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text(text, color = if (disabled) HighOrLowlight else textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||||
|
SettingsItemView() {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, text, tint = HighOrLowlight)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
SharedPreferenceToggle(text, pref, prefState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
@@ -237,12 +341,15 @@ fun PreviewSettingsLayout() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SettingsLayout(
|
SettingsLayout(
|
||||||
profile = Profile.sampleData,
|
profile = Profile.sampleData,
|
||||||
|
stopped = false,
|
||||||
runServiceInBackground = remember { mutableStateOf(true) },
|
runServiceInBackground = remember { mutableStateOf(true) },
|
||||||
setRunServiceInBackground = {},
|
setRunServiceInBackground = {},
|
||||||
showModal = {{}},
|
setPerformLA = {},
|
||||||
showCustomModal = {{}},
|
showModal = { {} },
|
||||||
|
showSettingsModal = { {} },
|
||||||
|
showCustomModal = { {} },
|
||||||
showTerminal = {},
|
showTerminal = {},
|
||||||
showVideoChatPrototype = {}
|
// showVideoChatPrototype = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -10,8 +10,7 @@ import androidx.compose.foundation.text.BasicTextField
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Close
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -25,8 +24,10 @@ import androidx.compose.ui.unit.sp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
import chat.simplex.app.model.Profile
|
import chat.simplex.app.model.Profile
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.isValidDisplayName
|
||||||
import com.google.accompanist.insets.ProvideWindowInsets
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -128,7 +129,12 @@ fun UserProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProfileNameTextField(displayName)
|
Box {
|
||||||
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
|
Icon(Icons.Outlined.Info, tint = Color.Red, contentDescription = stringResource(R.string.display_name_cannot_contain_whitespace))
|
||||||
|
}
|
||||||
|
ProfileNameTextField(displayName)
|
||||||
|
}
|
||||||
ProfileNameTextField(fullName)
|
ProfileNameTextField(fullName)
|
||||||
Row {
|
Row {
|
||||||
TextButton(stringResource(R.string.cancel_verb)) {
|
TextButton(stringResource(R.string.cancel_verb)) {
|
||||||
@@ -138,9 +144,22 @@ fun UserProfileLayout(
|
|||||||
editProfile.value = false
|
editProfile.value = false
|
||||||
}
|
}
|
||||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||||
TextButton(stringResource(R.string.save_and_notify_contacts)) {
|
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
saveProfile(displayName.value, fullName.value, profileImage.value)
|
val saveModifier: Modifier
|
||||||
|
val saveColor: Color
|
||||||
|
if (enabled) {
|
||||||
|
saveModifier = Modifier
|
||||||
|
.clickable { saveProfile(displayName.value, fullName.value, profileImage.value) }
|
||||||
|
saveColor = MaterialTheme.colors.primary
|
||||||
|
} else {
|
||||||
|
saveModifier = Modifier
|
||||||
|
saveColor = HighOrLowlight
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.save_and_notify_contacts),
|
||||||
|
modifier = saveModifier,
|
||||||
|
color = saveColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -187,6 +206,7 @@ private fun ProfileNameTextField(name: MutableState<String>) {
|
|||||||
onValueChange = { name.value = it },
|
onValueChange = { name.value = it },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 24.dp)
|
.padding(bottom = 24.dp)
|
||||||
|
.padding(start = 28.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
@@ -46,21 +46,45 @@
|
|||||||
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
|
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
|
||||||
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
|
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
|
||||||
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
||||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
|
||||||
<string name="error_deleting_contact">Ошибка удаления контакта</string>
|
<string name="error_deleting_contact">Ошибка удаления контакта</string>
|
||||||
<string name="error_deleting_group">Ошибка удаления группы</string>
|
<string name="error_deleting_group">Ошибка удаления группы</string>
|
||||||
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
||||||
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
||||||
|
|
||||||
<!-- background service notice - SimpleXAPI.kt -->
|
<!-- background service notice - SimpleXAPI.kt -->
|
||||||
|
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||||
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
|
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
|
||||||
|
<string name="private_instant_notifications_disabled">Приватные уведомления выключены!</string>
|
||||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
|
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
|
||||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Он может быть выключен через Настройки</b> – вы продолжите получать уведомления о сообщениях пока приложение запущено.</string>
|
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Он может быть выключен через Настройки</b> – вы продолжите получать уведомления о сообщениях пока приложение запущено.</string>
|
||||||
|
<string name="turn_off_battery_optimization">Для использования приватных уведомлений, пожалуйста отключите оптимизацию батареи для <xliff:g id="appName">SimpleX</xliff:g> в слеующем диалоге. Иначе уведомления будут выключены.</string>
|
||||||
|
<string name="turning_off_background_service">Оптимизация батареи включена, поэтому приватные уведомления выключены. Вы можете снова включить их через Настройки.</string>
|
||||||
|
|
||||||
<!-- SimpleX Chat foreground Service -->
|
<!-- SimpleX Chat foreground Service -->
|
||||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
|
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
|
||||||
<string name="simplex_service_notification_text">Приём сообщений…</string>
|
<string name="simplex_service_notification_text">Приём сообщений…</string>
|
||||||
|
|
||||||
|
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||||
|
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
|
||||||
|
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
|
||||||
|
<string name="la_notice_turn_on">Включить</string>
|
||||||
|
|
||||||
|
<!-- LocalAuthentication.kt -->
|
||||||
|
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
|
||||||
|
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
|
||||||
|
<string name="auth_unlock">Разблокировать</string>
|
||||||
|
<string name="auth_log_in_using_credential">Пройдите аутентификацию</string>
|
||||||
|
<string name="auth_enable_simplex_lock">Включить блокировку SimpleX</string>
|
||||||
|
<string name="auth_disable_simplex_lock">Отключить блокировку SimpleX</string>
|
||||||
|
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
|
||||||
|
<string name="auth_error">Ошибка аутентификации</string>
|
||||||
|
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||||
|
<string name="auth_failed">Ошибка аутентификации</string>
|
||||||
|
<string name="auth_unavailable">Аутентификация недоступна</string>
|
||||||
|
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
|
||||||
|
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
|
||||||
|
<string name="auth_retry">Повторить</string>
|
||||||
|
|
||||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||||
<string name="reply_verb">Ответить</string>
|
<string name="reply_verb">Ответить</string>
|
||||||
<string name="share_verb">Поделиться</string>
|
<string name="share_verb">Поделиться</string>
|
||||||
@@ -144,7 +168,7 @@
|
|||||||
<string name="toast_permission_denied">Разрешение не получено!</string>
|
<string name="toast_permission_denied">Разрешение не получено!</string>
|
||||||
<string name="use_camera_button">Камера</string>
|
<string name="use_camera_button">Камера</string>
|
||||||
<string name="from_gallery_button">Галерея</string>
|
<string name="from_gallery_button">Галерея</string>
|
||||||
<string name="choose_file">Файлы\n(v2.0)</string>
|
<string name="choose_file">Файлы</string>
|
||||||
|
|
||||||
<!-- help - ChatHelpView.kt -->
|
<!-- help - ChatHelpView.kt -->
|
||||||
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||||
@@ -165,6 +189,13 @@
|
|||||||
<string name="accept_contact_button">Принять</string>
|
<string name="accept_contact_button">Принять</string>
|
||||||
<string name="reject_contact_button">Отклонить</string>
|
<string name="reject_contact_button">Отклонить</string>
|
||||||
|
|
||||||
|
<!-- Clear Chat - ChatListNavLinkView.kt -->
|
||||||
|
<string name="clear_chat_question">Очистить чат?</string>
|
||||||
|
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
|
||||||
|
<string name="clear_verb">Очистить</string>
|
||||||
|
<string name="clear_chat_button">Очистить чат</string>
|
||||||
|
<string name="mark_read">Прочитано</string>
|
||||||
|
|
||||||
<!-- Pending contact connection alert dialogues -->
|
<!-- Pending contact connection alert dialogues -->
|
||||||
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
|
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
|
||||||
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
|
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
|
||||||
@@ -210,10 +241,10 @@
|
|||||||
<string name="share_invitation_link">Поделиться ссылкой</string>
|
<string name="share_invitation_link">Поделиться ссылкой</string>
|
||||||
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
|
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
|
||||||
|
|
||||||
|
|
||||||
<!-- settings - SettingsView.kt -->
|
<!-- settings - SettingsView.kt -->
|
||||||
<string name="your_settings">Настройки</string>
|
<string name="your_settings">Настройки</string>
|
||||||
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
|
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
|
||||||
|
<string name="database_export_and_import">Экспорт и импорт архива чата</string>
|
||||||
<string name="about_simplex_chat">Информация о <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
<string name="about_simplex_chat">Информация о <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||||
<string name="how_to_use_simplex_chat">Как использовать</string>
|
<string name="how_to_use_simplex_chat">Как использовать</string>
|
||||||
<string name="markdown_help">Форматирование сообщений</string>
|
<string name="markdown_help">Форматирование сообщений</string>
|
||||||
@@ -221,6 +252,7 @@
|
|||||||
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
|
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
|
||||||
<string name="send_us_an_email">Отправить email</string>
|
<string name="send_us_an_email">Отправить email</string>
|
||||||
<string name="private_notifications">Приватные уведомления</string>
|
<string name="private_notifications">Приватные уведомления</string>
|
||||||
|
<string name="chat_lock">Блокировка SimpleX</string>
|
||||||
<string name="chat_console">Консоль</string>
|
<string name="chat_console">Консоль</string>
|
||||||
<string name="smp_servers">SMP серверы</string>
|
<string name="smp_servers">SMP серверы</string>
|
||||||
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>
|
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>
|
||||||
@@ -284,18 +316,20 @@
|
|||||||
<string name="callstatus_missed">пропущенный звонок</string>
|
<string name="callstatus_missed">пропущенный звонок</string>
|
||||||
<string name="callstatus_rejected">отклоненный звонок</string>
|
<string name="callstatus_rejected">отклоненный звонок</string>
|
||||||
<string name="callstatus_accepted">принятый звонок</string>
|
<string name="callstatus_accepted">принятый звонок</string>
|
||||||
<string name="callstatus_connecting">соединяется…</string>
|
<string name="callstatus_connecting">звонок соединяется…</string>
|
||||||
<string name="callstatus_in_progress">активный звонок</string>
|
<string name="callstatus_in_progress">активный звонок</string>
|
||||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
|
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||||
<string name="callstatus_error">ошибка соединения</string>
|
<string name="callstatus_error">ошибка звонка</string>
|
||||||
|
|
||||||
<!-- CallState -->
|
<!-- CallState -->
|
||||||
<string name="callstate_starting">инициализация…</string>
|
<string name="callstate_starting">инициализация…</string>
|
||||||
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
|
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
|
||||||
<string name="callstate_waiting_for_confirmation">ожидается подтверждение…</string>
|
<string name="callstate_waiting_for_confirmation">ожидается подтверждение…</string>
|
||||||
<string name="callstate_received_answer">получен ответ…</string>
|
<string name="callstate_received_answer">получен ответ…</string>
|
||||||
|
<string name="callstate_received_confirmation">получено подтверждение…</string>
|
||||||
<string name="callstate_connecting">соединяется…</string>
|
<string name="callstate_connecting">соединяется…</string>
|
||||||
<string name="callstate_connected">соединено</string>
|
<string name="callstate_connected">соединено</string>
|
||||||
|
<string name="callstate_ended">завершен</string>
|
||||||
|
|
||||||
<!-- SimpleXInfo -->
|
<!-- SimpleXInfo -->
|
||||||
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
|
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
|
||||||
@@ -329,4 +363,124 @@
|
|||||||
<string name="or">или</string>
|
<string name="or">или</string>
|
||||||
<string name="connect_with_the_developers">Соединиться с разработчиками</string>
|
<string name="connect_with_the_developers">Соединиться с разработчиками</string>
|
||||||
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">Чтобы задать вопросы и получать уведомления о <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">Чтобы задать вопросы и получать уведомления о <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
||||||
|
|
||||||
|
<!-- Call -->
|
||||||
|
<string name="incoming_video_call">Входящий видеозвонок</string>
|
||||||
|
<string name="incoming_audio_call">Входящий аудиозвонок</string>
|
||||||
|
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> хочет связаться с вами через </string>
|
||||||
|
<string name="video_call_no_encryption">видеозвонок (не e2e зашифрованный)</string>
|
||||||
|
<string name="encrypted_video_call">e2e зашифрованный видеозвонок</string>
|
||||||
|
<string name="audio_call_no_encryption">аудиозвонок (не e2e зашифрованный)</string>
|
||||||
|
<string name="encrypted_audio_call">e2e зашифрованный аудиозвонок</string>
|
||||||
|
<string name="accept">Принять</string>
|
||||||
|
<string name="reject">Отклонить</string>
|
||||||
|
<string name="ignore">Закрыть</string>
|
||||||
|
<string name="call_already_ended">Звонок уже завершен!</string>
|
||||||
|
<string name="icon_descr_video_call">видеозвонок</string>
|
||||||
|
<string name="icon_descr_audio_call">аудиозвонок</string>
|
||||||
|
|
||||||
|
<!-- Call settings -->
|
||||||
|
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
|
||||||
|
<string name="your_calls">Ваши звонки</string>
|
||||||
|
<string name="connect_calls_via_relay">Соединяться через сервер (relay)</string>
|
||||||
|
<string name="call_on_lock_screen">Звонки на экране блокировки:</string>
|
||||||
|
<string name="accept_call_on_lock_screen">Принимать</string>
|
||||||
|
<string name="show_call_on_lock_screen">Показывать</string>
|
||||||
|
<string name="no_call_on_lock_screen">Выключить</string>
|
||||||
|
|
||||||
|
<!-- Call Lock Screen -->
|
||||||
|
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
|
||||||
|
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
|
||||||
|
<string name="open_verb">Открыть</string>
|
||||||
|
|
||||||
|
<!-- Call overlay -->
|
||||||
|
<string name="status_e2e_encrypted">e2e зашифровано</string>
|
||||||
|
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
|
||||||
|
<string name="status_contact_has_e2e_encryption">у контакта есть e2e шифрование</string>
|
||||||
|
<string name="status_contact_has_no_e2e_encryption">у контакта нет e2e шифрования</string>
|
||||||
|
<string name="call_connection_peer_to_peer">peer-to-peer</string>
|
||||||
|
<string name="call_connection_via_relay">через relay сервер</string>
|
||||||
|
<string name="icon_descr_hang_up">Закончить звонок</string>
|
||||||
|
<string name="icon_descr_video_off">Выключить видео</string>
|
||||||
|
<string name="icon_descr_video_on">Включить видео</string>
|
||||||
|
<string name="icon_descr_audio_off">Выключить звук</string>
|
||||||
|
<string name="icon_descr_audio_on">Включить звук</string>
|
||||||
|
<string name="icon_descr_speaker_off">Выключить спикер</string>
|
||||||
|
<string name="icon_descr_speaker_on">Включить спикер</string>
|
||||||
|
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
|
||||||
|
|
||||||
|
<!-- Call items -->
|
||||||
|
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
|
||||||
|
<string name="icon_descr_call_missed">Пропущенный звонок</string>
|
||||||
|
<string name="icon_descr_call_rejected">Отклоненный звонок</string>
|
||||||
|
<string name="icon_descr_call_connecting">Соединяющийся звонок</string>
|
||||||
|
<string name="icon_descr_call_progress">Текущий звонок</string>
|
||||||
|
<string name="icon_descr_call_ended">Звонок завершен</string>
|
||||||
|
<string name="answer_call">Принять звонок</string>
|
||||||
|
|
||||||
|
<!-- Message integrity -->
|
||||||
|
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений"</string>
|
||||||
|
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
|
||||||
|
<string name="integrity_msg_bad_id">ошибка ID сообщения</string>
|
||||||
|
<string name="integrity_msg_duplicate">повторное сообщение</string>
|
||||||
|
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
|
||||||
|
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
|
||||||
|
|
||||||
|
<!-- Privacy settings -->
|
||||||
|
<string name="privacy_and_security">Конфиденциальность</string>
|
||||||
|
<string name="your_privacy">Конфиденциальность</string>
|
||||||
|
<string name="auto_accept_images">Автоприем изображений</string>
|
||||||
|
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
||||||
|
|
||||||
|
<!-- Settings sections -->
|
||||||
|
<string name="settings_section_title_you">ВЫ</string>
|
||||||
|
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
|
||||||
|
<string name="settings_section_title_help">ПОМОЩЬ</string>
|
||||||
|
<string name="settings_section_title_develop">ДЛЯ РАЗРАБОТЧИКОВ</string>
|
||||||
|
<string name="settings_section_title_device">УСТРОЙСТВО</string>
|
||||||
|
<string name="settings_section_title_chats">ЧАТЫ</string>
|
||||||
|
<string name="settings_experimental_features">Экспериментальные функции</string>
|
||||||
|
|
||||||
|
<!-- DatabaseView.kt -->
|
||||||
|
<string name="your_chat_database">Данные чата</string>
|
||||||
|
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
|
||||||
|
<string name="chat_is_running">Чат запущен</string>
|
||||||
|
<string name="chat_is_stopped">Чат остановлен</string>
|
||||||
|
<string name="chat_database_section">АРХИВ ЧАТА</string>
|
||||||
|
<string name="export_database">Экспорт архива чата</string>
|
||||||
|
<string name="import_database">Импорт архива чата</string>
|
||||||
|
<string name="new_database_archive">Новый архив чата</string>
|
||||||
|
<string name="old_database_archive">Старый архив чата</string>
|
||||||
|
<string name="delete_database">Удалить данные чата</string>
|
||||||
|
<string name="error_starting_chat">Ошибка при запуске чата</string>
|
||||||
|
<string name="stop_chat_question">Остановить чат?</string>
|
||||||
|
<string name="stop_chat_to_export_import_or_delete_chat_database">Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен.</string>
|
||||||
|
<string name="stop_chat_confirmation">Остановить</string>
|
||||||
|
<string name="error_stopping_chat">Ошибка при остановке чата</string>
|
||||||
|
<string name="error_exporting_chat_database">Ошибка при экспорте архива чата</string>
|
||||||
|
<string name="import_database_question">Импортировать архив чата?</string>
|
||||||
|
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
|
||||||
|
<string name="import_database_confirmation">Импортировать</string>
|
||||||
|
<string name="error_deleting_database">Ошибка при удалении данных чата</string>
|
||||||
|
<string name="error_importing_database">Ошибка при импорте архива чата</string>
|
||||||
|
<string name="chat_database_imported">Архив чата импортирован</string>
|
||||||
|
<string name="restart_the_app_to_use_imported_chat_database">Перезапустите приложение, чтобы использовать импортированные данные чата.</string>
|
||||||
|
<string name="delete_chat_profile_question">Удалить профиль?</string>
|
||||||
|
<string name="delete_chat_profile_action_cannot_be_undone_warning">Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
|
||||||
|
<string name="chat_database_deleted">Данные чата удалены</string>
|
||||||
|
<string name="restart_the_app_to_create_a_new_chat_profile">Перезапустите приложение, чтобы создать новый профиль.</string>
|
||||||
|
<string name="you_must_use_the_most_recent_version_of_database">Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов.</string>
|
||||||
|
<string name="stop_chat_to_enable_database_actions">Остановите чат, чтобы разблокировать операции с архивом чата.</string>
|
||||||
|
<string name="restart_the_app_to_use_new_chat_database">Перезапустите приложение, чтобы использовать новый архив чата.</string>
|
||||||
|
|
||||||
|
<!-- ChatModel.chatRunning interactions -->
|
||||||
|
<string name="chat_is_stopped_indication">Чат остановлен</string>
|
||||||
|
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
|
||||||
|
|
||||||
|
<!-- ChatArchiveView.kt -->
|
||||||
|
<string name="chat_archive_header">Архив чата</string>
|
||||||
|
<string name="save_archive">Сохранить архив</string>
|
||||||
|
<string name="delete_archive">Удалить архив</string>
|
||||||
|
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||||
|
<string name="delete_chat_archive_question">Удалить архив чата?</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -46,21 +46,45 @@
|
|||||||
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
|
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
|
||||||
<string name="cannot_delete_contact">Can\'t delete contact!</string>
|
<string name="cannot_delete_contact">Can\'t delete contact!</string>
|
||||||
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
|
||||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
|
||||||
<string name="error_deleting_contact">Error deleting contact</string>
|
<string name="error_deleting_contact">Error deleting contact</string>
|
||||||
<string name="error_deleting_group">Error deleting group</string>
|
<string name="error_deleting_group">Error deleting group</string>
|
||||||
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
||||||
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
||||||
|
|
||||||
<!-- background service notice - SimpleXAPI.kt -->
|
<!-- background service notice - SimpleXAPI.kt -->
|
||||||
|
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||||
<string name="private_instant_notifications">Private instant notifications!</string>
|
<string name="private_instant_notifications">Private instant notifications!</string>
|
||||||
|
<string name="private_instant_notifications_disabled">Private notifications disabled!</string>
|
||||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> – it uses a few percent of the battery per day.</string>
|
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> – it uses a few percent of the battery per day.</string>
|
||||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>It can be disabled via settings</b> – notifications will still be shown while the app is running.</string>
|
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>It can be disabled via settings</b> – notifications will still be shown while the app is running.</string>
|
||||||
|
<string name="turn_off_battery_optimization">In order to use it, please <b>disable battery optimization</b> for <xliff:g id="appName">SimpleX</xliff:g> in the next dialog. Otherwise, the notifications will be disabled.</string>
|
||||||
|
<string name="turning_off_background_service">Battery optimization is active, turning off Private notifications. You can re-enable them via settings.</string>
|
||||||
|
|
||||||
<!-- SimpleX Chat foreground Service -->
|
<!-- SimpleX Chat foreground Service -->
|
||||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
|
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
|
||||||
<string name="simplex_service_notification_text">Receiving messages…</string>
|
<string name="simplex_service_notification_text">Receiving messages…</string>
|
||||||
|
|
||||||
|
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||||
|
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
|
||||||
|
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled.</string>
|
||||||
|
<string name="la_notice_turn_on">Turn on</string>
|
||||||
|
|
||||||
|
<!-- LocalAuthentication.kt -->
|
||||||
|
<string name="auth_simplex_lock_turned_on">SimpleX Lock turned on</string>
|
||||||
|
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">You will be required to authenticate when you start or resume the app after 30 seconds in background.</string>
|
||||||
|
<string name="auth_unlock">Unlock</string>
|
||||||
|
<string name="auth_log_in_using_credential">Log in using your credential</string>
|
||||||
|
<string name="auth_enable_simplex_lock">Enable SimpleX Lock</string>
|
||||||
|
<string name="auth_disable_simplex_lock">Disable SimpleX Lock</string>
|
||||||
|
<string name="auth_confirm_credential">Confirm your credential</string>
|
||||||
|
<string name="auth_error">Authentication error</string>
|
||||||
|
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
|
||||||
|
<string name="auth_failed">Authentication failed</string>
|
||||||
|
<string name="auth_unavailable">Authentication unavailable</string>
|
||||||
|
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
|
||||||
|
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
|
||||||
|
<string name="auth_retry">Retry</string>
|
||||||
|
|
||||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||||
<string name="reply_verb">Reply</string>
|
<string name="reply_verb">Reply</string>
|
||||||
<string name="share_verb">Share</string>
|
<string name="share_verb">Share</string>
|
||||||
@@ -145,7 +169,7 @@
|
|||||||
<string name="toast_permission_denied">Permission Denied!</string>
|
<string name="toast_permission_denied">Permission Denied!</string>
|
||||||
<string name="use_camera_button">Use Camera</string>
|
<string name="use_camera_button">Use Camera</string>
|
||||||
<string name="from_gallery_button">From Gallery</string>
|
<string name="from_gallery_button">From Gallery</string>
|
||||||
<string name="choose_file">Choose file\n(new in v2.0)</string>
|
<string name="choose_file">Choose file</string>
|
||||||
|
|
||||||
<!-- help - ChatHelpView.kt -->
|
<!-- help - ChatHelpView.kt -->
|
||||||
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||||
@@ -166,6 +190,13 @@
|
|||||||
<string name="accept_contact_button">Accept</string>
|
<string name="accept_contact_button">Accept</string>
|
||||||
<string name="reject_contact_button">Reject</string>
|
<string name="reject_contact_button">Reject</string>
|
||||||
|
|
||||||
|
<!-- Clear Chat - ChatListNavLinkView.kt -->
|
||||||
|
<string name="clear_chat_question">Clear chat?</string>
|
||||||
|
<string name="clear_chat_warning">All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</string>
|
||||||
|
<string name="clear_verb">Clear</string>
|
||||||
|
<string name="clear_chat_button">Clear chat</string>
|
||||||
|
<string name="mark_read">Mark read</string>
|
||||||
|
|
||||||
<!-- Pending contact connection alert dialogues -->
|
<!-- Pending contact connection alert dialogues -->
|
||||||
<string name="you_invited_your_contact">You invited your contact</string>
|
<string name="you_invited_your_contact">You invited your contact</string>
|
||||||
<string name="you_accepted_connection">You accepted connection</string>
|
<string name="you_accepted_connection">You accepted connection</string>
|
||||||
@@ -219,6 +250,7 @@
|
|||||||
<!-- settings - SettingsView.kt -->
|
<!-- settings - SettingsView.kt -->
|
||||||
<string name="your_settings">Your settings</string>
|
<string name="your_settings">Your settings</string>
|
||||||
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
|
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
|
||||||
|
<string name="database_export_and_import">Database export & import</string>
|
||||||
<string name="about_simplex_chat">About <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
<string name="about_simplex_chat">About <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||||
<string name="how_to_use_simplex_chat">How to use it</string>
|
<string name="how_to_use_simplex_chat">How to use it</string>
|
||||||
<string name="markdown_help">Markdown help</string>
|
<string name="markdown_help">Markdown help</string>
|
||||||
@@ -226,6 +258,7 @@
|
|||||||
<string name="chat_with_the_founder">Connect to the developers</string>
|
<string name="chat_with_the_founder">Connect to the developers</string>
|
||||||
<string name="send_us_an_email">Send us email</string>
|
<string name="send_us_an_email">Send us email</string>
|
||||||
<string name="private_notifications">Private notifications</string>
|
<string name="private_notifications">Private notifications</string>
|
||||||
|
<string name="chat_lock">SimpleX Lock</string>
|
||||||
<string name="chat_console">Chat console</string>
|
<string name="chat_console">Chat console</string>
|
||||||
<string name="smp_servers">SMP servers</string>
|
<string name="smp_servers">SMP servers</string>
|
||||||
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>
|
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>
|
||||||
@@ -280,23 +313,25 @@
|
|||||||
<string name="colored">colored</string>
|
<string name="colored">colored</string>
|
||||||
<string name="secret">secret</string>
|
<string name="secret">secret</string>
|
||||||
|
|
||||||
<!-- CICallStatus -->
|
<!-- CICallStatus - in chat items -->
|
||||||
<string name="callstatus_calling">calling…</string>
|
<string name="callstatus_calling">calling…</string>
|
||||||
<string name="callstatus_missed">missed</string>
|
<string name="callstatus_missed">missed call</string>
|
||||||
<string name="callstatus_rejected">rejected</string>
|
<string name="callstatus_rejected">rejected call</string>
|
||||||
<string name="callstatus_accepted">accepted</string>
|
<string name="callstatus_accepted">accepted call</string>
|
||||||
<string name="callstatus_connecting">connecting…</string>
|
<string name="callstatus_connecting">connecting call…</string>
|
||||||
<string name="callstatus_in_progress">in progress</string>
|
<string name="callstatus_in_progress">call in progress</string>
|
||||||
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
|
<string name="callstatus_ended">call ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||||
<string name="callstatus_error">error</string>
|
<string name="callstatus_error">call error</string>
|
||||||
|
|
||||||
<!-- CallState -->
|
<!-- CallState -->
|
||||||
<string name="callstate_starting">starting…</string>
|
<string name="callstate_starting">starting…</string>
|
||||||
<string name="callstate_waiting_for_answer">waiting for answer…</string>
|
<string name="callstate_waiting_for_answer">waiting for answer…</string>
|
||||||
<string name="callstate_waiting_for_confirmation">waiting for confirmation…</string>
|
<string name="callstate_waiting_for_confirmation">waiting for confirmation…</string>
|
||||||
<string name="callstate_received_answer">received answer…</string>
|
<string name="callstate_received_answer">received answer…</string>
|
||||||
|
<string name="callstate_received_confirmation">received confirmation…</string>
|
||||||
<string name="callstate_connecting">connecting…</string>
|
<string name="callstate_connecting">connecting…</string>
|
||||||
<string name="callstate_connected">connected</string>
|
<string name="callstate_connected">connected</string>
|
||||||
|
<string name="callstate_ended">ended</string>
|
||||||
|
|
||||||
<!-- SimpleXInfo -->
|
<!-- SimpleXInfo -->
|
||||||
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>
|
||||||
@@ -330,4 +365,124 @@
|
|||||||
<string name="or">or</string>
|
<string name="or">or</string>
|
||||||
<string name="connect_with_the_developers">Connect with the developers</string>
|
<string name="connect_with_the_developers">Connect with the developers</string>
|
||||||
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">To ask any questions and to receive <xliff:g id="appNameFull">SimpleX Chat</xliff:g> updates.</string>
|
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">To ask any questions and to receive <xliff:g id="appNameFull">SimpleX Chat</xliff:g> updates.</string>
|
||||||
|
|
||||||
|
<!-- Call -->
|
||||||
|
<string name="incoming_video_call">Incoming video call</string>
|
||||||
|
<string name="incoming_audio_call">Incoming audio call</string>
|
||||||
|
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> wants to connect with you via</string>
|
||||||
|
<string name="video_call_no_encryption">video call (not e2e encrypted)</string>
|
||||||
|
<string name="encrypted_video_call">e2e encrypted video call</string>
|
||||||
|
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
|
||||||
|
<string name="encrypted_audio_call">e2e encrypted audio call</string>
|
||||||
|
<string name="accept">Accept</string>
|
||||||
|
<string name="reject">Reject</string>
|
||||||
|
<string name="ignore">Ignore</string>
|
||||||
|
<string name="call_already_ended">Call already ended!</string>
|
||||||
|
<string name="icon_descr_video_call">video call</string>
|
||||||
|
<string name="icon_descr_audio_call">audio call</string>
|
||||||
|
|
||||||
|
<!-- Call settings -->
|
||||||
|
<string name="settings_audio_video_calls">Audio & video calls</string>
|
||||||
|
<string name="your_calls">Your calls</string>
|
||||||
|
<string name="connect_calls_via_relay">Connect via relay</string>
|
||||||
|
<string name="call_on_lock_screen">Calls on lock screen:</string>
|
||||||
|
<string name="accept_call_on_lock_screen">Accept</string>
|
||||||
|
<string name="show_call_on_lock_screen">Show</string>
|
||||||
|
<string name="no_call_on_lock_screen">Disable</string>
|
||||||
|
|
||||||
|
<!-- Call Lock Screen -->
|
||||||
|
<string name="open_simplex_chat_to_accept_call">Open <xliff:g id="appNameFull">SimpleX Chat</xliff:g> to accept call</string>
|
||||||
|
<string name="allow_accepting_calls_from_lock_screen">Enable calls from lock screen via Settings.</string>
|
||||||
|
<string name="open_verb">Open</string>
|
||||||
|
|
||||||
|
<!-- Call overlay -->
|
||||||
|
<string name="status_e2e_encrypted">e2e encrypted</string>
|
||||||
|
<string name="status_no_e2e_encryption">no e2e encryption</string>
|
||||||
|
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
|
||||||
|
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
|
||||||
|
<string name="call_connection_peer_to_peer">peer-to-peer</string>
|
||||||
|
<string name="call_connection_via_relay">via relay</string>
|
||||||
|
<string name="icon_descr_hang_up">Hang up</string>
|
||||||
|
<string name="icon_descr_video_off">Video off</string>
|
||||||
|
<string name="icon_descr_video_on">Video on</string>
|
||||||
|
<string name="icon_descr_audio_off">Audio off</string>
|
||||||
|
<string name="icon_descr_audio_on">Audio on</string>
|
||||||
|
<string name="icon_descr_speaker_off">Speaker off</string>
|
||||||
|
<string name="icon_descr_speaker_on">Speaker on</string>
|
||||||
|
<string name="icon_descr_flip_camera">Flip camera</string>
|
||||||
|
|
||||||
|
<!-- Call items -->
|
||||||
|
<string name="icon_descr_call_pending_sent">Pending call</string>
|
||||||
|
<string name="icon_descr_call_missed">Missed call</string>
|
||||||
|
<string name="icon_descr_call_rejected">Rejected call</string>
|
||||||
|
<string name="icon_descr_call_connecting">Connecting call</string>
|
||||||
|
<string name="icon_descr_call_progress">Call in progress</string>
|
||||||
|
<string name="icon_descr_call_ended">Call ended</string>
|
||||||
|
<string name="answer_call">Answer call</string>
|
||||||
|
|
||||||
|
<!-- Message integrity -->
|
||||||
|
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> skipped message(s)"</string>
|
||||||
|
<string name="integrity_msg_bad_hash">bad message hash</string>
|
||||||
|
<string name="integrity_msg_bad_id">bad message ID</string>
|
||||||
|
<string name="integrity_msg_duplicate">duplicate message</string>
|
||||||
|
<string name="alert_title_skipped_messages">Skipped messages</string>
|
||||||
|
<string name="alert_text_skipped_messages_it_can_happen_when">It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages.</string>
|
||||||
|
|
||||||
|
<!-- Privacy settings -->
|
||||||
|
<string name="privacy_and_security">Privacy & security</string>
|
||||||
|
<string name="your_privacy">Your privacy</string>
|
||||||
|
<string name="auto_accept_images">Auto-accept images</string>
|
||||||
|
<string name="send_link_previews">Send link previews</string>
|
||||||
|
|
||||||
|
<!-- Settings sections -->
|
||||||
|
<string name="settings_section_title_you">YOU</string>
|
||||||
|
<string name="settings_section_title_settings">SETTINGS</string>
|
||||||
|
<string name="settings_section_title_help">HELP</string>
|
||||||
|
<string name="settings_section_title_develop">DEVELOP</string>
|
||||||
|
<string name="settings_section_title_device">DEVICE</string>
|
||||||
|
<string name="settings_section_title_chats">CHATS</string>
|
||||||
|
<string name="settings_experimental_features">Experimental features</string>
|
||||||
|
|
||||||
|
<!-- DatabaseView.kt -->
|
||||||
|
<string name="your_chat_database">Your chat database</string>
|
||||||
|
<string name="run_chat_section">RUN CHAT</string>
|
||||||
|
<string name="chat_is_running">Chat is running</string>
|
||||||
|
<string name="chat_is_stopped">Chat is stopped</string>
|
||||||
|
<string name="chat_database_section">CHAT DATABASE</string>
|
||||||
|
<string name="export_database">Export database</string>
|
||||||
|
<string name="import_database">Import database</string>
|
||||||
|
<string name="new_database_archive">New database archive</string>
|
||||||
|
<string name="old_database_archive">Old database archive</string>
|
||||||
|
<string name="delete_database">Delete database</string>
|
||||||
|
<string name="error_starting_chat">Error starting chat</string>
|
||||||
|
<string name="stop_chat_question">Stop chat?</string>
|
||||||
|
<string name="stop_chat_to_export_import_or_delete_chat_database">Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped.</string>
|
||||||
|
<string name="stop_chat_confirmation">Stop</string>
|
||||||
|
<string name="error_stopping_chat">Error stopping chat</string>
|
||||||
|
<string name="error_exporting_chat_database">Error exporting chat database</string>
|
||||||
|
<string name="import_database_question">Import chat database?</string>
|
||||||
|
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Your current chat database will be DELETED and REPLACED with the imported one.\nThis action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
|
||||||
|
<string name="import_database_confirmation">Import</string>
|
||||||
|
<string name="error_deleting_database">Error deleting chat database</string>
|
||||||
|
<string name="error_importing_database">Error importing chat database</string>
|
||||||
|
<string name="chat_database_imported">Chat database imported</string>
|
||||||
|
<string name="restart_the_app_to_use_imported_chat_database">Restart the app to use imported chat database.</string>
|
||||||
|
<string name="delete_chat_profile_question">Delete chat profile?</string>
|
||||||
|
<string name="delete_chat_profile_action_cannot_be_undone_warning">This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
|
||||||
|
<string name="chat_database_deleted">Chat database deleted</string>
|
||||||
|
<string name="restart_the_app_to_create_a_new_chat_profile">Restart the app to create a new chat profile.</string>
|
||||||
|
<string name="you_must_use_the_most_recent_version_of_database">You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.</string>
|
||||||
|
<string name="stop_chat_to_enable_database_actions">Stop chat to enable database actions.</string>
|
||||||
|
<string name="restart_the_app_to_use_new_chat_database">Restart the app to use new chat database.</string>
|
||||||
|
|
||||||
|
<!-- ChatModel.chatRunning interactions -->
|
||||||
|
<string name="chat_is_stopped_indication">Chat is stopped</string>
|
||||||
|
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">You can start chat via app Settings / Database or by restarting the app.</string>
|
||||||
|
|
||||||
|
<!-- ChatArchiveView.kt -->
|
||||||
|
<string name="chat_archive_header">Chat archive</string>
|
||||||
|
<string name="save_archive">Save archive</string>
|
||||||
|
<string name="delete_archive">Delete archive</string>
|
||||||
|
<string name="archive_created_on_ts">Created on <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||||
|
<string name="delete_chat_archive_question">Delete chat archive?</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
classpath 'com.android.tools.build:gradle:7.2.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
|
||||||
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
|
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ buildscript {
|
|||||||
}
|
}
|
||||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '7.1.3' apply false
|
id 'com.android.application' version '7.2.0' apply false
|
||||||
id 'com.android.library' version '7.1.3' apply false
|
id 'com.android.library' version '7.2.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
|
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Mon Feb 14 14:23:51 GMT 2022
|
#Mon Feb 14 14:23:51 GMT 2022
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
@@ -20,18 +21,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
m.deviceToken = token
|
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
|
||||||
UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS)
|
m.deviceToken = deviceToken
|
||||||
// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications")
|
if m.savedToken != nil {
|
||||||
// if useNotifications {
|
registerToken(token: deviceToken)
|
||||||
// Task {
|
}
|
||||||
// do {
|
|
||||||
// m.tokenStatus = try await apiRegisterToken(token: token)
|
|
||||||
// } catch {
|
|
||||||
// logger.error("apiRegisterToken error: \(responseError(error))")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
@@ -42,19 +36,19 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
|
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
|
||||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
logger.debug("AppDelegate: didReceiveRemoteNotification")
|
logger.debug("AppDelegate: didReceiveRemoteNotification")
|
||||||
|
print("*** userInfo", userInfo)
|
||||||
|
let m = ChatModel.shared
|
||||||
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
||||||
UserDefaults.standard.bool(forKey: "useNotifications") {
|
m.notificationMode != .off {
|
||||||
if let verification = ntfData["verification"] as? String,
|
if let verification = ntfData["verification"] as? String,
|
||||||
let nonce = ntfData["nonce"] as? String {
|
let nonce = ntfData["nonce"] as? String {
|
||||||
if let token = ChatModel.shared.deviceToken {
|
if let token = ChatModel.shared.deviceToken {
|
||||||
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
|
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
|
||||||
Task {
|
Task {
|
||||||
let m = ChatModel.shared
|
|
||||||
do {
|
do {
|
||||||
if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed }
|
if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed }
|
||||||
try await apiVerifyToken(token: token, code: verification, nonce: nonce)
|
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
|
||||||
m.tokenStatus = .active
|
m.tokenStatus = .active
|
||||||
try await apiIntervalNofication(token: token, interval: 20)
|
|
||||||
} catch {
|
} catch {
|
||||||
if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr {
|
if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr {
|
||||||
m.tokenStatus = .expired
|
m.tokenStatus = .expired
|
||||||
@@ -67,15 +61,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
completionHandler(.noData)
|
completionHandler(.noData)
|
||||||
}
|
}
|
||||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||||
// TODO check if app in background
|
|
||||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||||
// TODO remove
|
if appStateGroupDefault.get().inactive {
|
||||||
NtfManager.shared.notifyCheckingMessages()
|
receiveMessages(completionHandler)
|
||||||
receiveMessages(completionHandler)
|
} else {
|
||||||
} else if let smpQueue = ntfData["checkMessage"] as? String {
|
completionHandler(.noData)
|
||||||
// TODO check if app in background
|
}
|
||||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)")
|
|
||||||
receiveMessages(completionHandler)
|
|
||||||
} else {
|
} else {
|
||||||
completionHandler(.noData)
|
completionHandler(.noData)
|
||||||
}
|
}
|
||||||
@@ -84,6 +75,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
logger.debug("AppDelegate: applicationWillTerminate")
|
||||||
|
terminateChat()
|
||||||
|
}
|
||||||
|
|
||||||
private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
let complete = BGManager.shared.completionHandler {
|
let complete = BGManager.shared.completionHandler {
|
||||||
logger.debug("AppDelegate: completed BGManager.receiveMessages")
|
logger.debug("AppDelegate: completed BGManager.receiveMessages")
|
||||||
|
|||||||
@@ -10,27 +10,109 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@State private var showNotificationAlert = false
|
@ObservedObject var callController = CallController.shared
|
||||||
|
@Binding var doAuthenticate: Bool
|
||||||
|
@Binding var userAuthorized: Bool?
|
||||||
|
@State private var showChatInfo: Bool = false // TODO comprehensively close modal views on authentication
|
||||||
|
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||||
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||||
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let step = chatModel.onboardingStage {
|
if prefPerformLA && userAuthorized != true {
|
||||||
|
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
|
||||||
|
} else if !chatModel.v3DBMigration.startChat {
|
||||||
|
MigrateToAppGroupView()
|
||||||
|
} else if let step = chatModel.onboardingStage {
|
||||||
if case .onboardingComplete = step,
|
if case .onboardingComplete = step,
|
||||||
let user = chatModel.currentUser {
|
chatModel.currentUser != nil {
|
||||||
ChatListView(user: user)
|
mainView()
|
||||||
.onAppear {
|
|
||||||
NtfManager.shared.requestAuthorization(onDeny: {
|
|
||||||
alertManager.showAlert(notificationAlert())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(onboarding: step)
|
OnboardingView(onboarding: step)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if doAuthenticate { runAuthenticate() }
|
||||||
|
}
|
||||||
|
.onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } }
|
||||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mainView() -> some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
ChatListView(showChatInfo: $showChatInfo)
|
||||||
|
.onAppear {
|
||||||
|
NtfManager.shared.requestAuthorization(onDeny: {
|
||||||
|
alertManager.showAlert(notificationAlert())
|
||||||
|
})
|
||||||
|
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||||
|
if (!prefLANoticeShown && prefShowLANotice) {
|
||||||
|
prefLANoticeShown = true
|
||||||
|
alertManager.showAlert(laNoticeAlert())
|
||||||
|
}
|
||||||
|
prefShowLANotice = true
|
||||||
|
}
|
||||||
|
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||||
|
ActiveCallView(call: call)
|
||||||
|
}
|
||||||
|
IncomingCallView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAuthenticate() {
|
||||||
|
if !prefPerformLA {
|
||||||
|
userAuthorized = true
|
||||||
|
} else if showChatInfo {
|
||||||
|
showChatInfo = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
justAuthenticate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
justAuthenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func justAuthenticate() {
|
||||||
|
userAuthorized = false
|
||||||
|
authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in
|
||||||
|
switch (laResult) {
|
||||||
|
case .success:
|
||||||
|
userAuthorized = true
|
||||||
|
case .failed:
|
||||||
|
AlertManager.shared.showAlert(laFailedAlert())
|
||||||
|
case .unavailable:
|
||||||
|
userAuthorized = true
|
||||||
|
prefPerformLA = false
|
||||||
|
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func laNoticeAlert() -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("SimpleX Lock"),
|
||||||
|
message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."),
|
||||||
|
primaryButton: .default(Text("Turn on")) {
|
||||||
|
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||||
|
switch laResult {
|
||||||
|
case .success:
|
||||||
|
prefPerformLA = true
|
||||||
|
alertManager.showAlert(laTurnedOnAlert())
|
||||||
|
case .failed:
|
||||||
|
prefPerformLA = false
|
||||||
|
alertManager.showAlert(laFailedAlert())
|
||||||
|
case .unavailable:
|
||||||
|
prefPerformLA = false
|
||||||
|
alertManager.showAlert(laUnavailableInstructionAlert())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func notificationAlert() -> Alert {
|
func notificationAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Notifications are disabled!"),
|
title: Text("Notifications are disabled!"),
|
||||||
@@ -90,11 +172,15 @@ final class AlertManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) {
|
func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) {
|
||||||
if let message = message {
|
showAlert(mkAlert(title: title, message: message))
|
||||||
showAlert(Alert(title: Text(title), message: Text(message)))
|
}
|
||||||
} else {
|
}
|
||||||
showAlert(Alert(title: Text(title)))
|
|
||||||
}
|
func mkAlert(title: LocalizedStringKey, message: LocalizedStringKey? = nil) -> Alert {
|
||||||
|
if let message = message {
|
||||||
|
return Alert(title: Text(title), message: Text(message))
|
||||||
|
} else {
|
||||||
|
return Alert(title: Text(title))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
private let receiveTaskId = "chat.simplex.app.receive"
|
private let receiveTaskId = "chat.simplex.app.receive"
|
||||||
|
|
||||||
@@ -16,11 +17,14 @@ private let waitForMessages: TimeInterval = 6
|
|||||||
|
|
||||||
private let bgRefreshInterval: TimeInterval = 450
|
private let bgRefreshInterval: TimeInterval = 450
|
||||||
|
|
||||||
|
private let maxTimerCount = 9
|
||||||
|
|
||||||
class BGManager {
|
class BGManager {
|
||||||
static let shared = BGManager()
|
static let shared = BGManager()
|
||||||
var chatReceiver: ChatReceiver?
|
var chatReceiver: ChatReceiver?
|
||||||
var bgTimer: Timer?
|
var bgTimer: Timer?
|
||||||
var completed = true
|
var completed = true
|
||||||
|
var timerCount = 0
|
||||||
|
|
||||||
func register() {
|
func register() {
|
||||||
logger.debug("BGManager.register")
|
logger.debug("BGManager.register")
|
||||||
@@ -43,11 +47,16 @@ class BGManager {
|
|||||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||||
logger.debug("BGManager.handleRefresh")
|
logger.debug("BGManager.handleRefresh")
|
||||||
schedule()
|
schedule()
|
||||||
let completeRefresh = completionHandler {
|
if appStateGroupDefault.get().inactive {
|
||||||
|
let completeRefresh = completionHandler {
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
task.expirationHandler = { completeRefresh("expirationHandler") }
|
||||||
|
receiveMessages(completeRefresh)
|
||||||
|
} else {
|
||||||
|
logger.debug("BGManager.completionHandler: already active, not started")
|
||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
task.expirationHandler = { completeRefresh("expirationHandler") }
|
|
||||||
receiveMessages(completeRefresh)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) {
|
func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) {
|
||||||
@@ -59,6 +68,8 @@ class BGManager {
|
|||||||
self.chatReceiver = nil
|
self.chatReceiver = nil
|
||||||
self.bgTimer?.invalidate()
|
self.bgTimer?.invalidate()
|
||||||
self.bgTimer = nil
|
self.bgTimer = nil
|
||||||
|
self.timerCount = 0
|
||||||
|
suspendBgRefresh()
|
||||||
complete()
|
complete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,20 +82,28 @@ class BGManager {
|
|||||||
}
|
}
|
||||||
self.completed = false
|
self.completed = false
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
initializeChat()
|
do {
|
||||||
|
try initializeChat(start: true)
|
||||||
|
} catch let error {
|
||||||
|
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||||
|
}
|
||||||
if ChatModel.shared.currentUser == nil {
|
if ChatModel.shared.currentUser == nil {
|
||||||
completeReceiving("no current user")
|
completeReceiving("no current user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug("BGManager.receiveMessages: starting chat")
|
logger.debug("BGManager.receiveMessages: starting chat")
|
||||||
|
activateChat(appState: .bgRefresh)
|
||||||
let cr = ChatReceiver()
|
let cr = ChatReceiver()
|
||||||
self.chatReceiver = cr
|
self.chatReceiver = cr
|
||||||
cr.start()
|
cr.start()
|
||||||
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
|
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
|
||||||
logger.debug("BGManager.receiveMessages: timer")
|
logger.debug("BGManager.receiveMessages: timer")
|
||||||
self.bgTimer = timer
|
self.bgTimer = timer
|
||||||
|
self.timerCount += 1
|
||||||
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
|
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
|
||||||
completeReceiving("timer (no messages after \(waitForMessages) seconds)")
|
completeReceiving("timer (no messages after \(waitForMessages) seconds)")
|
||||||
|
} else if self.timerCount >= maxTimerCount {
|
||||||
|
completeReceiving("timer (called \(maxTimerCount) times")
|
||||||
}
|
}
|
||||||
}, forMode: .default)
|
}, forMode: .default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
final class ChatModel: ObservableObject {
|
final class ChatModel: ObservableObject {
|
||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
|
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||||
@Published var currentUser: User?
|
@Published var currentUser: User?
|
||||||
|
@Published var chatRunning: Bool?
|
||||||
|
@Published var chatDbChanged = false
|
||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published var chats: [Chat] = []
|
||||||
// current chat
|
// current chat
|
||||||
@@ -24,13 +29,18 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var userAddress: String?
|
@Published var userAddress: String?
|
||||||
@Published var userSMPServers: [String]?
|
@Published var userSMPServers: [String]?
|
||||||
@Published var appOpenUrl: URL?
|
@Published var appOpenUrl: URL?
|
||||||
@Published var deviceToken: String?
|
@Published var deviceToken: DeviceToken?
|
||||||
@Published var tokenStatus = NtfTknStatus.new
|
@Published var savedToken: DeviceToken?
|
||||||
|
@Published var tokenRegistered = false
|
||||||
|
@Published var tokenStatus: NtfTknStatus?
|
||||||
|
@Published var notificationMode = NotificationsMode.off
|
||||||
|
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
|
||||||
// current WebRTC call
|
// current WebRTC call
|
||||||
@Published var callInvitations: Dictionary<String, CallInvitation> = [:]
|
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||||
@Published var activeCallInvitation: ContactRef?
|
|
||||||
@Published var activeCall: Call?
|
@Published var activeCall: Call?
|
||||||
@Published var callCommand: WCallCommand?
|
@Published var callCommand: WCallCommand?
|
||||||
|
@Published var showCallView = false
|
||||||
|
var callWebView: WKWebView?
|
||||||
|
|
||||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||||
|
|
||||||
@@ -84,13 +94,27 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func replaceChat(_ id: String, _ chat: Chat) {
|
func replaceChat(_ id: String, _ chat: Chat) {
|
||||||
if let i = getChatIndex(id) {
|
if let i = getChatIndex(id) {
|
||||||
|
let serverInfo = chats[i].serverInfo
|
||||||
chats[i] = chat
|
chats[i] = chat
|
||||||
|
chats[i].serverInfo = serverInfo
|
||||||
} else {
|
} else {
|
||||||
// invalid state, correcting
|
// invalid state, correcting
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateChats(with newChats: [ChatData]) {
|
||||||
|
for c in newChats {
|
||||||
|
if let chat = getChat(c.id) {
|
||||||
|
chat.chatInfo = c.chatInfo
|
||||||
|
chat.chatItems = c.chatItems
|
||||||
|
chat.chatStats = c.chatStats
|
||||||
|
} else {
|
||||||
|
addChat(Chat(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update previews
|
// update previews
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
@@ -185,6 +209,19 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearChat(_ cInfo: ChatInfo) {
|
||||||
|
// clear preview
|
||||||
|
if let chat = getChat(cInfo.id) {
|
||||||
|
chat.chatItems = []
|
||||||
|
chat.chatStats = ChatStats()
|
||||||
|
chat.chatInfo = cInfo
|
||||||
|
}
|
||||||
|
// clear current chat
|
||||||
|
if chatId == cInfo.id {
|
||||||
|
chatItems = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update preview
|
// update preview
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
@@ -227,6 +264,7 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
@Published var chatItems: [ChatItem]
|
@Published var chatItems: [ChatItem]
|
||||||
@Published var chatStats: ChatStats
|
@Published var chatStats: ChatStats
|
||||||
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
|
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
|
||||||
|
var created = Date.now
|
||||||
|
|
||||||
struct ServerInfo: Decodable {
|
struct ServerInfo: Decodable {
|
||||||
var networkStatus: NetworkStatus
|
var networkStatus: NetworkStatus
|
||||||
@@ -284,4 +322,6 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var id: ChatId { get { chatInfo.id } }
|
var id: ChatId { get { chatInfo.id } }
|
||||||
|
|
||||||
|
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
|
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
|
||||||
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
|
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
|
||||||
@@ -37,26 +38,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
Task { await acceptContactRequest(contactRequest) }
|
Task { await acceptContactRequest(contactRequest) }
|
||||||
} else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall),
|
} else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall),
|
||||||
let chatId = content.userInfo["chatId"] as? String,
|
let chatId = content.userInfo["chatId"] as? String,
|
||||||
case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo,
|
let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
|
||||||
let invitation = chatModel.callInvitations[chatId] {
|
let cc = CallController.shared
|
||||||
if action == ntfActionAcceptCall {
|
if action == ntfActionAcceptCall {
|
||||||
chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia)
|
cc.answerCall(invitation: invitation)
|
||||||
chatModel.chatId = nil
|
|
||||||
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
|
|
||||||
} else {
|
} else {
|
||||||
Task {
|
cc.endCall(invitation: invitation)
|
||||||
do {
|
|
||||||
try await apiRejectCall(contact)
|
|
||||||
if chatModel.activeCall?.contact.id == chatId {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.callCommand = .end
|
|
||||||
chatModel.activeCall = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
chatModel.callInvitations.removeValue(forKey: chatId)
|
|
||||||
} else {
|
} else {
|
||||||
chatModel.chatId = content.targetContentIdentifier
|
chatModel.chatId = content.targetContentIdentifier
|
||||||
}
|
}
|
||||||
@@ -89,6 +77,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
// this notification is deliverd from the notifications server
|
// this notification is deliverd from the notifications server
|
||||||
// when the app is in foreground it does not need to be shown
|
// when the app is in foreground it does not need to be shown
|
||||||
case ntfCategoryCheckMessage: return []
|
case ntfCategoryCheckMessage: return []
|
||||||
|
case ntfCategoryCallInvitation: return []
|
||||||
default: return [.sound, .banner, .list]
|
default: return [.sound, .banner, .list]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -136,22 +125,22 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
actions: [
|
actions: [
|
||||||
UNNotificationAction(
|
UNNotificationAction(
|
||||||
identifier: ntfActionAcceptCall,
|
identifier: ntfActionAcceptCall,
|
||||||
title: NSLocalizedString("Answer", comment: "accept incoming call via notification")
|
title: NSLocalizedString("Accept", comment: "accept incoming call via notification"),
|
||||||
|
options: .foreground
|
||||||
),
|
),
|
||||||
UNNotificationAction(
|
UNNotificationAction(
|
||||||
identifier: ntfActionRejectCall,
|
identifier: ntfActionRejectCall,
|
||||||
title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification")
|
title: NSLocalizedString("Reject", comment: "reject incoming call via notification")
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
intentIdentifiers: [],
|
intentIdentifiers: [],
|
||||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification")
|
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification")
|
||||||
),
|
),
|
||||||
// TODO remove
|
|
||||||
UNNotificationCategory(
|
UNNotificationCategory(
|
||||||
identifier: ntfCategoryCheckingMessages,
|
identifier: ntfCategoryConnectionEvent,
|
||||||
actions: [],
|
actions: [],
|
||||||
intentIdentifiers: [],
|
intentIdentifiers: [],
|
||||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification")
|
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -194,19 +183,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
addNotification(createMessageReceivedNtf(cInfo, cItem))
|
addNotification(createMessageReceivedNtf(cInfo, cItem))
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) {
|
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||||
logger.debug("NtfManager.notifyCallInvitation")
|
logger.debug("NtfManager.notifyCallInvitation")
|
||||||
addNotification(createCallInvitationNtf(contact, invitation))
|
addNotification(createCallInvitationNtf(invitation))
|
||||||
}
|
|
||||||
|
|
||||||
// TODO remove
|
|
||||||
func notifyCheckingMessages() {
|
|
||||||
logger.debug("NtfManager.notifyCheckingMessages")
|
|
||||||
let content = createNotification(
|
|
||||||
categoryIdentifier: ntfCategoryCheckingMessages,
|
|
||||||
title: NSLocalizedString("Checking new messages...", comment: "notification")
|
|
||||||
)
|
|
||||||
addNotification(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// PushEnvironment.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 27/06/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
let pushEnvironment: PushEnvironment = {
|
||||||
|
guard let provisioningProfile = try? provisioningProfile(),
|
||||||
|
let entitlements = provisioningProfile["Entitlements"] as? [String: Any],
|
||||||
|
let environment = entitlements["aps-environment"] as? String,
|
||||||
|
let env = PushEnvironment(rawValue: environment)
|
||||||
|
else {
|
||||||
|
logger.warning("pushEnvironment: unknown, assuming production")
|
||||||
|
return .production
|
||||||
|
}
|
||||||
|
logger.debug("pushEnvironment: \(env.rawValue)")
|
||||||
|
return env
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func provisioningProfile() throws -> [String: Any]? {
|
||||||
|
guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let binaryString = try String(contentsOf: url, encoding: .isoLatin1)
|
||||||
|
|
||||||
|
let scanner = Scanner(string: binaryString)
|
||||||
|
guard scanner.scanUpToString("<plist") != nil,
|
||||||
|
let plistString = scanner.scanUpToString("</plist>"),
|
||||||
|
let data = (plistString + "</plist>").data(using: .isoLatin1)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
// CallTypes.swift
|
|
||||||
// SimpleX (iOS)
|
|
||||||
//
|
|
||||||
// Created by Evgeny on 05/05/2022.
|
|
||||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct WebRTCCallOffer: Encodable {
|
|
||||||
var callType: CallType
|
|
||||||
var rtcSession: WebRTCSession
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WebRTCSession: Codable {
|
|
||||||
var rtcSession: String
|
|
||||||
var rtcIceCandidates: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WebRTCExtraInfo: Codable {
|
|
||||||
var rtcIceCandidates: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CallInvitation {
|
|
||||||
var peerMedia: CallMediaType
|
|
||||||
var sharedKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CallType: Codable {
|
|
||||||
var media: CallMediaType
|
|
||||||
var capabilities: CallCapabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CallMediaType: String, Codable, Equatable {
|
|
||||||
case video = "video"
|
|
||||||
case audio = "audio"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CallCapabilities: Codable, Equatable {
|
|
||||||
var encryption: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WebRTCCallStatus: String, Encodable {
|
|
||||||
case connected = "connected"
|
|
||||||
case disconnected = "disconnected"
|
|
||||||
case failed = "failed"
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//
|
|
||||||
// GroupDefaults.swift
|
|
||||||
// SimpleX (iOS)
|
|
||||||
//
|
|
||||||
// Created by Evgeny on 26/04/2022.
|
|
||||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
func getGroupDefaults() -> UserDefaults? {
|
|
||||||
UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAppState(_ phase: ScenePhase) {
|
|
||||||
if let defaults = getGroupDefaults() {
|
|
||||||
defaults.set(phase == .background, forKey: "appInBackground")
|
|
||||||
defaults.synchronize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAppState() -> ScenePhase {
|
|
||||||
if let defaults = getGroupDefaults() {
|
|
||||||
if defaults.bool(forKey: "appInBackground") {
|
|
||||||
return .background
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .active
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
//
|
|
||||||
// Notifications.swift
|
|
||||||
// SimpleX
|
|
||||||
//
|
|
||||||
// Created by Evgeny on 28/04/2022.
|
|
||||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
|
|
||||||
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
|
|
||||||
let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
|
|
||||||
let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
|
|
||||||
let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
|
|
||||||
// TODO remove
|
|
||||||
let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES"
|
|
||||||
|
|
||||||
let appNotificationId = "chat.simplex.app.notification"
|
|
||||||
|
|
||||||
func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
|
|
||||||
createNotification(
|
|
||||||
categoryIdentifier: ntfCategoryContactRequest,
|
|
||||||
title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName),
|
|
||||||
body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName),
|
|
||||||
targetContentIdentifier: nil,
|
|
||||||
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent {
|
|
||||||
createNotification(
|
|
||||||
categoryIdentifier: ntfCategoryContactConnected,
|
|
||||||
title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName),
|
|
||||||
body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName),
|
|
||||||
targetContentIdentifier: contact.id
|
|
||||||
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
|
|
||||||
createNotification(
|
|
||||||
categoryIdentifier: ntfCategoryMessageReceived,
|
|
||||||
title: "\(cInfo.chatViewName):",
|
|
||||||
body: hideSecrets(cItem),
|
|
||||||
targetContentIdentifier: cInfo.id
|
|
||||||
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent {
|
|
||||||
createNotification(
|
|
||||||
categoryIdentifier: ntfCategoryCallInvitation,
|
|
||||||
title: "\(contact.chatViewName):",
|
|
||||||
body: String.localizedStringWithFormat(NSLocalizedString("Incoming %@ call", comment: "notification body"), invitation.peerMedia.rawValue),
|
|
||||||
targetContentIdentifier: nil,
|
|
||||||
userInfo: ["chatId": contact.id]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
|
|
||||||
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.categoryIdentifier = categoryIdentifier
|
|
||||||
content.title = title
|
|
||||||
if let s = subtitle { content.subtitle = s }
|
|
||||||
if let s = body { content.body = s }
|
|
||||||
content.targetContentIdentifier = targetContentIdentifier
|
|
||||||
content.userInfo = userInfo
|
|
||||||
// TODO move logic of adding sound here, so it applies to background notifications too
|
|
||||||
content.sound = .default
|
|
||||||
// content.interruptionLevel = .active
|
|
||||||
// content.relevanceScore = 0.5 // 0-1
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideSecrets(_ cItem: ChatItem) -> String {
|
|
||||||
if cItem.content.text != "" {
|
|
||||||
if let md = cItem.formattedText {
|
|
||||||
var res = ""
|
|
||||||
for ft in md {
|
|
||||||
if case .secret = ft.format {
|
|
||||||
res = res + "..."
|
|
||||||
} else {
|
|
||||||
res = res + ft.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
} else {
|
|
||||||
return cItem.content.text
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return cItem.file?.fileName ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import UIKit
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
private var chatController: chat_ctrl?
|
private var chatController: chat_ctrl?
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ enum TerminalItem: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
|
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
|
||||||
var id: UIBackgroundTaskIdentifier!
|
var id: UIBackgroundTaskIdentifier!
|
||||||
var running = true
|
var running = true
|
||||||
let endTask = {
|
let endTask = {
|
||||||
@@ -71,7 +72,7 @@ private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
|
|||||||
let msgDelay: Double = 7.5
|
let msgDelay: Double = 7.5
|
||||||
let maxTaskDuration: Double = 15
|
let maxTaskDuration: Double = 15
|
||||||
|
|
||||||
private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) -> ChatResponse {
|
private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||||
let endTask = beginBGTask()
|
let endTask = beginBGTask()
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
|
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
|
||||||
let r = f()
|
let r = f()
|
||||||
@@ -92,11 +93,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
|||||||
if case let .response(_, json) = resp {
|
if case let .response(_, json) = resp {
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||||
}
|
}
|
||||||
if case .apiParseMarkdown = cmd {} else {
|
DispatchQueue.main.async {
|
||||||
DispatchQueue.main.async {
|
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
|
||||||
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
|
ChatModel.shared.terminalItems.append(.resp(.now, resp))
|
||||||
ChatModel.shared.terminalItems.append(.resp(.now, resp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -107,10 +106,10 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatRecvMsg() async -> ChatResponse {
|
func chatRecvMsg() async -> ChatResponse? {
|
||||||
await withCheckedContinuation { cont in
|
await withCheckedContinuation { cont in
|
||||||
_ = withBGTask(bgDelay: msgDelay) {
|
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
|
||||||
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
|
let resp = recvSimpleXMsg()
|
||||||
cont.resume(returning: resp)
|
cont.resume(returning: resp)
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -134,7 +133,7 @@ func apiCreateActiveUser(_ p: Profile) throws -> User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiStartChat() throws -> Bool {
|
func apiStartChat() throws -> Bool {
|
||||||
let r = chatSendCmdSync(.startChat)
|
let r = chatSendCmdSync(.startChat(subscribe: true))
|
||||||
switch r {
|
switch r {
|
||||||
case .chatStarted: return true
|
case .chatStarted: return true
|
||||||
case .chatRunning: return false
|
case .chatRunning: return false
|
||||||
@@ -142,15 +141,47 @@ func apiStartChat() throws -> Bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiStopChat() async throws {
|
||||||
|
let r = await chatSendCmd(.apiStopChat)
|
||||||
|
switch r {
|
||||||
|
case .chatStopped: return
|
||||||
|
default: throw r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiActivateChat() {
|
||||||
|
let r = chatSendCmdSync(.apiActivateChat)
|
||||||
|
if case .cmdOk = r { return }
|
||||||
|
logger.error("apiActivateChat error: \(String(describing: r))")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||||
|
let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
|
||||||
|
if case .cmdOk = r { return }
|
||||||
|
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||||
|
}
|
||||||
|
|
||||||
func apiSetFilesFolder(filesFolder: String) throws {
|
func apiSetFilesFolder(filesFolder: String) throws {
|
||||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
|
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
|
||||||
if case .cmdOk = r { return }
|
if case .cmdOk = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetChats() throws -> [Chat] {
|
func apiExportArchive(config: ArchiveConfig) async throws {
|
||||||
|
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiImportArchive(config: ArchiveConfig) async throws {
|
||||||
|
try await sendCommandOkResp(.apiImportArchive(config: config))
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiDeleteStorage() async throws {
|
||||||
|
try await sendCommandOkResp(.apiDeleteStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiGetChats() throws -> [ChatData] {
|
||||||
let r = chatSendCmdSync(.apiGetChats)
|
let r = chatSendCmdSync(.apiGetChats)
|
||||||
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
|
if case let .apiChats(chats) = r { return chats }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +191,18 @@ func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadChat(chat: Chat) {
|
||||||
|
do {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
|
||||||
|
let m = ChatModel.shared
|
||||||
|
m.updateChatInfo(chat.chatInfo)
|
||||||
|
m.chatItems = chat.chatItems
|
||||||
|
} catch let error {
|
||||||
|
logger.error("loadChat error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
|
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
|
||||||
let chatModel = ChatModel.shared
|
let chatModel = ChatModel.shared
|
||||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
|
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
|
||||||
@@ -195,21 +238,45 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiRegisterToken(token: String) async throws -> NtfTknStatus {
|
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
|
||||||
let r = await chatSendCmd(.apiRegisterToken(token: token))
|
let r = chatSendCmdSync(.apiGetNtfToken)
|
||||||
|
switch r {
|
||||||
|
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
|
||||||
|
case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
|
||||||
|
default:
|
||||||
|
logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)")
|
||||||
|
return (nil, nil, .off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus {
|
||||||
|
let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode))
|
||||||
if case let .ntfTokenStatus(status) = r { return status }
|
if case let .ntfTokenStatus(status) = r { return status }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiVerifyToken(token: String, code: String, nonce: String) async throws {
|
func registerToken(token: DeviceToken) {
|
||||||
try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce))
|
let m = ChatModel.shared
|
||||||
|
let mode = m.notificationMode
|
||||||
|
if mode != .off && !m.tokenRegistered {
|
||||||
|
m.tokenRegistered = true
|
||||||
|
logger.debug("registerToken \(mode.rawValue)")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let status = try await apiRegisterToken(token: token, notificationMode: mode)
|
||||||
|
await MainActor.run { m.tokenStatus = status }
|
||||||
|
} catch let error {
|
||||||
|
logger.error("registerToken apiRegisterToken error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiIntervalNofication(token: String, interval: Int) async throws {
|
func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws {
|
||||||
try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval))
|
try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code))
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteToken(token: String) async throws {
|
func apiDeleteToken(token: DeviceToken) async throws {
|
||||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +353,22 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
||||||
|
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||||
|
if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearChat(_ chat: Chat) async {
|
||||||
|
do {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
let updatedChatInfo = try await apiClearChat(type: cInfo.chatType, id: cInfo.apiId)
|
||||||
|
DispatchQueue.main.async { ChatModel.shared.clearChat(updatedChatInfo) }
|
||||||
|
} catch {
|
||||||
|
logger.error("clearChat apiClearChat error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||||
let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
|
let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
|
||||||
switch r {
|
switch r {
|
||||||
@@ -295,12 +378,6 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiParseMarkdown(text: String) throws -> [FormattedText]? {
|
|
||||||
let r = chatSendCmdSync(.apiParseMarkdown(text: text))
|
|
||||||
if case let .apiParsedMarkdown(formattedText) = r { return formattedText }
|
|
||||||
throw r
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiCreateUserAddress() async throws -> String {
|
func apiCreateUserAddress() async throws -> String {
|
||||||
let r = await chatSendCmd(.createMyAddress)
|
let r = await chatSendCmd(.createMyAddress)
|
||||||
if case let .userContactLinkCreated(connReq) = r { return connReq }
|
if case let .userContactLinkCreated(connReq) = r { return connReq }
|
||||||
@@ -382,18 +459,18 @@ func apiRejectCall(_ contact: Contact) async throws {
|
|||||||
try await sendCommandOkResp(.apiRejectCall(contact: contact))
|
try await sendCommandOkResp(.apiRejectCall(contact: contact))
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws {
|
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities) async throws {
|
||||||
let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
|
let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
|
||||||
let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession)
|
let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession)
|
||||||
try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer))
|
try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws {
|
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws {
|
||||||
let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
|
let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
|
||||||
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
|
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws {
|
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
|
||||||
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
|
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
|
||||||
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
|
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
|
||||||
}
|
}
|
||||||
@@ -402,6 +479,12 @@ func apiEndCall(_ contact: Contact) async throws {
|
|||||||
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
|
||||||
|
let r = chatSendCmdSync(.apiGetCallInvitations)
|
||||||
|
if case let .callInvitations(invs) = r { return invs }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||||
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
||||||
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
||||||
@@ -437,42 +520,49 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeChat() {
|
func initializeChat(start: Bool) throws {
|
||||||
logger.debug("initializeChat")
|
logger.debug("initializeChat")
|
||||||
do {
|
do {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||||
m.currentUser = try apiGetActiveUser()
|
m.currentUser = try apiGetActiveUser()
|
||||||
if m.currentUser == nil {
|
if m.currentUser == nil {
|
||||||
m.onboardingStage = .step1_SimpleXInfo
|
m.onboardingStage = .step1_SimpleXInfo
|
||||||
|
} else if start {
|
||||||
|
try startChat()
|
||||||
} else {
|
} else {
|
||||||
startChat()
|
m.chatRunning = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to initialize chat controller or database: \(error)")
|
fatalError("Failed to initialize chat controller or database: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startChat() {
|
func startChat() throws {
|
||||||
logger.debug("startChat")
|
logger.debug("startChat")
|
||||||
do {
|
let m = ChatModel.shared
|
||||||
let m = ChatModel.shared
|
let justStarted = try apiStartChat()
|
||||||
// TODO set file folder once, before chat is started
|
if justStarted {
|
||||||
let justStarted = try apiStartChat()
|
m.userAddress = try apiGetUserAddress()
|
||||||
if justStarted {
|
m.userSMPServers = try getUserSMPServers()
|
||||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
let chats = try apiGetChats()
|
||||||
m.userAddress = try apiGetUserAddress()
|
m.chats = chats.map { Chat.init($0) }
|
||||||
m.userSMPServers = try getUserSMPServers()
|
try refreshCallInvitations()
|
||||||
m.chats = try apiGetChats()
|
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
|
||||||
withAnimation {
|
if let token = m.deviceToken {
|
||||||
m.onboardingStage = m.chats.isEmpty
|
registerToken(token: token)
|
||||||
? .step3_MakeConnection
|
}
|
||||||
: .onboardingComplete
|
withAnimation {
|
||||||
}
|
m.onboardingStage = m.onboardingStage == .step2_CreateProfile
|
||||||
|
? .step3_SetNotificationsMode
|
||||||
|
: m.chats.isEmpty
|
||||||
|
? .step4_MakeConnection
|
||||||
|
: .onboardingComplete
|
||||||
}
|
}
|
||||||
ChatReceiver.shared.start()
|
|
||||||
} catch {
|
|
||||||
fatalError("Failed to start or load chats: \(error)")
|
|
||||||
}
|
}
|
||||||
|
ChatReceiver.shared.start()
|
||||||
|
m.chatRunning = true
|
||||||
|
chatLastStartGroupDefault.set(Date.now)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatReceiver {
|
class ChatReceiver {
|
||||||
@@ -493,12 +583,13 @@ class ChatReceiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receiveMsgLoop() async {
|
func receiveMsgLoop() async {
|
||||||
let msg = await chatRecvMsg()
|
// TODO use function that has timeout
|
||||||
self._lastMsgTime = .now
|
if let msg = await chatRecvMsg() {
|
||||||
processReceivedMsg(msg)
|
self._lastMsgTime = .now
|
||||||
|
await processReceivedMsg(msg)
|
||||||
|
}
|
||||||
if self.receiveMessages {
|
if self.receiveMessages {
|
||||||
do { try await Task.sleep(nanoseconds: 7_500_000) }
|
_ = try? await Task.sleep(nanoseconds: 7_500_000)
|
||||||
catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") }
|
|
||||||
await receiveMsgLoop()
|
await receiveMsgLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,9 +602,9 @@ class ChatReceiver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processReceivedMsg(_ res: ChatResponse) {
|
func processReceivedMsg(_ res: ChatResponse) async {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
m.terminalItems.append(.resp(.now, res))
|
m.terminalItems.append(.resp(.now, res))
|
||||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||||
switch res {
|
switch res {
|
||||||
@@ -561,7 +652,8 @@ func processReceivedMsg(_ res: ChatResponse) {
|
|||||||
m.addChatItem(cInfo, cItem)
|
m.addChatItem(cInfo, cItem)
|
||||||
if case .image = cItem.content.msgContent,
|
if case .image = cItem.content.msgContent,
|
||||||
let file = cItem.file,
|
let file = cItem.file,
|
||||||
file.fileSize <= maxImageSize {
|
file.fileSize <= maxImageSize,
|
||||||
|
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||||
Task {
|
Task {
|
||||||
await receiveFile(fileId: file.fileId)
|
await receiveFile(fileId: file.fileId)
|
||||||
}
|
}
|
||||||
@@ -612,23 +704,34 @@ func processReceivedMsg(_ res: ChatResponse) {
|
|||||||
let fileName = cItem.file?.filePath {
|
let fileName = cItem.file?.filePath {
|
||||||
removeFile(fileName)
|
removeFile(fileName)
|
||||||
}
|
}
|
||||||
case let .callInvitation(contact, callType, sharedKey):
|
case let .callInvitation(invitation):
|
||||||
let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey)
|
m.callInvitations[invitation.contact.id] = invitation
|
||||||
m.callInvitations[contact.id] = invitation
|
activateCall(invitation)
|
||||||
if (m.activeCallInvitation == nil) {
|
|
||||||
m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName)
|
// This will be called from notification service extension
|
||||||
}
|
// CXProvider.reportNewIncomingVoIPPushPayload([
|
||||||
NtfManager.shared.notifyCallInvitation(contact, invitation)
|
// "displayName": contact.displayName,
|
||||||
|
// "contactId": contact.id,
|
||||||
|
// "uuid": invitation.callkitUUID
|
||||||
|
// ]) { error in
|
||||||
|
// if let error = error {
|
||||||
|
// logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription)")
|
||||||
|
// } else {
|
||||||
|
// logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
case let .callOffer(contact, callType, offer, sharedKey, _):
|
case let .callOffer(contact, callType, offer, sharedKey, _):
|
||||||
// TODO askConfirmation?
|
|
||||||
// TODO check encryption is compatible
|
|
||||||
withCall(contact) { call in
|
withCall(contact) { call in
|
||||||
m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey)
|
call.callState = .offerReceived
|
||||||
m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey)
|
call.peerMedia = callType.media
|
||||||
|
call.sharedKey = sharedKey
|
||||||
|
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||||
|
logger.debug(".callOffer useRelay \(useRelay)")
|
||||||
|
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true, relay: useRelay)
|
||||||
}
|
}
|
||||||
case let .callAnswer(contact, answer):
|
case let .callAnswer(contact, answer):
|
||||||
withCall(contact) { call in
|
withCall(contact) { call in
|
||||||
m.activeCall = call.copy(callState: .negotiated)
|
call.callState = .answerReceived
|
||||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||||
}
|
}
|
||||||
case let .callExtraInfo(contact, extraInfo):
|
case let .callExtraInfo(contact, extraInfo):
|
||||||
@@ -636,10 +739,15 @@ func processReceivedMsg(_ res: ChatResponse) {
|
|||||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||||
}
|
}
|
||||||
case let .callEnded(contact):
|
case let .callEnded(contact):
|
||||||
m.activeCallInvitation = nil
|
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||||
withCall(contact) { _ in
|
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||||
m.callCommand = .end
|
|
||||||
}
|
}
|
||||||
|
withCall(contact) { call in
|
||||||
|
m.callCommand = .end
|
||||||
|
// CallController.shared.reportCallRemoteEnded(call: call)
|
||||||
|
}
|
||||||
|
case .chatSuspended:
|
||||||
|
chatSuspended()
|
||||||
default:
|
default:
|
||||||
logger.debug("unsupported event: \(res.responseType)")
|
logger.debug("unsupported event: \(res.responseType)")
|
||||||
}
|
}
|
||||||
@@ -682,6 +790,27 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
|
|||||||
m.updateNetworkStatus(contact.id, .error(err))
|
m.updateNetworkStatus(contact.id, .error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshCallInvitations() throws {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
let callInvitations = try apiGetCallInvitations()
|
||||||
|
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
|
||||||
|
if let inv = callInvitations.last {
|
||||||
|
activateCall(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||||
|
if let error = error {
|
||||||
|
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||||
|
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
logger.debug("reportNewIncomingCall success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct UserResponse: Decodable {
|
private struct UserResponse: Decodable {
|
||||||
var user: User?
|
var user: User?
|
||||||
var error: String?
|
var error: String?
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// SuspendChat.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 26/06/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
|
||||||
|
|
||||||
|
let appSuspendTimeout: Int = 15 // seconds
|
||||||
|
|
||||||
|
let bgSuspendTimeout: Int = 5 // seconds
|
||||||
|
|
||||||
|
let terminationTimeout: Int = 3 // seconds
|
||||||
|
|
||||||
|
private func _suspendChat(timeout: Int) {
|
||||||
|
appStateGroupDefault.set(.suspending)
|
||||||
|
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
||||||
|
let endTask = beginBGTask(chatSuspended)
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func suspendChat() {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
if appStateGroupDefault.get() != .stopped {
|
||||||
|
_suspendChat(timeout: appSuspendTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suspendBgRefresh() {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
if case .bgRefresh = appStateGroupDefault.get() {
|
||||||
|
_suspendChat(timeout: bgSuspendTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminateChat() {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
switch appStateGroupDefault.get() {
|
||||||
|
case .suspending:
|
||||||
|
// suspend instantly if already suspending
|
||||||
|
_chatSuspended()
|
||||||
|
apiSuspendChat(timeoutMicroseconds: 0)
|
||||||
|
case .stopped: ()
|
||||||
|
default:
|
||||||
|
_suspendChat(timeout: terminationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatSuspended() {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
if case .suspending = appStateGroupDefault.get() {
|
||||||
|
_chatSuspended()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _chatSuspended() {
|
||||||
|
logger.debug("_chatSuspended")
|
||||||
|
appStateGroupDefault.set(.suspended)
|
||||||
|
if ChatModel.shared.chatRunning == true {
|
||||||
|
ChatReceiver.shared.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateChat(appState: AppState = .active) {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
appStateGroupDefault.set(appState)
|
||||||
|
apiActivateChat()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//
|
|
||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
|
||||||
//
|
|
||||||
|
|
||||||
extern void hs_init(int argc, char **argv[]);
|
|
||||||
|
|
||||||
typedef void* chat_ctrl;
|
|
||||||
|
|
||||||
extern chat_ctrl chat_init(char *path);
|
|
||||||
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
|
|
||||||
extern char *chat_recv_msg(chat_ctrl ctl);
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
let logger = Logger()
|
let logger = Logger()
|
||||||
|
|
||||||
@@ -14,32 +15,121 @@ let logger = Logger()
|
|||||||
struct SimpleXApp: App {
|
struct SimpleXApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@StateObject private var chatModel = ChatModel.shared
|
@StateObject private var chatModel = ChatModel.shared
|
||||||
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
|
@State private var userAuthorized: Bool?
|
||||||
|
@State private var doAuthenticate = false
|
||||||
|
@State private var enteredBackground: Double? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
hs_init(0, nil)
|
hs_init(0, nil)
|
||||||
|
UserDefaults.standard.register(defaults: appDefaults)
|
||||||
|
setDbContainer()
|
||||||
BGManager.shared.register()
|
BGManager.shared.register()
|
||||||
NtfManager.shared.registerCategories()
|
NtfManager.shared.registerCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
return WindowGroup {
|
return WindowGroup {
|
||||||
ContentView()
|
ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized)
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
}
|
}
|
||||||
.onAppear() {
|
.onAppear() {
|
||||||
initializeChat()
|
do {
|
||||||
|
chatModel.v3DBMigration = v3DBMigrationDefault.get()
|
||||||
|
try initializeChat(start: chatModel.v3DBMigration.startChat)
|
||||||
|
} catch let error {
|
||||||
|
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
logger.debug("scenePhase \(String(describing: scenePhase))")
|
logger.debug("scenePhase \(String(describing: scenePhase))")
|
||||||
setAppState(phase)
|
switch (phase) {
|
||||||
if phase == .background {
|
case .background:
|
||||||
|
suspendChat()
|
||||||
BGManager.shared.schedule()
|
BGManager.shared.schedule()
|
||||||
|
if userAuthorized == true {
|
||||||
|
enteredBackground = ProcessInfo.processInfo.systemUptime
|
||||||
|
}
|
||||||
|
doAuthenticate = false
|
||||||
|
case .active:
|
||||||
|
if chatModel.chatRunning == true {
|
||||||
|
ChatReceiver.shared.start()
|
||||||
|
}
|
||||||
|
let appState = appStateGroupDefault.get()
|
||||||
|
activateChat()
|
||||||
|
if appState.inactive && chatModel.chatRunning == true {
|
||||||
|
updateChats()
|
||||||
|
updateCallInvitations()
|
||||||
|
}
|
||||||
|
doAuthenticate = authenticationExpired()
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setDbContainer() {
|
||||||
|
// Uncomment and run once to open DB in app documents folder:
|
||||||
|
// dbContainerGroupDefault.set(.documents)
|
||||||
|
// v3DBMigrationDefault.set(.offer)
|
||||||
|
// to create database in app documents folder also uncomment:
|
||||||
|
// let legacyDatabase = true
|
||||||
|
let legacyDatabase = hasLegacyDatabase()
|
||||||
|
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
|
||||||
|
dbContainerGroupDefault.set(.documents)
|
||||||
|
setMigrationState(.offer)
|
||||||
|
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db")
|
||||||
|
} else {
|
||||||
|
dbContainerGroupDefault.set(.group)
|
||||||
|
setMigrationState(.ready)
|
||||||
|
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
|
||||||
|
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setMigrationState(_ state: V3DBMigrationState) {
|
||||||
|
if case .migrated = v3DBMigrationDefault.get() { return }
|
||||||
|
v3DBMigrationDefault.set(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authenticationExpired() -> Bool {
|
||||||
|
if let enteredBackground = enteredBackground {
|
||||||
|
return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateChats() {
|
||||||
|
do {
|
||||||
|
let chats = try apiGetChats()
|
||||||
|
chatModel.updateChats(with: chats)
|
||||||
|
if let id = chatModel.chatId,
|
||||||
|
let chat = chatModel.getChat(id) {
|
||||||
|
loadChat(chat: chat)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
if chatModel.chatId == chat.id {
|
||||||
|
Task { await markChatRead(chat) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("apiGetChats: cannot update chats \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCallInvitations() {
|
||||||
|
do {
|
||||||
|
try refreshCallInvitations()
|
||||||
|
}
|
||||||
|
catch let error {
|
||||||
|
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,138 +7,209 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct ActiveCallView: View {
|
struct ActiveCallView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@ObservedObject var call: Call
|
||||||
@Binding var showCallView: Bool
|
@State private var rtcWebView: WKWebView? = nil
|
||||||
@State private var coordinator: WebRTCCoordinator? = nil
|
|
||||||
@State private var webViewReady: Bool = false
|
|
||||||
@State private var webViewMsg: WVAPIMessage? = nil
|
@State private var webViewMsg: WVAPIMessage? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg)
|
WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg)
|
||||||
.onAppear() { sendCommandToWebView() }
|
.onAppear() { sendCommandToWebView() }
|
||||||
.onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() }
|
.onChange(of: m.callCommand) { _ in sendCommandToWebView() }
|
||||||
.onChange(of: webViewReady) { _ in sendCommandToWebView() }
|
.onChange(of: rtcWebView) { _ in sendCommandToWebView() }
|
||||||
.onChange(of: webViewMsg) { _ in processWebViewMessage() }
|
.onChange(of: webViewMsg) { _ in processWebViewMessage() }
|
||||||
.background(.black)
|
.background(.black)
|
||||||
ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() })
|
if let call = m.activeCall, let webView = rtcWebView {
|
||||||
|
ActiveCallOverlay(call: call, webView: webView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendCommandToWebView() {
|
private func sendCommandToWebView() {
|
||||||
if chatModel.activeCall != nil && webViewReady,
|
if m.activeCall != nil,
|
||||||
let cmd = chatModel.callCommand,
|
let wv = rtcWebView,
|
||||||
let c = coordinator {
|
let cmd = m.callCommand {
|
||||||
chatModel.callCommand = nil
|
m.callCommand = nil
|
||||||
logger.debug("ActiveCallView: command \(cmd.cmdType)")
|
sendCallCommand(wv, cmd)
|
||||||
c.sendCommand(command: cmd)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processWebViewMessage() {
|
private func processWebViewMessage() {
|
||||||
let m = chatModel
|
|
||||||
if let msg = webViewMsg,
|
if let msg = webViewMsg,
|
||||||
let call = chatModel.activeCall {
|
let call = m.activeCall,
|
||||||
|
let webView = rtcWebView {
|
||||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||||
Task {
|
switch msg.resp {
|
||||||
switch msg.resp {
|
case let .capabilities(capabilities):
|
||||||
case let .capabilities(capabilities):
|
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
||||||
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
Task {
|
||||||
try await apiSendCallInvitation(call.contact, callType)
|
do {
|
||||||
m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities)
|
try await apiSendCallInvitation(call.contact, callType)
|
||||||
case let .offer(offer, iceCandidates, capabilities):
|
} catch {
|
||||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
logger.error("apiSendCallInvitation \(responseError(error))")
|
||||||
media: call.localMedia, capabilities: capabilities)
|
|
||||||
m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities)
|
|
||||||
case let .answer(answer, iceCandidates):
|
|
||||||
try await apiSendCallAnswer(call.contact, answer, iceCandidates)
|
|
||||||
m.activeCall = call.copy(callState: .negotiated)
|
|
||||||
case let .ice(iceCandidates):
|
|
||||||
try await apiSendCallExtraInfo(call.contact, iceCandidates)
|
|
||||||
case let .connection(state):
|
|
||||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
|
||||||
case .connected = callStatus {
|
|
||||||
m.activeCall = call.copy(callState: .connected)
|
|
||||||
}
|
}
|
||||||
try await apiCallStatus(call.contact, state.connectionState)
|
DispatchQueue.main.async {
|
||||||
case .ended:
|
call.callState = .invitationSent
|
||||||
m.activeCall = nil
|
call.localCapabilities = capabilities
|
||||||
m.activeCallInvitation = nil
|
|
||||||
m.callCommand = nil
|
|
||||||
showCallView = false
|
|
||||||
case .ok:
|
|
||||||
switch msg.command {
|
|
||||||
case let .media(media, enable):
|
|
||||||
switch media {
|
|
||||||
case .video: m.activeCall = call.copy(videoEnabled: enable)
|
|
||||||
case .audio: m.activeCall = call.copy(audioEnabled: enable)
|
|
||||||
}
|
|
||||||
case .end:
|
|
||||||
m.activeCall = nil
|
|
||||||
m.activeCallInvitation = nil
|
|
||||||
m.callCommand = nil
|
|
||||||
showCallView = false
|
|
||||||
default: ()
|
|
||||||
}
|
}
|
||||||
case let .error(message):
|
|
||||||
logger.debug("ActiveCallView: command error: \(message)")
|
|
||||||
case let .invalid(type):
|
|
||||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
|
||||||
}
|
}
|
||||||
|
case let .offer(offer, iceCandidates, capabilities):
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||||
|
media: call.localMedia, capabilities: capabilities)
|
||||||
|
} catch {
|
||||||
|
logger.error("apiSendCallOffer \(responseError(error))")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.callState = .offerSent
|
||||||
|
call.localCapabilities = capabilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .answer(answer, iceCandidates):
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiSendCallAnswer(call.contact, answer, iceCandidates)
|
||||||
|
} catch {
|
||||||
|
logger.error("apiSendCallAnswer \(responseError(error))")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.callState = .negotiated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .ice(iceCandidates):
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiSendCallExtraInfo(call.contact, iceCandidates)
|
||||||
|
} catch {
|
||||||
|
logger.error("apiSendCallExtraInfo \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .connection(state):
|
||||||
|
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||||
|
case .connected = callStatus {
|
||||||
|
// if case .outgoing = call.direction {
|
||||||
|
// CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||||
|
// }
|
||||||
|
call.callState = .connected
|
||||||
|
// CallKit doesn't work well with WKWebView
|
||||||
|
// This is a hack to enable microphone in WKWebView after CallKit takes over it
|
||||||
|
if CallController.useCallKit {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
m.callCommand = .camera(camera: call.localCamera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiCallStatus(call.contact, state.connectionState)
|
||||||
|
} catch {
|
||||||
|
logger.error("apiCallStatus \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .connected(connectionInfo):
|
||||||
|
call.callState = .connected
|
||||||
|
call.connectionInfo = connectionInfo
|
||||||
|
case .ended:
|
||||||
|
closeCallView(webView)
|
||||||
|
call.callState = .ended
|
||||||
|
if let uuid = call.callkitUUID {
|
||||||
|
CallController.shared.endCall(callUUID: uuid)
|
||||||
|
}
|
||||||
|
case .ok:
|
||||||
|
switch msg.command {
|
||||||
|
case .answer:
|
||||||
|
call.callState = .negotiated
|
||||||
|
case let .camera(camera):
|
||||||
|
call.localCamera = camera
|
||||||
|
Task {
|
||||||
|
// This disables microphone if it was disabled before flipping the camera
|
||||||
|
await webView.setMicrophoneCaptureState(call.audioEnabled ? .active : .muted)
|
||||||
|
// This compensates for the bug on some devices when remote video does not appear
|
||||||
|
// await webView.setCameraCaptureState(.muted)
|
||||||
|
// await webView.setCameraCaptureState(call.videoEnabled ? .active : .muted)
|
||||||
|
}
|
||||||
|
case .end:
|
||||||
|
closeCallView(webView)
|
||||||
|
m.activeCall = nil
|
||||||
|
default: ()
|
||||||
|
}
|
||||||
|
case let .error(message):
|
||||||
|
logger.debug("ActiveCallView: command error: \(message)")
|
||||||
|
case let .invalid(type):
|
||||||
|
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func closeCallView(_ webView: WKWebView) {
|
||||||
|
m.showCallView = false
|
||||||
|
Task {
|
||||||
|
await webView.setMicrophoneCaptureState(.muted)
|
||||||
|
await webView.setCameraCaptureState(.muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ActiveCallOverlay: View {
|
struct ActiveCallOverlay: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
var call: Call?
|
@ObservedObject var call: Call
|
||||||
var dismiss: () -> Void
|
var webView: WKWebView
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if let call = call {
|
switch call.localMedia {
|
||||||
switch call.localMedia {
|
case .video:
|
||||||
case .video:
|
callInfoView(call, .leading)
|
||||||
callInfoView(call, .leading)
|
.foregroundColor(.white)
|
||||||
.foregroundColor(.white)
|
.opacity(0.8)
|
||||||
.opacity(0.8)
|
.padding()
|
||||||
.padding()
|
|
||||||
case .audio:
|
|
||||||
VStack {
|
|
||||||
ProfileImage(imageStr: call.contact.profile.image)
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 192, height: 192)
|
|
||||||
callInfoView(call, .center)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.8)
|
|
||||||
.padding()
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack(alignment: .bottom) {
|
|
||||||
VStack(alignment: .leading) {
|
HStack {
|
||||||
if call.localMedia == .video {
|
toggleAudioButton()
|
||||||
callButton(call.videoEnabled ? "video.fill" : "video.slash", size: 48) {
|
Spacer()
|
||||||
chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled)
|
Color.clear.frame(width: 40, height: 40)
|
||||||
}
|
Spacer()
|
||||||
.foregroundColor(.white)
|
endCallButton()
|
||||||
.opacity(0.85)
|
Spacer()
|
||||||
}
|
if call.videoEnabled {
|
||||||
callButton(call.audioEnabled ? "mic.fill" : "mic.slash", size: 48) {
|
flipCameraButton()
|
||||||
chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
|
} else {
|
||||||
}
|
Color.clear.frame(width: 40, height: 40)
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.85)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.top)
|
|
||||||
}
|
}
|
||||||
callButton("phone.down.fill", size: 60) { dismiss() }
|
Spacer()
|
||||||
.foregroundColor(.red)
|
toggleVideoButton()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
|
case .audio:
|
||||||
|
VStack {
|
||||||
|
ProfileImage(imageStr: call.contact.profile.image)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 192, height: 192)
|
||||||
|
callInfoView(call, .center)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.opacity(0.8)
|
||||||
|
.padding()
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
toggleAudioButton()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
endCallButton()
|
||||||
}
|
}
|
||||||
.padding(.bottom, 60)
|
.padding(.bottom, 60)
|
||||||
.padding(.horizontal, 48)
|
.padding(.horizontal, 48)
|
||||||
@@ -153,18 +224,82 @@ struct ActiveCallOverlay: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
let status = call.callState == .connected
|
Group {
|
||||||
? call.encrypted
|
Text(call.callState.text)
|
||||||
? "end-to-end encrypted"
|
HStack {
|
||||||
: "no end-to-end encryption"
|
Text(call.encryptionStatus)
|
||||||
: call.callState.text
|
if let connInfo = call.connectionInfo?.text {
|
||||||
Text(status)
|
Text("(") + Text(connInfo) + Text(")")
|
||||||
.font(.subheadline)
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callButton(_ imageName: String, size: CGFloat, perform: @escaping () -> Void) -> some View {
|
private func endCallButton() -> some View {
|
||||||
|
let cc = CallController.shared
|
||||||
|
return callButton("phone.down.fill", size: 60) {
|
||||||
|
if let uuid = call.callkitUUID {
|
||||||
|
cc.endCall(callUUID: uuid)
|
||||||
|
} else {
|
||||||
|
cc.endCall(call: call) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleAudioButton() -> some View {
|
||||||
|
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
||||||
|
Task {
|
||||||
|
await webView.setMicrophoneCaptureState(call.audioEnabled ? .muted : .active)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.audioEnabled = !call.audioEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleVideoButton() -> some View {
|
||||||
|
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
|
||||||
|
Task {
|
||||||
|
await webView.setCameraCaptureState(call.videoEnabled ? .muted : .active)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.videoEnabled = !call.videoEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func flipCameraButton() -> some View {
|
||||||
|
let cmd = WCallCommand.camera(camera: call.localCamera == .user ? .environment : .user)
|
||||||
|
controlButton(call, "arrow.triangle.2.circlepath") {
|
||||||
|
if call.audioEnabled {
|
||||||
|
chatModel.callCommand = cmd
|
||||||
|
} else {
|
||||||
|
Task {
|
||||||
|
// Microphone has to be enabled before flipping the camera to avoid prompt for user permission when getUserMedia is called in webview
|
||||||
|
await webView.setMicrophoneCaptureState(.active)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
chatModel.callCommand = cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
|
||||||
|
if call.hasMedia {
|
||||||
|
callButton(imageName, size: 40, perform)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.opacity(0.85)
|
||||||
|
} else {
|
||||||
|
Color.clear.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||||
Button {
|
Button {
|
||||||
perform()
|
perform()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -179,9 +314,9 @@ struct ActiveCallOverlay: View {
|
|||||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group{
|
Group{
|
||||||
ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {})
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView())
|
||||||
.background(.black)
|
.background(.black)
|
||||||
ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {})
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView())
|
||||||
.background(.black)
|
.background(.black)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// CallController.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 21/05/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
//import CallKit
|
||||||
|
import AVFoundation
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
//class CallController: NSObject, CXProviderDelegate, ObservableObject {
|
||||||
|
class CallController: NSObject, ObservableObject {
|
||||||
|
static let useCallKit = false
|
||||||
|
static let shared = CallController()
|
||||||
|
// private let provider = CXProvider(configuration: CallController.configuration)
|
||||||
|
// private let controller = CXCallController()
|
||||||
|
private let callManager = CallManager()
|
||||||
|
@Published var activeCallInvitation: RcvCallInvitation?
|
||||||
|
|
||||||
|
// PKPushRegistry will be used from notification service extension
|
||||||
|
// let registry = PKPushRegistry(queue: nil)
|
||||||
|
|
||||||
|
// static let configuration: CXProviderConfiguration = {
|
||||||
|
// let configuration = CXProviderConfiguration()
|
||||||
|
// configuration.supportsVideo = true
|
||||||
|
// configuration.supportedHandleTypes = [.generic]
|
||||||
|
// configuration.includesCallsInRecents = true // TODO disable or add option
|
||||||
|
// configuration.maximumCallsPerCallGroup = 1
|
||||||
|
// return configuration
|
||||||
|
// }()
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
// self.provider.setDelegate(self, queue: nil)
|
||||||
|
// self.registry.delegate = self
|
||||||
|
// self.registry.desiredPushTypes = [.voIP]
|
||||||
|
}
|
||||||
|
|
||||||
|
// func providerDidReset(_ provider: CXProvider) {
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
|
// logger.debug("CallController.provider CXStartCallAction")
|
||||||
|
// if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
||||||
|
// action.fulfill()
|
||||||
|
// provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||||
|
// } else {
|
||||||
|
// action.fail()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
|
// logger.debug("CallController.provider CXAnswerCallAction")
|
||||||
|
// if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
||||||
|
// action.fulfill()
|
||||||
|
// } else {
|
||||||
|
// action.fail()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||||
|
// logger.debug("CallController.provider CXEndCallAction")
|
||||||
|
// callManager.endCall(callUUID: action.callUUID) { ok in
|
||||||
|
// if ok {
|
||||||
|
// action.fulfill()
|
||||||
|
// } else {
|
||||||
|
// action.fail()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
||||||
|
// print("timed out", #function)
|
||||||
|
// action.fulfill()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
|
// print("received", #function)
|
||||||
|
//// do {
|
||||||
|
//// try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
|
||||||
|
//// logger.debug("audioSession category set")
|
||||||
|
//// try audioSession.setActive(true)
|
||||||
|
//// logger.debug("audioSession activated")
|
||||||
|
//// } catch {
|
||||||
|
//// print(error)
|
||||||
|
//// logger.error("failed activating audio session")
|
||||||
|
//// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
|
// print("received", #function)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|
||||||
|
// This will be needed when we have notification service extension
|
||||||
|
// func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
|
// if type == .voIP {
|
||||||
|
// // Extract the call information from the push notification payload
|
||||||
|
// if let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||||
|
// let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||||
|
// let uuidStr = payload.dictionaryPayload["uuid"] as? String,
|
||||||
|
// let uuid = UUID(uuidString: uuidStr) {
|
||||||
|
// let callUpdate = CXCallUpdate()
|
||||||
|
// callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: displayName)
|
||||||
|
// provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in
|
||||||
|
// if error != nil {
|
||||||
|
// let m = ChatModel.shared
|
||||||
|
// m.callInvitations.removeValue(forKey: contactId)
|
||||||
|
// }
|
||||||
|
// // Tell PushKit that the notification is handled.
|
||||||
|
// completion()
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||||
|
logger.debug("CallController.reportNewIncomingCall")
|
||||||
|
// if CallController.useCallKit, let uuid = invitation.callkitUUID {
|
||||||
|
// let update = CXCallUpdate()
|
||||||
|
// update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName)
|
||||||
|
// update.hasVideo = invitation.peerMedia == .video
|
||||||
|
// provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||||
|
// } else {
|
||||||
|
NtfManager.shared.notifyCallInvitation(invitation)
|
||||||
|
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||||
|
activeCallInvitation = invitation
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||||
|
// if CallController.useCallKit, let uuid = call.callkitUUID {
|
||||||
|
// provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||||
|
// if CallController.useCallKit, let uuid = invitation.callkitUUID {
|
||||||
|
// provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
|
// } else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||||
|
activeCallInvitation = nil
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// func reportCallRemoteEnded(call: Call) {
|
||||||
|
// if CallController.useCallKit, let uuid = call.callkitUUID {
|
||||||
|
// provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||||
|
logger.debug("CallController.startCall")
|
||||||
|
let uuid = callManager.newOutgoingCall(contact, media)
|
||||||
|
// if CallController.useCallKit {
|
||||||
|
// let handle = CXHandle(type: .generic, value: contact.displayName)
|
||||||
|
// let action = CXStartCallAction(call: uuid, handle: handle)
|
||||||
|
// action.isVideo = media == .video
|
||||||
|
// requestTransaction(with: action)
|
||||||
|
// } else if callManager.startOutgoingCall(callUUID: uuid) {
|
||||||
|
if callManager.startOutgoingCall(callUUID: uuid) {
|
||||||
|
logger.debug("CallController.startCall: call started")
|
||||||
|
} else {
|
||||||
|
logger.error("CallController.startCall: no active call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func answerCall(invitation: RcvCallInvitation) {
|
||||||
|
callManager.answerIncomingCall(invitation: invitation)
|
||||||
|
if invitation.contact.id == self.activeCallInvitation?.contact.id {
|
||||||
|
self.activeCallInvitation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(callUUID: UUID) {
|
||||||
|
// if CallController.useCallKit {
|
||||||
|
// requestTransaction(with: CXEndCallAction(call: callUUID))
|
||||||
|
// } else {
|
||||||
|
callManager.endCall(callUUID: callUUID) { ok in
|
||||||
|
if ok {
|
||||||
|
logger.debug("CallController.endCall: call ended")
|
||||||
|
} else {
|
||||||
|
logger.error("CallController.endCall: no actove call pr call invitation to end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(invitation: RcvCallInvitation) {
|
||||||
|
callManager.endCall(invitation: invitation) {
|
||||||
|
if invitation.contact.id == self.activeCallInvitation?.contact.id {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.activeCallInvitation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(call: Call, completed: @escaping () -> Void) {
|
||||||
|
callManager.endCall(call: call, completed: completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// private func requestTransaction(with action: CXAction) {
|
||||||
|
// let t = CXTransaction()
|
||||||
|
// t.addAction(action)
|
||||||
|
// controller.request(t) { error in
|
||||||
|
// if let error = error {
|
||||||
|
// logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)")
|
||||||
|
// } else {
|
||||||
|
// logger.debug("CallController.requestTransaction requested transaction successfully")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// CallManager.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 22/05/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class CallManager {
|
||||||
|
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
||||||
|
let uuid = UUID()
|
||||||
|
ChatModel.shared.activeCall = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOutgoingCall(callUUID: UUID) -> Bool {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||||
|
m.showCallView = true
|
||||||
|
m.callCommand = .capabilities(media: call.localMedia, useWorker: true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func answerIncomingCall(callUUID: UUID) -> Bool {
|
||||||
|
if let invitation = getCallInvitation(callUUID) {
|
||||||
|
answerIncomingCall(invitation: invitation)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func answerIncomingCall(invitation: RcvCallInvitation) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
m.callInvitations.removeValue(forKey: invitation.contact.id)
|
||||||
|
m.activeCall = Call(
|
||||||
|
direction: .incoming,
|
||||||
|
contact: invitation.contact,
|
||||||
|
callkitUUID: invitation.callkitUUID,
|
||||||
|
callState: .invitationAccepted,
|
||||||
|
localMedia: invitation.callType.media,
|
||||||
|
sharedKey: invitation.sharedKey
|
||||||
|
)
|
||||||
|
m.showCallView = true
|
||||||
|
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||||
|
logger.debug("answerIncomingCall useRelay \(useRelay)")
|
||||||
|
m.callCommand = .start(media: invitation.callType.media, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
||||||
|
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||||
|
endCall(call: call) { completed(true) }
|
||||||
|
} else if let invitation = getCallInvitation(callUUID) {
|
||||||
|
endCall(invitation: invitation) { completed(true) }
|
||||||
|
} else {
|
||||||
|
completed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(call: Call, completed: @escaping () -> Void) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
if case .ended = call.callState {
|
||||||
|
logger.debug("CallManager.endCall: call ended")
|
||||||
|
m.activeCall = nil
|
||||||
|
m.showCallView = false
|
||||||
|
completed()
|
||||||
|
} else {
|
||||||
|
logger.debug("CallManager.endCall: ending call...")
|
||||||
|
m.callCommand = .end
|
||||||
|
m.showCallView = false
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiEndCall(call.contact)
|
||||||
|
} catch {
|
||||||
|
logger.error("CallController.provider apiEndCall error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
m.activeCall = nil
|
||||||
|
completed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCall(invitation: RcvCallInvitation, completed: @escaping () -> Void) {
|
||||||
|
ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await apiRejectCall(invitation.contact)
|
||||||
|
} catch {
|
||||||
|
logger.error("CallController.provider apiRejectCall error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
completed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
||||||
|
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
||||||
|
return invitation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// IncomingCallView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 24/05/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct IncomingCallView: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@ObservedObject var cc = CallController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let sp = SoundPlayer.shared
|
||||||
|
if let invitation = cc.activeCallInvitation {
|
||||||
|
if m.showCallView {
|
||||||
|
incomingCall(invitation)
|
||||||
|
} else {
|
||||||
|
incomingCall(invitation)
|
||||||
|
.onAppear { sp.startRingtone() }
|
||||||
|
.onDisappear { sp.stopRingtone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingCall(_ invitation: RcvCallInvitation) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
|
||||||
|
Text(invitation.callTypeText)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
ProfilePreview(profileOf: invitation.contact, color: .white)
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
callButton("Reject", "phone.down.fill", .red) {
|
||||||
|
cc.endCall(invitation: invitation)
|
||||||
|
}
|
||||||
|
|
||||||
|
callButton("Ignore", "multiply", .accentColor) {
|
||||||
|
cc.activeCallInvitation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
callButton("Accept", "checkmark", .green) {
|
||||||
|
if let call = m.activeCall {
|
||||||
|
cc.endCall(call: call) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
cc.answerCall(invitation: invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cc.answerCall(invitation: invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action, label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: image)
|
||||||
|
.scaleEffect(1.24)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 44)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IncomingCallView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData
|
||||||
|
return IncomingCallView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// SoundPlayer.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 24/05/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class SoundPlayer {
|
||||||
|
static let shared = SoundPlayer()
|
||||||
|
private var audioPlayer: AVAudioPlayer?
|
||||||
|
|
||||||
|
func startRingtone() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
logger.debug("startRingtone")
|
||||||
|
guard let path = Bundle.main.path(forResource: "ringtone2", ofType: "m4a", inDirectory: "sounds") else {
|
||||||
|
logger.debug("startRingtone: file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
if player.prepareToPlay() {
|
||||||
|
audioPlayer = player
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.debug("startRingtone: AVAudioPlayer error \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
while let player = audioPlayer {
|
||||||
|
player.play()
|
||||||
|
AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
|
||||||
|
_ = try? await Task.sleep(nanoseconds: UInt64(player.duration * 1_000_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRingtone() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
audioPlayer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,86 +8,85 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
class Call: Equatable {
|
class Call: ObservableObject, Equatable {
|
||||||
static func == (lhs: Call, rhs: Call) -> Bool {
|
static func == (lhs: Call, rhs: Call) -> Bool {
|
||||||
lhs.contact.apiId == rhs.contact.apiId
|
lhs.contact.apiId == rhs.contact.apiId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var direction: CallDirection
|
||||||
var contact: Contact
|
var contact: Contact
|
||||||
var callState: CallState
|
var callkitUUID: UUID?
|
||||||
var localMedia: CallMediaType
|
var localMedia: CallMediaType
|
||||||
var localCapabilities: CallCapabilities?
|
@Published var callState: CallState
|
||||||
var peerMedia: CallMediaType?
|
@Published var localCapabilities: CallCapabilities?
|
||||||
var sharedKey: String?
|
@Published var peerMedia: CallMediaType?
|
||||||
var audioEnabled: Bool
|
@Published var sharedKey: String?
|
||||||
var videoEnabled: Bool
|
@Published var audioEnabled = true
|
||||||
|
@Published var videoEnabled: Bool
|
||||||
|
@Published var localCamera = VideoCamera.user
|
||||||
|
@Published var connectionInfo: ConnectionInfo?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
direction: CallDirection,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
|
callkitUUID: UUID?,
|
||||||
callState: CallState,
|
callState: CallState,
|
||||||
localMedia: CallMediaType,
|
localMedia: CallMediaType,
|
||||||
localCapabilities: CallCapabilities? = nil,
|
sharedKey: String? = nil
|
||||||
peerMedia: CallMediaType? = nil,
|
|
||||||
sharedKey: String? = nil,
|
|
||||||
audioEnabled: Bool? = nil,
|
|
||||||
videoEnabled: Bool? = nil
|
|
||||||
) {
|
) {
|
||||||
|
self.direction = direction
|
||||||
self.contact = contact
|
self.contact = contact
|
||||||
|
self.callkitUUID = callkitUUID
|
||||||
self.callState = callState
|
self.callState = callState
|
||||||
self.localMedia = localMedia
|
self.localMedia = localMedia
|
||||||
self.localCapabilities = localCapabilities
|
|
||||||
self.peerMedia = peerMedia
|
|
||||||
self.sharedKey = sharedKey
|
self.sharedKey = sharedKey
|
||||||
self.audioEnabled = audioEnabled ?? true
|
self.videoEnabled = localMedia == .video
|
||||||
self.videoEnabled = videoEnabled ?? (localMedia == .video)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copy(
|
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
|
||||||
contact: Contact? = nil,
|
var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
|
||||||
callState: CallState? = nil,
|
var encryptionStatus: LocalizedStringKey {
|
||||||
localMedia: CallMediaType? = nil,
|
get {
|
||||||
localCapabilities: CallCapabilities? = nil,
|
switch callState {
|
||||||
peerMedia: CallMediaType? = nil,
|
case .waitCapabilities: return ""
|
||||||
sharedKey: String? = nil,
|
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
|
||||||
audioEnabled: Bool? = nil,
|
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
||||||
videoEnabled: Bool? = nil
|
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
||||||
) -> Call {
|
}
|
||||||
Call (
|
}
|
||||||
contact: contact ?? self.contact,
|
|
||||||
callState: callState ?? self.callState,
|
|
||||||
localMedia: localMedia ?? self.localMedia,
|
|
||||||
localCapabilities: localCapabilities ?? self.localCapabilities,
|
|
||||||
peerMedia: peerMedia ?? self.peerMedia,
|
|
||||||
sharedKey: sharedKey ?? self.sharedKey,
|
|
||||||
audioEnabled: audioEnabled ?? self.audioEnabled,
|
|
||||||
videoEnabled: videoEnabled ?? self.videoEnabled
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||||
|
}
|
||||||
|
|
||||||
var encrypted: Bool {
|
enum CallDirection {
|
||||||
(localCapabilities?.encryption ?? false) && sharedKey != nil
|
case incoming
|
||||||
}
|
case outgoing
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CallState {
|
enum CallState {
|
||||||
case waitCapabilities
|
case waitCapabilities // outgoing call started
|
||||||
case invitationSent
|
case invitationSent // outgoing call - sent invitation
|
||||||
case invitationReceived
|
case invitationAccepted // incoming call started
|
||||||
case offerSent
|
case offerSent // incoming - webrtc started and offer sent
|
||||||
case offerReceived
|
case offerReceived // outgoing - webrtc offer received via API
|
||||||
case negotiated
|
case answerReceived // incoming - webrtc answer received via API
|
||||||
|
case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed
|
||||||
case connected
|
case connected
|
||||||
|
case ended
|
||||||
|
|
||||||
var text: LocalizedStringKey {
|
var text: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .waitCapabilities: return "starting…"
|
case .waitCapabilities: return "starting…"
|
||||||
case .invitationSent: return "waiting for answer…"
|
case .invitationSent: return "waiting for answer…"
|
||||||
case .invitationReceived: return "starting…"
|
case .invitationAccepted: return "starting…"
|
||||||
case .offerSent: return "waiting for confirmation…"
|
case .offerSent: return "waiting for confirmation…"
|
||||||
case .offerReceived: return "received answer…"
|
case .offerReceived: return "received answer…"
|
||||||
|
case .answerReceived: return "received confirmation…"
|
||||||
case .negotiated: return "connecting…"
|
case .negotiated: return "connecting…"
|
||||||
case .connected: return "connected"
|
case .connected: return "connected"
|
||||||
|
case .ended: return "ended"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,29 +96,34 @@ struct WVAPICall: Encodable {
|
|||||||
var command: WCallCommand
|
var command: WCallCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WVAPIMessage: Equatable, Decodable {
|
struct WVAPIMessage: Equatable, Decodable, Encodable {
|
||||||
var corrId: Int?
|
var corrId: Int?
|
||||||
var resp: WCallResponse
|
var resp: WCallResponse
|
||||||
var command: WCallCommand?
|
var command: WCallCommand?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WCallCommand: Equatable, Encodable, Decodable {
|
enum WCallCommand: Equatable, Encodable, Decodable {
|
||||||
case capabilities
|
case capabilities(media: CallMediaType, useWorker: Bool? = nil)
|
||||||
case start(media: CallMediaType, aesKey: String? = nil)
|
case start(media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||||
case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
|
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||||
case answer(answer: String, iceCandidates: [String])
|
case answer(answer: String, iceCandidates: String)
|
||||||
case ice(iceCandidates: [String])
|
case ice(iceCandidates: String)
|
||||||
case media(media: CallMediaType, enable: Bool)
|
case media(media: CallMediaType, enable: Bool)
|
||||||
|
case camera(camera: VideoCamera)
|
||||||
case end
|
case end
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case type
|
case type
|
||||||
case media
|
case media
|
||||||
|
case camera
|
||||||
case aesKey
|
case aesKey
|
||||||
|
case useWorker
|
||||||
case offer
|
case offer
|
||||||
case answer
|
case answer
|
||||||
case iceCandidates
|
case iceCandidates
|
||||||
case enable
|
case enable
|
||||||
|
case iceServers
|
||||||
|
case relay
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdType: String {
|
var cmdType: String {
|
||||||
@@ -127,10 +131,11 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .capabilities: return "capabilities"
|
case .capabilities: return "capabilities"
|
||||||
case .start: return "start"
|
case .start: return "start"
|
||||||
case .accept: return "accept"
|
case .offer: return "offer"
|
||||||
case .answer: return "answer"
|
case .answer: return "answer"
|
||||||
case .ice: return "ice"
|
case .ice: return "ice"
|
||||||
case .media: return "media"
|
case .media: return "media"
|
||||||
|
case .camera: return "camera"
|
||||||
case .end: return "end"
|
case .end: return "end"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,18 +144,26 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
switch self {
|
switch self {
|
||||||
case .capabilities:
|
case let .capabilities(media, useWorker):
|
||||||
try container.encode("capabilities", forKey: .type)
|
try container.encode("capabilities", forKey: .type)
|
||||||
case let .start(media, aesKey):
|
try container.encode(media, forKey: .media)
|
||||||
|
try container.encode(useWorker, forKey: .useWorker)
|
||||||
|
case let .start(media, aesKey, useWorker, iceServers, relay):
|
||||||
try container.encode("start", forKey: .type)
|
try container.encode("start", forKey: .type)
|
||||||
try container.encode(media, forKey: .media)
|
try container.encode(media, forKey: .media)
|
||||||
try container.encode(aesKey, forKey: .aesKey)
|
try container.encode(aesKey, forKey: .aesKey)
|
||||||
case let .accept(offer, iceCandidates, media, aesKey):
|
try container.encode(useWorker, forKey: .useWorker)
|
||||||
try container.encode("accept", forKey: .type)
|
try container.encode(iceServers, forKey: .iceServers)
|
||||||
|
try container.encode(relay, forKey: .relay)
|
||||||
|
case let .offer(offer, iceCandidates, media, aesKey, useWorker, iceServers, relay):
|
||||||
|
try container.encode("offer", forKey: .type)
|
||||||
try container.encode(offer, forKey: .offer)
|
try container.encode(offer, forKey: .offer)
|
||||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||||
try container.encode(media, forKey: .media)
|
try container.encode(media, forKey: .media)
|
||||||
try container.encode(aesKey, forKey: .aesKey)
|
try container.encode(aesKey, forKey: .aesKey)
|
||||||
|
try container.encode(useWorker, forKey: .useWorker)
|
||||||
|
try container.encode(iceServers, forKey: .iceServers)
|
||||||
|
try container.encode(relay, forKey: .relay)
|
||||||
case let .answer(answer, iceCandidates):
|
case let .answer(answer, iceCandidates):
|
||||||
try container.encode("answer", forKey: .type)
|
try container.encode("answer", forKey: .type)
|
||||||
try container.encode(answer, forKey: .answer)
|
try container.encode(answer, forKey: .answer)
|
||||||
@@ -162,6 +175,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
try container.encode("media", forKey: .type)
|
try container.encode("media", forKey: .type)
|
||||||
try container.encode(media, forKey: .media)
|
try container.encode(media, forKey: .media)
|
||||||
try container.encode(enable, forKey: .enable)
|
try container.encode(enable, forKey: .enable)
|
||||||
|
case let .camera(camera):
|
||||||
|
try container.encode("camera", forKey: .type)
|
||||||
|
try container.encode(camera, forKey: .camera)
|
||||||
case .end:
|
case .end:
|
||||||
try container.encode("end", forKey: .type)
|
try container.encode("end", forKey: .type)
|
||||||
}
|
}
|
||||||
@@ -172,28 +188,39 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
let type = try container.decode(String.self, forKey: CodingKeys.type)
|
let type = try container.decode(String.self, forKey: CodingKeys.type)
|
||||||
switch type {
|
switch type {
|
||||||
case "capabilities":
|
case "capabilities":
|
||||||
self = .capabilities
|
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||||
|
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
|
||||||
|
self = .capabilities(media: media, useWorker: useWorker)
|
||||||
case "start":
|
case "start":
|
||||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||||
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
|
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
|
||||||
self = .start(media: media, aesKey: aesKey)
|
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
|
||||||
case "accept":
|
let iceServers = try container.decode(([RTCIceServer]?).self, forKey: .iceServers)
|
||||||
|
let relay = try container.decode((Bool?).self, forKey: .relay)
|
||||||
|
self = .start(media: media, aesKey: aesKey, useWorker: useWorker, iceServers: iceServers, relay: relay)
|
||||||
|
case "offer":
|
||||||
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
|
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||||
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
|
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
|
||||||
self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey)
|
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
|
||||||
|
let iceServers = try container.decode(([RTCIceServer]?).self, forKey: .iceServers)
|
||||||
|
let relay = try container.decode((Bool?).self, forKey: .relay)
|
||||||
|
self = .offer(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey, useWorker: useWorker, iceServers: iceServers, relay: relay)
|
||||||
case "answer":
|
case "answer":
|
||||||
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
|
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
||||||
case "ice":
|
case "ice":
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
self = .ice(iceCandidates: iceCandidates)
|
self = .ice(iceCandidates: iceCandidates)
|
||||||
case "media":
|
case "media":
|
||||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||||
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
||||||
self = .media(media: media, enable: enable)
|
self = .media(media: media, enable: enable)
|
||||||
|
case "camera":
|
||||||
|
let camera = try container.decode(VideoCamera.self, forKey: CodingKeys.camera)
|
||||||
|
self = .camera(camera: camera)
|
||||||
case "end":
|
case "end":
|
||||||
self = .end
|
self = .end
|
||||||
default:
|
default:
|
||||||
@@ -205,12 +232,11 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
|
|
||||||
enum WCallResponse: Equatable, Decodable {
|
enum WCallResponse: Equatable, Decodable {
|
||||||
case capabilities(capabilities: CallCapabilities)
|
case capabilities(capabilities: CallCapabilities)
|
||||||
case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities)
|
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
|
||||||
// TODO remove accept, it is needed for debugging
|
case answer(answer: String, iceCandidates: String)
|
||||||
// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
|
case ice(iceCandidates: String)
|
||||||
case answer(answer: String, iceCandidates: [String])
|
|
||||||
case ice(iceCandidates: [String])
|
|
||||||
case connection(state: ConnectionState)
|
case connection(state: ConnectionState)
|
||||||
|
case connected(connectionInfo: ConnectionInfo)
|
||||||
case ended
|
case ended
|
||||||
case ok
|
case ok
|
||||||
case error(message: String)
|
case error(message: String)
|
||||||
@@ -223,25 +249,23 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
case answer
|
case answer
|
||||||
case iceCandidates
|
case iceCandidates
|
||||||
case state
|
case state
|
||||||
|
case connectionInfo
|
||||||
case message
|
case message
|
||||||
// TODO remove media, aesKey
|
|
||||||
case media
|
|
||||||
case aesKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var respType: String {
|
var respType: String {
|
||||||
get {
|
get {
|
||||||
switch self {
|
switch self {
|
||||||
case .capabilities: return("capabilities")
|
case .capabilities: return "capabilities"
|
||||||
case .offer: return("offer")
|
case .offer: return "offer"
|
||||||
// case .accept: return("accept")
|
case .answer: return "answer"
|
||||||
case .answer: return("answer (TODO remove)")
|
case .ice: return "ice"
|
||||||
case .ice: return("ice")
|
case .connection: return "connection"
|
||||||
case .connection: return("connection")
|
case .connected: return "connected"
|
||||||
case .ended: return("ended")
|
case .ended: return "ended"
|
||||||
case .ok: return("ok")
|
case .ok: return "ok"
|
||||||
case .error: return("error")
|
case .error: return "error"
|
||||||
case .invalid: return("invalid")
|
case .invalid: return "invalid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,19 +280,22 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
self = .capabilities(capabilities: capabilities)
|
self = .capabilities(capabilities: capabilities)
|
||||||
case "offer":
|
case "offer":
|
||||||
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
|
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
|
let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
|
||||||
self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities)
|
self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities)
|
||||||
case "answer":
|
case "answer":
|
||||||
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
|
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
self = .answer(answer: answer, iceCandidates: iceCandidates)
|
||||||
case "ice":
|
case "ice":
|
||||||
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
self = .ice(iceCandidates: iceCandidates)
|
self = .ice(iceCandidates: iceCandidates)
|
||||||
case "connection":
|
case "connection":
|
||||||
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
|
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
|
||||||
self = .connection(state: state)
|
self = .connection(state: state)
|
||||||
|
case "connected":
|
||||||
|
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
|
||||||
|
self = .connected(connectionInfo: connectionInfo)
|
||||||
case "ended":
|
case "ended":
|
||||||
self = .ended
|
self = .ended
|
||||||
case "ok":
|
case "ok":
|
||||||
@@ -286,39 +313,42 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This protocol is for debugging
|
// This protocol is for debugging
|
||||||
//extension WCallResponse: Encodable {
|
extension WCallResponse: Encodable {
|
||||||
// func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
// var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
// switch self {
|
switch self {
|
||||||
// case .capabilities:
|
case .capabilities:
|
||||||
// try container.encode("capabilities", forKey: .type)
|
try container.encode("capabilities", forKey: .type)
|
||||||
// case let .offer(offer, iceCandidates, capabilities):
|
case let .offer(offer, iceCandidates, capabilities):
|
||||||
// try container.encode("offer", forKey: .type)
|
try container.encode("offer", forKey: .type)
|
||||||
// try container.encode(offer, forKey: .offer)
|
try container.encode(offer, forKey: .offer)
|
||||||
// try container.encode(iceCandidates, forKey: .iceCandidates)
|
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||||
// try container.encode(capabilities, forKey: .capabilities)
|
try container.encode(capabilities, forKey: .capabilities)
|
||||||
// case let .answer(answer, iceCandidates):
|
case let .answer(answer, iceCandidates):
|
||||||
// try container.encode("answer", forKey: .type)
|
try container.encode("answer", forKey: .type)
|
||||||
// try container.encode(answer, forKey: .answer)
|
try container.encode(answer, forKey: .answer)
|
||||||
// try container.encode(iceCandidates, forKey: .iceCandidates)
|
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||||
// case let .ice(iceCandidates):
|
case let .ice(iceCandidates):
|
||||||
// try container.encode("ice", forKey: .type)
|
try container.encode("ice", forKey: .type)
|
||||||
// try container.encode(iceCandidates, forKey: .iceCandidates)
|
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||||
// case let .connection(state):
|
case let .connection(state):
|
||||||
// try container.encode("connection", forKey: .type)
|
try container.encode("connection", forKey: .type)
|
||||||
// try container.encode(state, forKey: .state)
|
try container.encode(state, forKey: .state)
|
||||||
// case .ended:
|
case let .connected(connectionInfo):
|
||||||
// try container.encode("ended", forKey: .type)
|
try container.encode("connected", forKey: .type)
|
||||||
// case .ok:
|
try container.encode(connectionInfo, forKey: .connectionInfo)
|
||||||
// try container.encode("ok", forKey: .type)
|
case .ended:
|
||||||
// case let .error(message):
|
try container.encode("ended", forKey: .type)
|
||||||
// try container.encode("error", forKey: .type)
|
case .ok:
|
||||||
// try container.encode(message, forKey: .message)
|
try container.encode("ok", forKey: .type)
|
||||||
// case let .invalid(type):
|
case let .error(message):
|
||||||
// try container.encode(type, forKey: .type)
|
try container.encode("error", forKey: .type)
|
||||||
// }
|
try container.encode(message, forKey: .message)
|
||||||
// }
|
case let .invalid(type):
|
||||||
//}
|
try container.encode(type, forKey: .type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ConnectionState: Codable, Equatable {
|
struct ConnectionState: Codable, Equatable {
|
||||||
var connectionState: String
|
var connectionState: String
|
||||||
@@ -326,3 +356,41 @@ struct ConnectionState: Codable, Equatable {
|
|||||||
var iceGatheringState: String
|
var iceGatheringState: String
|
||||||
var signalingState: String
|
var signalingState: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ConnectionInfo: Codable, Equatable {
|
||||||
|
var localCandidate: RTCIceCandidate?
|
||||||
|
var remoteCandidate: RTCIceCandidate?
|
||||||
|
|
||||||
|
var text: LocalizedStringKey {
|
||||||
|
get {
|
||||||
|
if localCandidate?.candidateType == .host && remoteCandidate?.candidateType == .host {
|
||||||
|
return "peer-to-peer"
|
||||||
|
} else if localCandidate?.candidateType == .relay && remoteCandidate?.candidateType == .relay {
|
||||||
|
return "via relay"
|
||||||
|
} else {
|
||||||
|
let unknown = NSLocalizedString("unknown", comment: "connection info")
|
||||||
|
return "\(localCandidate?.candidateType?.rawValue ?? unknown) / \(remoteCandidate?.candidateType?.rawValue ?? unknown)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||||
|
struct RTCIceCandidate: Codable, Equatable {
|
||||||
|
var candidateType: RTCIceCandidateType?
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
|
||||||
|
enum RTCIceCandidateType: String, Codable {
|
||||||
|
case host = "host"
|
||||||
|
case serverReflexive = "srflx"
|
||||||
|
case peerReflexive = "prflx"
|
||||||
|
case relay = "relay"
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||||
|
struct RTCIceServer: Codable, Equatable {
|
||||||
|
var urls: [String]
|
||||||
|
var username: String? = nil
|
||||||
|
var credential: String? = nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,21 +8,26 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate {
|
||||||
var webViewReady: Binding<Bool>
|
var rtcWebView: Binding<WKWebView?>
|
||||||
var webViewMsg: Binding<WVAPIMessage?>
|
var webViewMsg: Binding<WVAPIMessage?>
|
||||||
private var webView: WKWebView?
|
|
||||||
|
|
||||||
internal init(webViewReady: Binding<Bool>, webViewMsg: Binding<WVAPIMessage?>) {
|
internal init(rtcWebView: Binding<WKWebView?>, webViewMsg: Binding<WVAPIMessage?>) {
|
||||||
self.webViewReady = webViewReady
|
self.rtcWebView = rtcWebView
|
||||||
self.webViewMsg = webViewMsg
|
self.webViewMsg = webViewMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
webView.allowsBackForwardNavigationGestures = false
|
webView.allowsBackForwardNavigationGestures = false
|
||||||
self.webView = webView
|
self.rtcWebView.wrappedValue = webView
|
||||||
webViewReady.wrappedValue = true
|
ChatModel.shared.callWebView = webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin : WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||||
|
print("webView", #function)
|
||||||
|
return .grant
|
||||||
}
|
}
|
||||||
|
|
||||||
// receive message from WKWebView
|
// receive message from WKWebView
|
||||||
@@ -31,31 +36,37 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
didReceive message: WKScriptMessage
|
didReceive message: WKScriptMessage
|
||||||
) {
|
) {
|
||||||
logger.debug("WebRTCCoordinator.userContentController")
|
logger.debug("WebRTCCoordinator.userContentController")
|
||||||
if let msgStr = message.body as? String,
|
switch message.name {
|
||||||
let msg: WVAPIMessage = decodeJSON(msgStr) {
|
case "webrtc":
|
||||||
webViewMsg.wrappedValue = msg
|
if let msgStr = message.body as? String,
|
||||||
} else {
|
let msg: WVAPIMessage = decodeJSON(msgStr) {
|
||||||
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
|
// this is the binding that communicates messages from webview to swift view
|
||||||
}
|
webViewMsg.wrappedValue = msg
|
||||||
}
|
if case .invalid = msg.resp {
|
||||||
|
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
|
||||||
func sendCommand(command: WCallCommand) {
|
}
|
||||||
if let webView = webView {
|
} else {
|
||||||
logger.debug("WebRTCCoordinator.sendCommand")
|
logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))")
|
||||||
let apiCmd = encodeJSON(WVAPICall(command: command))
|
}
|
||||||
let js = "processCommand(\(apiCmd))"
|
case "logger":
|
||||||
webView.evaluateJavaScript(js)
|
if let msgStr = message.body as? String {
|
||||||
|
logger.error("WebRTCCoordinator console.log: \(msgStr)")
|
||||||
|
} else {
|
||||||
|
logger.error("WebRTCCoordinator console.log: \(String(describing: message.body))")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.error("WebRTCCoordinator.userContentController: invalid message.name \(message.name)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WebRTCView: UIViewRepresentable {
|
struct WebRTCView: UIViewRepresentable {
|
||||||
@Binding var coordinator: WebRTCCoordinator?
|
@State private var coordinator: WebRTCCoordinator?
|
||||||
@Binding var webViewReady: Bool
|
@Binding var rtcWebView: WKWebView?
|
||||||
@Binding var webViewMsg: WVAPIMessage?
|
@Binding var webViewMsg: WVAPIMessage?
|
||||||
|
|
||||||
func makeCoordinator() -> WebRTCCoordinator {
|
func makeCoordinator() -> WebRTCCoordinator {
|
||||||
WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg)
|
WebRTCCoordinator(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
@@ -69,10 +80,14 @@ struct WebRTCView: UIViewRepresentable {
|
|||||||
cfg.mediaTypesRequiringUserActionForPlayback = []
|
cfg.mediaTypesRequiringUserActionForPlayback = []
|
||||||
cfg.allowsInlineMediaPlayback = true
|
cfg.allowsInlineMediaPlayback = true
|
||||||
|
|
||||||
let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))"
|
let addScript = { (handler: String, source: String) in
|
||||||
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
||||||
wkController.addUserScript(script)
|
wkController.addUserScript(script)
|
||||||
wkController.add(wkCoordinator, name: "webrtc")
|
wkController.add(wkCoordinator, name: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
addScript("webrtc", "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))")
|
||||||
|
addScript("logger", "console.log = (arg) => webkit.messageHandlers.logger.postMessage(JSON.stringify(arg))")
|
||||||
|
|
||||||
let wkWebView = WKWebView(frame: .zero, configuration: cfg)
|
let wkWebView = WKWebView(frame: .zero, configuration: cfg)
|
||||||
wkWebView.navigationDelegate = wkCoordinator
|
wkWebView.navigationDelegate = wkCoordinator
|
||||||
@@ -90,16 +105,22 @@ struct WebRTCView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) {
|
||||||
|
logger.debug("sendCallCommand: \(command.cmdType)")
|
||||||
|
let apiCmd = encodeJSON(WVAPICall(command: command))
|
||||||
|
let js = "processCommand(\(apiCmd))"
|
||||||
|
webView.evaluateJavaScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
//struct CallViewDebug: View {
|
//struct CallViewDebug: View {
|
||||||
// @State private var coordinator: WebRTCCoordinator? = nil
|
|
||||||
// @State private var commandStr = ""
|
// @State private var commandStr = ""
|
||||||
// @State private var webViewReady: Bool = false
|
// @State private var rtcWebView: WKWebView? = nil
|
||||||
// @State private var webViewMsg: WVAPIMessage? = nil
|
// @State private var webViewMsg: WVAPIMessage? = nil
|
||||||
// @FocusState private var keyboardVisible: Bool
|
// @FocusState private var keyboardVisible: Bool
|
||||||
//
|
//
|
||||||
// var body: some View {
|
// var body: some View {
|
||||||
// VStack(spacing: 30) {
|
// VStack(spacing: 30) {
|
||||||
// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260)
|
// WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg).frame(maxHeight: 260)
|
||||||
// .onChange(of: webViewMsg) { _ in
|
// .onChange(of: webViewMsg) { _ in
|
||||||
// if let resp = webViewMsg {
|
// if let resp = webViewMsg {
|
||||||
// commandStr = encodeJSON(resp)
|
// commandStr = encodeJSON(resp)
|
||||||
@@ -127,19 +148,21 @@ struct WebRTCView: UIViewRepresentable {
|
|||||||
// commandStr = ""
|
// commandStr = ""
|
||||||
// }
|
// }
|
||||||
// Button("Send") {
|
// Button("Send") {
|
||||||
// if let c = coordinator,
|
// if let wv = rtcWebView,
|
||||||
// let command: WCallCommand = decodeJSON(commandStr) {
|
// let command: WCallCommand = decodeJSON(commandStr) {
|
||||||
// c.sendCommand(command: command)
|
// sendCallCommand(wv, command)
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// HStack(spacing: 20) {
|
// HStack(spacing: 20) {
|
||||||
// Button("Capabilities") {
|
// Button("Capabilities") {
|
||||||
//
|
// if let wv = rtcWebView {
|
||||||
|
// sendCallCommand(wv, .capabilities(useWorker: true))
|
||||||
|
// }
|
||||||
// }
|
// }
|
||||||
// Button("Start") {
|
// Button("Start") {
|
||||||
// if let c = coordinator {
|
// if let wv = rtcWebView {
|
||||||
// c.sendCommand(command: .start(media: .video))
|
// sendCallCommand(wv, .start(media: .video))
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// Button("Accept") {
|
// Button("Accept") {
|
||||||
@@ -158,9 +181,3 @@ struct WebRTCView: UIViewRepresentable {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
//
|
|
||||||
//struct CallViewDebug_Previews: PreviewProvider {
|
|
||||||
// static var previews: some View {
|
|
||||||
// CallViewDebug()
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
||||||
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
|
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
|
||||||
|
|||||||
@@ -7,15 +7,23 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct ChatInfoView: View {
|
struct ChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var showChatInfo: Bool
|
@Binding var showChatInfo: Bool
|
||||||
@State var showDeleteAlert = false
|
@State var alert: ChatInfoViewAlert? = nil
|
||||||
@State var deletingContact: Contact?
|
@State var deletingContact: Contact?
|
||||||
|
|
||||||
|
enum ChatInfoViewAlert: Identifiable {
|
||||||
|
case deleteContactAlert
|
||||||
|
case clearChatAlert
|
||||||
|
|
||||||
|
var id: ChatInfoViewAlert { get { self } }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack{
|
VStack{
|
||||||
ChatInfoImage(chat: chat)
|
ChatInfoImage(chat: chat)
|
||||||
@@ -27,8 +35,8 @@ struct ChatInfoView: View {
|
|||||||
Text(chat.chatInfo.fullName).font(.title)
|
Text(chat.chatInfo.fullName).font(.title)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
|
||||||
if case let .direct(contact) = chat.chatInfo {
|
VStack {
|
||||||
VStack {
|
if case let .direct(contact) = chat.chatInfo {
|
||||||
HStack {
|
HStack {
|
||||||
serverImage()
|
serverImage()
|
||||||
Text(chat.serverInfo.networkStatus.statusString)
|
Text(chat.serverInfo.networkStatus.statusString)
|
||||||
@@ -41,18 +49,39 @@ struct ChatInfoView: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button() {
|
||||||
|
alert = .clearChatAlert
|
||||||
|
} label: {
|
||||||
|
Label("Clear conversation", systemImage: "gobackward")
|
||||||
|
}
|
||||||
|
.tint(Color.orange)
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
deletingContact = contact
|
deletingContact = contact
|
||||||
showDeleteAlert = true
|
alert = .deleteContactAlert
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete contact", systemImage: "trash")
|
Label("Delete contact", systemImage: "trash")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
else if case .group = chat.chatInfo {
|
||||||
|
Spacer()
|
||||||
|
Button() {
|
||||||
|
alert = .clearChatAlert
|
||||||
|
} label: {
|
||||||
|
Label("Clear conversation", systemImage: "gobackward")
|
||||||
|
}
|
||||||
|
.tint(Color.orange)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.alert(item: $alert) { alertItem in
|
||||||
|
switch(alertItem) {
|
||||||
|
case .deleteContactAlert: return deleteContactAlert(deletingContact!)
|
||||||
|
case .clearChatAlert: return clearChatAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
.alert(isPresented: $showDeleteAlert) { deleteContactAlert(deletingContact!) }
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverImage() -> some View {
|
func serverImage() -> some View {
|
||||||
@@ -81,6 +110,22 @@ struct ChatInfoView: View {
|
|||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearChatAlert() -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Clear conversation?"),
|
||||||
|
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
|
||||||
|
primaryButton: .destructive(Text("Clear")) {
|
||||||
|
Task {
|
||||||
|
await clearChat(chat)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
showChatInfo = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatInfoView_Previews: PreviewProvider {
|
struct ChatInfoView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// CICallItemView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 20/05/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct CICallItemView: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
var chatInfo: ChatInfo
|
||||||
|
var chatItem: ChatItem
|
||||||
|
var status: CICallStatus
|
||||||
|
var duration: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let sent = chatItem.chatDir.sent
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
switch status {
|
||||||
|
case .pending:
|
||||||
|
if sent {
|
||||||
|
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
acceptCallButton()
|
||||||
|
}
|
||||||
|
case .missed: missedCallIcon(sent).foregroundColor(.red)
|
||||||
|
case .rejected: Image(systemName: "phone.down").foregroundColor(.red)
|
||||||
|
case .accepted: connectingCallIcon()
|
||||||
|
case .negotiated: connectingCallIcon()
|
||||||
|
case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
|
||||||
|
case .ended: endedCallIcon(sent)
|
||||||
|
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatItem.timestampText
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func missedCallIcon(_ sent: Bool) -> some View {
|
||||||
|
Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectingCallIcon() -> some View {
|
||||||
|
Image(systemName: "phone.connection").foregroundColor(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "phone.down")
|
||||||
|
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ViewBuilder private func acceptCallButton() -> some View {
|
||||||
|
if case let .direct(contact) = chatInfo {
|
||||||
|
Button {
|
||||||
|
if let invitation = m.callInvitations[contact.id] {
|
||||||
|
CallController.shared.answerCall(invitation: invitation)
|
||||||
|
logger.debug("acceptCallButton call answered")
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct CICallItemView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// CICallItemView()
|
||||||
|
// }
|
||||||
|
//}
|
||||||
+12
-11
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct CIFileView: View {
|
struct CIFileView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -133,7 +134,7 @@ struct CIFileView: View {
|
|||||||
|
|
||||||
struct CIFileView_Previews: PreviewProvider {
|
struct CIFileView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let sentFile = ChatItem(
|
let sentFile: ChatItem = ChatItem(
|
||||||
chatDir: .directSnd,
|
chatDir: .directSnd,
|
||||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||||
content: .sndMsgContent(msgContent: .file("")),
|
content: .sndMsgContent(msgContent: .file("")),
|
||||||
@@ -148,16 +149,16 @@ struct CIFileView_Previews: PreviewProvider {
|
|||||||
file: nil
|
file: nil
|
||||||
)
|
)
|
||||||
Group{
|
Group{
|
||||||
ChatItemView(chatItem: sentFile)
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chatItem: fileChatItemWtFile)
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
+26
-8
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct CIImageView: View {
|
struct CIImageView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -23,9 +24,14 @@ struct CIImageView: View {
|
|||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.edgesIgnoringSafeArea(.all)
|
Color.black.edgesIgnoringSafeArea(.all)
|
||||||
Image(uiImage: uiImage)
|
ZoomableScrollView {
|
||||||
.resizable()
|
ZStack {
|
||||||
.scaledToFit()
|
Color.black.edgesIgnoringSafeArea(.all)
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = false }
|
.onTapGesture { showFullScreenImage = false }
|
||||||
.gesture(
|
.gesture(
|
||||||
@@ -42,11 +48,23 @@ struct CIImageView: View {
|
|||||||
let uiImage = UIImage(data: data) {
|
let uiImage = UIImage(data: data) {
|
||||||
imageView(uiImage)
|
imageView(uiImage)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if case .rcvAccepted = file?.fileStatus {
|
if let file = file {
|
||||||
AlertManager.shared.showAlertMsg(
|
switch file.fileStatus {
|
||||||
title: "Waiting for image",
|
case .rcvInvitation:
|
||||||
message: "Image will be received when your contact is online, please wait or check later!"
|
Task {
|
||||||
)
|
await receiveFile(fileId: file.fileId)
|
||||||
|
// TODO image accepted alert?
|
||||||
|
}
|
||||||
|
case .rcvAccepted:
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Waiting for image",
|
||||||
|
message: "Image will be received when your contact is online, please wait or check later!"
|
||||||
|
)
|
||||||
|
case .rcvTransfer: () // ?
|
||||||
|
case .rcvComplete: () // ?
|
||||||
|
case .rcvCancelled: () // TODO
|
||||||
|
default: ()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct DeletedItemView: View {
|
struct DeletedItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var showMember = false
|
var showMember = false
|
||||||
|
|
||||||
@@ -29,11 +29,6 @@ struct DeletedItemView: View {
|
|||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
// .background(Color(uiColor: .systemBackground))
|
|
||||||
// .overlay(
|
|
||||||
// RoundedRectangle(cornerRadius: 18)
|
|
||||||
// .stroke(.quaternary, lineWidth: 1)
|
|
||||||
// )
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user