Compare commits
124 Commits
v4.6.0-bet
...
ep/contact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f5443920 | ||
|
|
ccff79aa59 | ||
|
|
ccb52e0acd | ||
|
|
d84b30c071 | ||
|
|
5ae0afe1fe | ||
|
|
d250e503b0 | ||
|
|
afb0ae3d03 | ||
|
|
1a3f0bed47 | ||
|
|
1e280fb7e1 | ||
|
|
6feac55380 | ||
|
|
93d8eac037 | ||
|
|
a11f99be3d | ||
|
|
da17639309 | ||
|
|
10301aa742 | ||
|
|
2148d50393 | ||
|
|
12fb2a4ec5 | ||
|
|
8085e5b85c | ||
|
|
4ba310ec16 | ||
|
|
865c56f400 | ||
|
|
c510e73256 | ||
|
|
73638129bc | ||
|
|
1a7a79d504 | ||
|
|
d3268e4a72 | ||
|
|
15a93014a5 | ||
|
|
e7735329bc | ||
|
|
3e222c68eb | ||
|
|
a596bd9011 | ||
|
|
21a49710a8 | ||
|
|
ce6fdb2558 | ||
|
|
0baee848a6 | ||
|
|
6f304bc9e6 | ||
|
|
1ca0dfffa0 | ||
|
|
1420084f5e | ||
|
|
3e03474437 | ||
|
|
95366e4d1b | ||
|
|
df1775a1e6 | ||
|
|
30ccea18ab | ||
|
|
4cd90d74ad | ||
|
|
7f1214688a | ||
|
|
aa89d0d156 | ||
|
|
787cd94362 | ||
|
|
ec61a7fc51 | ||
|
|
9b627534f5 | ||
|
|
400a3707b2 | ||
|
|
38a5676b37 | ||
|
|
f00cfa9108 | ||
|
|
afa24722b2 | ||
|
|
ea5cec53bc | ||
|
|
61dc649c70 | ||
|
|
b20824e16c | ||
|
|
39330fdce3 | ||
|
|
6b725a8ef7 | ||
|
|
cbcdeb2b43 | ||
|
|
4351610eca | ||
|
|
935d826a21 | ||
|
|
a8c8137ade | ||
|
|
7b33e1fba8 | ||
|
|
ade7bba97b | ||
|
|
08dd321311 | ||
|
|
67961180c9 | ||
|
|
1093892ede | ||
|
|
ef05fa4905 | ||
|
|
6a99a4f1ae | ||
|
|
4895f396a2 | ||
|
|
c3dffc5909 | ||
|
|
af73e5993d | ||
|
|
dfec1cbb02 | ||
|
|
8d8f7b2524 | ||
|
|
db7b81587f | ||
|
|
31bb744ba7 | ||
|
|
f4b349162f | ||
|
|
7f8adf8f03 | ||
|
|
1f4bb8a224 | ||
|
|
0c3dc8a6e9 | ||
|
|
1f15cf54af | ||
|
|
c96ba30018 | ||
|
|
ffea61917d | ||
|
|
f5c11b8faf | ||
|
|
48b4b23204 | ||
|
|
9df78c8ac8 | ||
|
|
450bfe2e17 | ||
|
|
c79eb36a7a | ||
|
|
a58b3a42db | ||
|
|
e344958224 | ||
|
|
05c4a6c682 | ||
|
|
b2aec6d6a7 | ||
|
|
09c4609b6c | ||
|
|
f6d2aa7aae | ||
|
|
92facf58f7 | ||
|
|
15c36c5a84 | ||
|
|
cea0543e98 | ||
|
|
c0bbe77788 | ||
|
|
9f8cbe140d | ||
|
|
d0cf550b51 | ||
|
|
a86725480f | ||
|
|
9ad22e1f6d | ||
|
|
a266bcbae7 | ||
|
|
1ba210fe77 | ||
|
|
7898395359 | ||
|
|
aeb732c2f6 | ||
|
|
b665dce383 | ||
|
|
f349f124d8 | ||
|
|
8212d7a00e | ||
|
|
36bcb1b26e | ||
|
|
8d6fe2be99 | ||
|
|
d9571c70f2 | ||
|
|
babbca48f8 | ||
|
|
8c4e2e57f9 | ||
|
|
2a9c138a23 | ||
|
|
47c6daf0cc | ||
|
|
60d6a47bdb | ||
|
|
cfc323862f | ||
|
|
b0c9ba05f3 | ||
|
|
00d5f3b769 | ||
|
|
858f0f2650 | ||
|
|
a8fa9b5e58 | ||
|
|
f379fd0f8c | ||
|
|
34a3387830 | ||
|
|
12200a74ff | ||
|
|
fda41817e9 | ||
|
|
9b7fbfd513 | ||
|
|
e21b4d4236 | ||
|
|
bfc178faf3 | ||
|
|
d7f9e17bcb |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -52,9 +52,9 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
- os: ubuntu-22.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
run: brew install pkg-config
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
@@ -112,8 +112,8 @@ jobs:
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
|
||||
timeout-minutes: 20
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -75,3 +75,4 @@ website/package-lock.json
|
||||
# Ignore test files
|
||||
website/.cache
|
||||
website/test/stubs-layout-cache/_includes/*.js
|
||||
apps/android/app/release
|
||||
|
||||
37
README.md
37
README.md
@@ -4,7 +4,7 @@
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 19/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
@@ -66,6 +66,10 @@ The channel through which you share the link does not have to be secure - it is
|
||||
|
||||
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
|
||||
|
||||
## User guide (NEW)
|
||||
|
||||
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||
|
||||
## Help translating SimpleX Chat
|
||||
|
||||
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||
@@ -75,14 +79,15 @@ Join our translators to help SimpleX grow!
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español ||[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi_sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)|[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Hindi, Japanese, Spanish and [many others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
|
||||
@@ -175,13 +180,17 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Feb 04, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[Jan 03, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
|
||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
|
||||
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
|
||||
@@ -281,17 +290,21 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Multiple user profiles in the same chat database.
|
||||
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- ✅ Preserve message drafts.
|
||||
- 🏗 File server to optimize for efficient and private sending of large files.
|
||||
- 🏗 Improved audio & video calls.
|
||||
- ✅ File server to optimize for efficient and private sending of large files.
|
||||
- ✅ Improved audio & video calls.
|
||||
- ✅ Support older Android OS and 32-bit CPUs.
|
||||
- ✅ Hidden chat profiles.
|
||||
- 🏗 Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
- 🏗 Video messages.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Reduced battery and traffic usage in large groups.
|
||||
- 🏗 Support older Android OS and 32-bit CPUs.
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- Local app files encryption.
|
||||
- Video messages.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Privately share your location.
|
||||
- Feeds/broadcasts.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
|
||||
@@ -5,19 +5,16 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 33
|
||||
versionCode 104
|
||||
versionName "4.6-beta.0"
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 111
|
||||
versionName "4.6.1-beta.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
@@ -77,10 +74,27 @@ android {
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||
// if (isRelease) {
|
||||
// Comma separated list of languages that will be included in the apk
|
||||
android.defaultConfig.resConfigs("en", "cs", "de", "es", "fr", "it", "nl", "ru", "zh-rCN")
|
||||
// }
|
||||
if (isBundle) {
|
||||
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -129,6 +143,9 @@ dependencies {
|
||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||
|
||||
// Video support
|
||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
@@ -136,19 +153,12 @@ dependencies {
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
def buildType = "unknown"
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.doLast {
|
||||
buildType = "debug"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.doLast {
|
||||
buildType = "release"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
@@ -156,6 +166,13 @@ if (compression_level != "0") {
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def buildType
|
||||
if (isRelease) {
|
||||
buildType = "release"
|
||||
} else {
|
||||
buildType = "debug"
|
||||
}
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
@@ -196,6 +213,8 @@ tasks.register("compressApk") {
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
android:label="${app_name}"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<!-- android:localeConfig="@xml/locales_config"-->
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
|
||||
@@ -53,10 +53,6 @@ add_library( support SHARED IMPORTED )
|
||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||
|
||||
add_library( crypto SHARED IMPORTED )
|
||||
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link multiple libraries, such as libraries you define in this
|
||||
# build script, prebuilt third-party libraries, or system libraries.
|
||||
@@ -64,7 +60,7 @@ set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
||||
target_link_libraries( # Specifies the target library.
|
||||
app-lib
|
||||
|
||||
simplex support crypto
|
||||
simplex support
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
|
||||
@@ -7,6 +7,17 @@ void hs_init(int * argc, char **argv[]);
|
||||
void setLineBuffering(void);
|
||||
int pipe_std_to_socket(const char * name);
|
||||
|
||||
extern void __svfscanf(void){};
|
||||
extern void __vfwscanf(void){};
|
||||
extern void __memset_chk_fail(void){};
|
||||
extern void __strcpy_chk_generic(void){};
|
||||
extern void __strcat_chk_generic(void){};
|
||||
extern void __libc_globals(void){};
|
||||
extern void __rel_iplt_start(void){};
|
||||
|
||||
// Android 9 only, not 13
|
||||
extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
@@ -24,21 +35,24 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
||||
// from simplex-chat
|
||||
typedef long* chat_ctrl;
|
||||
|
||||
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
|
||||
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||
extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
jlong _ctrl = (jlong) 0;
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
|
||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||
@@ -85,3 +99,13 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -39,9 +39,8 @@ import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
@@ -109,8 +108,8 @@ class MainActivity: FragmentActivity() {
|
||||
processExternalIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
|
||||
runAuthenticate()
|
||||
@@ -129,6 +128,7 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
|
||||
@@ -160,25 +160,31 @@ class MainActivity: FragmentActivity() {
|
||||
} 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, LAResult.Failed ->
|
||||
laFailed.value = true
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(50)
|
||||
withContext(Dispatchers.Main) {
|
||||
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, LAResult.Failed ->
|
||||
laFailed.value = true
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,14 +267,6 @@ fun MainPage(
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(userAuthorized.value) {
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
delay(500L)
|
||||
}
|
||||
chatsAccessAuthorized = userAuthorized.value == true
|
||||
}
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
@@ -327,7 +325,7 @@ fun MainPage(
|
||||
}
|
||||
}
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
!chatsAccessAuthorized -> {
|
||||
userAuthorized.value != true -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
authView()
|
||||
} else {
|
||||
@@ -404,8 +402,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
withBGApi {
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
||||
chatModel.controller.changeActiveUser(userId)
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
@@ -416,8 +415,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
withBGApi {
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
||||
chatModel.controller.changeActiveUser(userId)
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
@@ -507,6 +507,20 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||
// Still decrypting database
|
||||
if (chatModel.chatRunning.value == null) {
|
||||
val step = 50L
|
||||
for (i in 0..(timeout / step)) {
|
||||
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||
break
|
||||
}
|
||||
delay(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
|
||||
@@ -26,12 +26,13 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String): Array<Any>
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
lateinit var chatController: ChatController
|
||||
@@ -40,10 +41,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
||||
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||
val res: DBMigrationResult = kotlin.runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
|
||||
@@ -94,6 +94,32 @@ class ChatModel(val controller: ChatController) {
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||
|
||||
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||
currentUser.value
|
||||
} else {
|
||||
users.firstOrNull { it.user.userId == userId }?.user
|
||||
}
|
||||
|
||||
private fun getUserIndex(user: User): Int =
|
||||
users.indexOfFirst { it.user.userId == user.userId }
|
||||
|
||||
fun updateUser(user: User) {
|
||||
val i = getUserIndex(user)
|
||||
if (i != -1) {
|
||||
users[i] = users[i].copy(user = user)
|
||||
}
|
||||
if (currentUser.value?.userId == user.userId) {
|
||||
currentUser.value = user
|
||||
}
|
||||
}
|
||||
|
||||
fun removeUser(user: User) {
|
||||
val i = getUserIndex(user)
|
||||
if (i != -1 && users[i].user.userId != currentUser.value?.userId) {
|
||||
users.removeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
@@ -166,10 +192,13 @@ class ChatModel(val controller: ChatController) {
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.none { it.id == cItem.id }) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,19 +225,19 @@ class ChatModel(val controller: ChatController) {
|
||||
res = true
|
||||
}
|
||||
// update current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
return false
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
return if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
false
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
true
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,13 +451,19 @@ data class User(
|
||||
val localDisplayName: String,
|
||||
val profile: LocalProfile,
|
||||
val fullPreferences: FullChatPreferences,
|
||||
val activeUser: Boolean
|
||||
val activeUser: Boolean,
|
||||
val showNtfs: Boolean,
|
||||
val viewPwdHash: UserPwdHash?
|
||||
): NamedChat {
|
||||
override val displayName: String get() = profile.displayName
|
||||
override val fullName: String get() = profile.fullName
|
||||
override val image: String? get() = profile.image
|
||||
override val localAlias: String = ""
|
||||
|
||||
val hidden: Boolean = viewPwdHash != null
|
||||
|
||||
val showNotifications: Boolean = activeUser || showNtfs
|
||||
|
||||
companion object {
|
||||
val sampleData = User(
|
||||
userId = 1,
|
||||
@@ -436,11 +471,19 @@ data class User(
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true
|
||||
activeUser = true,
|
||||
showNtfs = true,
|
||||
viewPwdHash = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserPwdHash(
|
||||
val hash: String,
|
||||
val salt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserInfo(
|
||||
val user: User,
|
||||
@@ -1379,7 +1422,7 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
const val TEMP_LIVE_CHAT_ITEM_ID = -2L
|
||||
|
||||
@@ -1671,18 +1714,31 @@ class CIFile(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val filePath: String? = null,
|
||||
val fileStatus: CIFileStatus
|
||||
val fileStatus: CIFileStatus,
|
||||
val fileProtocol: FileProtocol
|
||||
) {
|
||||
val loaded: Boolean = when (fileStatus) {
|
||||
CIFileStatus.SndStored -> true
|
||||
CIFileStatus.SndTransfer -> true
|
||||
CIFileStatus.SndComplete -> true
|
||||
CIFileStatus.SndCancelled -> true
|
||||
CIFileStatus.RcvInvitation -> false
|
||||
CIFileStatus.RcvAccepted -> false
|
||||
CIFileStatus.RcvTransfer -> false
|
||||
CIFileStatus.RcvCancelled -> false
|
||||
CIFileStatus.RcvComplete -> true
|
||||
is CIFileStatus.SndStored -> true
|
||||
is CIFileStatus.SndTransfer -> true
|
||||
is CIFileStatus.SndComplete -> true
|
||||
is CIFileStatus.SndCancelled -> true
|
||||
is CIFileStatus.RcvInvitation -> false
|
||||
is CIFileStatus.RcvAccepted -> false
|
||||
is CIFileStatus.RcvTransfer -> false
|
||||
is CIFileStatus.RcvCancelled -> false
|
||||
is CIFileStatus.RcvComplete -> true
|
||||
}
|
||||
|
||||
val cancellable: Boolean = when (fileStatus) {
|
||||
is CIFileStatus.SndStored -> fileProtocol != FileProtocol.XFTP // TODO true - enable when XFTP send supports cancel
|
||||
is CIFileStatus.SndTransfer -> fileProtocol != FileProtocol.XFTP // TODO true
|
||||
is CIFileStatus.SndComplete -> false
|
||||
is CIFileStatus.SndCancelled -> false
|
||||
is CIFileStatus.RcvInvitation -> false
|
||||
is CIFileStatus.RcvAccepted -> true
|
||||
is CIFileStatus.RcvTransfer -> true
|
||||
is CIFileStatus.RcvCancelled -> false
|
||||
is CIFileStatus.RcvComplete -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1693,21 +1749,27 @@ class CIFile(
|
||||
filePath: String? = "test.txt",
|
||||
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
|
||||
): CIFile =
|
||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
|
||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIFileStatus {
|
||||
@SerialName("snd_stored") SndStored,
|
||||
@SerialName("snd_transfer") SndTransfer,
|
||||
@SerialName("snd_complete") SndComplete,
|
||||
@SerialName("snd_cancelled") SndCancelled,
|
||||
@SerialName("rcv_invitation") RcvInvitation,
|
||||
@SerialName("rcv_accepted") RcvAccepted,
|
||||
@SerialName("rcv_transfer") RcvTransfer,
|
||||
@SerialName("rcv_complete") RcvComplete,
|
||||
@SerialName("rcv_cancelled") RcvCancelled;
|
||||
enum class FileProtocol {
|
||||
@SerialName("smp") SMP,
|
||||
@SerialName("xftp") XFTP;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIFileStatus {
|
||||
@Serializable @SerialName("sndStored") object SndStored: CIFileStatus()
|
||||
@Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus()
|
||||
@Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus()
|
||||
@Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus()
|
||||
@Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
|
||||
@Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
|
||||
@Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
|
||||
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
|
||||
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
|
||||
}
|
||||
|
||||
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
||||
@@ -1718,6 +1780,7 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
@@ -1771,6 +1834,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
element<String>("text")
|
||||
element<String>("image")
|
||||
})
|
||||
element("MCVideo", buildClassSerialDescriptor("MCVideo") {
|
||||
element<String>("text")
|
||||
element<String>("image")
|
||||
element<Int>("duration")
|
||||
})
|
||||
element("MCFile", buildClassSerialDescriptor("MCFile") {
|
||||
element<String>("text")
|
||||
})
|
||||
@@ -1794,6 +1862,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
MsgContent.MCImage(text, image)
|
||||
}
|
||||
"video" -> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVideo(text, image, duration)
|
||||
}
|
||||
"voice" -> {
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVoice(text, duration)
|
||||
@@ -1829,6 +1902,13 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
}
|
||||
is MsgContent.MCVideo ->
|
||||
buildJsonObject {
|
||||
put("type", "video")
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
put("duration", value.duration)
|
||||
}
|
||||
is MsgContent.MCVoice ->
|
||||
buildJsonObject {
|
||||
put("type", "voice")
|
||||
|
||||
@@ -80,7 +80,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||
notifyMessageReceived(
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
@@ -91,7 +91,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyContactConnected(user: User, contact: Contact) {
|
||||
notifyMessageReceived(
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
@@ -101,11 +101,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
|
||||
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
|
||||
notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
if (!user.showNotifications) return
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
|
||||
@@ -133,6 +133,8 @@ class AppPreferences(val context: Context) {
|
||||
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
|
||||
val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
|
||||
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
|
||||
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
|
||||
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
|
||||
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
|
||||
|
||||
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
|
||||
@@ -140,12 +142,15 @@ class AppPreferences(val context: Context) {
|
||||
val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
|
||||
val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
|
||||
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
|
||||
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
|
||||
|
||||
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
|
||||
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
|
||||
|
||||
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
|
||||
|
||||
val xftpSendEnabled = mkBoolPreference(SHARED_PREFS_XFTP_SEND_ENABLED, false)
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||
@@ -233,14 +238,18 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_INCOGNITO = "Incognito"
|
||||
private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
|
||||
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
|
||||
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
|
||||
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
|
||||
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
|
||||
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
|
||||
private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
|
||||
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
|
||||
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
|
||||
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
|
||||
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
|
||||
private const val SHARED_PREFS_XFTP_SEND_ENABLED = "XFTPSendEnabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,11 +270,24 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.incognito.value = appPrefs.incognito.get()
|
||||
}
|
||||
|
||||
private fun currentUserId(funcName: String): Long {
|
||||
val userId = chatModel.currentUser.value?.userId
|
||||
if (userId == null) {
|
||||
val error = "$funcName: no current user"
|
||||
Log.e(TAG, error)
|
||||
throw Exception(error)
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
suspend fun startChat(user: User) {
|
||||
Log.d(TAG, "user: $user")
|
||||
try {
|
||||
if (chatModel.chatRunning.value == true) return
|
||||
apiSetNetworkConfig(getNetCfg())
|
||||
apiSetTempFolder(getTempFilesDirectory(appContext))
|
||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||
apiSetXFTPConfig(getXFTPCfg())
|
||||
val justStarted = apiStartChat()
|
||||
val users = listUsers()
|
||||
chatModel.users.clear()
|
||||
@@ -273,7 +295,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
if (justStarted) {
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||
apiSetIncognito(chatModel.incognito.value)
|
||||
getUserChatData()
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
@@ -292,21 +313,26 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeActiveUser(toUserId: Long) {
|
||||
suspend fun changeActiveUser(toUserId: Long, viewPwd: String?) {
|
||||
try {
|
||||
changeActiveUser_(toUserId)
|
||||
changeActiveUser_(toUserId, viewPwd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_active_user_title), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeActiveUser_(toUserId: Long) {
|
||||
chatModel.currentUser.value = apiSetActiveUser(toUserId)
|
||||
suspend fun changeActiveUser_(toUserId: Long, viewPwd: String?) {
|
||||
val currentUser = apiSetActiveUser(toUserId, viewPwd)
|
||||
chatModel.currentUser.value = currentUser
|
||||
val users = listUsers()
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
getUserChatData()
|
||||
val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId }
|
||||
if (invitation != null) {
|
||||
chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUserChatData() {
|
||||
@@ -403,15 +429,33 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
throw Exception("failed to list users ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetActiveUser(userId: Long): User {
|
||||
val r = sendCmd(CC.ApiSetActiveUser(userId))
|
||||
suspend fun apiSetActiveUser(userId: Long, viewPwd: String?): User {
|
||||
val r = sendCmd(CC.ApiSetActiveUser(userId, viewPwd))
|
||||
if (r is CR.ActiveUser) return r.user
|
||||
Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}")
|
||||
throw Exception("failed to set the user as active ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean) {
|
||||
val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues))
|
||||
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
|
||||
setUserPrivacy(CC.ApiHideUser(userId, viewPwd))
|
||||
|
||||
suspend fun apiUnhideUser(userId: Long, viewPwd: String): User =
|
||||
setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd))
|
||||
|
||||
suspend fun apiMuteUser(userId: Long): User =
|
||||
setUserPrivacy(CC.ApiMuteUser(userId))
|
||||
|
||||
suspend fun apiUnmuteUser(userId: Long): User =
|
||||
setUserPrivacy(CC.ApiUnmuteUser(userId))
|
||||
|
||||
private suspend fun setUserPrivacy(cmd: CC): User {
|
||||
val r = sendCmd(cmd)
|
||||
if (r is CR.UserPrivacy) return r.updatedUser
|
||||
else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean, viewPwd: String?) {
|
||||
val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues, viewPwd))
|
||||
if (r is CR.CmdOk) return
|
||||
Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}")
|
||||
throw Exception("failed to delete the user ${r.responseType} ${r.details}")
|
||||
@@ -434,12 +478,24 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiSetTempFolder(tempFolder: String) {
|
||||
val r = sendCmd(CC.SetTempFolder(tempFolder))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("failed to set temp folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
private suspend fun apiSetFilesFolder(filesFolder: String) {
|
||||
val r = sendCmd(CC.SetFilesFolder(filesFolder))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) {
|
||||
val r = sendCmd(CC.ApiSetXFTPConfig(cfg))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetIncognito(incognito: Boolean) {
|
||||
val r = sendCmd(CC.SetIncognito(incognito))
|
||||
if (r is CR.CmdOk) return
|
||||
@@ -472,10 +528,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun apiGetChats(): List<Chat> {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiGetChats: no current user")
|
||||
return emptyList()
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() }
|
||||
val r = sendCmd(CC.ApiGetChats(userId))
|
||||
if (r is CR.ApiChats) return r.chats
|
||||
Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
@@ -527,10 +580,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
private suspend fun getUserSMPServers(): Pair<List<ServerCfg>, List<String>>? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "getUserSMPServers: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("getUserSMPServers") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.APIGetUserSMPServers(userId))
|
||||
if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
|
||||
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||
@@ -538,10 +588,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun setUserSMPServers(smpServers: List<ServerCfg>): Boolean {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "setUserSMPServers: no current user")
|
||||
return false
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("setUserSMPServers") }.getOrElse { return false }
|
||||
val r = sendCmd(CC.APISetUserSMPServers(userId, smpServers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
@@ -557,7 +604,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") }
|
||||
val userId = currentUserId("testSMPServer")
|
||||
val r = sendCmd(CC.APITestSMPServer(userId, smpServer))
|
||||
return when (r) {
|
||||
is CR.SmpTestResult -> r.smpTestFailure
|
||||
@@ -569,14 +616,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun getChatItemTTL(): ChatItemTTL {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("getChatItemTTL: no current user") }
|
||||
val userId = currentUserId("getChatItemTTL")
|
||||
val r = sendCmd(CC.APIGetChatItemTTL(userId))
|
||||
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
|
||||
throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("setChatItemTTL: no current user") }
|
||||
val userId = currentUserId("setChatItemTTL")
|
||||
val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
|
||||
@@ -760,10 +807,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun apiListContacts(): List<Contact>? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiListContacts: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiListContacts") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiListContacts(userId))
|
||||
if (r is CR.ContactsList) return r.contacts
|
||||
Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}")
|
||||
@@ -771,10 +815,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun apiUpdateProfile(profile: Profile): Profile? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiUpdateProfile: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
|
||||
if (r is CR.UserProfileNoChange) return profile
|
||||
if (r is CR.UserProfileUpdated) return r.toProfile
|
||||
@@ -804,10 +845,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun apiCreateUserAddress(): String? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiCreateUserAddress: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiCreateMyAddress(userId))
|
||||
return when (r) {
|
||||
is CR.UserContactLinkCreated -> r.connReqContact
|
||||
@@ -821,10 +859,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun apiDeleteUserAddress(): Boolean {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiDeleteUserAddress: no current user")
|
||||
return false
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiDeleteUserAddress") }.getOrElse { return false }
|
||||
val r = sendCmd(CC.ApiDeleteMyAddress(userId))
|
||||
if (r is CR.UserContactLinkDeleted) return true
|
||||
Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
|
||||
@@ -832,10 +867,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
private suspend fun apiGetUserAddress(): UserContactLinkRec? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiGetUserAddress: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiShowMyAddress(userId))
|
||||
if (r is CR.UserContactLink) return r.contactLink
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||
@@ -847,10 +879,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
|
||||
suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "userAddressAutoAccept: no current user")
|
||||
return null
|
||||
}
|
||||
val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept))
|
||||
if (r is CR.UserContactLinkUpdated) return r.contactLink
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||
@@ -969,11 +998,27 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiNewGroup: no current user")
|
||||
return null
|
||||
suspend fun cancelFile(user: User, fileId: Long) {
|
||||
val chatItem = apiCancelFile(fileId)
|
||||
if (chatItem != null) {
|
||||
chatItemSimpleUpdate(user, chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiCancelFile(fileId: Long): AChatItem? {
|
||||
val r = sendCmd(CC.CancelFile(fileId))
|
||||
return when (r) {
|
||||
is CR.SndFileCancelled -> r.chatItem
|
||||
is CR.RcvFileCancelled -> r.chatItem
|
||||
else -> {
|
||||
Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiNewGroup(userId, p))
|
||||
if (r is CR.GroupCreated) return r.groupInfo
|
||||
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
|
||||
@@ -1206,7 +1251,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
val contactRequest = r.contactRequest
|
||||
val cInfo = ChatInfo.ContactRequest(contactRequest)
|
||||
if (active(r.user)) {
|
||||
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
|
||||
if (chatModel.hasChat(contactRequest.id)) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
} else {
|
||||
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
|
||||
}
|
||||
}
|
||||
ntfManager.notifyContactRequestReceived(r.user, cInfo)
|
||||
}
|
||||
@@ -1292,7 +1341,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
val isLastChatItem = chatModel.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
|
||||
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
|
||||
ntfManager.cancelNotificationsForChat(cInfo.id)
|
||||
ntfManager.notifyMessageReceived(
|
||||
ntfManager.displayNotification(
|
||||
r.user,
|
||||
cInfo.id,
|
||||
cInfo.displayName,
|
||||
@@ -1364,14 +1413,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
if (active(r.user)) {
|
||||
chatModel.updateGroup(r.toGroup)
|
||||
}
|
||||
is CR.MemberRole ->
|
||||
if (active(r.user)) {
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
}
|
||||
is CR.RcvFileStart ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.RcvFileComplete ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.RcvFileSndCancelled ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.RcvFileProgressXFTP ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.SndFileStart ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.SndFileComplete -> {
|
||||
@@ -1387,8 +1436,25 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
removeFile(appContext, fileName)
|
||||
}
|
||||
}
|
||||
is CR.CallInvitation ->
|
||||
is CR.SndFileRcvCancelled ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.SndFileProgressXFTP ->
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
is CR.SndFileCompleteXFTP -> {
|
||||
chatItemSimpleUpdate(r.user, r.chatItem)
|
||||
val cItem = r.chatItem.chatItem
|
||||
val mc = cItem.content.msgContent
|
||||
val fileName = cItem.file?.fileName
|
||||
if (
|
||||
mc is MsgContent.MCFile
|
||||
&& fileName != null
|
||||
) {
|
||||
removeFile(appContext, fileName)
|
||||
}
|
||||
}
|
||||
is CR.CallInvitation -> {
|
||||
chatModel.callManager.reportNewIncomingCall(r.callInvitation)
|
||||
}
|
||||
is CR.CallOffer -> {
|
||||
// TODO askConfirmation?
|
||||
// TODO check encryption is compatible
|
||||
@@ -1683,6 +1749,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
fun getXFTPCfg(): XFTPFileConfig? {
|
||||
val prefXFTPSendEnabled = appPrefs.xftpSendEnabled.get()
|
||||
return if (prefXFTPSendEnabled) XFTPFileConfig(minFileSize = 0) else null
|
||||
}
|
||||
|
||||
fun getNetCfg(): NetCfg {
|
||||
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
|
||||
val socksProxy = if (useSocksProxy) ":9050" else null
|
||||
@@ -1754,11 +1825,17 @@ sealed class CC {
|
||||
class ShowActiveUser: CC()
|
||||
class CreateActiveUser(val profile: Profile): CC()
|
||||
class ListUsers: CC()
|
||||
class ApiSetActiveUser(val userId: Long): CC()
|
||||
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean): CC()
|
||||
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
|
||||
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
|
||||
class ApiUnhideUser(val userId: Long, val viewPwd: String): CC()
|
||||
class ApiMuteUser(val userId: Long): CC()
|
||||
class ApiUnmuteUser(val userId: Long): CC()
|
||||
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
|
||||
class StartChat(val expire: Boolean): CC()
|
||||
class ApiStopChat: CC()
|
||||
class SetTempFolder(val tempFolder: String): CC()
|
||||
class SetFilesFolder(val filesFolder: String): CC()
|
||||
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
|
||||
class SetIncognito(val incognito: Boolean): CC()
|
||||
class ApiExportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
@@ -1824,6 +1901,7 @@ sealed class CC {
|
||||
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
|
||||
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
|
||||
class ReceiveFile(val fileId: Long, val inline: Boolean?): CC()
|
||||
class CancelFile(val fileId: Long): CC()
|
||||
class ShowVersion(): CC()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
@@ -1831,11 +1909,17 @@ sealed class CC {
|
||||
is ShowActiveUser -> "/u"
|
||||
is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}"
|
||||
is ListUsers -> "/users"
|
||||
is ApiSetActiveUser -> "/_user $userId"
|
||||
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}"
|
||||
is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}"
|
||||
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
|
||||
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"
|
||||
is ApiMuteUser -> "/_mute user $userId"
|
||||
is ApiUnmuteUser -> "/_unmute user $userId"
|
||||
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}"
|
||||
is StartChat -> "/_start subscribe=on expire=${onOff(expire)}"
|
||||
is ApiStopChat -> "/_stop"
|
||||
is SetTempFolder -> "/_temp_folder $tempFolder"
|
||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
|
||||
is SetIncognito -> "/incognito ${onOff(incognito)}"
|
||||
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
@@ -1901,6 +1985,7 @@ sealed class CC {
|
||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
|
||||
is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}"
|
||||
is CancelFile -> "/fcancel $fileId"
|
||||
is ShowVersion -> "/version"
|
||||
}
|
||||
|
||||
@@ -1910,10 +1995,16 @@ sealed class CC {
|
||||
is CreateActiveUser -> "createActiveUser"
|
||||
is ListUsers -> "listUsers"
|
||||
is ApiSetActiveUser -> "apiSetActiveUser"
|
||||
is ApiHideUser -> "apiHideUser"
|
||||
is ApiUnhideUser -> "apiUnhideUser"
|
||||
is ApiMuteUser -> "apiMuteUser"
|
||||
is ApiUnmuteUser -> "apiUnmuteUser"
|
||||
is ApiDeleteUser -> "apiDeleteUser"
|
||||
is StartChat -> "startChat"
|
||||
is ApiStopChat -> "apiStopChat"
|
||||
is SetTempFolder -> "setTempFolder"
|
||||
is SetFilesFolder -> "setFilesFolder"
|
||||
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
|
||||
is SetIncognito -> "setIncognito"
|
||||
is ApiExportArchive -> "apiExportArchive"
|
||||
is ApiImportArchive -> "apiImportArchive"
|
||||
@@ -1979,6 +2070,7 @@ sealed class CC {
|
||||
is ApiChatRead -> "apiChatRead"
|
||||
is ApiChatUnread -> "apiChatUnread"
|
||||
is ReceiveFile -> "receiveFile"
|
||||
is CancelFile -> "cancelFile"
|
||||
is ShowVersion -> "showVersion"
|
||||
}
|
||||
|
||||
@@ -1992,13 +2084,26 @@ sealed class CC {
|
||||
val obfuscated: CC
|
||||
get() = when (this) {
|
||||
is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey)))
|
||||
is ApiSetActiveUser -> ApiSetActiveUser(userId, obfuscateOrNull(viewPwd))
|
||||
is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd))
|
||||
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
|
||||
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
|
||||
else -> this
|
||||
}
|
||||
|
||||
private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***"
|
||||
|
||||
private fun obfuscateOrNull(s: String?): String? =
|
||||
if (s != null) {
|
||||
obfuscate(s)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun onOff(b: Boolean): String = if (b) "on" else "off"
|
||||
|
||||
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
|
||||
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
|
||||
|
||||
@@ -2027,6 +2132,9 @@ sealed class ChatPagination {
|
||||
@Serializable
|
||||
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||
|
||||
@Serializable
|
||||
class XFTPFileConfig(val minFileSize: Long)
|
||||
|
||||
@Serializable
|
||||
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
|
||||
|
||||
@@ -2851,6 +2959,13 @@ class APIResponse(val resp: CR, val corr: String? = null) {
|
||||
resp = CR.ApiChat(user, chat),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
} else if (type == "chatCmdError") {
|
||||
val userObject = resp["user_"]?.jsonObject
|
||||
val user = runCatching<User?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
|
||||
return APIResponse(
|
||||
resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
|
||||
@@ -2907,6 +3022,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR()
|
||||
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR()
|
||||
@@ -2964,12 +3080,16 @@ sealed class CR {
|
||||
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: User, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR()
|
||||
// sending file events
|
||||
@Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
|
||||
@Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR()
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
|
||||
@Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
|
||||
@Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR()
|
||||
@@ -2977,11 +3097,11 @@ sealed class CR {
|
||||
@Serializable @SerialName("callEnded") class CallEnded(val user: User, val contact: Contact): CR()
|
||||
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR()
|
||||
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
|
||||
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
|
||||
@Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
|
||||
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user: User?, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("chatError") class ChatRespError(val user: User?, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: User?, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("chatError") class ChatRespError(val user_: User?, val chatError: ChatError): CR()
|
||||
@Serializable class Response(val type: String, val json: String): CR()
|
||||
@Serializable class Invalid(val str: String): CR()
|
||||
|
||||
@@ -3010,6 +3130,7 @@ sealed class CR {
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is UserPrivacy -> "userPrivacy"
|
||||
is ContactAliasUpdated -> "contactAliasUpdated"
|
||||
is ConnectionAliasUpdated -> "connectionAliasUpdated"
|
||||
is ContactPrefsUpdated -> "contactPrefsUpdated"
|
||||
@@ -3065,11 +3186,15 @@ sealed class CR {
|
||||
is RcvFileAccepted -> "rcvFileAccepted"
|
||||
is RcvFileStart -> "rcvFileStart"
|
||||
is RcvFileComplete -> "rcvFileComplete"
|
||||
is RcvFileCancelled -> "rcvFileCancelled"
|
||||
is RcvFileSndCancelled -> "rcvFileSndCancelled"
|
||||
is RcvFileProgressXFTP -> "rcvFileProgressXFTP"
|
||||
is SndFileCancelled -> "sndFileCancelled"
|
||||
is SndFileComplete -> "sndFileComplete"
|
||||
is SndFileRcvCancelled -> "sndFileRcvCancelled"
|
||||
is SndFileStart -> "sndFileStart"
|
||||
is SndGroupFileCancelled -> "sndGroupFileCancelled"
|
||||
is SndFileProgressXFTP -> "sndFileProgressXFTP"
|
||||
is SndFileCompleteXFTP -> "sndFileCompleteXFTP"
|
||||
is CallInvitation -> "callInvitation"
|
||||
is CallOffer -> "callOffer"
|
||||
is CallAnswer -> "callAnswer"
|
||||
@@ -3111,6 +3236,7 @@ sealed class CR {
|
||||
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
|
||||
is UserProfileNoChange -> withUser(user, noDetails())
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
is UserPrivacy -> withUser(user, json.encodeToString(updatedUser))
|
||||
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
|
||||
@@ -3167,11 +3293,15 @@ sealed class CR {
|
||||
is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileStart -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileComplete -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize")
|
||||
is SndFileCancelled -> json.encodeToString(chatItem)
|
||||
is SndFileComplete -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileStart -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndGroupFileCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize")
|
||||
is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem))
|
||||
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
|
||||
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
|
||||
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
|
||||
@@ -3179,10 +3309,12 @@ sealed class CR {
|
||||
is CallEnded -> withUser(user, "contact: ${contact.id}")
|
||||
is NewContactConnection -> withUser(user, json.encodeToString(connection))
|
||||
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
|
||||
is VersionInfo -> json.encodeToString(versionInfo)
|
||||
is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" +
|
||||
"chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" +
|
||||
"agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}"
|
||||
is CmdOk -> withUser(user, noDetails())
|
||||
is ChatCmdError -> withUser(user, chatError.string)
|
||||
is ChatRespError -> withUser(user, chatError.string)
|
||||
is ChatCmdError -> withUser(user_, chatError.string)
|
||||
is ChatRespError -> withUser(user_, chatError.string)
|
||||
is Response -> json
|
||||
is Invalid -> str
|
||||
}
|
||||
@@ -3254,11 +3386,13 @@ sealed class ChatError {
|
||||
is ChatErrorAgent -> "agent ${agentError.string}"
|
||||
is ChatErrorStore -> "store ${storeError.string}"
|
||||
is ChatErrorDatabase -> "database ${databaseError.string}"
|
||||
is ChatErrorInvalidJSON -> "invalid json ${json}"
|
||||
}
|
||||
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
|
||||
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
|
||||
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
|
||||
@Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError()
|
||||
@Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -13,12 +13,14 @@ class CallManager(val chatModel: ChatModel) {
|
||||
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(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
if (invitation.user.showNotifications) {
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "proximityLock")
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
@@ -133,6 +134,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
linkMode = chatModel.simplexLinkMode.value,
|
||||
allowVideoAttachment = chatModel.controller.appPrefs.xftpSendEnabled.get(),
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = {
|
||||
hideKeyboard(view)
|
||||
@@ -204,7 +206,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
val groupMember = toModerate?.second
|
||||
val deletedChatItem: ChatItem?
|
||||
val toChatItem: ChatItem?
|
||||
if (groupInfo != null && groupMember != null) {
|
||||
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||
val r = chatModel.controller.apiDeleteMemberChatItem(
|
||||
groupId = groupInfo.groupId,
|
||||
groupMemberId = groupMember.groupMemberId,
|
||||
@@ -230,10 +232,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
}
|
||||
},
|
||||
receiveFile = { fileId ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||
}
|
||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||
},
|
||||
cancelFile = { fileId ->
|
||||
withApi { chatModel.controller.cancelFile(user, fileId) }
|
||||
},
|
||||
joinGroup = { groupId ->
|
||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||
@@ -306,6 +308,7 @@ fun ChatLayout(
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
allowVideoAttachment: Boolean,
|
||||
chatModelIncognito: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
@@ -313,6 +316,7 @@ fun ChatLayout(
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
@@ -336,6 +340,7 @@ fun ChatLayout(
|
||||
sheetContent = {
|
||||
ChooseAttachmentView(
|
||||
attachmentOption,
|
||||
allowVideoAttachment,
|
||||
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||
)
|
||||
},
|
||||
@@ -357,7 +362,7 @@ fun ChatLayout(
|
||||
ChatItemsList(
|
||||
chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -530,6 +535,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
@@ -576,6 +582,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
stopListening = true
|
||||
}
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
VideoPlayer.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
@@ -595,10 +606,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
if (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,11 +651,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -653,7 +666,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,21 +946,26 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ProviderMedia {
|
||||
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
|
||||
data class Video(val uri: Uri, val preview: String): ProviderMedia()
|
||||
}
|
||||
|
||||
private fun providerForGallery(
|
||||
listStateIndex: Int,
|
||||
chatItems: List<ChatItem>,
|
||||
cItemId: Long,
|
||||
scrollTo: (Int) -> Unit
|
||||
): ImageGalleryProvider {
|
||||
fun canShowImage(item: ChatItem): Boolean =
|
||||
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
|
||||
fun canShowMedia(item: ChatItem): Boolean =
|
||||
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null)
|
||||
|
||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||
var processedInternalIndex = -skipInternalIndex.sign
|
||||
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
||||
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
||||
val item = chatItems[chatItemsIndex]
|
||||
if (canShowImage(item)) {
|
||||
if (canShowMedia(item)) {
|
||||
processedInternalIndex += skipInternalIndex.sign
|
||||
}
|
||||
if (processedInternalIndex == skipInternalIndex) {
|
||||
@@ -961,16 +979,28 @@ private fun providerForGallery(
|
||||
var initialChatId = cItemId
|
||||
return object: ImageGalleryProvider {
|
||||
override val initialIndex: Int = initialIndex
|
||||
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
|
||||
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
|
||||
override val totalMediaSize = mutableStateOf(Int.MAX_VALUE)
|
||||
override fun getMedia(index: Int): ProviderMedia? {
|
||||
val internalIndex = initialIndex - index
|
||||
val file = item(internalIndex, initialChatId)?.second?.file
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||
return if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
imageBitmap to uri
|
||||
} else null
|
||||
val item = item(internalIndex, initialChatId)?.second ?: return null
|
||||
return when (item.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file)
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (imageBitmap != null && filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Image(uri, imageBitmap)
|
||||
} else null
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||
if (filePath != null) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
|
||||
} else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun currentPageChanged(index: Int) {
|
||||
@@ -982,7 +1012,7 @@ private fun providerForGallery(
|
||||
|
||||
override fun scrollToStart() {
|
||||
initialIndex = 0
|
||||
initialChatId = chatItems.first { canShowImage(it) }.id
|
||||
initialChatId = chatItems.first { canShowMedia(it) }.id
|
||||
}
|
||||
|
||||
override fun onDismiss(index: Int) {
|
||||
@@ -1053,6 +1083,7 @@ fun PreviewChatLayout() {
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
allowVideoAttachment = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -1060,6 +1091,7 @@ fun PreviewChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
@@ -1112,6 +1144,7 @@ fun PreviewGroupChatLayout() {
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
allowVideoAttachment = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
@@ -1119,6 +1152,7 @@ fun PreviewGroupChatLayout() {
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
|
||||
@@ -7,17 +7,15 @@ import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
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.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
@@ -33,7 +31,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.*
|
||||
@@ -53,6 +50,7 @@ sealed class ComposePreview {
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||
@Serializable class VideoPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||
}
|
||||
@@ -99,6 +97,7 @@ data class ComposeState(
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VideoPreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty() || liveMessage != null
|
||||
@@ -112,6 +111,7 @@ data class ComposeState(
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VideoPreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
@@ -162,6 +162,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
// TODO: include correct type
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||
is MsgContent.MCVideo -> ComposePreview.VideoPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
|
||||
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
|
||||
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||
@@ -182,14 +183,17 @@ fun ComposeView(
|
||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val xftpSendEnabled = chatModel.controller.appPrefs.xftpSendEnabled.get()
|
||||
val maxFileSize = getMaxFileSize(fileProtocol = if (xftpSendEnabled) FileProtocol.XFTP else FileProtocol.SMP)
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
@@ -203,28 +207,21 @@ fun ComposeView(
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val drawable = try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: DecodeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
||||
if (drawable is AnimatedImageDrawable) {
|
||||
val drawable = getDrawableFromUri(uri)
|
||||
var bitmap: Bitmap? = if (drawable != null) getBitmapFromUri(uri) else null
|
||||
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||
if (isAnimNewApi || isAnimOldApi) {
|
||||
// It's a gif or webp
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
content.add(UploadContent.AnimatedImage(uri))
|
||||
} else {
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -239,10 +236,25 @@ fun ComposeView(
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
|
||||
}
|
||||
}
|
||||
val processPickedVideo = { uris: List<Uri>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
val (bitmap: Bitmap?, durationMs: Long?) = getBitmapFromVideo(uri)
|
||||
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||
if (bitmap != null) {
|
||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||
}
|
||||
}
|
||||
|
||||
if (imagesPreview.isNotEmpty()) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.VideoPreview(imagesPreview, content))
|
||||
}
|
||||
}
|
||||
val processPickedFile = { uri: Uri?, text: String? ->
|
||||
if (uri != null) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
val fileName = getFileName(SimplexApp.context, uri)
|
||||
if (fileName != null) {
|
||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||
@@ -250,13 +262,15 @@ fun ComposeView(
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedVideo(it, null) }
|
||||
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedVideo(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
@@ -275,9 +289,17 @@ fun ComposeView(
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
try {
|
||||
galleryLauncher.launch(0)
|
||||
galleryImageLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryLauncherFallback.launch("image/*")
|
||||
galleryImageLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickVideo -> {
|
||||
try {
|
||||
galleryVideoLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryVideoLauncherFallback.launch("video/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
@@ -398,6 +420,7 @@ fun ComposeView(
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
||||
@@ -442,6 +465,7 @@ fun ComposeView(
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
else -> return@forEachIndexed
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
@@ -449,6 +473,18 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VideoPreview -> {
|
||||
preview.content.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||
else -> return@forEachIndexed
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val tmpFile = File(preview.voice)
|
||||
AudioPlayer.stop(tmpFile.absolutePath)
|
||||
@@ -479,7 +515,12 @@ fun ComposeView(
|
||||
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||
)
|
||||
}
|
||||
if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) {
|
||||
if (sent == null &&
|
||||
(cs.preview is ComposePreview.ImagePreview ||
|
||||
cs.preview is ComposePreview.VideoPreview ||
|
||||
cs.preview is ComposePreview.FilePreview ||
|
||||
cs.preview is ComposePreview.VoicePreview)
|
||||
) {
|
||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||
}
|
||||
}
|
||||
@@ -606,6 +647,11 @@ fun ComposeView(
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VideoPreview -> ComposeImageView(
|
||||
preview.images,
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||
preview.voice,
|
||||
preview.durationMs,
|
||||
@@ -773,7 +819,7 @@ class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||
}
|
||||
|
||||
class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
@@ -798,3 +844,30 @@ class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "video/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -50,6 +52,7 @@ import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
import java.lang.reflect.Field
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
@@ -72,7 +75,7 @@ fun SendMsgView(
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 8.dp)) {
|
||||
val cs = composeState.value
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VideoPreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
@@ -240,7 +243,17 @@ private fun NativeKeyboard(
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
} else {
|
||||
try {
|
||||
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||
f.isAccessible = true
|
||||
f.set(editText, R.drawable.edit_text_cursor)
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
|
||||
@@ -5,7 +5,6 @@ import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -13,7 +12,6 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -23,7 +21,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
@@ -54,20 +51,15 @@ fun GroupMemberInfoView(
|
||||
developerTools,
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(it.chatItems)
|
||||
chatModel.chatId.value = it.chatInfo.id
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
newDirectChat = {
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
chatModel.addChat(c)
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
chatModel.addChat(c)
|
||||
}
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
@@ -150,8 +142,7 @@ fun GroupMemberInfoLayout(
|
||||
developerTools: Boolean,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
@@ -176,13 +167,8 @@ fun GroupMemberInfoLayout(
|
||||
if (contactId != null) {
|
||||
SectionView {
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
@@ -364,8 +350,7 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -64,7 +66,7 @@ fun CIFileView(
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -72,22 +74,30 @@ fun CIFileView(
|
||||
fun fileAction() {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation -> {
|
||||
is CIFileStatus.RcvInvitation -> {
|
||||
if (fileSizeValid()) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
||||
)
|
||||
CIFileStatus.RcvComplete -> {
|
||||
is CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> {
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
if (filePath != null) {
|
||||
saveFileLauncher.launch(file.fileName)
|
||||
@@ -105,10 +115,24 @@ fun CIFileView(
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun progressCircle(progress: Long, total: Long) {
|
||||
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||
val strokeColor = if (isInDarkTheme()) FileDark else FileLight
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIndicator() {
|
||||
Box(
|
||||
@@ -120,19 +144,32 @@ fun CIFileView(
|
||||
) {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndStored -> fileIcon()
|
||||
CIFileStatus.SndTransfer -> progressIndicator()
|
||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> fileIcon()
|
||||
}
|
||||
is CIFileStatus.SndTransfer ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
|
||||
else
|
||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
CIFileStatus.RcvComplete -> fileIcon()
|
||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvComplete -> fileIcon()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -191,7 +228,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
ChatItem.getFileMsgContentSample(),
|
||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10)),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -9,8 +10,7 @@ import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -27,8 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.CIFileStatus
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
@@ -45,6 +45,25 @@ fun CIImageView(
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
@Composable
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIcon(icon: ImageVector, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun loadingIndicator() {
|
||||
if (file != null) {
|
||||
@@ -55,39 +74,18 @@ fun CIImageView(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.SndComplete ->
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
stringResource(R.string.icon_descr_image_snd_complete),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
Icon(
|
||||
Icons.Outlined.MoreHoriz,
|
||||
stringResource(R.string.icon_descr_waiting_for_image),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
Icon(
|
||||
Icons.Outlined.ArrowDownward,
|
||||
stringResource(R.string.icon_descr_asked_to_receive),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> {}
|
||||
}
|
||||
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||
is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_image_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_image)
|
||||
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +134,7 @@ fun CIImageView(
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -179,15 +177,23 @@ fun CIImageView(
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> {} // ?
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
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.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun CIVideoView(
|
||||
image: String,
|
||||
duration: Int,
|
||||
file: CIFile?,
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
Box(
|
||||
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||
val preview = remember(image) { base64ToBitmap(image) }
|
||||
if (file != null && filePath != null) {
|
||||
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||
val view = LocalView.current
|
||||
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||
hideKeyboard(view)
|
||||
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Box {
|
||||
ImageView(preview, showMenu, onClick = {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
receiveFileIfValidSize(file, receiveFile)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||
)
|
||||
|
||||
FileProtocol.SMP ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_video),
|
||||
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||
)
|
||||
}
|
||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (file != null) {
|
||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||
}
|
||||
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
||||
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
loadingIndicator(file)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||
val progress = remember(uri.path) { player.progress }
|
||||
val duration = remember(uri.path) { player.duration }
|
||||
val preview by remember { player.preview }
|
||||
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||
val play = {
|
||||
player.enableSound(true)
|
||||
player.play(true)
|
||||
}
|
||||
val stop = {
|
||||
player.enableSound(false)
|
||||
player.stop()
|
||||
}
|
||||
val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
Box {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||
)
|
||||
)
|
||||
if (showPreview.value) {
|
||||
ImageView(preview, showMenu, onClick)
|
||||
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||
}
|
||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||
Surface(
|
||||
Modifier.align(Alignment.Center),
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(percent = 50)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
Modifier.size(25.dp),
|
||||
tint = if (error) WarningOrange else Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, duration: MutableState<Long>, progress: MutableState<Long>/*, soundEnabled: MutableState<Boolean>*/) {
|
||||
if (duration.value > 0L || progress.value > 0) {
|
||||
Row {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(DEFAULT_PADDING_HALF)
|
||||
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
) {
|
||||
val time = if (progress.value > 0) progress.value else duration.value
|
||||
val timeStr = durationText((time / 1000).toInt())
|
||||
val width = if (timeStr.length <= 5) 44 else 50
|
||||
Text(
|
||||
timeStr,
|
||||
Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White
|
||||
)
|
||||
/*if (!soundEnabled.value) {
|
||||
Icon(Icons.Outlined.VolumeOff, null,
|
||||
Modifier.padding(start = 5.dp).size(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}*/
|
||||
}
|
||||
if (!playing.value) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = DEFAULT_PADDING_HALF)
|
||||
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
formatBytes(file.fileSize),
|
||||
Modifier.padding(horizontal = 4.dp),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||
Image(
|
||||
preview.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = onClick
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalWindowWidth(): Dp {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current.density
|
||||
return remember {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
(rect.width() / density).dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fileIcon(icon: ImageVector, @StringRes stringId: Int) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(stringId),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressCircle(progress: Long, total: Long) {
|
||||
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||
val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() }
|
||||
val strokeColor = Color.White
|
||||
Surface(
|
||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||
) {
|
||||
Box(Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun loadingIndicator(file: CIFile?) {
|
||||
if (file != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
is CIFileStatus.SndStored ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressIndicator()
|
||||
FileProtocol.SMP -> {}
|
||||
}
|
||||
is CIFileStatus.SndTransfer ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||
FileProtocol.SMP -> progressIndicator()
|
||||
}
|
||||
is CIFileStatus.SndComplete -> fileIcon(Icons.Filled.Check, R.string.icon_descr_video_snd_complete)
|
||||
is CIFileStatus.SndCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(Icons.Outlined.ArrowDownward, R.string.icon_descr_video_asked_to_receive)
|
||||
is CIFileStatus.RcvAccepted -> fileIcon(Icons.Outlined.MoreHoriz, R.string.icon_descr_waiting_for_video)
|
||||
is CIFileStatus.RcvTransfer ->
|
||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||
} else {
|
||||
progressIndicator()
|
||||
}
|
||||
is CIFileStatus.RcvCancelled -> fileIcon(Icons.Outlined.Close, R.string.icon_descr_file)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileSizeValid(file: CIFile?): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||
if (fileSizeValid(file)) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
||||
val approximatePadding = 100.dp
|
||||
return minOf(1000.dp, windowWidth - approximatePadding)
|
||||
}
|
||||
@@ -210,9 +210,9 @@ private fun VoiceMsgIndicator(
|
||||
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||
}
|
||||
} else {
|
||||
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus == CIFileStatus.RcvAccepted
|
||||
if (file?.fileStatus is CIFileStatus.RcvInvitation
|
||||
|| file?.fileStatus is CIFileStatus.RcvTransfer
|
||||
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -228,7 +228,7 @@ private fun VoiceMsgIndicator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||
val brush = Brush.linearGradient(
|
||||
0f to Color.Transparent,
|
||||
0f to color,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -25,6 +27,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
@@ -40,6 +43,7 @@ fun ChatItemView(
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
@@ -128,14 +132,20 @@ fun ChatItemView(
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCImage -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||
saveImage(context, cItem.file)
|
||||
} else {
|
||||
writePermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
@@ -158,6 +168,9 @@ fun ChatItemView(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancellable) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile)
|
||||
}
|
||||
if (!(live && cItem.meta.isLive)) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
@@ -260,6 +273,23 @@ fun ChatItemView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelFileItemAction(
|
||||
fileId: Long,
|
||||
showMenu: MutableState<Boolean>,
|
||||
cancelFile: (Long) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.cancel_verb),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
@@ -313,6 +343,18 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.cancel_file__question),
|
||||
text = generalGetString(R.string.file_transfer_will_be_cancelled_warning),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
cancelFile(fileId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
@@ -373,6 +415,7 @@ fun PreviewChatItemView() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
@@ -393,6 +436,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
|
||||
@@ -125,6 +125,18 @@ fun FramedItemView(
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(R.string.video_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
@@ -151,7 +163,8 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
|
||||
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
@@ -198,6 +211,14 @@ fun FramedItemView(
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
|
||||
@@ -3,21 +3,28 @@ package chat.simplex.app.views.chat.item
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.isVisible
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.chat.ProviderMedia
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
@@ -26,13 +33,16 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.accompanist.pager.*
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface ImageGalleryProvider {
|
||||
val initialIndex: Int
|
||||
val totalImagesSize: MutableState<Int>
|
||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
||||
val totalMediaSize: MutableState<Int>
|
||||
fun getMedia(index: Int): ProviderMedia?
|
||||
fun currentPageChanged(index: Int)
|
||||
fun scrollToStart()
|
||||
fun onDismiss(index: Int)
|
||||
@@ -48,13 +58,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||
LaunchedEffect(Unit) {
|
||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
||||
if (provider.getMedia(provider.initialIndex - 1) == null) {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||
DisposableEffectOnGone(
|
||||
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||
)
|
||||
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -74,13 +88,13 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
if (settledCurrentPage != provider.initialIndex)
|
||||
provider.currentPageChanged(index)
|
||||
}
|
||||
val image = provider.getImage(index)
|
||||
if (image == null) {
|
||||
val media = provider.getMedia(index)
|
||||
if (media == null) {
|
||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||
SideEffect {
|
||||
scope.launch {
|
||||
when (settledCurrentPage) {
|
||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||
index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
@@ -89,7 +103,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
@@ -100,54 +113,106 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
val modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
if (media is ProviderMedia.Image) {
|
||||
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else if (media is ProviderMedia.Video) {
|
||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { playersToRelease.add(media.uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||
val play = {
|
||||
player.play(true)
|
||||
}
|
||||
val stop = {
|
||||
player.stop()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) play() else stop() }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(false)
|
||||
setShowSubtitleButton(false)
|
||||
setShowVrButton(false)
|
||||
controllerAutoShow = false
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -31,10 +30,12 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||
} else {
|
||||
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||
Box(Modifier.weight(1f, false)) {
|
||||
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||
} else {
|
||||
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||
}
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
|
||||
@@ -208,9 +208,9 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
} else if (chatModel.users.isEmpty()) {
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
} else {
|
||||
val users by remember { derivedStateOf { chatModel.users.toList() } }
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val allRead = users
|
||||
.filter { !it.user.activeUser }
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
if (users.size == 1) {
|
||||
@@ -247,7 +247,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
Box {
|
||||
ProfileImage(
|
||||
|
||||
@@ -24,12 +24,15 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.Indigo
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
|
||||
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Column(
|
||||
@@ -45,23 +48,41 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
|
||||
chatModel.sharedContent.value = null
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyList() {
|
||||
Box {
|
||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.you_have_no_chats), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
BackHandler(onBack = hideSearchOnBack)
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val navButton: @Composable RowScope.() -> Unit = {
|
||||
when {
|
||||
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
||||
users.size > 1 -> {
|
||||
val allRead = users
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
||||
}
|
||||
}
|
||||
if (chatModel.chats.size >= 8) {
|
||||
barButtons.add {
|
||||
IconButton({ showSearch = true }) {
|
||||
@@ -87,7 +108,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
}
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
|
||||
navigationButton = navButton,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
|
||||
@@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -33,10 +34,24 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, switchingUsers: MutableState<Boolean>, openSettings: () -> Unit) {
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
switchingUsers: MutableState<Boolean>,
|
||||
showSettings: Boolean = true,
|
||||
showCancel: Boolean = false,
|
||||
cancelClicked: () -> Unit = {},
|
||||
settingsClicked: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||
val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } }
|
||||
val users by remember {
|
||||
derivedStateOf {
|
||||
chatModel.users
|
||||
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||
.sortedByDescending { it.user.activeUser }
|
||||
}
|
||||
}
|
||||
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
@@ -94,23 +109,22 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
||||
.width(IntrinsicSize.Min)
|
||||
.height(IntrinsicSize.Min)
|
||||
.shadow(8.dp, MaterialTheme.shapes.medium, clip = false)
|
||||
.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)
|
||||
.background(if (isInDarkTheme()) MaterialTheme.colors.background.darker(-0.7f) else MaterialTheme.colors.background, MaterialTheme.shapes.medium)
|
||||
) {
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
users.forEach { u ->
|
||||
UserProfilePickerItem(u.user, u.unreadCount, openSettings = {
|
||||
openSettings()
|
||||
settingsClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!u.user.activeUser) {
|
||||
chatModel.chats.clear()
|
||||
scope.launch {
|
||||
val job = launch {
|
||||
delay(500)
|
||||
switchingUsers.value = true
|
||||
}
|
||||
chatModel.controller.changeActiveUser(u.user.userId)
|
||||
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||
job.cancel()
|
||||
switchingUsers.value = false
|
||||
}
|
||||
@@ -120,9 +134,17 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
||||
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||
}
|
||||
}
|
||||
SettingsPickerItem {
|
||||
openSettings()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
if (showSettings) {
|
||||
SettingsPickerItem {
|
||||
settingsClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
if (showCancel) {
|
||||
CancelPickerItem {
|
||||
cancelClicked()
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,33 +166,19 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProfileImage(
|
||||
image = u.image,
|
||||
size = 54.dp
|
||||
)
|
||||
Text(
|
||||
u.displayName,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 8.dp),
|
||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
UserProfileRow(u)
|
||||
if (u.activeUser) {
|
||||
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (u.hidden) {
|
||||
Icon(Icons.Outlined.Lock, null, Modifier.size(20.dp), tint = HighOrLowlight)
|
||||
} else if (unreadCount > 0) {
|
||||
Row {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.background(if (u.showNtfs) MaterialTheme.colors.primary else HighOrLowlight, shape = CircleShape)
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp),
|
||||
@@ -179,12 +187,35 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
} else {
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(Icons.Outlined.NotificationsOff, null, Modifier.size(20.dp), tint = HighOrLowlight)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfileRow(u: User) {
|
||||
Row(
|
||||
Modifier
|
||||
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProfileImage(
|
||||
image = u.image,
|
||||
size = 54.dp
|
||||
)
|
||||
Text(
|
||||
u.displayName,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 8.dp),
|
||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
|
||||
@@ -196,3 +227,15 @@ private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
|
||||
val text = generalGetString(R.string.cancel_verb)
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
Icon(Icons.Outlined.Close, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
@@ -25,13 +26,13 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
|
||||
@@ -161,7 +162,9 @@ fun DatabaseEncryptionLayout(
|
||||
}
|
||||
|
||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
currentKey,
|
||||
generalGetString(R.string.current_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -170,7 +173,9 @@ fun DatabaseEncryptionLayout(
|
||||
)
|
||||
}
|
||||
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
newKey,
|
||||
generalGetString(R.string.new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -201,7 +206,9 @@ fun DatabaseEncryptionLayout(
|
||||
!validKey(newKey.value) ||
|
||||
progressIndicator.value
|
||||
|
||||
DatabaseKeyField(
|
||||
SectionDivider()
|
||||
|
||||
PassphraseField(
|
||||
confirmNewKey,
|
||||
generalGetString(R.string.confirm_new_passphrase),
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
@@ -212,7 +219,9 @@ fun DatabaseEncryptionLayout(
|
||||
}),
|
||||
)
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
|
||||
SectionDivider()
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
@@ -285,9 +294,10 @@ fun SavePassphraseSetting(
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView {
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
|
||||
@@ -349,13 +359,14 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DatabaseKeyField(
|
||||
fun PassphraseField(
|
||||
key: MutableState<String>,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showStrength: Boolean = false,
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
dependsOn: MutableState<String>? = null,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
@@ -436,6 +447,13 @@ fun DatabaseKeyField(
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { dependsOn?.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
valid = isValid(state.value.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||
|
||||
@@ -4,6 +4,7 @@ import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.*
|
||||
@@ -20,6 +22,7 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.AppVersionText
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -39,24 +42,39 @@ fun DatabaseErrorView(
|
||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
val context = LocalContext.current
|
||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||
val saveAndRunChatOnClick: () -> Unit = {
|
||||
|
||||
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||
val useKey = if (useKeychain) null else dbKey.value
|
||||
runChat(useKey, confirmMigrations, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
|
||||
fun saveAndRunChatOnClick() {
|
||||
DatabaseUtils.setDatabaseKey(dbKey.value)
|
||||
storedDBKey = dbKey.value
|
||||
appPreferences.storeDBPassphrase.set(true)
|
||||
useKeychain = true
|
||||
appPreferences.initialRandomDBPassphrase.set(false)
|
||||
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
callRunChat()
|
||||
}
|
||||
val title = when (chatDbStatus.value) {
|
||||
is DBMigrationResult.OK -> ""
|
||||
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
|
||||
generalGetString(R.string.wrong_passphrase)
|
||||
else
|
||||
generalGetString(R.string.encrypted_database)
|
||||
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
|
||||
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
|
||||
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
|
||||
null -> "" // should never be here
|
||||
|
||||
@Composable
|
||||
fun DatabaseErrorDetails(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
|
||||
Text(
|
||||
generalGetString(title),
|
||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileNameText(dbFile: String) {
|
||||
Text(String.format(generalGetString(R.string.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrationsText(ms: List<String>) {
|
||||
Text(String.format(generalGetString(R.string.database_migrations), ms.joinToString(", ")))
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -64,63 +82,91 @@ fun DatabaseErrorView(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase -> {
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||
when (val status = chatDbStatus.value) {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||
DatabaseErrorDetails(R.string.wrong_passphrase) {
|
||||
Text(generalGetString(R.string.passphrase_is_different))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
saveAndRunChatOnClick()
|
||||
}
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||
SectionSpacer()
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
} else {
|
||||
FileNameText(status.dbFile)
|
||||
}
|
||||
} else {
|
||||
DatabaseErrorDetails(R.string.encrypted_database) {
|
||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
||||
}
|
||||
if (useKeychain) {
|
||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
||||
DatabaseKeyField(dbKey, buttonEnabled, ::saveAndRunChatOnClick)
|
||||
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||
} else {
|
||||
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
|
||||
DatabaseKeyField(dbKey, buttonEnabled) { callRunChat() }
|
||||
OpenChatButton(buttonEnabled) { callRunChat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
|
||||
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||
is MigrationError.Upgrade ->
|
||||
DatabaseErrorDetails(R.string.database_upgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(R.string.upgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
FileNameText(status.dbFile)
|
||||
MigrationsText(err.upMigrations.map { it.upName })
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Downgrade ->
|
||||
DatabaseErrorDetails(R.string.database_downgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(R.string.downgrade_and_open_chat))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(generalGetString(R.string.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||
FileNameText(status.dbFile)
|
||||
MigrationsText(err.downMigrations)
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Error ->
|
||||
DatabaseErrorDetails(R.string.incompatible_database_version) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(R.string.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationSQLError))
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
DatabaseErrorDetails(R.string.keychain_error) {
|
||||
Text(generalGetString(R.string.cannot_access_keychain))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
DatabaseErrorDetails(R.string.invalid_migration_confirmation) {
|
||||
// this can only happen if incorrect parameter is passed
|
||||
}
|
||||
is DBMigrationResult.Unknown ->
|
||||
DatabaseErrorDetails(R.string.database_error) {
|
||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||
}
|
||||
is DBMigrationResult.OK -> {
|
||||
}
|
||||
null -> {
|
||||
}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
is DBMigrationResult.OK -> {}
|
||||
null -> {}
|
||||
}
|
||||
if (restoreDbFromBackup.value) {
|
||||
SectionSpacer()
|
||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
RestoreDbButton {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.restore_database_alert_title),
|
||||
text = generalGetString(R.string.restore_database_alert_desc),
|
||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +187,8 @@ fun DatabaseErrorView(
|
||||
}
|
||||
|
||||
private fun runChat(
|
||||
dbKey: String,
|
||||
dbKey: String? = null,
|
||||
confirmMigrations: MigrationConfirmation? = null,
|
||||
chatDbStatus: State<DBMigrationResult?>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
prefs: AppPreferences
|
||||
@@ -150,7 +197,7 @@ private fun runChat(
|
||||
if (progressIndicator.value) return@launch
|
||||
progressIndicator.value = true
|
||||
try {
|
||||
SimplexApp.context.initChatController(dbKey)
|
||||
SimplexApp.context.initChatController(dbKey, confirmMigrations)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||
}
|
||||
@@ -163,18 +210,17 @@ private fun runChat(
|
||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorNotADatabase -> {
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||
}
|
||||
is DBMigrationResult.Error -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationSQLError)
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
is DBMigrationResult.Unknown ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||
}
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.invalid_migration_confirmation))
|
||||
is DBMigrationResult.ErrorMigration -> {}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
@@ -204,9 +250,17 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
|
||||
}
|
||||
}
|
||||
|
||||
private fun mtrErrorDescription(err: MTRError): String =
|
||||
when (err) {
|
||||
is MTRError.NoDown ->
|
||||
String.format(generalGetString(R.string.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||
is MTRError.Different ->
|
||||
String.format(generalGetString(R.string.mtr_error_different), err.appMigration, err.dbMigration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||
DatabaseKeyField(
|
||||
PassphraseField(
|
||||
text,
|
||||
generalGetString(R.string.enter_passphrase),
|
||||
isValid = ::validKey,
|
||||
|
||||
@@ -8,7 +8,6 @@ import SectionView
|
||||
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
|
||||
@@ -40,6 +39,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -620,7 +620,7 @@ private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): Strin
|
||||
if (inputStream != null && archiveName != null) {
|
||||
val archivePath = "${context.cacheDir}/$archiveName"
|
||||
val destFile = File(archivePath)
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
archivePath
|
||||
} else {
|
||||
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||
|
||||
@@ -15,12 +15,14 @@ import chat.simplex.app.views.newchat.ActionButton
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickVideo: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
allowVideoAttachment: Boolean,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
@@ -45,6 +47,12 @@ fun ChooseAttachmentView(
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
hide()
|
||||
}
|
||||
if (allowVideoAttachment) {
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Videocam) {
|
||||
attachmentOption.value = AttachmentOption.PickVideo
|
||||
hide()
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
hide()
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -22,7 +23,11 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
|
||||
Modifier
|
||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||
content = {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NavigationButtonBack(close)
|
||||
Row {
|
||||
endButtons()
|
||||
|
||||
@@ -66,8 +66,39 @@ object DatabaseUtils {
|
||||
@Serializable
|
||||
sealed class DBMigrationResult {
|
||||
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
||||
@Serializable @SerialName("invalidConfirmation") object InvalidConfirmation: DBMigrationResult()
|
||||
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
||||
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
|
||||
@Serializable @SerialName("errorMigration") class ErrorMigration(val dbFile: String, val migrationError: MigrationError): DBMigrationResult()
|
||||
@Serializable @SerialName("errorSQL") class ErrorSQL(val dbFile: String, val migrationSQLError: String): DBMigrationResult()
|
||||
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
||||
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
||||
}
|
||||
|
||||
|
||||
enum class MigrationConfirmation(val value: String) {
|
||||
YesUp("yesUp"),
|
||||
YesUpDown ("yesUpDown"),
|
||||
Error("error")
|
||||
}
|
||||
|
||||
fun defaultMigrationConfirmation(appPrefs: AppPreferences): MigrationConfirmation =
|
||||
if (appPrefs.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
|
||||
@Serializable
|
||||
sealed class MigrationError {
|
||||
@Serializable @SerialName("upgrade") class Upgrade(val upMigrations: List<UpMigration>): MigrationError()
|
||||
@Serializable @SerialName("downgrade") class Downgrade(val downMigrations: List<String>): MigrationError()
|
||||
@Serializable @SerialName("migrationError") class Error(val mtrError: MTRError): MigrationError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UpMigration(
|
||||
val upName: String,
|
||||
// val withDown: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class MTRError {
|
||||
@Serializable @SerialName("noDown") class NoDown(val dbMigrations: List<String>): MTRError()
|
||||
@Serializable @SerialName("different") class Different(val appMigration: String, val dbMigration: String): MTRError()
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fun DefaultTopAppBar(
|
||||
if (!showSearch) {
|
||||
title?.invoke()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = false, onSearchValueChanged)
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
|
||||
@@ -48,4 +48,5 @@ object UriSerializer : KSerializer<Uri> {
|
||||
sealed class UploadContent {
|
||||
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
||||
@Serializable data class Video(val uri: Uri, val duration: Int): UploadContent()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.graphics.ImageDecoder.DecodeException
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
@@ -114,7 +113,7 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
||||
tmpFile = File.createTempFile("image", ".bmp", File(getAppFilesDirectory(SimplexApp.context)))
|
||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||
tmpFile?.deleteOnExit()
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
@@ -205,17 +204,10 @@ fun GetImageBottomSheet(
|
||||
val context = LocalContext.current
|
||||
val processPickedImage = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
try {
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val bitmap = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = uri
|
||||
onImageChange(bitmap)
|
||||
} catch (e: DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
}
|
||||
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
VideoPlayer.stopAll()
|
||||
AudioPlayer.stop()
|
||||
val rec: MediaRecorder
|
||||
recorder = initRecorder().also { rec = it }
|
||||
@@ -152,6 +153,7 @@ object AudioPlayer {
|
||||
return null
|
||||
}
|
||||
|
||||
VideoPlayer.stopAll()
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != filePath) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -29,15 +30,18 @@ import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, alwaysVisible: Boolean, onValueChange: (String) -> Unit) {
|
||||
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
if (!alwaysVisible) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -87,7 +91,14 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
||||
Text(placeholder)
|
||||
},
|
||||
trailingIcon = if (searchText.text.isNotEmpty()) {{
|
||||
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
|
||||
IconButton({
|
||||
if (alwaysVisible) {
|
||||
keyboard?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
searchText = TextFieldValue("");
|
||||
onValueChange("")
|
||||
}) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
@@ -81,6 +82,7 @@ fun imageMimeType(fileName: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */
|
||||
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
|
||||
@@ -2,13 +2,15 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.LocaleManager
|
||||
//import android.app.LocaleManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.OpenableColumns
|
||||
@@ -33,10 +35,12 @@ import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -234,12 +238,18 @@ const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
|
||||
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
const val MAX_FILE_SIZE_SMP: Long = 8000000
|
||||
|
||||
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
return context.filesDir.toString()
|
||||
}
|
||||
|
||||
fun getTempFilesDirectory(context: Context): String {
|
||||
return "${getFilesDirectory(context)}/temp_files"
|
||||
}
|
||||
|
||||
fun getAppFilesDirectory(context: Context): String {
|
||||
return "${getFilesDirectory(context)}/app_files"
|
||||
}
|
||||
@@ -322,6 +332,14 @@ fun getFileName(context: Context, uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
|
||||
fun getAppFilePath(context: Context, uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
getAppFilePath(context, cursor.getString(nameIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
@@ -330,9 +348,48 @@ fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromUri(uri: Uri, withAlertOnException: Boolean = true): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
} else {
|
||||
BitmapFactory.decodeFile(getAppFilePath(SimplexApp.context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.image_decoding_exception_title),
|
||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Drawable.createFromPath(getAppFilePath(SimplexApp.context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, uri: Uri): String? {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
val bitmap = getBitmapFromUri(uri) ?: return null
|
||||
return saveImage(context, bitmap)
|
||||
}
|
||||
|
||||
@@ -403,7 +460,7 @@ fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(context, fileToSave)
|
||||
val destFile = File(getAppFilePath(context, destFileName))
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
destFileName
|
||||
} else {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||
@@ -439,9 +496,9 @@ fun formatBytes(bytes: Long): String {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1000.toDouble()
|
||||
val k = 1024.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
||||
val i = floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
@@ -486,6 +543,26 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
|
||||
return when (fileProtocol) {
|
||||
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
|
||||
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
mmr.setDataSource(SimplexApp.context, uri)
|
||||
val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
val image = when {
|
||||
timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||
random -> mmr.frameAtTime
|
||||
else -> mmr.getFrameAtIndex(0)
|
||||
}
|
||||
mmr.release()
|
||||
return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0)
|
||||
}
|
||||
|
||||
fun Color.darker(factor: Float = 0.1f): Color =
|
||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||
|
||||
@@ -506,24 +583,24 @@ inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
)
|
||||
|
||||
fun saveAppLocale(pref: SharedPreference<String?>, activity: Activity, languageCode: String? = null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||
localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||
} else {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||
// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||
// } else {
|
||||
pref.set(languageCode)
|
||||
if (languageCode == null) {
|
||||
activity.applyLocale(SimplexApp.context.defaultLocale)
|
||||
}
|
||||
activity.recreate()
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
fun Activity.applyAppLocale(pref: SharedPreference<String?>) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
val lang = pref.get()
|
||||
if (lang == null || lang == Locale.getDefault().language) return
|
||||
applyLocale(Locale.forLanguageTag(lang))
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
private fun Activity.applyLocale(locale: Locale) {
|
||||
@@ -543,3 +620,24 @@ fun UriHandler.openUriCatching(uri: String) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
|
||||
save = { it.width to it.height },
|
||||
restore = { IntSize(it.first, it.second) }
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
always()
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
whenDispose()
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
whenGone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
|
||||
class VideoPlayer private constructor(
|
||||
private val uri: Uri,
|
||||
private val gallery: Boolean,
|
||||
private val defaultPreview: Bitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
context: Context
|
||||
) {
|
||||
companion object {
|
||||
private val players: MutableMap<Pair<Uri, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
private val previewsAndDurations: MutableMap<Uri, PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
fun getOrCreate(
|
||||
uri: Uri,
|
||||
gallery: Boolean,
|
||||
defaultPreview: Bitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
context: Context
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled, context) }
|
||||
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
fileName ?: return null
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
fun release(uri: Uri, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove)
|
||||
|
||||
fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long)
|
||||
|
||||
private val currentVolume: Float
|
||||
val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||
val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
val progress: MutableState<Long> = mutableStateOf(0L)
|
||||
val duration: MutableState<Long> = mutableStateOf(defaultDuration)
|
||||
val preview: MutableState<Bitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
init {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
|
||||
val player = ExoPlayer.Builder(context,
|
||||
DefaultRenderersFactory(context))
|
||||
/*.setLoadControl(DefaultLoadControl.Builder()
|
||||
.setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed
|
||||
.createDefaultLoadControl())*/
|
||||
.setSeekBackIncrementMs(10_000)
|
||||
.setSeekForwardIncrementMs(10_000)
|
||||
.build()
|
||||
.apply {
|
||||
// Repeat the same track endlessly
|
||||
repeatMode = 1
|
||||
currentVolume = volume
|
||||
if (!soundEnabled) {
|
||||
volume = 0f
|
||||
}
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.build(),
|
||||
true // disallow to play multiple instances simultaneously
|
||||
)
|
||||
}
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
enum class TrackState {
|
||||
PLAYING, PAUSED, STOPPED
|
||||
}
|
||||
|
||||
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
|
||||
val filepath = getAppFilePath(SimplexApp.context, uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
Log.e(TAG, "No such file: $uri")
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
if (soundEnabled.value) {
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
stopAll()
|
||||
if (listener.value == null) {
|
||||
runCatching {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory())
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri))
|
||||
player.setMediaSource(source, seek ?: 0L)
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (player.playbackState == PlaybackState.STATE_NONE || player.playbackState == PlaybackState.STATE_STOPPED) {
|
||||
runCatching { player.prepare() }.onFailure {
|
||||
// Can happen when video file is broken
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (seek != null) player.seekTo(seek)
|
||||
player.play()
|
||||
listener.value = onProgressUpdate
|
||||
// Player can only be accessed in one specific thread
|
||||
progressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
while (isActive && player.playbackState != Player.STATE_IDLE && player.playWhenReady) {
|
||||
// Even when current position is equal to duration, the player has isPlaying == true for some time,
|
||||
// so help to make the playback stopped in UI immediately
|
||||
if (player.currentPosition == player.duration) {
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
break
|
||||
}
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
}
|
||||
/*
|
||||
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
|
||||
* the player can show position != duration even if they actually equal.
|
||||
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
|
||||
* */
|
||||
if (isActive) {
|
||||
onProgressUpdate(player.duration, TrackState.PAUSED)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
}
|
||||
player.addListener(object: Player.Listener{
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
// Produce non-ideal transition from stopped to playing state while showing preview image in ChatView
|
||||
// videoPlaying.value = isPlaying
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
val afterCoroutineCancel: CompletionHandler = {
|
||||
// Notify prev video listener about stop
|
||||
listener.value?.invoke(null, TrackState.STOPPED)
|
||||
}
|
||||
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order)
|
||||
* */
|
||||
if (progressJob != null) {
|
||||
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||
} else {
|
||||
afterCoroutineCancel(null)
|
||||
}
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
fun play(resetOnEnd: Boolean) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if (pro == null || pro == duration.value) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
progress.value = 0 //
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSound(enable: Boolean): Boolean {
|
||||
if (soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.volume = if (enable) currentVolume else 0f
|
||||
return true
|
||||
}
|
||||
|
||||
fun release(remove: Boolean) {
|
||||
player.release()
|
||||
if (remove) {
|
||||
players.remove(uri to gallery)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPreviewAndDuration() {
|
||||
// It freezes main thread, doing it in IO thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
|
||||
withContext(Dispatchers.Main) {
|
||||
preview.value = previewAndDuration.preview ?: defaultPreview
|
||||
duration.value = (previewAndDuration.duration ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,14 +97,14 @@ fun AppearanceView(m: ChatModel) {
|
||||
AppBarTitle(stringResource(R.string.appearance_settings))
|
||||
SectionView(stringResource(R.string.settings_section_title_language), padding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
SectionItemWithValue(
|
||||
generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
remember { mutableStateOf("system") },
|
||||
listOf(ValueTitleDesc("system", generalGetString(R.string.change_verb), "")),
|
||||
onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
)
|
||||
} else {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// SectionItemWithValue(
|
||||
// generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
// remember { mutableStateOf("system") },
|
||||
// listOf(ValueTitleDesc("system", generalGetString(R.string.change_verb), "")),
|
||||
// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
// )
|
||||
// } else {
|
||||
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
|
||||
SectionItemView {
|
||||
LangSelector(state) {
|
||||
@@ -122,7 +122,7 @@ fun AppearanceView(m: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
@@ -264,9 +264,9 @@ private fun ThemeSelector(state: State<DefaultTheme>, onSelected: (DefaultTheme)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openSystemLangPicker(activity: Activity) {
|
||||
activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName)))
|
||||
}
|
||||
//private fun openSystemLangPicker(activity: Activity) {
|
||||
// activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName)))
|
||||
//}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun DeveloperView(
|
||||
m: ChatModel,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
AppBarTitle(stringResource(R.string.settings_developer_tools))
|
||||
val developerTools = m.controller.appPrefs.developerTools
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SectionView() {
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.DriveFolderUpload, stringResource(R.string.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Code, stringResource(R.string.show_developer_options), developerTools, devTools)
|
||||
}
|
||||
SectionTextFooter(
|
||||
generalGetString(if (devTools.value) R.string.show_dev_options else R.string.hide_dev_options) + " " +
|
||||
generalGetString(R.string.developer_options)
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
val xftpSendEnabled = m.controller.appPrefs.xftpSendEnabled
|
||||
val xftpEnabled = remember { mutableStateOf(xftpSendEnabled.get()) }
|
||||
SectionView(generalGetString(R.string.settings_section_title_experimenta)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), xftpSendEnabled, xftpEnabled) {
|
||||
withApi { m.controller.apiSetXFTPConfig(m.controller.getXFTPCfg()) }
|
||||
}
|
||||
}
|
||||
if (xftpEnabled.value) {
|
||||
SectionTextFooter(generalGetString(R.string.xftp_requires_v461))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ 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.material.icons.outlined.UploadFile
|
||||
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
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
@@ -27,7 +27,11 @@ fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boo
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), chatModel.controller.appPrefs.xftpSendEnabled) {
|
||||
withApi {
|
||||
chatModel.controller.apiSetXFTPConfig(chatModel.controller.getXFTPCfg())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chatlist.UserProfileRow
|
||||
import chat.simplex.app.views.database.PassphraseField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun HiddenProfileView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
HiddenProfileLayout(
|
||||
user,
|
||||
saveProfilePassword = { hidePassword ->
|
||||
withBGApi {
|
||||
try {
|
||||
val u = m.controller.apiHideUser(user.userId, hidePassword)
|
||||
m.updateUser(u)
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.error_saving_user_password),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenProfileLayout(
|
||||
user: User,
|
||||
saveProfilePassword: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.hide_profile))
|
||||
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
|
||||
UserProfileRow(user)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
val hidePassword = rememberSaveable { mutableStateOf("") }
|
||||
val confirmHidePassword = rememberSaveable { mutableStateOf("") }
|
||||
val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } }
|
||||
val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } }
|
||||
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } }
|
||||
SectionView(stringResource(R.string.hidden_profile_password).uppercase()) {
|
||||
SectionItemView {
|
||||
PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { passwordValid }, showStrength = true)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
PassphraseField(confirmHidePassword, stringResource(R.string.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(R.string.save_profile_password), color = if (saveDisabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.to_reveal_profile_enter_password))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -13,6 +14,7 @@ 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.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -20,7 +22,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
@@ -53,11 +57,22 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
chatModel.chatDbEncrypted.value == true,
|
||||
chatModel.incognito,
|
||||
chatModel.controller.appPrefs.incognito,
|
||||
developerTools = chatModel.controller.appPrefs.developerTools,
|
||||
user.displayName,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
|
||||
showSettingsModalWithSearch = { modalView ->
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight,
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { modalView(chatModel, search) })
|
||||
}
|
||||
},
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
@@ -110,11 +125,11 @@ fun SettingsLayout(
|
||||
encrypted: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
developerTools: SharedPreference<Boolean>,
|
||||
userDisplayName: String,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
@@ -141,7 +156,8 @@ fun SettingsLayout(
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModal { UserProfilesView(it) }() } }, disabled = stopped)
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
|
||||
SectionDivider()
|
||||
@@ -189,16 +205,9 @@ fun SettingsLayout(
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools, devTools)
|
||||
SettingsActionItem(Icons.Outlined.Code, stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
|
||||
SectionDivider()
|
||||
if (devTools.value) {
|
||||
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
}
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it) })
|
||||
// SectionDivider()
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
@@ -352,7 +361,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
@Composable fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SectionItemView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
@@ -364,7 +373,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
@@ -377,9 +386,11 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
|
||||
SectionItemView(showVersion) {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
SectionItemView(showVersion) { AppVersionText() }
|
||||
}
|
||||
|
||||
@Composable fun AppVersionText() {
|
||||
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) {
|
||||
@@ -526,11 +537,11 @@ fun PreviewSettingsLayout() {
|
||||
encrypted = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = SharedPreference({ false }, {}),
|
||||
developerTools = SharedPreference({ false }, {}),
|
||||
userDisplayName = "Alice",
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showSettingsModalWithSearch = { },
|
||||
showCustomModal = { {} },
|
||||
showVersion = {},
|
||||
withAuth = {},
|
||||
|
||||
@@ -2,14 +2,18 @@ package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -17,18 +21,30 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.chatPasswordHash
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.chatlist.UserProfilePickerItem
|
||||
import chat.simplex.app.views.chatlist.UserProfileRow
|
||||
import chat.simplex.app.views.database.PassphraseField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.CreateProfile
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UserProfilesView(m: ChatModel) {
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
|
||||
val searchTextOrPassword = rememberSaveable { search }
|
||||
val users by remember { derivedStateOf { m.users.map { it.user } } }
|
||||
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
|
||||
UserProfilesView(
|
||||
users = users,
|
||||
filteredUsers = filteredUsers,
|
||||
profileHidden = profileHidden,
|
||||
searchTextOrPassword = searchTextOrPassword,
|
||||
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
|
||||
visibleUsersCount = visibleUsersCount(m),
|
||||
addUser = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
@@ -36,38 +52,85 @@ fun UserProfilesView(m: ChatModel) {
|
||||
},
|
||||
activateUser = { user ->
|
||||
withBGApi {
|
||||
m.controller.changeActiveUser(user.userId)
|
||||
m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
|
||||
}
|
||||
},
|
||||
removeUser = { user ->
|
||||
val text = buildAnnotatedString {
|
||||
append(generalGetString(R.string.users_delete_all_chats_deleted) + "\n\n" + generalGetString(R.string.users_delete_profile_for) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(user.displayName)
|
||||
if (m.users.size > 1 && (user.hidden || visibleUsersCount(m) > 1)) {
|
||||
val text = buildAnnotatedString {
|
||||
append(generalGetString(R.string.users_delete_all_chats_deleted) + "\n\n" + generalGetString(R.string.users_delete_profile_for) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(user.displayName)
|
||||
}
|
||||
append(":")
|
||||
}
|
||||
append(":")
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(R.string.users_delete_question),
|
||||
text = text,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, true, searchTextOrPassword.value.trim())
|
||||
}) {
|
||||
Text(stringResource(R.string.users_delete_with_connections), color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false, searchTextOrPassword.value.trim())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.users_delete_data_only), color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.cant_delete_user_profile),
|
||||
text = if (m.users.size > 1) {
|
||||
generalGetString(R.string.should_be_at_least_one_visible_profile)
|
||||
} else {
|
||||
generalGetString(R.string.should_be_at_least_one_profile)
|
||||
}
|
||||
)
|
||||
}
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(R.string.users_delete_question),
|
||||
text = text,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, true)
|
||||
}) {
|
||||
Text(stringResource(R.string.users_delete_with_connections), color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeUser(m, user, users, false)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.users_delete_data_only), color = Color.Red)
|
||||
},
|
||||
unhideUser = { user ->
|
||||
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) }
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value.trim()) } }
|
||||
}
|
||||
},
|
||||
muteUser = { user ->
|
||||
withBGApi {
|
||||
setUserPrivacy(m, onSuccess = {
|
||||
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
|
||||
}) { m.controller.apiMuteUser(user.userId) }
|
||||
}
|
||||
},
|
||||
unmuteUser = { user ->
|
||||
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } }
|
||||
},
|
||||
showHiddenProfile = { user ->
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
HiddenProfileView(m, user) {
|
||||
profileHidden.value = true
|
||||
withBGApi {
|
||||
delay(10_000)
|
||||
profileHidden.value = false
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -75,9 +138,18 @@ fun UserProfilesView(m: ChatModel) {
|
||||
@Composable
|
||||
private fun UserProfilesView(
|
||||
users: List<User>,
|
||||
filteredUsers: List<User>,
|
||||
searchTextOrPassword: MutableState<String>,
|
||||
profileHidden: MutableState<Boolean>,
|
||||
visibleUsersCount: Int,
|
||||
showHiddenProfilesNotice: SharedPreference<Boolean>,
|
||||
addUser: () -> Unit,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
unhideUser: (User) -> Unit,
|
||||
muteUser: (User) -> Unit,
|
||||
unmuteUser: (User) -> Unit,
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -85,25 +157,59 @@ private fun UserProfilesView(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_PADDING),
|
||||
) {
|
||||
if (profileHidden.value) {
|
||||
SectionView {
|
||||
SettingsActionItem(Icons.Outlined.LockOpen, stringResource(R.string.enter_password_to_show), click = {
|
||||
profileHidden.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
AppBarTitle(stringResource(R.string.your_chat_profiles))
|
||||
|
||||
SectionView {
|
||||
for (user in users) {
|
||||
UserView(user, users, activateUser, removeUser)
|
||||
for (user in filteredUsers) {
|
||||
UserView(user, users, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView(addUser, minHeight = 68.dp) {
|
||||
Icon(Icons.Outlined.Add, stringResource(R.string.users_add), tint = MaterialTheme.colors.primary)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.users_add), color = MaterialTheme.colors.primary)
|
||||
if (searchTextOrPassword.value.trim().isEmpty()) {
|
||||
SectionItemView(addUser, minHeight = 68.dp) {
|
||||
Icon(Icons.Outlined.Add, stringResource(R.string.users_add), tint = MaterialTheme.colors.primary)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.users_add), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.tap_to_activate_profile))
|
||||
LaunchedEffect(Unit) {
|
||||
if (showHiddenProfilesNotice.state.value && users.size > 1) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.make_profile_private),
|
||||
text = generalGetString(R.string.you_can_hide_or_mute_user_profile),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
dismissText = generalGetString(R.string.dont_show_again),
|
||||
onDismiss = {
|
||||
showHiddenProfilesNotice.set(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(R.string.your_chat_profiles_stored_locally))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserView(user: User, users: List<User>, activateUser: (User) -> Unit, removeUser: (User) -> Unit) {
|
||||
private fun UserView(
|
||||
user: User,
|
||||
users: List<User>,
|
||||
visibleUsersCount: Int,
|
||||
activateUser: (User) -> Unit,
|
||||
removeUser: (User) -> Unit,
|
||||
unhideUser: (User) -> Unit,
|
||||
muteUser: (User) -> Unit,
|
||||
unmuteUser: (User) -> Unit,
|
||||
showHiddenProfile: (User) -> Unit,
|
||||
) {
|
||||
var showDropdownMenu by remember { mutableStateOf(false) }
|
||||
UserProfilePickerItem(user, onLongClick = { if (users.size > 1) showDropdownMenu = true }) {
|
||||
activateUser(user)
|
||||
@@ -114,28 +220,172 @@ private fun UserView(user: User, users: List<User>, activateUser: (User) -> Unit
|
||||
onDismissRequest = { showDropdownMenu = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (user.hidden) {
|
||||
ItemAction(stringResource(R.string.user_unhide), Icons.Outlined.LockOpen, onClick = {
|
||||
showDropdownMenu = false
|
||||
unhideUser(user)
|
||||
})
|
||||
} else {
|
||||
if (visibleUsersCount > 1) {
|
||||
ItemAction(stringResource(R.string.user_hide), Icons.Outlined.Lock, onClick = {
|
||||
showDropdownMenu = false
|
||||
showHiddenProfile(user)
|
||||
})
|
||||
}
|
||||
if (user.showNtfs) {
|
||||
ItemAction(stringResource(R.string.user_mute), Icons.Outlined.NotificationsOff, onClick = {
|
||||
showDropdownMenu = false
|
||||
muteUser(user)
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(R.string.user_unmute), Icons.Outlined.Notifications, onClick = {
|
||||
showDropdownMenu = false
|
||||
unmuteUser(user)
|
||||
})
|
||||
}
|
||||
}
|
||||
ItemAction(stringResource(R.string.delete_verb), Icons.Outlined.Delete, color = Color.Red, onClick = {
|
||||
removeUser(user)
|
||||
showDropdownMenu = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean) {
|
||||
enum class UserProfileAction {
|
||||
DELETE,
|
||||
UNHIDE
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (String) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = DEFAULT_BOTTOM_PADDING),
|
||||
) {
|
||||
val actionPassword = rememberSaveable { mutableStateOf("") }
|
||||
val passwordValid by remember { derivedStateOf { actionPassword.value == actionPassword.value.trim() } }
|
||||
val actionEnabled by remember { derivedStateOf { actionPassword.value != "" && passwordValid && correctPassword(user, actionPassword.value) } }
|
||||
|
||||
@Composable fun ActionHeader(@StringRes title: Int) {
|
||||
AppBarTitle(stringResource(title))
|
||||
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
|
||||
UserProfileRow(user)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
@Composable fun PasswordAndAction(@StringRes label: Int, color: Color = MaterialTheme.colors.primary) {
|
||||
SectionView() {
|
||||
SectionItemView {
|
||||
PassphraseField(actionPassword, generalGetString(R.string.profile_password), isValid = { passwordValid }, showStrength = true)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(label), color = if (actionEnabled) color else HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (action) {
|
||||
UserProfileAction.DELETE -> {
|
||||
ActionHeader(R.string.delete_profile)
|
||||
PasswordAndAction(R.string.delete_chat_profile, color = Color.Red)
|
||||
if (actionEnabled) {
|
||||
SectionTextFooter(stringResource(R.string.users_delete_all_chats_deleted))
|
||||
}
|
||||
}
|
||||
UserProfileAction.UNHIDE -> {
|
||||
ActionHeader(R.string.unhide_profile)
|
||||
PasswordAndAction(R.string.unhide_chat_profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
|
||||
val s = searchTextOrPassword.trim()
|
||||
val lower = s.lowercase()
|
||||
return m.users.filter { u ->
|
||||
if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) {
|
||||
true
|
||||
} else {
|
||||
correctPassword(u.user, s)
|
||||
}
|
||||
}.map { it.user }
|
||||
}
|
||||
|
||||
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
|
||||
|
||||
private fun correctPassword(user: User, pwd: String): Boolean {
|
||||
val ph = user.viewPwdHash
|
||||
return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
|
||||
}
|
||||
|
||||
private fun userViewPassword(user: User, searchTextOrPassword: String): String? =
|
||||
if (user.hidden) searchTextOrPassword.trim() else null
|
||||
|
||||
private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boolean =
|
||||
user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword.trim())
|
||||
|
||||
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, searchTextOrPassword: String) {
|
||||
if (passwordEntryRequired(user, searchTextOrPassword)) {
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ProfileActionView(UserProfileAction.DELETE, user) { pwd ->
|
||||
withBGApi {
|
||||
doRemoveUser(m, user, users, delSMPQueues, pwd)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withBGApi { doRemoveUser(m, user, users, delSMPQueues, userViewPassword(user, searchTextOrPassword.trim())) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, viewPwd: String?) {
|
||||
if (users.size < 2) return
|
||||
|
||||
withBGApi {
|
||||
try {
|
||||
if (user.activeUser) {
|
||||
val newActive = users.first { !it.activeUser }
|
||||
m.controller.changeActiveUser_(newActive.userId)
|
||||
suspend fun deleteUser(user: User) {
|
||||
m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd)
|
||||
m.removeUser(user)
|
||||
}
|
||||
try {
|
||||
if (user.activeUser) {
|
||||
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
|
||||
if (newActive != null) {
|
||||
m.controller.changeActiveUser_(newActive.userId, null)
|
||||
deleteUser(user.copy(activeUser = false))
|
||||
}
|
||||
m.controller.apiDeleteUser(user.userId, delSMPQueues)
|
||||
m.users.removeAll { it.user.userId == user.userId }
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
|
||||
} else {
|
||||
deleteUser(user)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) {
|
||||
try {
|
||||
m.updateUser(api())
|
||||
onSuccess?.invoke()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.error_updating_user_privacy),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMuteProfileAlert(showMuteProfileAlert: SharedPreference<Boolean>) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.muted_when_inactive),
|
||||
text = generalGetString(R.string.you_will_still_receive_calls_and_ntfs),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
dismissText = generalGetString(R.string.dont_show_again),
|
||||
onDismiss = {
|
||||
showMuteProfileAlert.set(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
|
||||
<solid android:color="@color/highOrLowLight" />
|
||||
<size android:width="1dp" />
|
||||
</shape>
|
||||
@@ -26,4 +26,25 @@
|
||||
<string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string>
|
||||
<string name="switch_receiving_address_desc">هذه الميزة تجريبية! ستعمل فقط إذا كان لدى العميل الآخر الإصدار 4.2 مثبتًا. يجب أن ترى الرسالة في المحادثة بمجرد اكتمال تغيير العنوان - يرجى التحقق من أنه لا يزال بإمكانك تلقي الرسائل من جهة الاتصال هذه (أو عضو المجموعة).</string>
|
||||
<string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string>
|
||||
<string name="allow_verb">يسمح</string>
|
||||
<string name="smp_servers_preset_add">أضف خوادم محددة مسبقًا</string>
|
||||
<string name="smp_servers_add_to_another_device">أضف إلى جهاز آخر</string>
|
||||
<string name="users_delete_all_chats_deleted">سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا!</string>
|
||||
<string name="network_enable_socks_info">الوصول إلى الخوادم عبر بروكسي SOCKS على المنفذ 9050؟ يجب بدء تشغيل الوكيل قبل تمكين هذا الخيار.</string>
|
||||
<string name="accept_requests">قبول طلبات</string>
|
||||
<string name="smp_servers_add">إضافة خادم …</string>
|
||||
<string name="network_settings">إعدادات الشبكة المتقدمة</string>
|
||||
<string name="all_group_members_will_remain_connected">سيبقى جميع أعضاء المجموعة على اتصال.</string>
|
||||
<string name="allow_disappearing_messages_only_if">السماح باختفاء الرسائل فقط إذا سمحت جهة الاتصال الخاصة بك بذلك.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك.</string>
|
||||
<string name="group_member_role_admin">مسؤل</string>
|
||||
<string name="users_add">إضافة ملف التعريف</string>
|
||||
<string name="allow_direct_messages">السماح بإرسال رسائل مباشرة إلى الأعضاء.</string>
|
||||
<string name="accept_contact_incognito_button">قبول التخفي</string>
|
||||
<string name="button_add_welcome_message">أضف رسالة ترحيب</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">أضف الخوادم عن طريق مسح رموز QR.</string>
|
||||
<string name="v4_2_group_links_desc">يمكن للمسؤولين إنشاء روابط للانضمام إلى المجموعات.</string>
|
||||
<string name="accept_connection_request__question">قبول طلب الاتصال؟</string>
|
||||
<string name="clear_chat_warning">سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك.</string>
|
||||
<string name="callstatus_accepted">مكالمة مقبولة</string>
|
||||
</resources>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="allow_voice_messages_only_if">Povolte hlasové zprávy, pouze pokud je váš kontakt povolí.</string>
|
||||
<string name="allow_voice_messages_only_if">Povolit hlasové zprávy, pokud je váš kontakt povolí.</string>
|
||||
<string name="allow_to_send_disappearing">Mizící zprávy povoleny.</string>
|
||||
<string name="allow_to_send_voice">Hlasové zprávy povoleny.</string>
|
||||
<string name="v4_2_group_links_desc">Správci mohou vytvářet odkazy pro připojení ke skupinám.</string>
|
||||
@@ -48,7 +48,7 @@
|
||||
<string name="conn_stats_section_title_servers">SERVERY</string>
|
||||
<string name="receiving_via">Příjímáno přez</string>
|
||||
<string name="create_secret_group_title">Vytvoření tajné skupiny</string>
|
||||
<string name="group_display_name_field">Zobrazení názvu skupiny:</string>
|
||||
<string name="group_display_name_field">Zobrazený název skupiny:</string>
|
||||
<string name="group_full_name_field">Úplný název skupiny:</string>
|
||||
<string name="group_main_profile_sent">Váš chat profil bude zaslán členům skupiny</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">Profil skupiny je uložen v zařízeních členů, nikoli na serverech.</string>
|
||||
@@ -93,7 +93,7 @@
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Váš profil bude odeslán kontaktu, od kterého jste obdrželi tento odkaz.</string>
|
||||
<string name="server_connected">připojeno</string>
|
||||
<string name="server_error">chyba</string>
|
||||
<string name="server_connecting">připojení</string>
|
||||
<string name="server_connecting">připojování</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu.</string>
|
||||
<string name="deleted_description">smazáno</string>
|
||||
<string name="invalid_chat">neplatný chat</string>
|
||||
@@ -101,7 +101,7 @@
|
||||
<string name="connection_local_display_name">spojení <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="display_name_connection_established">spojení navázáno</string>
|
||||
<string name="display_name_invited_to_connect">pozvánka k připojení</string>
|
||||
<string name="display_name_connecting">připojení…</string>
|
||||
<string name="display_name_connecting">připojování…</string>
|
||||
<string name="description_you_shared_one_time_link">sdíleli jste jednorázový odkaz</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">sdíleli jste jednorázový odkaz inkognito</string>
|
||||
<string name="description_via_group_link">prostřednictvím skupinového odkazu</string>
|
||||
@@ -685,7 +685,7 @@
|
||||
<string name="callstate_waiting_for_confirmation">čekání na potvrzení…</string>
|
||||
<string name="callstate_received_answer">obdržel odpověď…</string>
|
||||
<string name="callstate_received_confirmation">obdržel potvrzení…</string>
|
||||
<string name="callstate_connecting">připojení…</string>
|
||||
<string name="callstate_connecting">připojování…</string>
|
||||
<string name="privacy_redefined">Nové vymezení soukromí</string>
|
||||
<string name="first_platform_without_user_ids">1. platforma bez jakýchkoliv uživatelských identifikátorů – soukromá již od návrhu.</string>
|
||||
<string name="immune_to_spam_and_abuse">Odolná vůči spamu a zneužití</string>
|
||||
@@ -854,7 +854,7 @@
|
||||
<string name="skip_inviting_button">Přeskočit pozvání členů</string>
|
||||
<string name="select_contacts">Vybrat kontakty</string>
|
||||
<string name="icon_descr_contact_checked">Zkontrolované kontakty</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> kontakt(y) vybrán(y)</string>
|
||||
<string name="num_contacts_selected">%d kontakt(y) vybrán(y)</string>
|
||||
<string name="button_add_members">Pozvat členy</string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBERS</string>
|
||||
<string name="group_info_member_you">vy: <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
@@ -906,7 +906,7 @@
|
||||
<string name="theme_light">Světlé</string>
|
||||
<string name="theme_dark">Tmavé</string>
|
||||
<string name="theme">Téma</string>
|
||||
<string name="chat_preferences_contact_allows">Kontakt povolen</string>
|
||||
<string name="chat_preferences_contact_allows">Kontakt povolil</string>
|
||||
<string name="chat_preferences_on">zapnuto</string>
|
||||
<string name="chat_preferences_off">vypnuto</string>
|
||||
<string name="chat_preferences">Chat předvolby</string>
|
||||
@@ -963,7 +963,6 @@
|
||||
<string name="your_contact_address">Vaše adresa</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Váš chat profil bude odeslán
|
||||
\nvašemu kontaktu</string>
|
||||
<string name="your_chat_profiles_stored_locally">Vaše chat profily jsou uloženy lokálně, pouze ve vašem zařízení.</string>
|
||||
<string name="your_chats">Vaše konverzace</string>
|
||||
<string name="paste_connection_link_below_to_connect">Do níže uvedeného pole vložte odkaz, který jste obdrželi pro spojení s kontaktem.</string>
|
||||
<string name="share_invitation_link">Sdílet pozvánku</string>
|
||||
@@ -981,4 +980,82 @@
|
||||
<string name="error_updating_link_for_group">Chyba aktualizace odkazu skupiny</string>
|
||||
<string name="initial_member_role">Počáteční role</string>
|
||||
<string name="language_system">Systém</string>
|
||||
<string name="smp_save_servers_question">Uložit servery\?</string>
|
||||
<string name="dont_show_again">Znovu neukazuj</string>
|
||||
<string name="cant_delete_user_profile">Nemohu smazat uživatelský profil!</string>
|
||||
<string name="button_add_welcome_message">Přidat uvítací zprávu</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Čínské a Španělské rozhranní</string>
|
||||
<string name="v4_6_audio_video_calls">Hlasové a video hovory</string>
|
||||
<string name="confirm_password">Potvrdit heslo</string>
|
||||
<string name="enter_password_to_show">Zadejte heslo do hledání</string>
|
||||
<string name="v4_6_reduced_battery_usage">Další snížení spotřeby baterie</string>
|
||||
<string name="error_saving_user_password">Chyba ukládání hesla uživatele</string>
|
||||
<string name="error_updating_user_privacy">Chyba aktualizace soukromí uživatele</string>
|
||||
<string name="v4_6_group_moderation">Správa skupin</string>
|
||||
<string name="v4_6_group_welcome_message">Uvítací zpráva skupin</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Skryté chat profily</string>
|
||||
<string name="hidden_profile_password">Hesla skrytých profilů</string>
|
||||
<string name="user_hide">Skrýt</string>
|
||||
<string name="hide_profile">Skrýt profil</string>
|
||||
<string name="make_profile_private">Změnit profil na soukromý!</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">Další vylepšení již brzy!</string>
|
||||
<string name="v4_6_group_moderation_descr">Nyní mohou správci:
|
||||
\n- mazat zprávy členů.
|
||||
\n- zakázat členy (role \"pozorovatel\")</string>
|
||||
<string name="save_profile_password">Uložit heslo profilu</string>
|
||||
<string name="user_mute">Ztlumit</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Chraňte své chat profily heslem!</string>
|
||||
<string name="save_and_update_group_profile">Uložit a aktualizovat profil skupiny</string>
|
||||
<string name="muted_when_inactive">Ztlumit při neaktivitě!</string>
|
||||
<string name="password_to_show">Heslo k zobrazení</string>
|
||||
<string name="save_welcome_message_question">Uložit uvítací zprávu\?</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Nastavte zprávu zobrazenou novým členům!</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Podpora bluetooth a další vylepšení.</string>
|
||||
<string name="tap_to_activate_profile">Klepnutím aktivujete profil.</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Díky uživatelům - překládejte prostřednictvím Weblate!</string>
|
||||
<string name="should_be_at_least_one_profile">Měl by tam být alespoň jeden uživatelský profil.</string>
|
||||
<string name="button_welcome_message">Uvítací zpráva</string>
|
||||
<string name="group_welcome_title">Uvítací zpráva</string>
|
||||
<string name="user_unmute">Zrušit ztlumení</string>
|
||||
<string name="to_reveal_profile_enter_password">Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce Chat profily.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Měl by tam být alespoň jeden viditelný uživatelský profil.</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Stále budete přijímat volání a upozornění od umlčených profilů pokud budou aktivní.</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Můžete skrýt nebo ztlumit uživatelský profil - Podržte pro menu.</string>
|
||||
<string name="user_unhide">Odkrýt</string>
|
||||
<string name="settings_send_files_via_xftp">Poslat videa a soubory přes XFTP</string>
|
||||
<string name="database_upgrade">Aktualizace databáze</string>
|
||||
<string name="database_downgrade_warning">Upozornění: můžete ztratit nějaká data!</string>
|
||||
<string name="confirm_database_upgrades">Potvrdit aktualizaci databáze</string>
|
||||
<string name="database_downgrade">Původní databáze</string>
|
||||
<string name="mtr_error_no_down_migration">verze databáze je novější než aplikace, ale žádný přechod dolů pro: %s</string>
|
||||
<string name="downgrade_and_open_chat">Snížit a otevřít chat</string>
|
||||
<string name="database_migrations">Migrací: %s</string>
|
||||
<string name="mtr_error_different">různé migrace v aplikaci/databázi: %s / %s</string>
|
||||
<string name="incompatible_database_version">Nekompatibilní verze databáze</string>
|
||||
<string name="invalid_migration_confirmation">Neplatné potvrzení migrace</string>
|
||||
<string name="upgrade_and_open_chat">Zvýšit a otevřít chat</string>
|
||||
<string name="hide_dev_options">Skrýt:</string>
|
||||
<string name="show_developer_options">Zobrazit možnosti vývojáře</string>
|
||||
<string name="settings_section_title_experimenta">POKUSNÝ</string>
|
||||
<string name="xftp_requires_v461">Pro příjem přes XFTP je vyžadována verze 4.6.1+.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">Obrázek bude přijat, až kontakt dokončí jeho nahrání.</string>
|
||||
<string name="show_dev_options">Zobrazit:</string>
|
||||
<string name="developer_options">ID databáze a možnost Izolace přenosu.</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Soubor bude přijat, jakmile váš kontakt dokončí nahrávání.</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Přenos souboru bude zrušen. Pokud probíhá, bude zastaven.</string>
|
||||
<string name="delete_chat_profile">Smazat chat profil</string>
|
||||
<string name="delete_profile">Smazat profil</string>
|
||||
<string name="profile_password">Heslo profilu</string>
|
||||
<string name="unhide_chat_profile">Odkrýt chat profil</string>
|
||||
<string name="unhide_profile">Odkrýt profil</string>
|
||||
<string name="cancel_file__question">Zrušit přenos souboru\?</string>
|
||||
<string name="icon_descr_video_asked_to_receive">Žádost o přijetí videa</string>
|
||||
<string name="videos_limit_desc">Současně lze odeslat pouze 10 videí</string>
|
||||
<string name="videos_limit_title">Příliš mnoho videí!</string>
|
||||
<string name="video_descr">Video</string>
|
||||
<string name="icon_descr_waiting_for_video">Čekám na video</string>
|
||||
<string name="icon_descr_video_snd_complete">Video odesláno</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">Video bude přijato, až kontakt dokončí jeho nahrávání.</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo se podívejte později!</string>
|
||||
<string name="waiting_for_video">Čekám na video</string>
|
||||
</resources>
|
||||
@@ -765,7 +765,7 @@
|
||||
<string name="select_contacts">Kontakte auswählen</string>
|
||||
<string name="icon_descr_contact_checked">Kontakt geprüft</string>
|
||||
<string name="clear_contacts_selection_button">Löschen</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> Kontakt(e) ausgewählt</string>
|
||||
<string name="num_contacts_selected">%d Kontakt(e) ausgewählt</string>
|
||||
<string name="no_contacts_selected">Keine Kontakte ausgewählt</string>
|
||||
<string name="invite_prohibited">Kontakt kann nicht eingeladen werden!</string>
|
||||
<string name="invite_prohibited_description">Sie versuchen, einen Kontakt, mit dem Sie ein Inkognito-Profil geteilt haben, in die Gruppe einzuladen, in der Sie Ihr Hauptprofil verwenden.</string>
|
||||
@@ -1023,7 +1023,6 @@
|
||||
<string name="users_delete_data_only">Nur lokale Profildaten</string>
|
||||
<string name="users_delete_with_connections">Profil und Serververbindungen</string>
|
||||
<string name="messages_section_description">Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil</string>
|
||||
<string name="your_chat_profiles_stored_locally">Ihre Chat-Profile werden nur lokal auf Ihrem Endgerät gespeichert</string>
|
||||
<string name="failed_to_create_user_duplicate_title">Doppelter Anzeigename!</string>
|
||||
<string name="failed_to_create_user_title">Fehler beim Erstellen des Profils!</string>
|
||||
<string name="failed_to_active_user_title">Fehler beim Umschalten des Profils!</string>
|
||||
@@ -1055,4 +1054,82 @@
|
||||
<string name="observer_cant_send_message_desc">Bitte kontaktieren Sie den Gruppen-Administrator.</string>
|
||||
<string name="moderate_message_will_be_deleted_warning">Diese Nachricht wird für alle Gruppenmitglieder gelöscht.</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="confirm_password">Passwort bestätigen</string>
|
||||
<string name="cant_delete_user_profile">Das Benutzerprofil kann nicht gelöscht werden!</string>
|
||||
<string name="dont_show_again">Nicht nochmals anzeigen</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Chinesische und spanische Bedienoberfläche</string>
|
||||
<string name="v4_6_audio_video_calls">Audio- und Videoanrufe</string>
|
||||
<string name="button_add_welcome_message">Begrüßungsmeldung hinzufügen</string>
|
||||
<string name="error_updating_user_privacy">Fehler beim Aktualisieren der Benutzer-Privatsphäre</string>
|
||||
<string name="smp_save_servers_question">Alle Server speichern\?</string>
|
||||
<string name="hide_profile">Profil verbergen</string>
|
||||
<string name="password_to_show">Passwort anzeigen</string>
|
||||
<string name="save_profile_password">Profil-Passwort speichern</string>
|
||||
<string name="error_saving_user_password">Fehler beim Speichern des Benutzer-Passworts</string>
|
||||
<string name="hidden_profile_password">Verborgenes Profil-Passwort</string>
|
||||
<string name="button_welcome_message">Begrüßungsmeldung</string>
|
||||
<string name="save_welcome_message_question">Begrüßungsmeldung speichern\?</string>
|
||||
<string name="user_unhide">Verbergen aufheben</string>
|
||||
<string name="enter_password_to_show">Für die Anzeige das Passwort im Suchfeld eingeben</string>
|
||||
<string name="make_profile_private">Privates Profil erzeugen!</string>
|
||||
<string name="user_mute">Stummschalten</string>
|
||||
<string name="tap_to_activate_profile">Tippen Sie, um das Profil zu aktivieren.</string>
|
||||
<string name="should_be_at_least_one_profile">Es muss mindestens ein Benutzer-Profil vorhanden sein.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Es muss mindestens ein sichtbares Benutzer-Profil vorhanden sein.</string>
|
||||
<string name="user_unmute">Stummschaltung aufheben</string>
|
||||
<string name="muted_when_inactive">Bei Inaktivität stummgeschaltet!</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Schützen Sie Ihre Chat-Profile mit einem Passwort!</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Bluetooth-Unterstützung und weitere Verbesserungen.</string>
|
||||
<string name="v4_6_group_moderation_descr">Administratoren können nun
|
||||
\n- Nachrichten von Gruppenmitgliedern löschen
|
||||
\n- Gruppenmitglieder deaktivieren (\"Beobachter\"-Rolle)</string>
|
||||
<string name="v4_6_group_welcome_message">Gruppen-Begrüßungsmeldung</string>
|
||||
<string name="v4_6_reduced_battery_usage">Weiter reduzierter Batterieverbrauch</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">Weitere Verbesserungen sind bald verfügbar!</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Definieren Sie eine Begrüßungsmeldung, die neuen Mitgliedern angezeigt wird!</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Dank der Nutzer - Tragen Sie per Weblate bei!</string>
|
||||
<string name="v4_6_group_moderation">Gruppenmoderation</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Verborgene Chat-Profile</string>
|
||||
<string name="user_hide">Verberge</string>
|
||||
<string name="save_and_update_group_profile">Gruppen-Profil sichern und aktualisieren</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind.</string>
|
||||
<string name="group_welcome_title">Begrüßungsmeldung</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Sie können ein Benutzerprofil verbergen oder stummschalten - für das Menü gedrückt halten.</string>
|
||||
<string name="to_reveal_profile_enter_password">Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Meine Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen.</string>
|
||||
<string name="settings_send_files_via_xftp">Videos und Dateien per XFTP versenden</string>
|
||||
<string name="invalid_migration_confirmation">Migrations-Bestätigung ungültig</string>
|
||||
<string name="upgrade_and_open_chat">Aktualisieren und den Chat öffnen</string>
|
||||
<string name="confirm_database_upgrades">Datenbank-Aktualisierungen bestätigen</string>
|
||||
<string name="show_dev_options">Anzeigen:</string>
|
||||
<string name="show_developer_options">Entwickleroptionen anzeigen</string>
|
||||
<string name="settings_section_title_experimenta">EXPERIMENTELL</string>
|
||||
<string name="database_upgrade">Datenbank-Aktualisierung</string>
|
||||
<string name="mtr_error_different">Unterschiedlicher Migrationsstand in der App/Datenbank: %s / %s</string>
|
||||
<string name="downgrade_and_open_chat">Herabstufen und den Chat öffnen</string>
|
||||
<string name="incompatible_database_version">Inkompatible Datenbank-Version</string>
|
||||
<string name="database_downgrade_warning">Warnung: Sie könnten einige Daten verlieren!</string>
|
||||
<string name="database_downgrade">Datenbank-Herabstufung</string>
|
||||
<string name="developer_options">Datenbank-IDs und Transport-Isolationsoption.</string>
|
||||
<string name="mtr_error_no_down_migration">Die Datenbank-Version ist neuer als die App, keine Abwärts-Migration für: %s</string>
|
||||
<string name="hide_dev_options">Verberge:</string>
|
||||
<string name="database_migrations">Migrationen: %s</string>
|
||||
<string name="xftp_requires_v461">Für den Empfang per XFTP wird v4.6.1 oder neuer benötigt.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist.</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist.</string>
|
||||
<string name="cancel_file__question">Dateitransfer abbrechen\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Der Dateitransfer wird abgebrochen. Falls er gerade abläuft, wird er angehalten.</string>
|
||||
<string name="delete_chat_profile">Chat-Profil löschen</string>
|
||||
<string name="delete_profile">Profil löschen</string>
|
||||
<string name="unhide_profile">Verbergen des Profils aufheben</string>
|
||||
<string name="profile_password">Passwort für Profil</string>
|
||||
<string name="unhide_chat_profile">Verbergen des Chat-Profils aufheben</string>
|
||||
<string name="icon_descr_video_asked_to_receive">Aufforderung zum Empfang des Videos</string>
|
||||
<string name="videos_limit_desc">Es können nur 10 Videos zur gleichen Zeit versendet werden</string>
|
||||
<string name="videos_limit_title">Zu viele Videos auf einmal!</string>
|
||||
<string name="video_descr">Video</string>
|
||||
<string name="icon_descr_video_snd_complete">Video gesendet</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat.</string>
|
||||
<string name="icon_descr_waiting_for_video">Auf das Video warten</string>
|
||||
<string name="waiting_for_video">Auf das Video warten</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!</string>
|
||||
</resources>
|
||||
@@ -10,20 +10,20 @@
|
||||
<string name="chat_item_ttl_day">un dia</string>
|
||||
<string name="chat_item_ttl_month">un mes</string>
|
||||
<string name="chat_item_ttl_week">una semana</string>
|
||||
<string name="allow_disappearing_messages_only_if">Permitir que desaparezcan los mensajes sólo si su contacto lo permite.</string>
|
||||
<string name="allow_disappearing_messages_only_if">Permitir mensajes temporales sólo si tu contacto los permite.</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Añadir servidores escaneando códigos QR.</string>
|
||||
<string name="smp_servers_preset_add">Añadir servidores predefinidos</string>
|
||||
<string name="all_group_members_will_remain_connected">Todos los miembros del grupo permanecerán conectados.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Permitir la eliminación irreversible de mensajes sólo si tu contacto también lo permite para tí.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Permitir la eliminación irreversible de mensajes sólo si tu contacto también lo permite.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">Android Keystore se usará para almacenar de forma segura la frase de contraseña después de reiniciar la aplicación o cambiar la frase de contraseña - permitirá recibir notificaciones.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Permitir a tus contactos enviar mensajes que desaparecen.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Permitir a tus contactos enviar mensajes temporales</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Permitir a tus contactos enviar mensajes de voz.</string>
|
||||
<string name="chat_preferences_always">siempre</string>
|
||||
<string name="notifications_mode_off_desc">La aplicación sólo puede recibir notificaciones cuando se está ejecutando, no se iniciará ningún servicio en segundo plano.</string>
|
||||
<string name="settings_section_title_icon">ICONO DE LA APLICACIÓN</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Se enviará un perfil aleatorio al contacto del que recibió este enlace</string>
|
||||
<string name="turning_off_service_and_periodic">La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración.</string>
|
||||
<string name="notifications_mode_service_desc">El servicio en segundo plano está siempre en funcionamiento – las notificaciones se mostrarán en cuanto los mensajes estén disponibles.</string>
|
||||
<string name="notifications_mode_service_desc">El servicio en segundo plano está siempre en funcionamiento – las notificaciones se muestran en cuanto los mensajes estén disponibles.</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Se puede desactivar en Configuración</b> – las notificaciones se seguirán mostrando mientras la app esté en funcionamiento.</string>
|
||||
<string name="notifications_mode_service">Siempre activo</string>
|
||||
<string name="allow_verb">Permitir</string>
|
||||
@@ -47,7 +47,7 @@
|
||||
<string name="accept">Aceptar</string>
|
||||
<string name="audio_call_no_encryption">llamada de audio (sin cifrado e2e)</string>
|
||||
<string name="icon_descr_audio_call">llamada de audio</string>
|
||||
<string name="settings_audio_video_calls">Llamadas de audio y vídeo</string>
|
||||
<string name="settings_audio_video_calls">Llamadas y videollamadas</string>
|
||||
<string name="icon_descr_audio_off">Audio desactivado</string>
|
||||
<string name="icon_descr_audio_on">Audio activado</string>
|
||||
<string name="integrity_msg_bad_id">ID de mensaje erróneo</string>
|
||||
@@ -80,28 +80,28 @@
|
||||
<string name="icon_descr_asked_to_receive">Solicita recibir la imagen</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Ten en cuenta</b>: NO podrás recuperar o cambiar la contraseña si la pierdes.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Tanto tú como tu contacto podéis enviar mensajes de voz.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>¡Usa más batería!</b> El servicio en segundo plano está siempre en funcionamiento - las notificaciones se mostrarán tan pronto como los mensajes estén disponibles.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>¡Gasta más batería!</b> El servicio en segundo plano está siempre en funcionamiento - las notificaciones se mostrarán tan pronto como los mensajes estén disponibles.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Tanto tú como tu contacto podéis eliminar de forma irreversible los mensajes enviados.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Tanto tú como tu contacto podéis enviar mensajes temporales.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Escanear código QR</b>: para conectar con tu contacto que te muestre código QR.</string>
|
||||
<string name="create_profile_button">Crear</string>
|
||||
<string name="create_one_time_link">Crear enlace único de invitación</string>
|
||||
<string name="create_one_time_link">Crear enlace de invitación de un uso.</string>
|
||||
<string name="create_group">Crear grupo secreto</string>
|
||||
<string name="database_passphrase_will_be_updated">La contraseña de cifrado de la base de datos será actualizada.</string>
|
||||
<string name="info_row_database_id">ID de la base de datos</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Los mensajes directos entre miembros del grupo están prohibidos.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Los mensajes directos entre miembros del grupo no están permitidos.</string>
|
||||
<string name="passphrase_is_different">La contraseña es distinta a la almacenada en Keystore</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">La base de datos será cifrada y la contraseña se guardará en Keystore.</string>
|
||||
<string name="delete_contact_question">¿Eliminar contacto\?</string>
|
||||
<string name="delete_message__question">Eliminar mensaje\?</string>
|
||||
<string name="delete_message__question">¿Eliminar mensaje\?</string>
|
||||
<string name="delete_chat_profile_question">¿Eliminar el perfil de chat\?</string>
|
||||
<string name="rcv_group_event_group_deleted">grupo eliminado</string>
|
||||
<string name="delete_group_question">¿Eliminar grupo\?</string>
|
||||
<string name="delete_messages_after">Eliminar mensaje después</string>
|
||||
<string name="delete_messages_after">Eliminar después de</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Autenticación de dispositivo desactivada. Puedes habilitar SimpleX Lock en Configuración, después de activar la autenticación de dispositivo.</string>
|
||||
<string name="no_call_on_lock_screen">Desactivar</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Los mensajes temporales están prohibidos en este chat.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Los mensajes temporales están prohibidos en este grupo.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Los mensajes temporales no están permitidos en este chat.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Los mensajes temporales no están permitidos en este grupo.</string>
|
||||
<string name="display_name_cannot_contain_whitespace">El nombre mostrado no puede contener espacios en blanco.</string>
|
||||
<string name="encrypted_video_call">Videollamada con cifrado e2e</string>
|
||||
<string name="display_name_connection_established">conexión establecida</string>
|
||||
@@ -118,7 +118,7 @@
|
||||
<string name="integrity_msg_duplicate">mensaje duplicado</string>
|
||||
<string name="settings_section_title_develop">DESARROLLO</string>
|
||||
<string name="settings_developer_tools">Herramientas desarrollo</string>
|
||||
<string name="delete_files_and_media_for_all_users">Eliminar archivos para todos los perfiles de chat</string>
|
||||
<string name="delete_files_and_media_for_all_users">Eliminar archivos para todos los perfiles Chat</string>
|
||||
<string name="delete_messages">Eliminar mensaje</string>
|
||||
<string name="database_encrypted">¡Base de datos cifrada!</string>
|
||||
<string name="encrypted_with_random_passphrase">La base de datos está cifrada con una contraseña aleatoria, puedes cambiarla.</string>
|
||||
@@ -152,13 +152,13 @@
|
||||
<string name="notification_preview_mode_contact">Nombre del contacto</string>
|
||||
<string name="copy_verb">Copiar</string>
|
||||
<string name="create_your_profile">Crear tu perfil</string>
|
||||
<string name="always_use_relay">Conectar mediante relay</string>
|
||||
<string name="always_use_relay">Siempre usar relay</string>
|
||||
<string name="set_password_to_export_desc">La base de datos está cifrada con una contraseña aleatoria. Cámbiala antes de exportar.</string>
|
||||
<string name="total_files_count_and_size">%d archivo(s) con tamaño total de %s</string>
|
||||
<string name="enable_automatic_deletion_question">¿Activar eliminación automática de mensajes\?</string>
|
||||
<string name="contact_preferences">Preferencias de contacto</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="delete_after">Eliminar después</string>
|
||||
<string name="delete_after">Eliminar después de</string>
|
||||
<string name="ttl_sec">%d seg</string>
|
||||
<string name="contact_already_exists">El contácto ya existe</string>
|
||||
<string name="connection_error_auth">Error de conexión (Autenticación)</string>
|
||||
@@ -166,7 +166,7 @@
|
||||
<string name="icon_descr_server_status_disconnected">Desconectado</string>
|
||||
<string name="icon_descr_server_status_connected">Conectado</string>
|
||||
<string name="copied">Copiado en portapapeles</string>
|
||||
<string name="share_one_time_link">Crear enlace único de invitación</string>
|
||||
<string name="share_one_time_link">Crear enlace de invitación de un uso.</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 PC: escanéa el código QR desde la app mediante <b>Escanéo de código QR </b></string>
|
||||
<string name="delete_contact_menu_action">Eliminar</string>
|
||||
<string name="delete_group_menu_action">Eliminar</string>
|
||||
@@ -250,7 +250,8 @@
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="connect_button">Conectar</string>
|
||||
<string name="connect_via_link">Conectar mediante enlace</string>
|
||||
<string name="database_passphrase_and_export">Contraseña y exportar la base de datos</string>
|
||||
<string name="database_passphrase_and_export">Base de datos
|
||||
\ny frase de contraseña</string>
|
||||
<string name="contribute">Contribuye</string>
|
||||
<string name="core_build_timestamp">Core compilado: %s</string>
|
||||
<string name="core_version">Core versión: v%s</string>
|
||||
@@ -272,7 +273,7 @@
|
||||
<string name="callstatus_calling">llamando</string>
|
||||
<string name="callstatus_in_progress">llamada en curso</string>
|
||||
<string name="colored">coloreado</string>
|
||||
<string name="rcv_group_event_changed_your_role">su rol a cambiado a %s</string>
|
||||
<string name="rcv_group_event_changed_your_role">ha cambiado tu rol a %s</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">cambiando dirección por %s</string>
|
||||
<string name="group_member_status_complete">completado</string>
|
||||
<string name="invite_prohibited">¡No se puede invitar el contacto!</string>
|
||||
@@ -280,14 +281,14 @@
|
||||
<string name="database_initialization_error_title">No se puede iniciar la base de datos</string>
|
||||
<string name="clear_chat_question">Limpiar chat\?</string>
|
||||
<string name="network_session_mode_user">Perfil de Chat</string>
|
||||
<string name="chat_is_stopped_indication">El chat está detenido</string>
|
||||
<string name="chat_is_stopped_indication">Chat está detenido</string>
|
||||
<string name="rcv_group_event_changed_member_role">rol de %s cambiado a %s</string>
|
||||
<string name="change_role">Cambiar rol</string>
|
||||
<string name="v4_5_transport_isolation_descr">A través del perfil de chat (por defecto) or a través de conexión (BETA)</string>
|
||||
<string name="v4_5_transport_isolation_descr">Mediante perfil de Chat (por defecto) o por conexión (BETA)</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">cambiando dirección…</string>
|
||||
<string name="chat_preferences">Preferencias de chat</string>
|
||||
<string name="chat_preferences">Preferencias de Chat</string>
|
||||
<string name="feature_cancelled_item">cancelado %s</string>
|
||||
<string name="chat_is_stopped">El chat está detenido</string>
|
||||
<string name="chat_is_stopped">Chat está detenido</string>
|
||||
<string name="settings_section_title_calls">LLAMADAS</string>
|
||||
<string name="chat_is_running">El chat está en ejecución</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">cambiando dirección…</string>
|
||||
@@ -319,7 +320,7 @@
|
||||
<string name="network_disable_socks_info">Si confirmas los servidores de mensajería podrán ver tu IP, y tu proveedor de acceso a internet a qué servidores te estás conectando.</string>
|
||||
<string name="image_saved">Imagen guardada en la Galería</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">El archivo se recibirá cuando tu contacto esté en línea, por favor espera o compruébalo más tarde.</string>
|
||||
<string name="add_contact">Enlace único de invitación</string>
|
||||
<string name="add_contact">Enlace de invitación de un uso</string>
|
||||
<string name="paste_the_link_you_received">Pegar enlace recibido</string>
|
||||
<string name="error_saving_group_profile">Error guardando perfil de grupo</string>
|
||||
<string name="exit_without_saving">Salir sin guardar</string>
|
||||
@@ -328,8 +329,8 @@
|
||||
<string name="group_invitation_expired">Invitación de grupo caducada</string>
|
||||
<string name="alert_message_group_invitation_expired">La invitación al grupo ya no es válida, ha sido eliminada por el remitente.</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">El grupo se eliminará para tí. ¡No puede deshacerse!</string>
|
||||
<string name="how_to_use_markdown">Cómo usar el marcador</string>
|
||||
<string name="description_via_one_time_link_incognito">Incógnito mediante enlace único</string>
|
||||
<string name="how_to_use_markdown">Cómo usar sintaxis markdown</string>
|
||||
<string name="description_via_one_time_link_incognito">Incógnito mediante enlace de un uso</string>
|
||||
<string name="simplex_link_contact">Dirección de contacto SimpleX</string>
|
||||
<string name="error_saving_smp_servers">Error guardando servidores SMP</string>
|
||||
<string name="simplex_link_mode_browser_warning">Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo.</string>
|
||||
@@ -337,7 +338,7 @@
|
||||
<string name="error_creating_address">Error creando dirección</string>
|
||||
<string name="error_deleting_user">Error eliminando perfil de usuario</string>
|
||||
<string name="auth_enable_simplex_lock">Activar SimpleX Lock</string>
|
||||
<string name="one_time_link">Enlace único de invitación</string>
|
||||
<string name="one_time_link">Enlace de invitación de un uso</string>
|
||||
<string name="smp_servers">Servidores SMP</string>
|
||||
<string name="settings_experimental_features">Características experimentales</string>
|
||||
<string name="error_importing_database">Error importando la base de datos</string>
|
||||
@@ -346,7 +347,7 @@
|
||||
<string name="failed_to_active_user_title">¡Error cambiando perfil!</string>
|
||||
<string name="smp_servers_enter_manually">Introduce el servidor manualmente</string>
|
||||
<string name="how_to_use_your_servers">Cómo usar tus servidores</string>
|
||||
<string name="error_stopping_chat">Error deteniendo el chat</string>
|
||||
<string name="error_stopping_chat">Error deteniendo Chat</string>
|
||||
<string name="enter_correct_passphrase">Introduce la contraseña correcta.</string>
|
||||
<string name="enter_passphrase">Introduce la contraseña…</string>
|
||||
<string name="icon_descr_group_inactive">Grupo inactivo</string>
|
||||
@@ -406,7 +407,7 @@
|
||||
<string name="v4_4_french_interface">Interfaz en francés</string>
|
||||
<string name="image_descr">Imagen</string>
|
||||
<string name="file_not_found">Archivo no encontrado</string>
|
||||
<string name="how_to_use_simplex_chat">Cómo usar</string>
|
||||
<string name="how_to_use_simplex_chat">Guia de uso</string>
|
||||
<string name="full_name_optional__prompt">Nombre completo (opcional)</string>
|
||||
<string name="callstate_ended">finalizado</string>
|
||||
<string name="settings_section_title_help">AYUDA</string>
|
||||
@@ -419,7 +420,7 @@
|
||||
<string name="error_changing_role">Error cambiando rol</string>
|
||||
<string name="conn_stats_section_title_servers">SERVIDORES</string>
|
||||
<string name="group_display_name_field">Nombre mostrado del grupo:</string>
|
||||
<string name="group_preferences">Preferencias del grupo</string>
|
||||
<string name="group_preferences">Preferencias de grupo</string>
|
||||
<string name="group_members_can_send_dms">Los miembros del grupo pueden enviar mensajes directos.</string>
|
||||
<string name="group_members_can_delete">Los miembros del grupo pueden eliminar mensajes de forma irreversible.</string>
|
||||
<string name="v4_3_improved_privacy_and_security_desc">Ocultar pantalla de aplicaciones en aplicaciones recientes.</string>
|
||||
@@ -449,10 +450,10 @@
|
||||
<string name="install_simplex_chat_for_terminal">Instalar <xliff:g id="appNameFull">SimpleX Chat</xliff:g> para terminal</string>
|
||||
<string name="group_invitation_item_description">invitación al grupo <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_member_added">invitado <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="incognito_info_allows">Permite tener muchas conexiones anónimas sin datos compartidos entre estas en un único perfil de chat.</string>
|
||||
<string name="incognito_info_allows">Permite tener varias conexiones anónimas sin datos compartidos entre estas en un único perfil de chat.</string>
|
||||
<string name="invite_to_group_button">Invitar al grupo</string>
|
||||
<string name="to_verify_compare">Para comprobar el cifrado de extremo a extremo con su contacto compare (o escanee) el código en sus dispositivos.</string>
|
||||
<string name="database_is_not_encrypted">La base de datos no está cifrada. Establece una contraseña para protegerla.</string>
|
||||
<string name="database_is_not_encrypted">La base de datos no está cifrada. Escribe una contraseña para protegerla.</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Asegúrate de que las direcciones del servidor SMP tienen el formato correcto, están separadas por líneas y no duplicadas.</string>
|
||||
<string name="icon_descr_instant_notifications">Notificación instantánea</string>
|
||||
<string name="network_settings_title">Configuración de red</string>
|
||||
@@ -465,8 +466,8 @@
|
||||
<string name="conn_level_desc_indirect">indirecto (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="theme_light">Claro</string>
|
||||
<string name="chat_preferences_on">Activado</string>
|
||||
<string name="message_deletion_prohibited">La eliminación irreversible de mensajes está prohibida en este chat.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">La eliminación irreversible de mensajes está prohibida en este grupo.</string>
|
||||
<string name="message_deletion_prohibited">La eliminación irreversible de mensajes no está permitida en este chat.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">La eliminación irreversible de mensajes no está permitida en este grupo.</string>
|
||||
<string name="v4_3_improved_server_configuration">Configuración del servidor mejorada</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">Esto puede suceder cuando:
|
||||
\n1. Los mensajes caducan en el servidor si no se han recibido durante 30 días.
|
||||
@@ -492,7 +493,7 @@
|
||||
<string name="mark_unread">Marcar como no leído</string>
|
||||
<string name="invalid_QR_code">Código QR inválido</string>
|
||||
<string name="incorrect_code">¡Código de seguridad incorrecto!</string>
|
||||
<string name="markdown_in_messages">Marcadores en mensajes</string>
|
||||
<string name="markdown_in_messages">Sintaxis markdown en mensajes</string>
|
||||
<string name="network_use_onion_hosts_no">No</string>
|
||||
<string name="callstatus_missed">llamada perdida</string>
|
||||
<string name="import_database_confirmation">Importar</string>
|
||||
@@ -528,7 +529,7 @@
|
||||
<string name="snd_group_event_user_left">has salido</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">has cambiado la dirección</string>
|
||||
<string name="feature_off">apagado</string>
|
||||
<string name="v4_3_irreversible_message_deletion">Eliminación del mensaje irreversible</string>
|
||||
<string name="v4_3_irreversible_message_deletion">Eliminación irreversible del mensaje</string>
|
||||
<string name="v4_3_voice_messages_desc">Máximo 40 segundos, recibido al instante.</string>
|
||||
<string name="v4_5_italian_interface">Interfaz en italiano</string>
|
||||
<string name="v4_5_message_draft">Borrador de mensaje</string>
|
||||
@@ -538,7 +539,7 @@
|
||||
<string name="no_details">sin detalles</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="only_stored_on_members_devices">(sólo almacenado por miembros del grupo)</string>
|
||||
<string name="markdown_help">Ayuda marcadores</string>
|
||||
<string name="markdown_help">Ayuda sintaxis markdown</string>
|
||||
<string name="network_and_servers">Redes y servidores</string>
|
||||
<string name="network_use_onion_hosts_prefer_desc">Se usarán hosts .onion cuando estén disponibles.</string>
|
||||
<string name="italic">cursiva</string>
|
||||
@@ -574,7 +575,7 @@
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">has cambiado la dirección por %s</string>
|
||||
<string name="rcv_group_event_member_left">ha salido</string>
|
||||
<string name="button_leave_group">Salir del grupo</string>
|
||||
<string name="only_group_owners_can_change_prefs">Sólo los propietarios del grupo pueden cambiar las preferencias de grupo.</string>
|
||||
<string name="only_group_owners_can_change_prefs">Sólo los propietarios del grupo pueden modificar las preferencias de grupo.</string>
|
||||
<string name="users_delete_data_only">Sólo datos del perfil local</string>
|
||||
<string name="chat_preferences_no">no</string>
|
||||
<string name="thousand_abbreviation">k</string>
|
||||
@@ -635,7 +636,7 @@
|
||||
<string name="send_live_message_desc">Envía un mensaje en vivo: se actualizará para el(los) destinatario(s) a medida que se escribe</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">error de envío</string>
|
||||
<string name="sending_via">Enviando mediante</string>
|
||||
<string name="contact_developers">Actualiza la aplicación y ponte en contacto con los desarrolladores.</string>
|
||||
<string name="contact_developers">Por favor, actualiza la aplicación y ponte en contacto con los desarrolladores.</string>
|
||||
<string name="sender_cancelled_file_transfer">El remitente ha cancelado la transferencia de archivos.</string>
|
||||
<string name="smp_server_test_secure_queue">Cola segura</string>
|
||||
<string name="enter_passphrase_notification_title">Se necesita contraseña</string>
|
||||
@@ -663,7 +664,7 @@
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(escanear o pegar desde el portapapeles)</string>
|
||||
<string name="icon_descr_profile_image_placeholder">Espacio reservado para la imagen del perfil</string>
|
||||
<string name="image_descr_qr_code">Código QR</string>
|
||||
<string name="chat_with_the_founder">Envía consultas e ideas</string>
|
||||
<string name="chat_with_the_founder">Consultas y sugerencias</string>
|
||||
<string name="smp_servers_preset_address">Dirección del servidor predefinida</string>
|
||||
<string name="send_us_an_email">Contacta por email</string>
|
||||
<string name="rate_the_app">Valora la aplicación</string>
|
||||
@@ -698,14 +699,14 @@
|
||||
<string name="v4_2_security_assessment">Evaluación de la seguridad</string>
|
||||
<string name="receiving_files_not_yet_supported">la recepción de archivos aún no está disponible</string>
|
||||
<string name="sending_files_not_yet_supported">el envío de archivos aún no está disponible</string>
|
||||
<string name="call_connection_peer_to_peer">entre particulares</string>
|
||||
<string name="call_connection_peer_to_peer">p2p</string>
|
||||
<string name="icon_descr_call_rejected">Llamada rechazada</string>
|
||||
<string name="remove_passphrase">Eliminar</string>
|
||||
<string name="remove_passphrase_from_keychain">¿Eliminar contraseña de Keystore\?</string>
|
||||
<string name="open_chat">Abrir chat</string>
|
||||
<string name="restore_database">Restaurar copia de seguridad de la base de datos</string>
|
||||
<string name="save_passphrase_and_open_chat">Guardar contraseña y abrir el chat</string>
|
||||
<string name="restore_passphrase_not_found_desc">La contraseña no se ha encontrado en Keystore, introdúzcala manualmente. Esto puede haber ocurrido si has restaurado los datos de la aplicación con una herramienta de copia de seguridad. Si no es así, ponte en contacto con los desarrolladores.</string>
|
||||
<string name="restore_passphrase_not_found_desc">La contraseña no se ha encontrado en Keystore, introdúzcala manualmente. Esto puede haber ocurrido si has restaurado los datos de la aplicación con una herramienta de copia de seguridad. Si no es así, por favor ponte en contacto con los desarrolladores.</string>
|
||||
<string name="remove_member_confirmation">Eliminar</string>
|
||||
<string name="button_remove_member">Eliminar miembro</string>
|
||||
<string name="button_send_direct_message">Enviar mensaje directo</string>
|
||||
@@ -740,7 +741,7 @@
|
||||
<string name="la_notice_turn_on">Activar</string>
|
||||
<string name="share_verb">Compartir</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">envío no autorizado</string>
|
||||
<string name="set_contact_name">Introduce el nombre del contacto</string>
|
||||
<string name="set_contact_name">Escribe el nombre del contacto</string>
|
||||
<string name="network_socks_toggle">Usar proxy SOCKS (puerto 9050)</string>
|
||||
<string name="unknown_error">Error desconocido</string>
|
||||
<string name="member_role_will_be_changed_with_notification">El rol cambiará a \"%s\". Se notificará a todos los miembros del grupo.</string>
|
||||
@@ -748,7 +749,7 @@
|
||||
<string name="v4_4_disappearing_messages_desc">Los mensajes enviados se eliminarán una vez transcurrido el tiempo establecido.</string>
|
||||
<string name="ntf_channel_messages">Mensajes de chat SimpleX</string>
|
||||
<string name="icon_descr_received_msg_status_unread">no leído</string>
|
||||
<string name="text_field_set_contact_placeholder">Introduce el nombre del contacto…</string>
|
||||
<string name="text_field_set_contact_placeholder">Escribe el nombre del contacto…</string>
|
||||
<string name="switch_receiving_address_question">¿Cambiar dirección de recepción\?</string>
|
||||
<string name="use_camera_button">Usar cámara</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">¡El contacto con el que has compartido este enlace NO podrá conectarse!</string>
|
||||
@@ -758,20 +759,20 @@
|
||||
<string name="share_invitation_link">Compartir enlace de invitación</string>
|
||||
<string name="update_network_session_mode_question">¿Actualizar el modo de aislamiento de transporte\?</string>
|
||||
<string name="icon_descr_speaker_on">Altavoz activado</string>
|
||||
<string name="stop_chat_to_enable_database_actions">Detener Chat para habilitar acciones sobre la base de datos.</string>
|
||||
<string name="stop_chat_to_enable_database_actions">Detén Chat para habilitar las acciones sobre la base de datos.</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">¡La conexión que has aceptado se cancelará!</string>
|
||||
<string name="database_initialization_error_desc">La base de datos no funciona correctamente. Pulsa para obtener más información</string>
|
||||
<string name="moderate_message_will_be_marked_warning">El mensaje será marcado como moderado para todos los miembros.</string>
|
||||
<string name="next_generation_of_private_messaging">La próxima generación de mensajería privada</string>
|
||||
<string name="delete_files_and_media_desc">Esta acción no se puede deshacer. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán.</string>
|
||||
<string name="enable_automatic_deletion_message">Esta acción no se puede deshacer. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Puede tardar varios minutos.</string>
|
||||
<string name="messages_section_description">Esta configuración se aplica a los mensajes en su perfil actual de Chat</string>
|
||||
<string name="messages_section_description">Esta configuración se aplica a los mensajes en tu perfil actual</string>
|
||||
<string name="this_string_is_not_a_connection_link">¡Esta cadena no es un enlace de conexión!</string>
|
||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Para preservar tu privacidad, en lugar de notificaciones automáticas la aplicación cuenta con un <b>servicio en segundo plano<xliff:g id="appName">SimpleX</xliff:g></b>, utiliza un pequeño porcentaje de la batería al día.</string>
|
||||
<string name="icon_descr_settings">Configuración</string>
|
||||
<string name="icon_descr_speaker_off">Altavoz apagado</string>
|
||||
<string name="add_contact_or_create_group">Inciar chat nuevo</string>
|
||||
<string name="stop_chat_to_export_import_or_delete_chat_database">Detener Chat para exportar, importar o eliminar la base de datos del chat. No podrá recibir ni enviar mensajes mientras el chat esté detenido.</string>
|
||||
<string name="stop_chat_to_export_import_or_delete_chat_database">Detén Chat para poder exportar, importar o eliminar la base de datos. No puedes recibir ni enviar mensajes mientras Chat esté detenido.</string>
|
||||
<string name="thank_you_for_installing_simplex">Gracias por instalar <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Para proteger la privacidad, en lugar de los identificadores de usuario que utilizan el resto de plataformas, <xliff:g id="appName">SimpleX</xliff:g> dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos.</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">Para proteger tu información, activa SimpleX Lock.
|
||||
@@ -792,7 +793,7 @@
|
||||
<string name="moderate_message_will_be_deleted_warning">El mensaje se eliminará para todos los miembros.</string>
|
||||
<string name="share_file">Compartir archivo…</string>
|
||||
<string name="images_limit_title">¡Demasiadas imágenes!</string>
|
||||
<string name="image_decoding_exception_desc">La imagen no se puede decodificar. Pruebe otra imagen o pónte en contacto con los desarrolladores.</string>
|
||||
<string name="image_decoding_exception_desc">La imagen no se puede decodificar. Pruebe con otra imagen o contacta con los desarrolladores.</string>
|
||||
<string name="network_enable_socks">¿Usa proxy SOCKS\?</string>
|
||||
<string name="network_use_onion_hosts">Usar hosts .onion</string>
|
||||
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
|
||||
@@ -801,8 +802,8 @@
|
||||
<string name="alert_message_no_group">Este grupo ya no existe.</string>
|
||||
<string name="incognito_info_find">Para encontrar el perfil usado en una conexión en modo incógnito, pulsa el nombre del contacto o del grupo en la parte superior del chat.</string>
|
||||
<string name="accept_feature_set_1_day">Establecer 1 día</string>
|
||||
<string name="v4_4_french_interface_descr">Gracias a los usuarios: ¡contribuye a través de Weblate!</string>
|
||||
<string name="v4_5_italian_interface_descr">Gracias a los usuarios: ¡contribuye a través de Weblate!</string>
|
||||
<string name="v4_4_french_interface_descr">Agradecimientos a los usuarios. ¡Contribuye a través de Weblate!</string>
|
||||
<string name="v4_5_italian_interface_descr">Agradecimientos a los usuarios. ¡Contribuye a través de Weblate!</string>
|
||||
<string name="v4_5_private_filenames_descr">Para proteger la zona horaria, los archivos de imagen/voz usan la hora UTC.</string>
|
||||
<string name="v4_5_transport_isolation">Aislamiento de transporte</string>
|
||||
<string name="to_share_with_your_contact">(para compartir con tu contacto)</string>
|
||||
@@ -827,7 +828,7 @@
|
||||
<string name="theme">Tema</string>
|
||||
<string name="set_group_preferences">Establecer preferencias de grupo</string>
|
||||
<string name="settings_section_title_support">SOPORTE SIMPLEX CHAT</string>
|
||||
<string name="set_password_to_export">Seleccióna contraseña para exportar</string>
|
||||
<string name="set_password_to_export">Escribe la contraseña para exportar</string>
|
||||
<string name="update_database">Actualizar</string>
|
||||
<string name="update_database_passphrase">Actualizar contraseña base de datos</string>
|
||||
<string name="group_invitation_tap_to_join_incognito">Pulsa para unirte en modo incógnito</string>
|
||||
@@ -855,7 +856,7 @@
|
||||
<string name="smp_servers_use_server">Usar servidor</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Usar para conexiones nuevas</string>
|
||||
<string name="theme_system">Sistema</string>
|
||||
<string name="description_via_one_time_link">mediante enlace único</string>
|
||||
<string name="description_via_one_time_link">mediante enlace de un uso</string>
|
||||
<string name="your_chats">Tus chats</string>
|
||||
<string name="voice_message_send_text">Mensaje de voz…</string>
|
||||
<string name="your_contact_address">Tu dirección de contacto</string>
|
||||
@@ -870,16 +871,16 @@
|
||||
<string name="v4_3_irreversible_message_deletion_desc">Tus contactos pueden permitir la eliminación completa de mensajes.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Tú controlas a través de qué servidor(es) <b>recibes</b> los mensajes. Tus contactos controlan a través de qué servidor(es) <b>envías</b> tus mensajes.</string>
|
||||
<string name="voice_messages">Mensajes de voz</string>
|
||||
<string name="voice_messages_are_prohibited">Los mensajes de voz están prohibidos en este grupo.</string>
|
||||
<string name="voice_messages_are_prohibited">Los mensajes de voz no están permitidos en este grupo.</string>
|
||||
<string name="v4_4_verify_connection_security">Comprobar la seguridad de la conexión</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">¡Ya estás conectado a <xliff:g id="contactName" example="Alice">%1$s! </xliff:g>.</string>
|
||||
<string name="welcome">¡Bienvenido!</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Tu perfil de chat será enviado
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Tu perfil Chat será enviado
|
||||
\na tu contacto</string>
|
||||
<string name="your_ICE_servers">Tus servidores ICE</string>
|
||||
<string name="you_rejected_group_invitation">Has rechazado la invitación del grupo.</string>
|
||||
<string name="snd_group_event_changed_member_role">has cambiado el rol de %s a %s</string>
|
||||
<string name="call_connection_via_relay">mediante servidor de retransmisión</string>
|
||||
<string name="call_connection_via_relay">mediante relay</string>
|
||||
<string name="contact_wants_to_connect_with_you">¡quiere contactar contigo!</string>
|
||||
<string name="voice_message">Mensaje de voz</string>
|
||||
<string name="waiting_for_image">Esperando imagen</string>
|
||||
@@ -887,11 +888,11 @@
|
||||
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> quiere conectarse contigo mediante</string>
|
||||
<string name="failed_to_create_user_duplicate_desc">Tienes un perfil de chat con el mismo nombre mostrado. Debes elegir otro nombre.</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">También puedes conectarte haciendo clic en el enlace. Si se abre en el navegador, haz clic en <b>Abrir en aplicación móvil</b>.</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Puedes <font color="#0088ff">conectar con los desarrolladores de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> para hacer cualquier pregunta y recibir actualizaciones</font>.</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Puedes <font color="#0088ff">ponerte en contacto con los desarrolladores de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> para consultas y para recibir actualizaciones</font>.</string>
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Puedes compartir tu dirección como enlace o como código QR: cualquiera podrá conectarse contigo. Si lo eliminas más tarde tus contactos no se perderán.</string>
|
||||
<string name="observer_cant_send_message_title">¡No puedes enviar mensajes!</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Puedes usar marcadores para dar formato a los mensajes:</string>
|
||||
<string name="you_must_use_the_most_recent_version_of_database">Debes usar la versión más reciente de tu base de datos SÓLO en un dispositivo, de lo contrario podrías dejar de recibir mensajes de algunos contactos.</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Puedes usar la sintaxis markdown para dar formato a los mensajes:</string>
|
||||
<string name="you_must_use_the_most_recent_version_of_database">Debes usar la versión más reciente de tu base de datos ÚNICAMENTE en un dispositivo, de lo contrario podrías dejar de recibir mensajes de algunos contactos.</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Tu contacto debe estar en línea para que se complete la conexión.
|
||||
\nPuedes cancelar esta conexión y eliminar el contacto (e intentarlo más tarde con un enlace nuevo).</string>
|
||||
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">La base de datos actual será ELIMINADA y SUSTITUIDA por la importada.
|
||||
@@ -936,13 +937,12 @@
|
||||
<string name="your_ice_servers">Tus servidores ICE</string>
|
||||
<string name="your_privacy">Tu privacidad</string>
|
||||
<string name="settings_section_title_you">TU</string>
|
||||
<string name="your_chat_database">Base de datos de Chat</string>
|
||||
<string name="your_chat_database">Base de datos Chat</string>
|
||||
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación.</string>
|
||||
<string name="you_sent_group_invitation">Has enviado una invitación de grupo</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contacto(s) seleccionado(s)</string>
|
||||
<string name="num_contacts_selected">%d contacto(s) seleccionado(s)</string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members"> %1$s </xliff:g> MIEMBROS</string>
|
||||
<string name="your_chat_profiles_stored_locally">Tus perfiles de chat se almacenan localmente, sólo en tu dispositivo</string>
|
||||
<string name="voice_prohibited_in_this_chat">Los mensajes de voz están prohibidos en este chat.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Los mensajes de voz no están permitidos en este chat.</string>
|
||||
<string name="whats_new">Novedades</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">La contraseña no se almacena en el dispositivo, tienes que introducirla cada vez que inicies la aplicación.</string>
|
||||
<string name="you_joined_this_group">Te has unido a este grupo</string>
|
||||
@@ -955,8 +955,8 @@
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Estás conectado al servidor utilizado para recibir mensajes de este contacto.</string>
|
||||
<string name="description_via_contact_address_link">mediante enlace de dirección de contacto</string>
|
||||
<string name="description_via_group_link">mediante enlace de grupo</string>
|
||||
<string name="description_you_shared_one_time_link">has compartido un enlace único</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">has compartido un enlace único en módo incógnito</string>
|
||||
<string name="description_you_shared_one_time_link">has compartido enlace de un uso</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">has compartido enlace de un uso en módo incógnito</string>
|
||||
<string name="you_have_no_chats">No tienes chats</string>
|
||||
<string name="contact_sent_large_file">El contacto ha enviado un archivo mayor al máximo admitido (<xliff:g id="maxFileSize">%1$s</xliff:g> ).</string>
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> mensaje(s) omitido(s)</string>
|
||||
@@ -964,12 +964,12 @@
|
||||
<string name="view_security_code">Ver código de seguridad</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Para poder enviar mensajes de voz debes permitir que tu contacto pueda enviarlos.</string>
|
||||
<string name="voice_messages_prohibited">¡Mensajes de voz prohibidos!</string>
|
||||
<string name="group_main_profile_sent">Tu perfil de chat se enviará a los miembros del grupo</string>
|
||||
<string name="group_main_profile_sent">Tu perfil Chat será enviado a los miembros del grupo</string>
|
||||
<string name="icon_descr_address">Dirección <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_simplex_team">Equipo <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="your_profile_will_be_sent">Tu perfil de chat se enviará a tu contacto</string>
|
||||
<string name="your_chat_profiles">Tus perfiles de chat</string>
|
||||
<string name="your_profile_will_be_sent">Tu perfil Chat se enviará a tu contacto</string>
|
||||
<string name="your_chat_profiles">Tus perfiles Chat</string>
|
||||
<string name="your_simplex_contact_address">Tu dirección de contacto <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="smp_servers_your_server">Tu servidor</string>
|
||||
<string name="smp_servers_your_server_address">Dirección de tu servidor</string>
|
||||
@@ -979,4 +979,75 @@
|
||||
\n
|
||||
\nLos servidores <xliff:g id="appName">SimpleX</xliff:g> no pueden ver tu perfil.</string>
|
||||
<string name="language_system">Sistema</string>
|
||||
<string name="button_add_welcome_message">Agregar mensaje de bienvenida</string>
|
||||
<string name="v4_6_audio_video_calls">Llamadas y videollamadas</string>
|
||||
<string name="smp_save_servers_question">¿Guardar servidores\?</string>
|
||||
<string name="hide_profile">Ocultar perfil</string>
|
||||
<string name="save_profile_password">Guardar contraseña de perfil</string>
|
||||
<string name="password_to_show">Contraseña para hacerlo visible</string>
|
||||
<string name="error_saving_user_password">Error guardando la contraseña de usuario</string>
|
||||
<string name="relay_server_if_necessary">El relay sólo se usa en caso de necesidad. Un tercero podría ver tu IP.</string>
|
||||
<string name="relay_server_protects_ip">El servidor relay protege tu IP pero puede ver la duración de la llamada.</string>
|
||||
<string name="cant_delete_user_profile">¡No se puede eliminar el perfil!</string>
|
||||
<string name="enter_password_to_show">Introduce la contraseña</string>
|
||||
<string name="user_hide">Ocultar</string>
|
||||
<string name="user_mute">Silenciar</string>
|
||||
<string name="save_and_update_group_profile">Guardar y actualizar perfil del grupo</string>
|
||||
<string name="tap_to_activate_profile">Pulsa para activar el perfil.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Debe haber al menos un perfil de usuario visible.</string>
|
||||
<string name="user_unhide">Mostrar</string>
|
||||
<string name="button_welcome_message">Mensaje de bienvenida</string>
|
||||
<string name="group_welcome_title">Mensaje de bienvenida</string>
|
||||
<string name="should_be_at_least_one_profile">Debe haber al menos un perfil de usuario.</string>
|
||||
<string name="make_profile_private">¡Hacer un perfil privado!</string>
|
||||
<string name="dont_show_again">No mostrar de nuevo</string>
|
||||
<string name="muted_when_inactive">¡Silenciado cuando está inactivo!</string>
|
||||
<string name="v4_6_group_moderation">Moderación de grupos</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Perfiles Chat ocultos</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">¡Protege tus perfiles Chat con contraseña!</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Soporte bluetooth y otras mejoras.</string>
|
||||
<string name="v4_6_group_welcome_message_descr">¡Establece el mensaje mostrado a los miembros nuevos!</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Interfaz en chino y español</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Agradecimientos a los usuarios. ¡Contribuye a través de Weblate!</string>
|
||||
<string name="error_updating_user_privacy">Error actualizando la privacidad de usuario</string>
|
||||
<string name="confirm_password">Confirmar contraseña</string>
|
||||
<string name="v4_6_reduced_battery_usage">Consumo de batería reducido aun más</string>
|
||||
<string name="v4_6_group_welcome_message">Mensaje de bienvenida en grupos</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">¡Más mejoras en camino!</string>
|
||||
<string name="hidden_profile_password">Contraseña de perfil oculto</string>
|
||||
<string name="save_welcome_message_question">¿Guardar mensaje de bienvenida\?</string>
|
||||
<string name="user_unmute">Activar audio</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Puedes ocultar o silenciar un perfil. Mantenlo pulsado para abrir el menú.</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos.</string>
|
||||
<string name="v4_6_group_moderation_descr">Ahora los administradores pueden
|
||||
\n- borrar mensajes de los miembros.
|
||||
\n- desactivar el rol a miembros (a rol \"observador\")</string>
|
||||
<string name="to_reveal_profile_enter_password">Para hacer visible tu perfil oculto, introduce la contraseña completa en el campo de búsqueda de la página Tus perfiles Chat.</string>
|
||||
<string name="settings_send_files_via_xftp">Enviar archivos mediante XFTP</string>
|
||||
<string name="database_upgrade">Actualización de la base de datos</string>
|
||||
<string name="database_downgrade">Degradación de la base de datos</string>
|
||||
<string name="invalid_migration_confirmation">Confirmación de migración no válida</string>
|
||||
<string name="upgrade_and_open_chat">Actualizar y abrir Chat</string>
|
||||
<string name="database_migrations">Migraciones: %s</string>
|
||||
<string name="mtr_error_different">migración diferente en la aplicación/base de datos: %s / %s</string>
|
||||
<string name="downgrade_and_open_chat">Degradar y abrir Chat</string>
|
||||
<string name="database_downgrade_warning">Atención: ¡puedes perder algunos datos!</string>
|
||||
<string name="incompatible_database_version">Versión de base de datos incompatible</string>
|
||||
<string name="confirm_database_upgrades">Confirmar actualizaciones de la bases de datos</string>
|
||||
<string name="mtr_error_no_down_migration">la versión de la base de datos es más reciente que la aplicación, pero no hay migración hacia abajo para: %s</string>
|
||||
<string name="settings_section_title_experimenta">EXPERIMENTAL</string>
|
||||
<string name="developer_options">ID de base de datos y opción de aislamiento de transporte.</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">El archivo se recibirá cuando tu contacto termine de subirlo.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">La imagen se recibirá cuando tu contacto termine de subirla.</string>
|
||||
<string name="xftp_requires_v461">Se requiere v4.6.1+ para recibir vía XFTP.</string>
|
||||
<string name="show_developer_options">Mostrar opciones de desarrollador</string>
|
||||
<string name="hide_dev_options">Ocultar:</string>
|
||||
<string name="show_dev_options">Mostrar:</string>
|
||||
<string name="cancel_file__question">¿Cancelar el envío de archivos\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">El envío de archivos será cancelado. Si está en progreso se detendrá.</string>
|
||||
<string name="delete_chat_profile">Eliminar perfil de chat</string>
|
||||
<string name="profile_password">Contraseña del perfil</string>
|
||||
<string name="unhide_chat_profile">Mostrar perfil de chat</string>
|
||||
<string name="unhide_profile">Mostrar perfil</string>
|
||||
<string name="delete_profile">Eliminar perfil</string>
|
||||
</resources>
|
||||
2
apps/android/app/src/main/res/values-fi/strings.xml
Normal file
2
apps/android/app/src/main/res/values-fi/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -744,7 +744,7 @@
|
||||
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
|
||||
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
|
||||
<string name="clear_contacts_selection_button">Effacer</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
|
||||
<string name="num_contacts_selected">%d contact·s sélectionné·e·s</string>
|
||||
<string name="skip_inviting_button">Passer l’invitation de membres</string>
|
||||
<string name="select_contacts">Sélectionnez des contacts</string>
|
||||
<string name="no_contacts_selected">Aucun contact sélectionné</string>
|
||||
@@ -942,7 +942,6 @@
|
||||
<string name="users_delete_with_connections">Profil et connexions au serveur</string>
|
||||
<string name="network_session_mode_transport_isolation">Isolement du transport</string>
|
||||
<string name="update_network_session_mode_question">Mettre à jour le mode d\'isolation du transport \?</string>
|
||||
<string name="your_chat_profiles_stored_locally">Vos profils de chat sont stockés localement, uniquement sur votre appareil</string>
|
||||
<string name="network_session_mode_entity_description">Une connexion TCP distincte (et identifiant SOCKS) sera utilisée <b>pour chaque contact et membre de groupe</b>.
|
||||
\n<b>Veuillez noter</b> : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer.</string>
|
||||
<string name="network_session_mode_user">Profil de chat</string>
|
||||
@@ -981,4 +980,73 @@
|
||||
<string name="observer_cant_send_message_title">Vous ne pouvez pas envoyer de messages !</string>
|
||||
<string name="group_member_role_observer">observateur</string>
|
||||
<string name="language_system">Système</string>
|
||||
<string name="smp_save_servers_question">Sauvegarder les serveurs \?</string>
|
||||
<string name="dont_show_again">Ne plus afficher</string>
|
||||
<string name="button_add_welcome_message">Ajouter un message d\'accueil</string>
|
||||
<string name="cant_delete_user_profile">Impossible de supprimer le profil d\'utilisateur !</string>
|
||||
<string name="v4_6_group_moderation">Modération de groupe</string>
|
||||
<string name="user_hide">Cacher</string>
|
||||
<string name="muted_when_inactive">Mute en cas d\'inactivité !</string>
|
||||
<string name="confirm_password">Confirmer le mot de passe</string>
|
||||
<string name="v4_6_reduced_battery_usage">Réduction accrue de l\'utilisation de la batterie</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Interface en chinois et en espagnol</string>
|
||||
<string name="enter_password_to_show">Entrez le mot de passe dans le champ de recherche</string>
|
||||
<string name="v4_6_audio_video_calls">Appels audio et vidéo</string>
|
||||
<string name="v4_6_group_welcome_message">Message d\'accueil du groupe</string>
|
||||
<string name="error_saving_user_password">Erreur d\'enregistrement du mot de passe de l\'utilisateur</string>
|
||||
<string name="error_updating_user_privacy">Erreur de mise à jour de la confidentialité de l\'utilisateur</string>
|
||||
<string name="hidden_profile_password">Mot de passe de profil caché</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Profils de chat cachés</string>
|
||||
<string name="v4_6_group_moderation_descr">Désormais, les administrateurs peuvent :
|
||||
\n- supprimer les messages des membres.
|
||||
\n- désactiver des membres (rôle \"observateur\")</string>
|
||||
<string name="save_welcome_message_question">Sauvegarder le message d\'accueil \?</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Choisissez un message à l\'attention des nouveaux membres !</string>
|
||||
<string name="hide_profile">Masquer le profil</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">D\'autres améliorations sont à venir !</string>
|
||||
<string name="password_to_show">Mot de passe à afficher</string>
|
||||
<string name="make_profile_private">Rendre un profil privé !</string>
|
||||
<string name="user_mute">Mute</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Protégez vos profils de chat par un mot de passe !</string>
|
||||
<string name="tap_to_activate_profile">Appuyez pour activer le profil.</string>
|
||||
<string name="save_and_update_group_profile">Sauvegarder et mettre à jour le profil du groupe</string>
|
||||
<string name="save_profile_password">Enregistrer le mot de passe du profil</string>
|
||||
<string name="to_reveal_profile_enter_password">Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page Profils de chat.</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Prise en charge du Bluetooth et autres améliorations.</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Merci aux utilisateurs - contribuez via Weblate !</string>
|
||||
<string name="should_be_at_least_one_profile">Il doit y avoir au moins un profil d\'utilisateur.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Il doit y avoir au moins un profil d\'utilisateur visible.</string>
|
||||
<string name="user_unhide">Dévoiler</string>
|
||||
<string name="user_unmute">Démute</string>
|
||||
<string name="button_welcome_message">Message d\'accueil</string>
|
||||
<string name="group_welcome_title">Message d\'accueil</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Vous pouvez masquer ou mettre en sourdine un profil d\'utilisateur - maintenez-le enfoncé pour accéder au menu.</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu\'ils sont actifs.</string>
|
||||
<string name="settings_send_files_via_xftp">Envoi de fichiers via XFTP</string>
|
||||
<string name="database_downgrade">Rétrogradation de la base de données</string>
|
||||
<string name="database_upgrade">Mise à niveau de la base de données</string>
|
||||
<string name="incompatible_database_version">Version de la base de données incompatible</string>
|
||||
<string name="downgrade_and_open_chat">Rétrograder et ouvrir le chat</string>
|
||||
<string name="invalid_migration_confirmation">Confirmation de migration invalide</string>
|
||||
<string name="upgrade_and_open_chat">Mettre à niveau et ouvrir le chat</string>
|
||||
<string name="database_migrations">Migrations : %s</string>
|
||||
<string name="database_downgrade_warning">Attention : vous risquez de perdre des données !</string>
|
||||
<string name="confirm_database_upgrades">Confirmer la mise à niveau de la base de données</string>
|
||||
<string name="mtr_error_no_down_migration">la base de données a une version plus récente que celle de l\'application, mais il n\'y a pas de rétrogradation pour : %s</string>
|
||||
<string name="mtr_error_different">migration différente dans l\'app/la base de données : %s / %s</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">L\'image sera reçue lorsque votre contact aura terminé de la mettre en ligne.</string>
|
||||
<string name="show_dev_options">Afficher :</string>
|
||||
<string name="show_developer_options">Afficher les options pour les développeurs</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Le fichier sera reçu lorsque votre contact aura terminé de le mettre en ligne.</string>
|
||||
<string name="xftp_requires_v461">v4.6.1+ nécessaire pour la réception via XFTP.</string>
|
||||
<string name="developer_options">IDs de base de données et option d\'isolation du transport.</string>
|
||||
<string name="settings_section_title_experimenta">EXPÉRIMENTALE</string>
|
||||
<string name="hide_dev_options">Cacher :</string>
|
||||
<string name="unhide_chat_profile">Dévoiler le profil de chat</string>
|
||||
<string name="unhide_profile">Dévoiler le profil</string>
|
||||
<string name="delete_chat_profile">Supprimer le profil de chat</string>
|
||||
<string name="delete_profile">Supprimer le profil</string>
|
||||
<string name="cancel_file__question">Annuler le transfert de fichiers \?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Le transfert de fichiers sera annulé. S\'il est en cours, il sera interrompu.</string>
|
||||
<string name="profile_password">Mot de passe de profil</string>
|
||||
</resources>
|
||||
@@ -819,7 +819,7 @@
|
||||
<string name="button_send_direct_message">Invia messaggio diretto</string>
|
||||
<string name="skip_inviting_button">Salta l\'invito di membri</string>
|
||||
<string name="switch_verb">Cambia</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contatto/i selezionato/i</string>
|
||||
<string name="num_contacts_selected">%d contatto/i selezionato/i</string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRI</string>
|
||||
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Puoi condividere un link o un codice QR: chiunque potrà unirsi al gruppo. Non perderai i membri del gruppo se in seguito lo elimini.</string>
|
||||
<string name="invite_prohibited_description">Stai tentando di invitare un contatto con cui hai condiviso un profilo in incognito nel gruppo in cui stai usando il tuo profilo principale</string>
|
||||
@@ -948,7 +948,6 @@
|
||||
<string name="delete_files_and_media_for_all_users">Elimina i file per tutti i profili di chat</string>
|
||||
<string name="failed_to_active_user_title">Errore nel cambio di profilo!</string>
|
||||
<string name="failed_to_create_user_title">Errore nella creazione del profilo!</string>
|
||||
<string name="your_chat_profiles_stored_locally">I tuoi profili di chat sono memorizzati localmente, solo sul tuo dispositivo</string>
|
||||
<string name="error_deleting_user">Errore nell\'eliminazione del profilo utente</string>
|
||||
<string name="users_delete_with_connections">Profilo e connessioni al server</string>
|
||||
<string name="your_chat_profiles">I tuoi profili di chat</string>
|
||||
@@ -981,4 +980,73 @@
|
||||
<string name="observer_cant_send_message_desc">Contatta l\'amministratore del gruppo.</string>
|
||||
<string name="observer_cant_send_message_title">Non puoi inviare messaggi!</string>
|
||||
<string name="language_system">Sistema</string>
|
||||
<string name="button_add_welcome_message">Aggiungi messaggio di benvenuto</string>
|
||||
<string name="button_welcome_message">Messaggio di benvenuto</string>
|
||||
<string name="save_welcome_message_question">Salvare il messaggio di benvenuto\?</string>
|
||||
<string name="group_welcome_title">Messaggio di benvenuto</string>
|
||||
<string name="save_and_update_group_profile">Salva e aggiorna il profilo del gruppo</string>
|
||||
<string name="enter_password_to_show">Inserisci password nella ricerca</string>
|
||||
<string name="user_mute">Silenzia</string>
|
||||
<string name="tap_to_activate_profile">Tocca per attivare il profilo.</string>
|
||||
<string name="user_unhide">Svela</string>
|
||||
<string name="make_profile_private">Rendi privato il profilo!</string>
|
||||
<string name="should_be_at_least_one_profile">Deve esserci almeno un profilo utente.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Deve esserci almeno un profilo utente visibile.</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Puoi nascondere o silenziare un profilo utente - tienilo premuto per il menu.</string>
|
||||
<string name="dont_show_again">Non mostrare più</string>
|
||||
<string name="muted_when_inactive">Silenzioso quando inattivo!</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Continuerai a ricevere chiamate e notifiche da profili silenziati quando sono attivi.</string>
|
||||
<string name="v4_6_audio_video_calls">Chiamate audio e video</string>
|
||||
<string name="v4_6_group_moderation">Moderazione del gruppo</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Proteggi i tuoi profili di chat con una password!</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Supporto a bluetooth e altri miglioramenti.</string>
|
||||
<string name="v4_6_group_welcome_message">Messaggio di benvenuto del gruppo</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">Altri miglioramenti sono in arrivo!</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Interfaccia cinese e spagnola</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Grazie agli utenti – contribuite via Weblate!</string>
|
||||
<string name="hidden_profile_password">Password del profilo nascosta</string>
|
||||
<string name="save_profile_password">Salva la password del profilo</string>
|
||||
<string name="to_reveal_profile_enter_password">Per rivelare il tuo profilo nascosto, inserisci una password completa in un campo di ricerca nella pagina \"I tuoi profili di chat\".</string>
|
||||
<string name="password_to_show">Password per mostrare</string>
|
||||
<string name="v4_6_group_moderation_descr">Ora gli amministratori possono:
|
||||
\n- eliminare i messaggi dei membri.
|
||||
\n- disattivare i membri (ruolo \"osservatore\")</string>
|
||||
<string name="cant_delete_user_profile">Impossibile eliminare il profilo utente!</string>
|
||||
<string name="hide_profile">Nascondi il profilo</string>
|
||||
<string name="confirm_password">Conferma password</string>
|
||||
<string name="error_updating_user_privacy">Errore nell\'aggiornamento della privacy dell\'utente</string>
|
||||
<string name="smp_save_servers_question">Salvare i server\?</string>
|
||||
<string name="error_saving_user_password">Errore nel salvataggio della password utente</string>
|
||||
<string name="v4_6_reduced_battery_usage">Ulteriore riduzione del consumo della batteria</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Profili di chat nascosti</string>
|
||||
<string name="user_hide">Nascondi</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Imposta il messaggio mostrato ai nuovi membri!</string>
|
||||
<string name="user_unmute">Riattiva audio</string>
|
||||
<string name="settings_send_files_via_xftp">Invia file via XFTP</string>
|
||||
<string name="database_downgrade">Downgrade del database</string>
|
||||
<string name="database_upgrade">Aggiornamento del database</string>
|
||||
<string name="incompatible_database_version">Versione del database incompatibile</string>
|
||||
<string name="upgrade_and_open_chat">Aggiorna e apri chat</string>
|
||||
<string name="developer_options">ID del database e opzione isolamento del trasporto.</string>
|
||||
<string name="hide_dev_options">Nascondi:</string>
|
||||
<string name="show_dev_options">Mostra:</string>
|
||||
<string name="show_developer_options">Mostra opzioni sviluppatore</string>
|
||||
<string name="xftp_requires_v461">v4.6.1+ necessaria per ricevere via XFTP.</string>
|
||||
<string name="downgrade_and_open_chat">Esegui downgrade e apri chat</string>
|
||||
<string name="database_migrations">Migrazioni: %s</string>
|
||||
<string name="database_downgrade_warning">Attenzione: potresti perdere alcuni dati!</string>
|
||||
<string name="confirm_database_upgrades">Conferma aggiornamenti database</string>
|
||||
<string name="mtr_error_different">migrazione diversa nell\'app/nel database: %s / %s</string>
|
||||
<string name="invalid_migration_confirmation">Conferma di migrazione non valida</string>
|
||||
<string name="settings_section_title_experimenta">SPERIMENTALE</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">L\'immagine verrà ricevuta quando il tuo contatto completerà l\'invio.</string>
|
||||
<string name="mtr_error_no_down_migration">la versione del database è più recente di quella dell\'app, ma nessuna migrazione downgrade per: %s</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Il file verrà ricevuto quando il tuo contatto completerà l\'invio.</string>
|
||||
<string name="cancel_file__question">Annullare il trasferimento di file\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Il trasferimento di file verrà annullato. Se è in corso, verrà interrotto.</string>
|
||||
<string name="unhide_chat_profile">Svela il profilo chat</string>
|
||||
<string name="unhide_profile">Svela profilo</string>
|
||||
<string name="delete_chat_profile">Elimina il profilo di chat</string>
|
||||
<string name="delete_profile">Elimina profilo</string>
|
||||
<string name="profile_password">Password del profilo</string>
|
||||
</resources>
|
||||
@@ -802,7 +802,7 @@
|
||||
<string name="you_sent_group_invitation">グループの招待を送りました</string>
|
||||
<string name="snd_group_event_changed_member_role">%sの役割を次に変えました:%s</string>
|
||||
<string name="button_send_direct_message">ダイレクトメッセージを送信</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g>連絡先が選択中</string>
|
||||
<string name="num_contacts_selected">%d 連絡先が選択中</string>
|
||||
<string name="snd_group_event_member_deleted">除名しました: <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="switch_verb">切り替える</string>
|
||||
<string name="member_role_will_be_changed_with_notification">役割が「%s」となります。グループの全員に通知が出ます。</string>
|
||||
@@ -812,7 +812,6 @@
|
||||
<string name="network_options_save">保存</string>
|
||||
<string name="update_network_settings_question">ネットワーク設定を更新しますか?</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">設定を更新すると、全サーバにクライントの再接続が行われます。</string>
|
||||
<string name="your_chat_profiles_stored_locally">チャットプロフィールはローカルであなたの端末だけに保存されます。</string>
|
||||
<string name="save_color">色を保存</string>
|
||||
<string name="chat_preferences_you_allow">あなたが次を許可しています:</string>
|
||||
<string name="chat_preferences_yes">はい</string>
|
||||
|
||||
619
apps/android/app/src/main/res/values-ko/strings.xml
Normal file
619
apps/android/app/src/main/res/values-ko/strings.xml
Normal file
@@ -0,0 +1,619 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="server_connected">연결됨</string>
|
||||
<string name="server_connecting">연결 중</string>
|
||||
<string name="connect_via_group_link">그룹 링크를 통해 연결할까요\?</string>
|
||||
<string name="connect_via_invitation_link">초대 링크로 연결할까요\?</string>
|
||||
<string name="display_name_connection_established">연결 수립됨</string>
|
||||
<string name="connection_timeout">연결 시간 초과</string>
|
||||
<string name="cannot_receive_file">파일을 받을 수 없습니다</string>
|
||||
<string name="contact_already_exists">이미 추가된 연락처에요.</string>
|
||||
<string name="smp_server_test_connect">연결</string>
|
||||
<string name="connection_error_auth">연결 오류 (인증)</string>
|
||||
<string name="smp_server_test_create_queue">대기열 생성</string>
|
||||
<string name="database_initialization_error_title">데이터베이스를 초기화할 수 없어요</string>
|
||||
<string name="notifications_mode_service_desc">앱이 백그라운드에서 항상 실행돼요. 대신 메시지가 도착하자마자 바로 알림이 떠요.</string>
|
||||
<string name="notifications_mode_periodic_desc">10분마다 최대 1분간 새 메시지 확인</string>
|
||||
<string name="notification_contact_connected">연결됨</string>
|
||||
<string name="notification_preview_somebody">숨긴 대화 상대 :</string>
|
||||
<string name="notification_preview_mode_contact">대화 상대 이름</string>
|
||||
<string name="allow_verb">허용</string>
|
||||
<string name="auth_confirm_credential">자격 증명 확인</string>
|
||||
<string name="copy_verb">복사</string>
|
||||
<string name="contact_connection_pending">연결 중…</string>
|
||||
<string name="group_connection_pending">연결 중…</string>
|
||||
<string name="attach">첨부파일</string>
|
||||
<string name="icon_descr_cancel_file_preview">파일 미리보기 취소</string>
|
||||
<string name="icon_descr_context">컨텍스트 아이콘</string>
|
||||
<string name="icon_descr_server_status_connected">연결됨</string>
|
||||
<string name="back">뒤로</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>새 연락처 추가</b> : 일회용 QR 코드 만들기</string>
|
||||
<string name="cancel_verb">취소</string>
|
||||
<string name="icon_descr_cancel_live_message">라이브 메시지 취소</string>
|
||||
<string name="choose_file">파일 선택</string>
|
||||
<string name="confirm_verb">확인</string>
|
||||
<string name="connect_via_link_or_qr">링크 / QR 코드를 통해 연결</string>
|
||||
<string name="copied">클립보드로 복사됨</string>
|
||||
<string name="create_group">비밀 그룹 생성</string>
|
||||
<string name="accept_contact_button">수락</string>
|
||||
<string name="clear_chat_warning">모든 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요! 메시지는 나에게서만 삭제돼요.</string>
|
||||
<string name="clear_verb">지우기</string>
|
||||
<string name="clear_chat_menu_action">지우기</string>
|
||||
<string name="clear_chat_button">채팅 지우기</string>
|
||||
<string name="clear_chat_question">채팅을 지울까요\?</string>
|
||||
<string name="connection_request_sent">연결 요청 완료</string>
|
||||
<string name="connect_via_link">링크를 통해 연결</string>
|
||||
<string name="smp_servers_preset_add">프리셋 서버 추가</string>
|
||||
<string name="smp_servers_add">서버 추가…</string>
|
||||
<string name="chat_console">채팅 콘솔</string>
|
||||
<string name="smp_servers_check_address">서버 주소를 확인 후 다시 시도하십시오.</string>
|
||||
<string name="configure_ICE_servers">ICE 서버 설정</string>
|
||||
<string name="contribute">기여</string>
|
||||
<string name="network_settings">고급 네트워크 설정</string>
|
||||
<string name="network_session_mode_user">채팅 프로필</string>
|
||||
<string name="network_session_mode_entity">연결</string>
|
||||
<string name="accept_requests">요청 수락</string>
|
||||
<string name="app_version_code">앱 빌드 : %s</string>
|
||||
<string name="appearance_settings">외관</string>
|
||||
<string name="app_version_title">앱 버전</string>
|
||||
<string name="app_version_name">앱 버전 : v%s</string>
|
||||
<string name="accept_automatically">자동</string>
|
||||
<string name="contact_requests">대화 상대의 요청</string>
|
||||
<string name="core_version">코어 버전 : v%s</string>
|
||||
<string name="callstatus_accepted">전화 받음</string>
|
||||
<string name="bold">굵게</string>
|
||||
<string name="callstatus_in_progress">전화 연결 중</string>
|
||||
<string name="colored">색깔</string>
|
||||
<string name="confirm_password">암호 확인</string>
|
||||
<string name="callstatus_connecting">전화 연결 중</string>
|
||||
<string name="create_profile_button">생성</string>
|
||||
<string name="create_profile">프로필 생성</string>
|
||||
<string name="callstatus_error">통화 오류</string>
|
||||
<string name="callstate_connected">연결됨</string>
|
||||
<string name="callstate_connecting">연결 중…</string>
|
||||
<string name="create_your_profile">내 프로필 생성</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>배터리에 좋음</b>. 백그라운드 서비스는 10분마다 새 메시지를 확인합니다. 전화 및 긴급 메시지를 놓칠 수 있습니다.</string>
|
||||
<string name="call_already_ended">전화가 이미 종료되었습니다!</string>
|
||||
<string name="always_use_relay">항상 릴레이 사용</string>
|
||||
<string name="icon_descr_audio_call">음성 전화</string>
|
||||
<string name="settings_audio_video_calls">음성 & 영상 전화</string>
|
||||
<string name="call_on_lock_screen">잠금 화면에서의 전화</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">대화 상대와 종단간 암호화되지 않음</string>
|
||||
<string name="answer_call">응답</string>
|
||||
<string name="icon_descr_audio_on">소리 켜기</string>
|
||||
<string name="icon_descr_audio_off">소리 끄기</string>
|
||||
<string name="icon_descr_call_ended">통화 종료됨</string>
|
||||
<string name="icon_descr_call_connecting">전화 연결 중</string>
|
||||
<string name="auto_accept_images">이미지 자동 다운로드하기</string>
|
||||
<string name="integrity_msg_bad_hash">잘못된 메시지 해시</string>
|
||||
<string name="integrity_msg_bad_id">잘못된 메시지 아이디</string>
|
||||
<string name="settings_section_title_calls">전화</string>
|
||||
<string name="chat_is_running">채팅 기능이 작동하고 있어요</string>
|
||||
<string name="settings_section_title_chats">채팅</string>
|
||||
<string name="chat_database_imported">채팅 데이테베이스를 불러 왔어요</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>주의</b>: 비밀번호를 분실하면 복구나 비밀번호 변경을 할 수 없어요.</string>
|
||||
<string name="change_database_passphrase_question">데이터베이스 암호를 바꾸겠습니까\?</string>
|
||||
<string name="confirm_new_passphrase">새로운 암호 확인…</string>
|
||||
<string name="chat_archive_section">채팅 기록 보관함</string>
|
||||
<string name="rcv_group_event_changed_your_role">내 역할이 %s 역할로 변경되었습니다.</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">주소 바꾸기…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">주소 바꾸기…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">%s의 주소 바꾸기…</string>
|
||||
<string name="rcv_group_event_member_connected">연결됨</string>
|
||||
<string name="group_member_status_complete">완료</string>
|
||||
<string name="group_member_status_connected">연결됨</string>
|
||||
<string name="group_member_status_connecting">연결 중</string>
|
||||
<string name="group_member_status_accepted">연결 중 (수락됨)</string>
|
||||
<string name="group_member_status_announced">연결 중 (알림)</string>
|
||||
<string name="group_member_status_intro_invitation">연결 중(초대 시작)</string>
|
||||
<string name="group_member_status_creator">제작자</string>
|
||||
<string name="invite_prohibited">상대를 초대할 수 없습니다</string>
|
||||
<string name="clear_contacts_selection_button">지우기</string>
|
||||
<string name="icon_descr_contact_checked">대화 상대 확인됨</string>
|
||||
<string name="create_group_link">그룹 링크 생성</string>
|
||||
<string name="button_create_group_link">링크 생성</string>
|
||||
<string name="change_member_role_question">그룹 역할을 바꾸겠습니까\?</string>
|
||||
<string name="info_row_connection">연결</string>
|
||||
<string name="users_add">프로필 추가</string>
|
||||
<string name="incognito_random_profile_description">대화 상대에게 랜덤으로 만들어진 익명 프로필이 보내져요</string>
|
||||
<string name="cant_delete_user_profile">사용자 프로필을 삭제할 수 없습니다</string>
|
||||
<string name="chat_preferences_always">항상</string>
|
||||
<string name="chat_preferences_contact_allows">대화 상대가 허용했어요.</string>
|
||||
<string name="contact_preferences">연락처 설정</string>
|
||||
<string name="allow_voice_messages_only_if">대화 상대도 허용한 경우에만 음성 메시지를 보낼 수 있습니다.</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">모두에게서 메시지 영구 삭제 허용하기.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">대화 상대에게 자동 삭제되는 메시지 허용하기.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">상대가 음성 메시지를 보내는 것을 허용하기.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">나와 대화 상대 모두 메시지를 영구 삭제할 수 있어요.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">나와 대화 상대 모두 음성 메시지를 보낼 수 있어요.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">상대가 메시지에 삭제 표시를 할 수 있습니다. 그러나 삭제 표시된 메시지 내용은 여전히 볼 수 있습니다.</string>
|
||||
<string name="allow_to_delete_messages">모두에게서 메시지 영구 삭제 허용하기.</string>
|
||||
<string name="feature_cancelled_item">%s 취소됨</string>
|
||||
<string name="v4_2_auto_accept_contact_requests">대화 상대의 요청 자동 수락</string>
|
||||
<string name="v4_5_transport_isolation_descr">채팅 프로필(기본값) 또는 연결(베타).</string>
|
||||
<string name="v4_4_verify_connection_security_desc">대화 상대와 보안 코드를 비교해 보세요.</string>
|
||||
<string name="v4_6_chinese_spanish_interface">중국어 및 스페인어 인터페이스</string>
|
||||
<string name="about_simplex">SimpleX에 대하여</string>
|
||||
<string name="accept">수락</string>
|
||||
<string name="share_one_time_link">일회용 초대 링크 생성</string>
|
||||
<string name="create_address">주소 생성</string>
|
||||
<string name="chat_item_ttl_day">1일</string>
|
||||
<string name="about_simplex_chat"><xliff:g id="appNameFull">SimpleX</xliff:g>에 대하여</string>
|
||||
<string name="color_primary">강조 색상</string>
|
||||
<string name="accept_call_on_lock_screen">응답</string>
|
||||
<string name="accept_connection_request__question">연결 요청을 수락할까요\?</string>
|
||||
<string name="accept_feature">수락</string>
|
||||
<string name="network_enable_socks_info">SOCKS 프록시(포트 9050)를 통해 서버에 액세스할까요\? 이 설정을 활성화하기 전에 프록시를 시작해야 해요.</string>
|
||||
<string name="smp_servers_add_to_another_device">다른 기기에 추가</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">QR 코드 스캔으로 서버 추가</string>
|
||||
<string name="button_add_welcome_message">환영 메시지 추가</string>
|
||||
<string name="group_member_role_admin">관리자</string>
|
||||
<string name="v4_2_group_links_desc">관리자는 그룹 가입을 위한 링크를 만들 수 있어요.</string>
|
||||
<string name="allow_to_send_disappearing">자동 삭제되는 메시지 허용하기.</string>
|
||||
<string name="users_delete_all_chats_deleted">모든 채팅과 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요!</string>
|
||||
<string name="allow_to_send_voice">음성 메시지 허용하기.</string>
|
||||
<string name="allow_voice_messages_question">음성 메시지를 허용하겠습니까\?</string>
|
||||
<string name="allow_disappearing_messages_only_if">상대도 허용하는 경우에만 자동 삭제되는 메시지를 사용할 수 있어요.</string>
|
||||
<string name="allow_direct_messages">그룹 멤버에게 1:1 채팅 허용하기.</string>
|
||||
<string name="all_group_members_will_remain_connected">모든 그룹 멤버는 연결 상태가 계속 유지돼요.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">상대도 허용한 경우에만 모두에게서 메시지 영구 삭제가 가능해요.</string>
|
||||
<string name="all_your_contacts_will_remain_connected">모든 연락처와 연결 상태가 계속 유지돼요.</string>
|
||||
<string name="notifications_mode_service">항상 켜기</string>
|
||||
<string name="keychain_is_storing_securely">안드로이드 암호 저장소는 비밀번호를 안전하게 저장하는 데 사용되고 알림이 작동하도록 해요.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">안드로이드 암호 저장소는 앱을 다시 시작하거나 비밀번호 변경을 하고 나서 비밀번호를 안전하게 저장하는 데 사용되고 알림이 작동되도록 해요.</string>
|
||||
<string name="notifications_mode_off_desc">앱이 실행 중일 때만 알림을 받을 수 있고 백그라운드에서 실행되지 않아요.</string>
|
||||
<string name="full_backup">앱 데이터 백업</string>
|
||||
<string name="settings_section_title_icon">앱 아이콘</string>
|
||||
<string name="incognito_random_profile_from_contact_description">링크를 보낸 사람한테 랜덤으로 만들어진 익명 프로필이 보내져요</string>
|
||||
<string name="network_session_mode_user_description">별도로 분리된 TCP 연결(그리고 SOCKS 자격 증명)이 <b>각각의 채팅 프로필</b>에 사용될 거예요.</string>
|
||||
<string name="network_session_mode_entity_description">별도로 분리된 TCP 연결(및 SOCKS 자격 증명)이 <b>각각의 연락처 및 그룹 구성원</b>에게 사용될 거예요.
|
||||
\n<b>참고</b>: 연결이 많은 경우 배터리 및 트래픽 소비가 엄청 높을 수 있고 일부 연결이 실패할 수 있어요.</string>
|
||||
<string name="icon_descr_asked_to_receive">이미지 수신 요청됨</string>
|
||||
<string name="v4_6_audio_video_calls">음성 및 영상 전화</string>
|
||||
<string name="audio_call_no_encryption">음성 전화 (종단간 암호화 X)</string>
|
||||
<string name="auth_unavailable">인증할 수 없어요</string>
|
||||
<string name="turning_off_service_and_periodic">새 메시지를 수신하기 위해 배터리 최적화 설정을 바꿉니다. 설정에서 언제든지 다시 바꿀 수 있습니다.</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>배터리에 가장 좋음</b>. 앱이 실행 중일 때만 알림을 받게 되며 백그라운드에서 실행되지 않습니다.</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>설정을 통해 비활성화할 수 있어요.</b> – 앱이 실행되는 동안 알림이 표시되요.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">나와 대화 상대 모두 자동 삭제되는 메시지를 보낼 수 있어요.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>QR 코드 스캔</b>: QR 코드를 보여주는 사람과 연결해요.</string>
|
||||
<string name="cannot_access_keychain">데이터베이스 암호를 저장하고 있는 암호키 저장소에 접근할 수 없습니다</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>배터리 많이 사용</b>! 백그라운드에서 항상 실행돼요. 메시지를 수신하자마자 알림이 떠요.</string>
|
||||
<string name="callstatus_ended">통화 종료됨 <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_calling">전화 중…</string>
|
||||
<string name="icon_descr_call_progress">전화 연결 중</string>
|
||||
<string name="icon_descr_cancel_link_preview">링크 미리보기 취소</string>
|
||||
<string name="icon_descr_cancel_image_preview">이미지 미리보기 취소</string>
|
||||
<string name="rcv_group_event_changed_member_role">%s 역할에서 %s 역할로 변경되었습니다</string>
|
||||
<string name="chat_database_section">채팅 데이터베이스</string>
|
||||
<string name="alert_title_cant_invite_contacts">대화 상대를 초대할 수 없습니다!</string>
|
||||
<string name="change_verb">변경</string>
|
||||
<string name="chat_archive_header">채팅 기록 보관함</string>
|
||||
<string name="change_role">역할 바꾸기</string>
|
||||
<string name="chat_database_deleted">채팅 데이터베이스를 삭제했어요</string>
|
||||
<string name="chat_is_stopped">채팅 기능이 멈췄어요</string>
|
||||
<string name="chat_is_stopped_indication">채팅 기능이 멈췄어요</string>
|
||||
<string name="chat_preferences">채팅 설정</string>
|
||||
<string name="chat_with_developers">개발자와 대화</string>
|
||||
<string name="connect_via_link_verb">연결</string>
|
||||
<string name="display_name_connecting">연결 중…</string>
|
||||
<string name="icon_descr_close_button">닫기 버튼</string>
|
||||
<string name="connect_button">연결</string>
|
||||
<string name="group_member_status_introduced">연결 중 (도입)</string>
|
||||
<string name="connection_error">연결 오류</string>
|
||||
<string name="connection_local_display_name">연결 <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="connect_via_contact_link">링크를 통해 연결하겠습니까\?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">대화 상대와 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요!</string>
|
||||
<string name="status_contact_has_e2e_encryption">대화 상대와 종단간 암호화됨</string>
|
||||
<string name="alert_title_contact_connection_pending">대화 상대와 아직 연결되지 않았어요!</string>
|
||||
<string name="core_build_timestamp">코어 빌드: %s</string>
|
||||
<string name="archive_created_on_ts"><xliff:g id="archive_ts">%1$s</xliff:g>에 생성 완료</string>
|
||||
<string name="create_one_time_link">일회용 초대 링크 생성</string>
|
||||
<string name="create_secret_group_title">비밀 그룹 생성</string>
|
||||
<string name="accept_contact_incognito_button">익명 수락</string>
|
||||
<string name="chat_item_ttl_month">1개월</string>
|
||||
<string name="chat_item_ttl_week">1주</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="deleted_description">삭제됨</string>
|
||||
<string name="simplex_link_mode_description">설명</string>
|
||||
<string name="smp_server_test_delete_queue">대기열 삭제</string>
|
||||
<string name="delete_verb">삭제</string>
|
||||
<string name="delete_message__question">메시지를 삭제할까요\?</string>
|
||||
<string name="for_me_only">나에게서만 삭제</string>
|
||||
<string name="delete_member_message__question">멤버의 메시지를 삭제할까요\?</string>
|
||||
<string name="maximum_supported_file_size">현재 지원되는 최대 파일 크기는 <xliff:g id="maxFileSize">%1$s</xliff:g>입니다.</string>
|
||||
<string name="image_decoding_exception_title">디코딩 오류</string>
|
||||
<string name="button_delete_contact">대화 상대 삭제</string>
|
||||
<string name="delete_contact_question">연락처를 삭제할까요\?</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 데스크톱: <b>QR 코드 스캔</b>을 통해 앱에서 표시된 QR 코드를 스캔해 주세요.</string>
|
||||
<string name="delete_contact_menu_action">삭제</string>
|
||||
<string name="delete_group_menu_action">삭제</string>
|
||||
<string name="delete_pending_connection__question">대기 중인 연결을 삭제할까요\?</string>
|
||||
<string name="clear_verification">인증 취소</string>
|
||||
<string name="database_passphrase_and_export">데이터베이스 비밀번호 & 내보내기</string>
|
||||
<string name="smp_servers_delete_server">서버 삭제</string>
|
||||
<string name="delete_address">주소 삭제</string>
|
||||
<string name="delete_address__question">주소를 삭제할까요\?</string>
|
||||
<string name="delete_image">이미지 삭제</string>
|
||||
<string name="decentralized">탈중앙화</string>
|
||||
<string name="settings_section_title_develop">개발</string>
|
||||
<string name="settings_developer_tools">개발자 도구</string>
|
||||
<string name="settings_section_title_device">기기</string>
|
||||
<string name="database_passphrase">데이터베이스 비밀번호</string>
|
||||
<string name="delete_files_and_media_for_all_users">모든 채팅 프로필 파일 삭제</string>
|
||||
<string name="database_error">데이터베이스 에러</string>
|
||||
<string name="passphrase_is_different">데이터베이스 비밀번호가 암호 저장소에 저장된 것과 일치하지 않습니다.</string>
|
||||
<string name="database_passphrase_is_required">채팅을 열려면 데이터베이스 비밀번호가 필요해요.</string>
|
||||
<string name="delete_archive">보관된 채팅 삭제</string>
|
||||
<string name="delete_chat_archive_question">보관된 채팅을 삭제할까요\?</string>
|
||||
<string name="num_contacts_selected">%d 개의 연락처가 선택되었습니다.</string>
|
||||
<string name="info_row_database_id">데이터베이스 아이디</string>
|
||||
<string name="users_delete_profile_for">다음 채팅 프로필 삭제</string>
|
||||
<string name="theme_dark">어둡게</string>
|
||||
<string name="delete_after">다음 기간 이후 자동 삭제</string>
|
||||
<string name="above_then_preposition_continuation">위 다음 :</string>
|
||||
<string name="delete_database">데이터베이스 삭제</string>
|
||||
<string name="set_password_to_export_desc">데이터베이스는 임의의 비밀번호로 암호화되었습니다. 내보내기 기능 사용 전 비밀번호를 변경해 주세요.</string>
|
||||
<string name="delete_files_and_media_question">파일과 미디어를 삭제할까요\?</string>
|
||||
<string name="current_passphrase">현재 비밀번호…</string>
|
||||
<string name="database_encrypted">데이터베이스 암호화 완료!</string>
|
||||
<string name="database_passphrase_will_be_updated">데이터베이스 비밀번호가 업데이트되요.</string>
|
||||
<string name="encrypted_with_random_passphrase">데이터베이스는 임의의 비밀번호로 암호화되었고, 원하시면 비밀번호를 변경할 수 있어요.</string>
|
||||
<string name="database_will_be_encrypted">데이터베이스는 암호화될 거예요.</string>
|
||||
<string name="delete_messages">메시지 삭제</string>
|
||||
<string name="delete_messages_after">다음 기간 이후 자동 삭제</string>
|
||||
<string name="rcv_group_event_group_deleted">삭제된 그룹</string>
|
||||
<string name="delete_link">링크 삭제</string>
|
||||
<string name="delete_link_question">링크를 삭제할까요\?</string>
|
||||
<string name="chat_preferences_default">기본값 (%s)</string>
|
||||
<string name="ttl_day">%d일</string>
|
||||
<string name="ttl_d">%d일</string>
|
||||
<string name="ttl_days">%d일</string>
|
||||
<string name="button_delete_group">그룹 삭제</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">주소가 변경되었습니다.</string>
|
||||
<string name="database_encryption_will_be_updated">데이터베이스 비밀번호가 업데이트되고 암호 저장소에 보관됩니다.</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">데이터베이스는 암호화되고, 비밀번호는 암호 저장소에 보관될 거에요.</string>
|
||||
<string name="users_delete_question">채팅 프로필을 삭제할까요\?</string>
|
||||
<string name="delete_files_and_media_all">모든 파일 삭제</string>
|
||||
<string name="delete_chat_profile_question">채팅 프로필을 삭제할까요\?</string>
|
||||
<string name="full_deletion">모두에게서 삭제</string>
|
||||
<string name="delete_group_question">그룹을 삭제할까요\?</string>
|
||||
<string name="failed_to_create_user_duplicate_title">표시 이름이 중복되어요!</string>
|
||||
<string name="smp_server_test_disconnect">연결 끊기</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">기기 인증이 비활성화되어 SimpleX 잠금 기능이 작동하지 않아요.</string>
|
||||
<string name="auth_disable_simplex_lock">SimpleX 잠금 비활성화</string>
|
||||
<string name="icon_descr_server_status_disconnected">연결 끊김</string>
|
||||
<string name="add_contact">일회용 초대 링크</string>
|
||||
<string name="add_contact_or_create_group">새로운 채팅 시작</string>
|
||||
<string name="display_name__field">표시 이름</string>
|
||||
<string name="display_name_cannot_contain_whitespace">표시 이름에는 공백문자가 쓰일 수 없어요.</string>
|
||||
<string name="display_name">표시 이름</string>
|
||||
<string name="encrypted_audio_call">종단간 암호화된 음성 전화</string>
|
||||
<string name="encrypted_video_call">종단간 암호화된 영상 전화</string>
|
||||
<string name="no_call_on_lock_screen">비활성화</string>
|
||||
<string name="status_e2e_encrypted">종단간 암호화</string>
|
||||
<string name="integrity_msg_duplicate">중복된 메시지</string>
|
||||
<string name="accept_feature_set_1_day">1일로 설정</string>
|
||||
<string name="v4_4_disappearing_messages">자동 삭제되는 메시지</string>
|
||||
<string name="total_files_count_and_size">전체 크기가 %s인 %d개의 파일</string>
|
||||
<string name="conn_level_desc_direct">다이렉트</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">이 채팅에서는 자동 삭제되는 메시지를 사용할 수 없어요.</string>
|
||||
<string name="disappearing_messages_are_prohibited">이 그룹에서는 자동 삭제되는 메시지를 사용할 수 없어요.</string>
|
||||
<string name="ttl_m">%d분</string>
|
||||
<string name="ttl_months">%d 개월</string>
|
||||
<string name="ttl_min">%d 분</string>
|
||||
<string name="ttl_month">%d 개월</string>
|
||||
<string name="ttl_week">%d 주</string>
|
||||
<string name="downgrade_and_open_chat">다운그레이드하고 채팅 열기</string>
|
||||
<string name="direct_messages">1:1 메시지</string>
|
||||
<string name="timed_messages">자동 삭제되는 메시지</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">이 그룹에서는 멤버들의 1:1 채팅이 금지되어 있어요.</string>
|
||||
<string name="ttl_s">%d초</string>
|
||||
<string name="ttl_sec">%d 초</string>
|
||||
<string name="ttl_h">%d시</string>
|
||||
<string name="ttl_mth">%d개월</string>
|
||||
<string name="ttl_w">%d주</string>
|
||||
<string name="ttl_weeks">%d 주</string>
|
||||
<string name="confirm_database_upgrades">데이터베이스 업그레이드 확인</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">기기 인증을 하고 있지 않아요. 기기 인증을 켜면 설정에서 SimpleX 잠금 기능을 사용할 수 있어요.</string>
|
||||
<string name="ttl_hour">%d 시간</string>
|
||||
<string name="ttl_hours">%d 시간</string>
|
||||
<string name="mtr_error_different">앱/데이터베이스의 다른 마이그레이션: %s / %s</string>
|
||||
<string name="v4_5_multiple_chat_profiles_descr">다른 이름, 아바타 그리고 전송 격리.</string>
|
||||
<string name="dont_show_again">다시 보지 않기</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">이 대화 상대로부터의 메시지를 수신할 서버와 연결되었어요.</string>
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="contact_developers">앱 업데이트 후 개발자에게 연락해 주세요.</string>
|
||||
<string name="connection_error_auth_desc">대화 상대가 나갔거나 초대 링크가 이미 사용된 경우가 아니면 버그일 수 있어요. 이 경우 개발자에게 알려주세요.
|
||||
\n대화 상대에게 다른 초대 링크 만들도록 부탁하고 네트워크 연결이 안정적인지 확인하세요.</string>
|
||||
<string name="auth_enable_simplex_lock">SimpleX 잠금 활성화</string>
|
||||
<string name="auth_log_in_using_credential">자격 증명으로 로그인</string>
|
||||
<string name="auth_open_chat_console">채팅 콘솔 열기</string>
|
||||
<string name="auth_stop_chat">채팅 중지하기</string>
|
||||
<string name="auth_unlock">잠금 해제하기</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">앱을 사용하지 않는 지 30초가 지나면 다시 인증해야 해요.</string>
|
||||
<string name="contact_wants_to_connect_with_you">님이 연결하고 싶어해요!</string>
|
||||
<string name="callstate_starting">시작…</string>
|
||||
<string name="callstate_waiting_for_answer">응답 대기 중…</string>
|
||||
<string name="callstate_waiting_for_confirmation">확인 대기 중…</string>
|
||||
<string name="alert_title_skipped_messages">읽지 않는 메시지</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">이 그룹에서 익명 프로필을 사용하고 있어요. 내 원래 프로필이 노출되는 걸 방지하기 위해 대화 상대 초대가 허용되지 않아요.</string>
|
||||
<string name="button_remove_member">멤버 삭제하기</string>
|
||||
<string name="chat_item_ttl_seconds">%s 초</string>
|
||||
<string name="alert_message_group_invitation_expired">이 링크로 참여할 수 없어요. 이미 삭제된 링크에요.</string>
|
||||
<string name="alert_message_no_group">존재하지 않는 그룹이에요.</string>
|
||||
<string name="alert_title_no_group">그룹을 찾을 수 없어요!</string>
|
||||
<string name="button_add_members">맴버 초대하기</string>
|
||||
<string name="button_welcome_message">환영 메시지</string>
|
||||
<string name="button_edit_group_profile">그룹 프로필 수정</string>
|
||||
<string name="button_leave_group">그룹 나가기</string>
|
||||
<string name="button_send_direct_message">1:1 채팅 시작하기</string>
|
||||
<string name="conn_stats_section_title_servers">서버</string>
|
||||
<string name="conn_level_desc_indirect">인다이렉트 (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="chat_preferences_you_allow">허용함</string>
|
||||
<string name="chat_preferences_off">꺼짐</string>
|
||||
<string name="chat_preferences_on">켜짐</string>
|
||||
<string name="chat_preferences_no">아니요</string>
|
||||
<string name="ask_your_contact_to_enable_voice">대화 상대에게 음성 메시지 기능을 활성화 해달라고 부탁해보세요.</string>
|
||||
<string name="chat_help_tap_button">탭 버튼</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">수락한 연결이 취소됩니다!</string>
|
||||
<string name="chat_lock">SimpleX 잠금</string>
|
||||
<string name="callstatus_rejected">거절된 전화</string>
|
||||
<string name="callstate_ended">종료됨</string>
|
||||
<string name="call_connection_peer_to_peer">P2P</string>
|
||||
<string name="call_connection_via_relay">릴레이를 경유</string>
|
||||
<string name="alert_title_group_invitation_expired">만료된 초대 링크에요!</string>
|
||||
<string name="chat_preferences_yes">네</string>
|
||||
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g>님이 다음을 통해 연결하려고 해요 :</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">설정에서 잠금 화면에서 바로 전화를 받을 수 있도록 설정할 수 있어요.</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">연결을 완료하려면 대화 상대가 온라인 상태여야 해요.
|
||||
\n연결 요청을 취소하고 대화 상대를 삭제할 수 있어요 (그리고 새 링크로 재시도).</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">다음과 같은 경우에 발생할 수 있어요.
|
||||
\n1. 대화 상대가 메시지를 보낸 지 30일 지나서 서버에서 삭제된 경우
|
||||
\n2. 메시지를 수신하는 데 사용된 서버가 업데이트되고 재부팅된 경우
|
||||
\n3. 침해된 연결의 경우
|
||||
\n서버 업데이트를 받으려면 설정을 통해 개발자에게 연락해 주세요.
|
||||
\n저희 개발팀은 메시지 손실을 방지하기 위해 중복된 서버를 추가할 예정이에요.</string>
|
||||
<string name="auth_simplex_lock_turned_on">SimpleX 잠금 켜짐</string>
|
||||
<string name="callstate_received_answer">응답됨…</string>
|
||||
<string name="callstate_received_confirmation">확인 받음…</string>
|
||||
<string name="callstatus_missed">부재 중 전화</string>
|
||||
<string name="chat_item_ttl_none">사용 안 함</string>
|
||||
<string name="chat_with_the_founder">질문이나 아이디어 보내기</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(스캔하거나 클립보드에서 붙여넣기)</string>
|
||||
<string name="contact_sent_large_file">대화 상대가 현재 지원되는 최대 크기(<xliff:g id="maxFileSize">%1$s</xliff:g>)보다 큰 파일을 보냈습니다.</string>
|
||||
<string name="display_name_invited_to_connect">초대를 받았어요.</string>
|
||||
<string name="failed_to_create_user_title">프로필 생성 오류!</string>
|
||||
<string name="description_via_group_link_incognito">그룹 링크로 익명 채팅</string>
|
||||
<string name="description_via_group_link">그룹 링크로 채팅</string>
|
||||
<string name="description_via_one_time_link">일회용 링크로 채팅</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">일회용 익명 연락처를 공유했어요.</string>
|
||||
<string name="description_you_shared_one_time_link">일회용 프로필 연락처를 공유했어요.</string>
|
||||
<string name="description_via_contact_address_link_incognito">상대의 연락처 링크로 익명 채팅</string>
|
||||
<string name="description_via_contact_address_link">상대의 연락처 링크로 채팅</string>
|
||||
<string name="description_via_one_time_link_incognito">일회용 연락처로 익명 채팅</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">SMP 서버 주소가 올바른 형식이고 줄로 구분되어 있고 중복이 없는지 확인해 주세요.</string>
|
||||
<string name="error_saving_smp_servers">SMP 서버 저장 오류</string>
|
||||
<string name="error_setting_network_config">네트워크 설정 업데이트 오류</string>
|
||||
<string name="failed_to_active_user_title">프로필 변경 오류!</string>
|
||||
<string name="failed_to_create_user_duplicate_desc">동일한 표시 이름을 가진 채팅 프로필이 있어요. 다른 이름을 선택해 주세요.</string>
|
||||
<string name="failed_to_parse_chats_title">채팅 불러오기 실패</string>
|
||||
<string name="failed_to_parse_chat_title">채팅 불러오기 실패</string>
|
||||
<string name="error_adding_members">멤버 추가 오류</string>
|
||||
<string name="error_joining_group">그룹 참여 오류</string>
|
||||
<string name="error_sending_message">메시지 전송 오류</string>
|
||||
<string name="error_creating_address">주소 생성 오류</string>
|
||||
<string name="error_receiving_file">파일 다운로드 오류</string>
|
||||
<string name="error_accepting_contact_request">상대 요청 수락 오류</string>
|
||||
<string name="error_changing_address">주소 변경 오류</string>
|
||||
<string name="error_deleting_contact">연락처 삭제 오류</string>
|
||||
<string name="error_deleting_contact_request">대화 요청 삭제 오류</string>
|
||||
<string name="error_deleting_group">그룹 삭제 오류</string>
|
||||
<string name="error_deleting_pending_contact_connection">대기 중 대화 요청 삭제 오류</string>
|
||||
<string name="error_smp_test_certificate">서버 주소의 인증서의 지문(fingerprint)이 잘못되었을 수도 있어요.</string>
|
||||
<string name="error_smp_test_failed_at_step">테스트가 %s단계에서 실패했어요.</string>
|
||||
<string name="error_smp_test_server_auth">서버는 대기열을 생성하고 비밀번호를 확인하려면 인증이 필요해요.</string>
|
||||
<string name="enter_passphrase_notification_desc">알림을 받으려면 데이터베이스 암호를 입력해 주세요.</string>
|
||||
<string name="enter_passphrase_notification_title">비밀번호가 필요해요.</string>
|
||||
<string name="error_deleting_user">프로필 삭제 오류</string>
|
||||
<string name="error_updating_user_privacy">사용자 개인정보 업데이트 오류</string>
|
||||
<string name="database_initialization_error_desc">데이터베이스가 올바르게 작동하지 안하요. 자세히 알아보려면 탭하세요.</string>
|
||||
<string name="edit_verb">수정하기</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">메시지가 삭제돼요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="delete_message_mark_deleted_warning">메시지가 삭제 표시될 거예요. 대화 상대는 여전히 삭제된 내용을 볼 수 있어요.</string>
|
||||
<string name="ensure_ICE_server_address_are_correct_format_and_unique">WebRTC ICE 서버 주소가 올바른 형식이고 줄로 구분되고 중복이 없는지 확인해 주세요.</string>
|
||||
<string name="enter_one_ICE_server_per_line">ICE 서버(한 줄에 하나씩)</string>
|
||||
<string name="error_saving_ICE_servers">ICE 서버 저장 오류</string>
|
||||
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
|
||||
<string name="exit_without_saving">저장하지 않고 나가기</string>
|
||||
<string name="encrypt_database">암호화</string>
|
||||
<string name="encrypted_database">암호화된 데이터베이스</string>
|
||||
<string name="feature_off">꺼짐</string>
|
||||
<string name="feature_enabled_for_you">나에게 켜짐</string>
|
||||
<string name="feature_enabled_for_contact">대화 상대에게 켜짐</string>
|
||||
<string name="feature_enabled">켜짐</string>
|
||||
<string name="feature_received_prohibited">수신됨, 금지됨</string>
|
||||
<string name="export_database">데이터베이스 내보내기</string>
|
||||
<string name="enable_automatic_deletion_question">자동 삭제되는 메시지를 사용할까요\?</string>
|
||||
<string name="error_changing_message_deletion">설정 변경 오류</string>
|
||||
<string name="enable_automatic_deletion_message">이 작업은 되돌릴 수 없어요. 선택한 시간보다 일찍 보내거나 받은 메시지는 삭제돼요. 이는 몇 분 걸릴 수 있어요.</string>
|
||||
<string name="error_with_info">오류: %s</string>
|
||||
<string name="enter_correct_passphrase">올바른 비밀번호를 입력해 주세요.</string>
|
||||
<string name="database_backup_can_be_restored">데이터베이스 비밀번호 변경이 완료되지 않았어요.</string>
|
||||
<string name="database_restore_error">데이터베이스 오류 복구</string>
|
||||
<string name="error_creating_link_for_group">그룹 링크 생성 오류</string>
|
||||
<string name="error_updating_link_for_group">그룹 링크 업데이트 오류</string>
|
||||
<string name="error_changing_role">역할 변경 오류</string>
|
||||
<string name="error_removing_member">멤버 삭제 오류</string>
|
||||
<string name="database_downgrade">데이터베이스 다운그레이드</string>
|
||||
<string name="database_migrations">마이그레이션: %s</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">모든 멤버에게서 그룹이 삭제돼요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">나에게서만 그룹이 삭제되요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="file_not_found">파일을 찾을 수 없음</string>
|
||||
<string name="error_saving_user_password">사용자 비밀번호 저장 오류</string>
|
||||
<string name="error_stopping_chat">채팅 정지하기 오류</string>
|
||||
<string name="error_exporting_chat_database">채팅 데이터베이스 내보내기 오류</string>
|
||||
<string name="database_is_not_encrypted">채팅 데이터베이스가 암호화되지 않았어요. 비밀번호를 설정하여 보호해 주세요.</string>
|
||||
<string name="enter_passphrase">비밀번호를 입력해 주세요…</string>
|
||||
<string name="enter_password_to_show">검색에 비밀번호 입력</string>
|
||||
<string name="edit_image">이미지 수정하기</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">이 작업은 실행 취소될 수 없어요. 프로필, 연락처, 메시지 및 파일이 영구적으로 손실돼요.</string>
|
||||
<string name="error_importing_database">채팅 데이터베이스 가져오기 오류</string>
|
||||
<string name="encrypt_database_question">데이터베이스를 암호화할까요\?</string>
|
||||
<string name="developer_options">데이터베이스 ID 및 전송 격리 옵션.</string>
|
||||
<string name="error_starting_chat">채팅 시작하기 오류</string>
|
||||
<string name="error_encrypting_database">데이터베이스 암호화 오류</string>
|
||||
<string name="enter_correct_current_passphrase">올바른 현재 비밀번호를 입력해 주세요.</string>
|
||||
<string name="delete_chat_profile">채팅 프로필 삭제</string>
|
||||
<string name="delete_profile">프로필 삭제</string>
|
||||
<string name="database_downgrade_warning">경고: 일부 데이터가 손실될 수 있어요!</string>
|
||||
<string name="database_upgrade">데이터베이스 업그레이드</string>
|
||||
<string name="delete_files_and_media_desc">이 작업은 실행 취소될 수 없어요. 수신 및 전송된 모든 파일과 미디어가 삭제돼요. 저해상도 사진만 삭제되지 않아요.</string>
|
||||
<string name="error_deleting_database">채팅 데이터베이스 삭제 오류</string>
|
||||
<string name="error_deleting_link_for_group">그룹 링크 삭제 오류</string>
|
||||
<string name="error_saving_file">파일 저장 오류</string>
|
||||
<string name="error_saving_group_profile">그룹 프로필 저장 오류</string>
|
||||
<string name="feature_offered_item">%s 제안</string>
|
||||
<string name="feature_offered_item_with_param">%s 제안: %2s</string>
|
||||
<string name="icon_descr_instant_notifications">즉시 알림</string>
|
||||
<string name="hide_notification">숨김</string>
|
||||
<string name="hide_verb">숨기기</string>
|
||||
<string name="for_everybody">모두에게</string>
|
||||
<string name="icon_descr_edited">수정됨</string>
|
||||
<string name="icon_descr_image_snd_complete">이미지 보냄</string>
|
||||
<string name="full_name__field">이름 :</string>
|
||||
<string name="hide_profile">프로필 숨기기</string>
|
||||
<string name="icon_descr_flip_camera">카메라 전환</string>
|
||||
<string name="icon_descr_hang_up">전화 끊기</string>
|
||||
<string name="file_with_path">파일 : %s</string>
|
||||
<string name="group_invitation_tap_to_join">탭하여 참여</string>
|
||||
<string name="group_invitation_tap_to_join_incognito">탭하여 익명으로 참여</string>
|
||||
<string name="group_member_status_left">나감</string>
|
||||
<string name="group_member_role_member">멤버</string>
|
||||
<string name="group_member_role_owner">소유자</string>
|
||||
<string name="group_member_status_group_deleted">그룹 삭제됨</string>
|
||||
<string name="group_member_status_invited">초대됨</string>
|
||||
<string name="group_member_status_removed">삭제됨</string>
|
||||
<string name="icon_descr_expand_role">역할 선택지 펼치기</string>
|
||||
<string name="files_and_media_section">파일 & 미디어</string>
|
||||
<string name="group_invitation_item_description">그룹으로 초대 <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="group_link">그룹 링크</string>
|
||||
<string name="group_welcome_title">환영 메시지</string>
|
||||
<string name="group_display_name_field">보여지는 그룹 이름</string>
|
||||
<string name="group_full_name_field">그룹 이름 :</string>
|
||||
<string name="group_is_decentralized">그룹은 완전히 탈중앙화되어 있으며 구성원만 그룹을 볼 수 있어요.</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">여기에서는 시크릿 모드가 지원되지 않아요. 기본 프로필이 그룹 멤버들에게 전송될 거예요.</string>
|
||||
<string name="group_main_profile_sent">프로필이 그룹 구성원에게 전송될 거예요.</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">그룹 프로필은 서버가 아닌 멤버들의 기기에 저장되어요.</string>
|
||||
<string name="group_preferences">그룹 설정</string>
|
||||
<string name="group_members_can_send_disappearing">그룹 구성원은 자동 삭제되는 메시지를 보낼 수 있어요.</string>
|
||||
<string name="group_members_can_send_dms">그룹 멤버들끼리 1:1 채팅을 할 수 있어요.</string>
|
||||
<string name="icon_descr_add_members">멤버 초대하기</string>
|
||||
<string name="icon_descr_group_inactive">비활성 그룹</string>
|
||||
<string name="group_member_role_observer">관찰자</string>
|
||||
<string name="group_info_member_you">나 : <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> 멤버</string>
|
||||
<string name="file_saved">파일 저장됨</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">대화 상대가 업로드를 완료하면 파일이 저장되어요.</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">대화 상대가 온라인 상태일 때 파일이 전송되어요. 대화 상대가 온라인이 되기를 기다리거나 나중에 다시 확인해 주세요!</string>
|
||||
<string name="icon_descr_record_voice_message">음성 메시지 녹화하기</string>
|
||||
<string name="from_gallery_button">갤러리에서</string>
|
||||
<string name="icon_descr_profile_image_placeholder">프로필 이미지 플레이스 홀더</string>
|
||||
<string name="icon_descr_address"><xliff:g id="appName">SimpleX</xliff:g> 주소</string>
|
||||
<string name="how_to">설명서</string>
|
||||
<string name="how_to_use_your_servers">내 서버 사용법</string>
|
||||
<string name="how_to_use_markdown">마크다운 사용법</string>
|
||||
<string name="how_simplex_works"><xliff:g id="appName">SimpleX</xliff:g> 작동 방식</string>
|
||||
<string name="group_invitation_expired">그룹 초대가 만료되었어요.</string>
|
||||
<string name="group_members_can_delete">그룹 멤버는 보낸 메시지를 영구 삭제할 수 있어요.</string>
|
||||
<string name="group_members_can_send_voice">그룹 멤버는 음성 메시지를 보낼 수 있어요.</string>
|
||||
<string name="hidden_profile_password">숨긴 프로필 비밀번호</string>
|
||||
<string name="full_name_optional__prompt">이름 (선택 사항)</string>
|
||||
<string name="how_it_works">작동 방식</string>
|
||||
<string name="hide_dev_options">숨기기 :</string>
|
||||
<string name="cancel_file__question">파일 전송을 취소할까요\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">파일 전송이 취소될 거예요. 이미 전송이 시작되었다면 중지될 거예요.</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">보냄</string>
|
||||
<string name="group_preview_join_as">%s(으)로 참여</string>
|
||||
<string name="group_preview_you_are_invited">그룹에 초대되었어요.</string>
|
||||
<string name="icon_descr_received_msg_status_unread">잃지 않음</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">보내기 실패</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">비인증 전송</string>
|
||||
<string name="icon_descr_help">도움말</string>
|
||||
<string name="first_platform_without_user_ids">개인을 식별할 수 있는 어떠한 정보(임의의 숫자 포함)도 없는 첫 번째 플랫폼. 단순히 약속이 아니라 프로그램 설계상 완전한 익명성을 제공해요.</string>
|
||||
<string name="icon_descr_call_rejected">거절된 전화</string>
|
||||
<string name="icon_descr_call_pending_sent">대기 중인 전화</string>
|
||||
<string name="icon_descr_call_missed">부재중 전화</string>
|
||||
<string name="icon_descr_file">파일</string>
|
||||
<string name="icon_descr_more_button">더 보기</string>
|
||||
<string name="icon_descr_send_message">메시지 보내기</string>
|
||||
<string name="icon_descr_server_status_error">오류</string>
|
||||
<string name="how_to_use_simplex_chat">사용법</string>
|
||||
<string name="icon_descr_email">이메일</string>
|
||||
<string name="icon_descr_server_status_pending">대기 중</string>
|
||||
<string name="icon_descr_settings">설정</string>
|
||||
<string name="icon_descr_waiting_for_image">이미지 기다리는 중</string>
|
||||
<string name="image_decoding_exception_desc">이미지를 디코딩할 수 없어요. 다른 이미지를 시도하거나 개발자에게 문의해 주세요.</string>
|
||||
<string name="image_descr">이미지</string>
|
||||
<string name="image_saved">갤러리에 사진 저장됨</string>
|
||||
<string name="images_limit_desc">동시에 최대 10개까지만 이미지를 보낼 수 있어요.</string>
|
||||
<string name="images_limit_title">이미지 수가 너무 많아요!</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">거절해도 상대에게 알림이 전송되지 않아요.</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">직접 만날 수 없다면 <b>영상 통화에서 QR 코드를 보여주거나</b> 링크를 공유해 주세요.</string>
|
||||
<string name="icon_descr_video_call">영상 전화</string>
|
||||
<string name="icon_descr_video_off">영상 끄기</string>
|
||||
<string name="icon_descr_speaker_on">스피커 켜기</string>
|
||||
<string name="icon_descr_video_on">영상 켜기</string>
|
||||
<string name="icon_descr_speaker_off">스피커 끄기</string>
|
||||
<string name="import_database">데이터베이스 가져오기</string>
|
||||
<string name="import_database_confirmation">가져오기</string>
|
||||
<string name="incognito">익명 모드</string>
|
||||
<string name="incognito_info_find">익명 채팅에 사용되는 프로필을 찾으려면 채팅 상단에 있는 연락처 또는 그룹 이름을 탭하세요.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">대화 상대가 업로드를 완료하면 이미지가 수신될 거예요.</string>
|
||||
<string name="image_descr_profile_image">프로필 이미지</string>
|
||||
<string name="incognito_info_allows">하나의 프로필로 여러 사람과 연락할 필요 없이 무수히 많은 익명 프로필로 연락할 수 있어요.</string>
|
||||
<string name="immune_to_spam_and_abuse">스팸 및 남용에 면역</string>
|
||||
<string name="ignore">무시하기</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser"><xliff:g id="appName">SimpleX Chat</xliff:g> 초대 링크를 받으면 브라우저에서 참여할 수 있어요 :</string>
|
||||
<string name="image_descr_link_preview">링크 미리보기 이미지</string>
|
||||
<string name="image_descr_qr_code">QR 코드</string>
|
||||
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> 팀</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">직접 만날 수 없다면 <b>영상 통화에서 QR 코드를 스캔</b>하거나 상대에게 초대 링크를 공유할 수 있어요.</string>
|
||||
<string name="icon_descr_video_snd_complete">동영상 보내짐</string>
|
||||
<string name="icon_descr_video_asked_to_receive">동영상 수신 요청됨</string>
|
||||
<string name="icon_descr_waiting_for_video">동영상 기다리는 중</string>
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> 로고</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">대화 상대가 온라인 상태일 때 이미지가 수신될 거예요. 기다리거나 나중에 확인하세요!</string>
|
||||
<string name="import_database_question">채팅 데이터베이스를 가져올까요\?</string>
|
||||
<string name="server_error">오류</string>
|
||||
<string name="invalid_chat">유효하지 않는 채팅</string>
|
||||
<string name="invalid_data">잘못된 데이터</string>
|
||||
<string name="invalid_message_format">잘못된 메시지 형식</string>
|
||||
<string name="invalid_connection_link">잘못된 연결 링크</string>
|
||||
<string name="notifications">알림</string>
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> 읽지 않은 메시지</string>
|
||||
<string name="keychain_error">키체인 오류</string>
|
||||
<string name="invite_to_group_button">그룹에 초대하기</string>
|
||||
<string name="info_row_local_name">로컬 네임</string>
|
||||
<string name="join_group_button">참여</string>
|
||||
<string name="join_group_question">그룹에 참여할까요\?</string>
|
||||
<string name="invite_prohibited_description">이 그룹에서는 기본 프로필을 사용하는 중인데 반해, 익명 프로필로 연락하고 있는 대화 상대를 초대하려고 하셨어요.</string>
|
||||
<string name="initial_member_role">초기 역할</string>
|
||||
<string name="info_row_group">그룹</string>
|
||||
<string name="incompatible_database_version">호환되지 않는 데이터베이스 버전</string>
|
||||
<string name="joining_group">그룹에 참여 중</string>
|
||||
<string name="incognito_info_protects">익명 모드는 기본 프로필 이름과 사진과 같은 개인 정보를 보호해줘요. 새 대화 상대마다 새로운 랜덤 프로필이 만들어져요.</string>
|
||||
<string name="is_verified">%s 은(는) 인증되었어요.</string>
|
||||
<string name="italic">기울게</string>
|
||||
<string name="incognito_info_share">익명 프로필 사용 중 초대받은 그룹에 참여하면, 그 그룹에서도 동일한 익명 프로필이 사용되어요.</string>
|
||||
<string name="incognito_random_profile">내 랜덤 프로필</string>
|
||||
<string name="incoming_audio_call">음성 전화 옴</string>
|
||||
<string name="is_not_verified">%s은(는) 인증되지 않았어요.</string>
|
||||
<string name="install_simplex_chat_for_terminal">터미널용 <xliff:g id="appNameFull">SimpleX Chat</xliff:g>를 설치하세요</string>
|
||||
<string name="incoming_video_call">영상 전화 옴</string>
|
||||
<string name="invalid_migration_confirmation">잘못된 마이그레이션 확인</string>
|
||||
<string name="join_group_incognito_button">익명 모드로 참여</string>
|
||||
<string name="invalid_QR_code">잘못된 QR 코드</string>
|
||||
<string name="incorrect_code">잘못된 보안 코드!</string>
|
||||
<string name="invalid_contact_link">잘못된 링크!</string>
|
||||
</resources>
|
||||
@@ -8,4 +8,43 @@
|
||||
<string name="about_simplex">Apie SimpleX</string>
|
||||
<string name="smp_servers_add">Pridėti serverį…</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Pridėti serverius skenuojant QR kodus.</string>
|
||||
<string name="appearance_settings">Išvaizda</string>
|
||||
<string name="app_version_title">Programėlės versija</string>
|
||||
<string name="app_version_name">Programėlės versija: v%s</string>
|
||||
<string name="app_version_code">Programėlės darinys: %s</string>
|
||||
<string name="accept_automatically">Automatiškai</string>
|
||||
<string name="callstatus_calling">skambinama…</string>
|
||||
<string name="callstatus_error">skambučio klaida</string>
|
||||
<string name="call_already_ended">Skambutis jau baigtas!</string>
|
||||
<string name="answer_call">Atsiliepti</string>
|
||||
<string name="icon_descr_call_ended">Skambutis baigtas</string>
|
||||
<string name="settings_section_title_calls">SKAMBUČIAI</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Leisti jūsų adresatams negrįžtamai ištrinti išsiųstas žinutes.</string>
|
||||
<string name="back">Atgal</string>
|
||||
<string name="settings_section_title_icon">PROGRAMĖLĖS PIKTOGRAMA</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Adresatui, iš kurio gavote šią nuorodą, bus išsiųstas atsitiktinis profilis</string>
|
||||
<string name="chat_preferences_always">visada</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Leisti jūsų adresatams siųsti balso žinutes.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Leisti negrįžtamą žinučių ištrynimą tik tuo atveju, jei jūsų adresatas jums tai leidžia.</string>
|
||||
<string name="allow_voice_messages_only_if">Leisti balso žinutes tik tuo atveju, jei jūsų adresatas jas leidžia.</string>
|
||||
<string name="allow_verb">Leisti</string>
|
||||
<string name="allow_voice_messages_question">Leisti balso žinutes\?</string>
|
||||
<string name="bold">pusjuodis</string>
|
||||
<string name="callstatus_ended">skambutis baigtas <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="icon_descr_audio_call">garso skambutis</string>
|
||||
<string name="settings_audio_video_calls">Garso ir vaizdo skambučiai</string>
|
||||
<string name="integrity_msg_bad_hash">bloga žinutės maiša</string>
|
||||
<string name="integrity_msg_bad_id">blogas žinutės ID</string>
|
||||
<string name="incognito_random_profile_description">Jūsų adresatui bus išsiųstas atsitiktinis profilis</string>
|
||||
<string name="allow_disappearing_messages_only_if">Leisti išnykstančias žinutes tik tuo atveju, jei jūsų adresatas jas leidžia.</string>
|
||||
<string name="clear_chat_warning">Visos žinutės bus ištrintos – to neįmanoma bus atšaukti! Žinutės bus ištrintos TIK jums.</string>
|
||||
<string name="allow_to_delete_messages">Leisti negrįžtamai ištrinti išsiųstas žinutes.</string>
|
||||
<string name="allow_to_send_disappearing">Leisti siųsti išnykstančias žinutes.</string>
|
||||
<string name="allow_to_send_voice">Leisti siųsti balso žinutes.</string>
|
||||
<string name="allow_direct_messages">Leisti siųsti tiesiogines žinutes nariams.</string>
|
||||
<string name="v4_6_audio_video_calls">Garso ir vaizdo skambučiai</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Leisti jūsų adresatams siųsti išnykstančias žinutes.</string>
|
||||
<string name="auth_unavailable">Tapatybės nustatymas neprieinamas</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Turėkite omenyje</b>: jeigu prarasite slaptafrazę, NEBEGALĖSITE jos atkurti ar pakeisti.</string>
|
||||
<string name="cancel_verb">Atsisakyti</string>
|
||||
</resources>
|
||||
@@ -74,7 +74,7 @@
|
||||
<string name="chat_item_ttl_month">1 maand</string>
|
||||
<string name="about_simplex">Over SimpleX</string>
|
||||
<string name="about_simplex_chat">Over <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="above_then_preposition_continuation">hierboven, dan:</string>
|
||||
<string name="above_then_preposition_continuation">hier boven, dan:</string>
|
||||
<string name="accept_requests">Verzoeken accepteren</string>
|
||||
<string name="users_delete_all_chats_deleted">Alle gesprekken en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!</string>
|
||||
<string name="clear_chat_warning">Alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! De berichten worden ALLEEN voor jou verwijderd.</string>
|
||||
@@ -491,7 +491,7 @@
|
||||
\nWe zullen serverredundantie toevoegen om verloren berichten te voorkomen.</string>
|
||||
<string name="joining_group">Deel nemen aan groep</string>
|
||||
<string name="leave_group_button">Verlaten</string>
|
||||
<string name="group_member_role_member">gebruiker</string>
|
||||
<string name="group_member_role_member">Gebruiker</string>
|
||||
<string name="image_descr_link_preview">link voorbeeld afbeelding</string>
|
||||
<string name="member_info_section_title_member">GEBRUIKER</string>
|
||||
<string name="settings_section_title_messages">BERICHTEN</string>
|
||||
@@ -576,8 +576,8 @@
|
||||
<string name="feature_offered_item_with_param">voorgesteld %s: %2s</string>
|
||||
<string name="old_database_archive">Oud database archief</string>
|
||||
<string name="enter_correct_current_passphrase">Voer het juiste huidige wachtwoord in.</string>
|
||||
<string name="group_member_role_owner">eigenaar</string>
|
||||
<string name="network_option_ping_count">PING telling</string>
|
||||
<string name="group_member_role_owner">Eigenaar</string>
|
||||
<string name="network_option_ping_count">PING count</string>
|
||||
<string name="network_option_ping_interval">PING interval</string>
|
||||
<string name="v4_5_message_draft_descr">Bewaar het laatste berichtconcept, met bijlagen.</string>
|
||||
<string name="v4_5_private_filenames">Privé bestandsnamen</string>
|
||||
@@ -729,7 +729,7 @@
|
||||
<string name="network_socks_toggle">SOCKS-proxy gebruiken (poort 9050)</string>
|
||||
<string name="save_preferences_question">Voorkeuren opslaan\?</string>
|
||||
<string name="save_and_notify_contact">Opslaan en Contact melden</string>
|
||||
<string name="section_title_welcome_message">WELKOMS BERICHT</string>
|
||||
<string name="section_title_welcome_message">WELKOMST BERICHT</string>
|
||||
<string name="your_current_profile">Je huidige profiel</string>
|
||||
<string name="save_and_notify_contacts">Opslaan en Contacten melden</string>
|
||||
<string name="save_and_notify_group_members">Opslaan en Groepsleden melden</string>
|
||||
@@ -815,7 +815,7 @@
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">je bent van adres veranderd</string>
|
||||
<string name="select_contacts">Selecteer contacten</string>
|
||||
<string name="skip_inviting_button">Sla het uitnodigen van leden over</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(en) geselecteerd</string>
|
||||
<string name="num_contacts_selected">%d contact(en) geselecteerd</string>
|
||||
<string name="remove_member_confirmation">Verwijderen</string>
|
||||
<string name="button_remove_member">Gebruiker verwijderen</string>
|
||||
<string name="role_in_group">Rol</string>
|
||||
@@ -883,7 +883,7 @@
|
||||
<string name="prohibit_direct_messages">Verbied het sturen van directe berichten naar leden.</string>
|
||||
<string name="prohibit_sending_voice">Verbieden het verzenden van spraak berichten.</string>
|
||||
<string name="v4_2_security_assessment_desc">De beveiliging van SimpleX Chat is gecontroleerd door Trail of Bits.</string>
|
||||
<string name="v4_2_auto_accept_contact_requests_desc">Met optioneel welkomstbericht.</string>
|
||||
<string name="v4_2_auto_accept_contact_requests_desc">Met optioneel welkomst bericht.</string>
|
||||
<string name="v4_3_voice_messages">Spraak berichten</string>
|
||||
<string name="v4_3_irreversible_message_deletion_desc">Uw contacten kunnen volledige verwijdering van berichten toestaan.</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">U moet elke keer dat de app start het wachtwoord invoeren, deze wordt niet op het apparaat opgeslagen.</string>
|
||||
@@ -898,7 +898,6 @@
|
||||
<string name="update_network_settings_question">Netwerk instellingen bijwerken\?</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">Door de instellingen bij te werken, wordt de client opnieuw verbonden met alle servers.</string>
|
||||
<string name="update_network_settings_confirmation">Update</string>
|
||||
<string name="your_chat_profiles_stored_locally">Uw chat profielen worden lokaal opgeslagen, alleen op uw apparaat</string>
|
||||
<string name="incognito_random_profile">Je willekeurige profiel</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Verbied het verzenden van verdwijnende berichten.</string>
|
||||
<string name="prohibit_sending_voice_messages">Verbieden het verzenden van spraak berichten.</string>
|
||||
@@ -976,8 +975,86 @@
|
||||
<string name="observer_cant_send_message_desc">Neem contact op met de groep beheerder.</string>
|
||||
<string name="error_updating_link_for_group">Fout bij bijwerken van groep link</string>
|
||||
<string name="initial_member_role">Initiële rol</string>
|
||||
<string name="group_member_role_observer">waarnemer</string>
|
||||
<string name="group_member_role_observer">Waarnemer</string>
|
||||
<string name="observer_cant_send_message_title">Je kunt geen berichten versturen!</string>
|
||||
<string name="you_are_observer">jij bent waarnemer</string>
|
||||
<string name="language_system">Systeem</string>
|
||||
<string name="v4_6_audio_video_calls">Audio en video oproepen</string>
|
||||
<string name="cant_delete_user_profile">Kan gebruikers profiel niet verwijderen!</string>
|
||||
<string name="confirm_password">Bevestig wachtwoord</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Chinese en Spaanse interface</string>
|
||||
<string name="enter_password_to_show">Voer wachtwoord in bij zoeken</string>
|
||||
<string name="error_saving_user_password">Fout bij opslaan gebruikers wachtwoord</string>
|
||||
<string name="button_add_welcome_message">Welkomst bericht toevoegen</string>
|
||||
<string name="dont_show_again">Niet meer weergeven</string>
|
||||
<string name="v4_6_group_moderation">Groep moderatie</string>
|
||||
<string name="error_updating_user_privacy">Fout bij updaten van gebruikers privacy</string>
|
||||
<string name="v4_6_reduced_battery_usage">Verder verminderd batterij verbruik</string>
|
||||
<string name="v4_6_group_welcome_message">Groep welkomst bericht</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Verborgen chat profielen</string>
|
||||
<string name="hide_profile">Profiel verbergen</string>
|
||||
<string name="user_hide">Verbergen</string>
|
||||
<string name="hidden_profile_password">Verborgen profiel wachtwoord</string>
|
||||
<string name="muted_when_inactive">Gedempt wanneer inactief!</string>
|
||||
<string name="user_mute">Dempen</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">Meer verbeteringen volgen snel!</string>
|
||||
<string name="make_profile_private">Profiel privé maken!</string>
|
||||
<string name="v4_6_group_moderation_descr">Nu kunnen beheerders:
|
||||
\n- berichten van leden verwijderen.
|
||||
\n- schakel leden uit (\"waarnemer\" rol)</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Bescherm je chat profielen met een wachtwoord!</string>
|
||||
<string name="password_to_show">Wachtwoord om weer te geven</string>
|
||||
<string name="save_and_update_group_profile">Groep profiel opslaan en bijwerken</string>
|
||||
<string name="smp_save_servers_question">Servers opslaan\?</string>
|
||||
<string name="save_profile_password">Bewaar profiel wachtwoord</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Stel het getoonde bericht in voor nieuwe leden!</string>
|
||||
<string name="save_welcome_message_question">Welkomst bericht opslaan\?</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Ondersteuning voor bluetooth en andere verbeteringen.</string>
|
||||
<string name="tap_to_activate_profile">Tik om profiel te activeren.</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Dank aan de gebruikers – draag bij via Weblate!</string>
|
||||
<string name="should_be_at_least_one_profile">Er moet ten minste één gebruikers profiel zijn.</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">U kunt een gebruikers profiel verbergen of dempen - houd het vast voor het menu.</string>
|
||||
<string name="user_unhide">zichtbaar maken</string>
|
||||
<string name="user_unmute">Dempen opheffen</string>
|
||||
<string name="group_welcome_title">Welkomst bericht</string>
|
||||
<string name="should_be_at_least_one_visible_profile">"Er moet ten minste één zichtbaar gebruikers profiel zijn."</string>
|
||||
<string name="to_reveal_profile_enter_password">Om uw verborgen profiel te onthullen, voert u een volledig wachtwoord in een zoekveld in op de pagina Uw chat profielen.</string>
|
||||
<string name="button_welcome_message">Welkomst bericht</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">U ontvangt nog steeds oproepen en meldingen van gedempte profielen wanneer deze actief zijn.</string>
|
||||
<string name="settings_send_files_via_xftp">Verzend video\'s en bestanden via XFTP</string>
|
||||
<string name="database_downgrade">Database downgraden</string>
|
||||
<string name="invalid_migration_confirmation">Ongeldige migratie bevestiging</string>
|
||||
<string name="upgrade_and_open_chat">Upgrade en open chat</string>
|
||||
<string name="mtr_error_different">verschillende migratie in de app/database: %s / %s</string>
|
||||
<string name="downgrade_and_open_chat">Downgraden en chat openen</string>
|
||||
<string name="database_migrations">Migraties: %s</string>
|
||||
<string name="database_downgrade_warning">Waarschuwing: u kunt sommige gegevens verliezen!</string>
|
||||
<string name="database_upgrade">Database upgrade</string>
|
||||
<string name="confirm_database_upgrades">Bevestig database upgrades</string>
|
||||
<string name="mtr_error_no_down_migration">database versie is nieuwer dan de app, maar geen down migratie voor: %s</string>
|
||||
<string name="incompatible_database_version">Incompatibele database versie</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Het bestand wordt ontvangen wanneer uw contactpersoon het uploaden heeft voltooid.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">De afbeelding wordt ontvangen wanneer uw contactpersoon het uploaden heeft voltooid.</string>
|
||||
<string name="show_dev_options">Toon:</string>
|
||||
<string name="developer_options">Database ID\'s en Transport isolatie optie.</string>
|
||||
<string name="hide_dev_options">Verbergen:</string>
|
||||
<string name="show_developer_options">Ontwikkelaars opties tonen</string>
|
||||
<string name="settings_section_title_experimenta">EXPERIMENTEEL</string>
|
||||
<string name="xftp_requires_v461">v4.6.1+ is vereist om te ontvangen via XFTP.</string>
|
||||
<string name="cancel_file__question">Bestand overdracht annuleren\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Bestand overdracht wordt geannuleerd. Als het bezig is, wordt het gestopt.</string>
|
||||
<string name="delete_profile">Verwijder profiel</string>
|
||||
<string name="profile_password">Profiel wachtwoord</string>
|
||||
<string name="unhide_chat_profile">Chat profiel zichtbaar maken</string>
|
||||
<string name="unhide_profile">Profiel zichtbaar maken</string>
|
||||
<string name="delete_chat_profile">Chat profiel verwijderen\?</string>
|
||||
<string name="icon_descr_video_asked_to_receive">Gevraagd om de video te ontvangen</string>
|
||||
<string name="videos_limit_desc">Er kunnen slechts 10 video\'s tegelijk worden verzonden</string>
|
||||
<string name="videos_limit_title">Te veel video\'s!</string>
|
||||
<string name="icon_descr_video_snd_complete">Video verzonden</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">De video wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later!</string>
|
||||
<string name="icon_descr_waiting_for_video">Wachten op video</string>
|
||||
<string name="waiting_for_video">Wachten op video</string>
|
||||
<string name="video_descr">Video</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">De video wordt ontvangen wanneer uw contactpersoon het uploaden heeft voltooid.</string>
|
||||
</resources>
|
||||
@@ -6,22 +6,22 @@
|
||||
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
|
||||
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
|
||||
<string name="connect_via_group_link">Соединиться через ссылку группы?</string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого Вы получили эту ссылку.</string>
|
||||
<string name="you_will_join_group">Вы вступите в группу, на которую ссылается эта ссылка.</string>
|
||||
<string name="connect_via_link_verb">Соединиться</string>
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
<string name="server_connected">соединено</string>
|
||||
<string name="server_error">ошибка</string>
|
||||
<string name="server_connecting">соединяется</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта.</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта.</string>
|
||||
<!-- Item Content - ChatModel.kt -->
|
||||
<string name="deleted_description">удалено</string>
|
||||
<string name="marked_deleted_description">помечено к удалению</string>
|
||||
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
|
||||
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
|
||||
<string name="sender_you_pronoun">вы</string>
|
||||
<string name="sender_you_pronoun">Вы</string>
|
||||
<string name="unknown_message_format">неизвестный формат сообщения</string>
|
||||
<string name="invalid_message_format">неверный формат сообщения</string>
|
||||
<!-- PendingContactConnection - ChatModel.kt -->
|
||||
@@ -29,8 +29,8 @@
|
||||
<string name="display_name_connection_established">соединение установлено</string>
|
||||
<string name="display_name_invited_to_connect">приглашение соединиться</string>
|
||||
<string name="display_name_connecting">соединяется…</string>
|
||||
<string name="description_you_shared_one_time_link">вы создали одноразовую ссылку</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">вы создали одноразовую ссылку инкогнито</string>
|
||||
<string name="description_you_shared_one_time_link">Вы создали одноразовую ссылку</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">Вы создали одноразовую ссылку инкогнито</string>
|
||||
<string name="description_via_group_link">через ссылку группы</string>
|
||||
<string name="description_via_group_link_incognito">инкогнито через ссылку группы</string>
|
||||
<string name="description_via_contact_address_link">через ссылку-контакт</string>
|
||||
@@ -54,7 +54,7 @@
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Превышено время соединения</string>
|
||||
<string name="connection_error">Ошибка соединения</string>
|
||||
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
|
||||
<string name="network_error_desc">Пожалуйста, проверьте Ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
|
||||
<string name="error_sending_message">Ошибка при отправке сообщения</string>
|
||||
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
|
||||
<string name="error_joining_group">Ошибка при вступлении в группу</string>
|
||||
@@ -65,9 +65,10 @@
|
||||
<string name="contact_already_exists">Существующий контакт</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Вы уже соединены с контактом <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
|
||||
<string name="invalid_connection_link">Ошибка в ссылке контакта</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую.</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Пожалуйста, проверьте, что Вы использовали правильную ссылку, или попросите Ваш контакт отправить Вам новую.</string>
|
||||
<string name="connection_error_auth">Ошибка соединения (AUTH)</string>
|
||||
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
|
||||
<string name="connection_error_auth_desc">Возможно, Ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.
|
||||
\nЧтобы установить соединение, попросите Ваш контакт создать еще одну ссылку и проверьте Ваше соединение с сетью.</string>
|
||||
<string name="error_accepting_contact_request">Ошибка при принятии запроса на соединение</string>
|
||||
<string name="sender_may_have_deleted_the_connection_request">Отправитель мог удалить запрос на соединение.</string>
|
||||
<string name="error_deleting_contact">Ошибка при удалении контакта</string>
|
||||
@@ -87,13 +88,13 @@
|
||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||
<string name="service_notifications">Мгновенные уведомления!</string>
|
||||
<string name="service_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="it_can_disabled_via_settings_notifications_still_shown"><b>Он может быть выключен через Настройки</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="turn_off_battery_optimization">Для использования этой функции, пожалуйста, отключите оптимизацию батареи для <xliff:g id="appName">SimpleX</xliff:g> в следующем диалоге. Иначе уведомления будут выключены.</string>
|
||||
<string name="turning_off_service_and_periodic">Оптимизация батареи включена, поэтому сервис уведомлений выключен. Вы можете снова включить его через Настройки.</string>
|
||||
<string name="periodic_notifications">Периодические уведомления</string>
|
||||
<string name="periodic_notifications_disabled">Периодические уведомления выключены!</string>
|
||||
<string name="periodic_notifications_desc">Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с вашего устройства на сервер.</string>
|
||||
<string name="periodic_notifications_desc">Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с Вашего устройства на сервер.</string>
|
||||
<string name="enter_passphrase_notification_title">Введите пароль</string>
|
||||
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
|
||||
<string name="database_initialization_error_title">Ошибка базы данных</string>
|
||||
@@ -127,7 +128,8 @@
|
||||
<string name="notification_contact_connected">Соединен(а)</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_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>
|
||||
@@ -144,7 +146,7 @@
|
||||
<string name="auth_open_chat_console">Открыть консоль</string>
|
||||
<!-- Chat Alerts - ChatItemView.kt -->
|
||||
<string name="message_delivery_error_title">Ошибка доставки сообщения</string>
|
||||
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с вами.</string>
|
||||
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с Вами.</string>
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Ответить</string>
|
||||
<string name="share_verb">Поделиться</string>
|
||||
@@ -172,12 +174,12 @@
|
||||
<string name="this_text_is_available_in_settings">Этот текст можно найти в Настройках</string>
|
||||
<string name="your_chats">Ваши чаты</string>
|
||||
<string name="contact_connection_pending">соединяется…</string>
|
||||
<string name="group_preview_you_are_invited">вы приглашены в группу</string>
|
||||
<string name="group_preview_you_are_invited">Вы приглашены в группу</string>
|
||||
<string name="group_preview_join_as">вступить как %s</string>
|
||||
<string name="group_connection_pending">соединяется…</string>
|
||||
<string name="tap_to_start_new_chat">Нажмите, чтобы начать чат</string>
|
||||
<string name="chat_with_developers">Соединиться с разработчиками</string>
|
||||
<string name="you_have_no_chats">У вас нет чатов</string>
|
||||
<string name="you_have_no_chats">У Вас нет чатов</string>
|
||||
<!-- ShareListView.kt -->
|
||||
<string name="share_message">Отправить сообщение…</string>
|
||||
<string name="share_image">Отправить изображение…</string>
|
||||
@@ -197,7 +199,7 @@
|
||||
<string name="icon_descr_asked_to_receive">Предложено получить изображение</string>
|
||||
<string name="icon_descr_image_snd_complete">Изображение отправлено</string>
|
||||
<string name="waiting_for_image">Ожидается прием изображения</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда Ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="image_saved">Изображение сохранено в Галерею</string>
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">Файл</string>
|
||||
@@ -205,7 +207,7 @@
|
||||
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="maximum_supported_file_size">В настоящее время максимальный поддерживаемый размер файла составляет <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="waiting_for_file">Ожидается прием файла</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">Файл будет принят, когда Ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="file_saved">Файл сохранен</string>
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||
@@ -225,14 +227,14 @@
|
||||
<string name="icon_descr_server_status_error">Ошибка соединения с сервером</string>
|
||||
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
|
||||
<string name="switch_receiving_address_question">Переключить адрес получения?</string>
|
||||
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса вы увидите сообщение — убедитесь, что вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
|
||||
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса Вы увидите сообщение — убедитесь, что Вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||
<string name="allow_voice_messages_question">Разрешить голосовые сообщения?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их вашему контакту.</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их Вашему контакту.</string>
|
||||
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Попросите Вашего контакта разрешить отправку голосовых сообщений.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Назад</string>
|
||||
@@ -249,7 +251,7 @@
|
||||
<string name="connect_via_link_or_qr">Соединиться через ссылку / QR код</string>
|
||||
<string name="scan_QR_code">Сканировать\nQR код</string>
|
||||
<string name="create_group">Создать секретную группу</string>
|
||||
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
|
||||
<string name="to_share_with_your_contact">(чтобы отправить Вашему контакту)</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(сканировать или вставить из буфера)</string>
|
||||
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
|
||||
<!-- GetImageView -->
|
||||
@@ -263,21 +265,21 @@
|
||||
<string name="to_start_a_new_chat_help_header">Чтобы начать новый чат</string>
|
||||
<string name="chat_help_tap_button">Нажмите кнопку</string>
|
||||
<string name="above_then_preposition_continuation">сверху, затем:</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>Добавить новый контакт</b>: чтобы создать одноразовый QR код/ссылку для вашего контакта.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Сканировать QR код</b>: чтобы соединиться с контактом, который показывает вам QR код.</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>Добавить новый контакт</b>: чтобы создать одноразовый QR код/ссылку для Вашего контакта.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Сканировать QR код</b>: чтобы соединиться с контактом, который показывает Вам QR код.</string>
|
||||
<string name="to_connect_via_link_title">Чтобы соединиться через ссылку</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если Вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, Вы можете открыть ее в браузере:</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
|
||||
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
|
||||
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
|
||||
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если Вы отклоните запрос на соединение.</string>
|
||||
<string name="accept_contact_button">Принять</string>
|
||||
<string name="accept_contact_incognito_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_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для Вас.</string>
|
||||
<string name="clear_verb">Очистить</string>
|
||||
<string name="clear_chat_button">Очистить чат</string>
|
||||
<string name="clear_chat_menu_action">Очистить</string>
|
||||
@@ -290,16 +292,16 @@
|
||||
<string name="mute_chat">Без звука</string>
|
||||
<string name="unmute_chat">Уведомлять</string>
|
||||
<!-- 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="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому Вы отправили эту ссылку, не сможет соединиться!</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
|
||||
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
|
||||
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
|
||||
<!-- Contact Request Information - ContactRequestView.kt -->
|
||||
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
|
||||
<string name="contact_wants_to_connect_with_you">хочет соединиться с Вами!</string>
|
||||
<!-- Image Placeholder - ChatInfoImage.kt -->
|
||||
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
|
||||
<string name="image_descr_profile_image">аватар</string>
|
||||
@@ -324,15 +326,16 @@
|
||||
<string name="this_link_is_not_a_valid_connection_link">Эта ссылка не является ссылкой-приглашением!</string>
|
||||
<string name="connection_request_sent">Запрос на соединение послан!</string>
|
||||
<string name="you_will_be_connected_when_group_host_device_is_online">Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="you_will_be_connected_when_your_contacts_device_is_online">Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="you_will_be_connected_when_your_contacts_device_is_online">Соединение будет установлено, когда Ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Ваш контакт может сосканировать QR код в приложении.</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если вы не можете встретиться лично, вы можете <b>показать QR код во время видеозвонка</b> или поделиться ссылкой.</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен\nвашему контакту</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если Вы не можете встретиться лично, Вы можете <b>показать QR код во время видеозвонка</b> или поделиться ссылкой.</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен
|
||||
\nВашему контакту</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если Вы не можете встретиться лично, Вы можете <b>сосканировать QR код во время видеозвонка</b>, или Ваш контакт может отправить Вам ссылку.</string>
|
||||
<string name="share_invitation_link">Поделиться ссылкой</string>
|
||||
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
|
||||
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
|
||||
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от Вашего контакта.</string>
|
||||
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен Вашему контакту</string>
|
||||
<!-- PasteToConnect.kt -->
|
||||
<string name="connect_button">Соединиться</string>
|
||||
<string name="paste_button">Вставить</string>
|
||||
@@ -365,7 +368,7 @@
|
||||
<string name="smp_servers_enter_manually">Ввести сервер вручную</string>
|
||||
<string name="smp_servers_preset_server">Сервер по умолчанию</string>
|
||||
<string name="smp_servers_your_server">Ваш сервер</string>
|
||||
<string name="smp_servers_your_server_address">Адрес вашего сервера</string>
|
||||
<string name="smp_servers_your_server_address">Адрес Вашего сервера</string>
|
||||
<string name="smp_servers_use_server">Использовать сервер</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Использовать для новых соединений</string>
|
||||
<string name="smp_servers_add_to_another_device">Добавить на другое устройство</string>
|
||||
@@ -395,7 +398,7 @@
|
||||
<string name="network_enable_socks">Использовать SOCKS прокси?</string>
|
||||
<string name="network_enable_socks_info">Соединяться с серверами через SOCKS прокси через порт 9050? Прокси должен быть запущен до включения этой опции.</string>
|
||||
<string name="network_disable_socks">Использовать прямое соединение с Интернет?</string>
|
||||
<string name="network_disable_socks_info">Если вы подтвердите, серверы смогут видеть ваш IP адрес, а провайдер - с какими серверами вы соединяетесь.</string>
|
||||
<string name="network_disable_socks_info">Если Вы подтвердите, серверы смогут видеть Ваш IP адрес, а провайдер - с какими серверами Вы соединяетесь.</string>
|
||||
<string name="update_onion_hosts_settings_question">Обновить настройки .onion хостов?</string>
|
||||
<string name="network_use_onion_hosts">Использовать .onion хосты</string>
|
||||
<string name="network_use_onion_hosts_prefer">Когда возможно</string>
|
||||
@@ -412,7 +415,7 @@
|
||||
<string name="create_address">Создать адрес</string>
|
||||
<string name="delete_address__question">Удалить адрес?</string>
|
||||
<string name="all_your_contacts_will_remain_connected">Все контакты, которые соединились через этот адрес, сохранятся.</string>
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать Ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с Вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
|
||||
<string name="share_link">Поделиться\nссылкой</string>
|
||||
<string name="delete_address">Удалить\nадрес</string>
|
||||
<!-- AcceptRequestsView.kt -->
|
||||
@@ -424,7 +427,9 @@
|
||||
<string name="display_name__field">Имя профиля:</string>
|
||||
<string name="full_name__field">"Полное имя:</string>
|
||||
<string name="your_current_profile">Ваш активный профиль</string>
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам.
|
||||
\n
|
||||
\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к Вашему профилю.</string>
|
||||
<string name="edit_image">Поменять аватар</string>
|
||||
<string name="delete_image">Удалить аватар</string>
|
||||
<string name="save_preferences_question">Сохранить предпочтения?</string>
|
||||
@@ -433,12 +438,12 @@
|
||||
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
|
||||
<string name="exit_without_saving">Выйти без сохранения</string>
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
|
||||
<string name="we_do_not_store_contacts_or_messages_on_servers">Мы не храним ваши контакты и сообщения (после доставки) на серверах.</string>
|
||||
<string name="you_control_your_chat">Вы котролируете Ваш чат!</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает Вашу личную информацию и безопасность.</string>
|
||||
<string name="we_do_not_store_contacts_or_messages_on_servers">Мы не храним Ваши контакты и сообщения (после доставки) на серверах.</string>
|
||||
<string name="create_profile">Создать профиль</string>
|
||||
<string name="your_profile_is_stored_on_your_device">Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве.</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">Профиль отправляется только вашим контактам.</string>
|
||||
<string name="your_profile_is_stored_on_your_device">Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве.</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">Профиль отправляется только Вашим контактам.</string>
|
||||
<string name="display_name_cannot_contain_whitespace">Имя профиля не может содержать пробелы.</string>
|
||||
<string name="display_name">Имя профиля</string>
|
||||
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
|
||||
@@ -455,7 +460,7 @@
|
||||
<string name="secret">секрет</string>
|
||||
<string name="connect_via_link">Соединиться через ссылку</string>
|
||||
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где Вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
|
||||
<!-- CICallStatus -->
|
||||
<string name="callstatus_calling">входящий звонок…</string>
|
||||
<string name="callstatus_missed">пропущенный звонок</string>
|
||||
@@ -479,7 +484,7 @@
|
||||
<string name="privacy_redefined">Более конфиденциальный</string>
|
||||
<string name="first_platform_without_user_ids">Первая в мире платформа без идентификаторов пользователей.</string>
|
||||
<string name="immune_to_spam_and_abuse">Защищен от спама</string>
|
||||
<string name="people_can_connect_only_via_links_you_share">С вами можно соединиться только через созданные вами ссылки.</string>
|
||||
<string name="people_can_connect_only_via_links_you_share">С Вами можно соединиться только через созданные Вами ссылки.</string>
|
||||
<string name="decentralized">Децентрализованный</string>
|
||||
<string name="opensource_protocol_and_code_anybody_can_run_servers">Открытый протокол и код - кто угодно может запустить сервер.</string>
|
||||
<string name="create_your_profile">Создать профиль</string>
|
||||
@@ -488,8 +493,8 @@
|
||||
<!-- How SimpleX Works -->
|
||||
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, <xliff:g id="appName">SimpleX</xliff:g> использует ID для очередей сообщений, разные для каждого контакта.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Вы определяете через какие серверы вы <b>получаете сообщения</b>, ваши контакты - серверы, которые вы используете для отправки.</string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Чтобы защитить Вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, <xliff:g id="appName">SimpleX</xliff:g> использует ID для очередей сообщений, разные для каждого контакта.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Вы определяете через какие серверы Вы <b>получаете сообщения</b>, Ваши контакты - серверы, которые Вы используете для отправки.</string>
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
|
||||
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
|
||||
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
|
||||
@@ -500,7 +505,7 @@
|
||||
<!-- 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="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>
|
||||
@@ -521,8 +526,8 @@
|
||||
<string name="no_call_on_lock_screen">Выключить</string>
|
||||
<string name="your_ice_servers">Ваши ICE серверы</string>
|
||||
<string name="webrtc_ice_servers">WebRTC ICE серверы</string>
|
||||
<string name="relay_server_protects_ip">Relay сервер защищает ваш IP адрес, но может отслеживать продолжительность звонка.</string>
|
||||
<string name="relay_server_if_necessary">Relay сервер используется только при необходимости. Другая сторона может видеть ваш IP адрес.</string>
|
||||
<string name="relay_server_protects_ip">Relay сервер защищает Ваш IP адрес, но может отслеживать продолжительность звонка.</string>
|
||||
<string name="relay_server_if_necessary">Relay сервер используется только при необходимости. Другая сторона может видеть Ваш IP адрес.</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>
|
||||
@@ -556,7 +561,12 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -601,17 +611,18 @@
|
||||
<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="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="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="you_must_use_the_most_recent_version_of_database">Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе Вы можете перестать получать сообщения от некоторых контактов.</string>
|
||||
<string name="stop_chat_to_enable_database_actions">Остановите чат, чтобы разблокировать операции с архивом чата.</string>
|
||||
<string name="delete_files_and_media_for_all_users">Удалить файлы во всех профилях чата</string>
|
||||
<string name="delete_files_and_media_all">Удалить все файлы</string>
|
||||
@@ -643,20 +654,20 @@
|
||||
<string name="confirm_new_passphrase">Подтвердите новый пароль…</string>
|
||||
<string name="update_database_passphrase">Поменять пароль</string>
|
||||
<string name="enter_correct_current_passphrase">Пожалуйста, введите правильный пароль.</string>
|
||||
<string name="database_is_not_encrypted">База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные.</string>
|
||||
<string name="database_is_not_encrypted">База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные.</string>
|
||||
<string name="keychain_is_storing_securely">Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
|
||||
<string name="encrypted_with_random_passphrase">База данных зашифрована случайным паролем, вы можете его поменять.</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Внимание</b>: вы не сможете восстановить или поменять пароль, если потеряете его.</string>
|
||||
<string name="encrypted_with_random_passphrase">База данных зашифрована случайным паролем, Вы можете его поменять.</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Внимание</b>: Вы не сможете восстановить или поменять пароль, если потеряете его.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления.</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата.</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата.</string>
|
||||
<string name="encrypt_database_question">Зашифровать базу данных?</string>
|
||||
<string name="change_database_passphrase_question">Поменять пароль базы данных?</string>
|
||||
<string name="database_will_be_encrypted">База данных будет зашифрована.</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">База данных будет зашифрована и пароль сохранен в Keystore.</string>
|
||||
<string name="database_encryption_will_be_updated">Пароль базы данных будет изменен и сохранен в Keystore.</string>
|
||||
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
|
||||
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
|
||||
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его.</string>
|
||||
<!-- DatabaseErrorView.kt -->
|
||||
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
|
||||
<string name="encrypted_database">База данных зашифрована</string>
|
||||
@@ -680,7 +691,7 @@
|
||||
<string name="restore_database_alert_desc">Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить.</string>
|
||||
<string name="restore_database_alert_confirm">Восстановить</string>
|
||||
<string name="database_restore_error">Ошибка при восстановлении базы данных</string>
|
||||
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</string>
|
||||
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если Вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</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>
|
||||
@@ -709,7 +720,7 @@
|
||||
<string name="alert_title_no_group">Группа не найдена!</string>
|
||||
<string name="alert_message_no_group">Эта группа больше не существует.</string>
|
||||
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие Вашего основного профиля, приглашать контакты не разрешено</string>
|
||||
<!-- CIGroupInvitationView.kt -->
|
||||
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
|
||||
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
|
||||
@@ -723,23 +734,23 @@
|
||||
<string name="rcv_group_event_member_connected">соединен(а)</string>
|
||||
<string name="rcv_group_event_member_left">покинул(а) группу</string>
|
||||
<string name="rcv_group_event_changed_member_role">поменял(а) роль члена %s на: %s</string>
|
||||
<string name="rcv_group_event_changed_your_role">поменял(а) вашу роль на: %s</string>
|
||||
<string name="rcv_group_event_changed_your_role">поменял(а) Вашу роль на: %s</string>
|
||||
<string name="rcv_group_event_member_deleted">удалил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_user_deleted">удалил(а) вас из группы</string>
|
||||
<string name="rcv_group_event_user_deleted">удалил(а) Вас из группы</string>
|
||||
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
|
||||
<string name="rcv_group_event_updated_group_profile">обновил(а) профиль группы</string>
|
||||
<string name="rcv_group_event_invited_via_your_group_link">приглашен(а) через вашу ссылку группы</string>
|
||||
<string name="snd_group_event_changed_member_role">вы поменяли роль члена %s на: %s</string>
|
||||
<string name="snd_group_event_changed_role_for_yourself">вы поменяли роль себе на: %s</string>
|
||||
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="snd_group_event_user_left">вы покинули группу</string>
|
||||
<string name="rcv_group_event_invited_via_your_group_link">приглашен(а) через Вашу ссылку группы</string>
|
||||
<string name="snd_group_event_changed_member_role">Вы поменяли роль члена %s на: %s</string>
|
||||
<string name="snd_group_event_changed_role_for_yourself">Вы поменяли роль себе на: %s</string>
|
||||
<string name="snd_group_event_member_deleted">Вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="snd_group_event_user_left">Вы покинули группу</string>
|
||||
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
|
||||
<!-- Conn event chat items -->
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для вас</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для Вас</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">вы поменяли адрес для %s</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">Вы поменяли адрес для %s</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">смена адреса для %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">вы поменяли адрес</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">Вы поменяли адрес</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
<!-- GroupMemberRole -->
|
||||
<string name="group_member_role_member">член группы</string>
|
||||
@@ -767,18 +778,18 @@
|
||||
<string name="select_contacts">Выберите контакты</string>
|
||||
<string name="icon_descr_contact_checked">Контакт выбран</string>
|
||||
<string name="clear_contacts_selection_button">Очистить</string>
|
||||
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
|
||||
<string name="num_contacts_selected">Выбрано контактов: %d</string>
|
||||
<string name="no_contacts_selected">Контакты не выбраны</string>
|
||||
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
|
||||
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
|
||||
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль</string>
|
||||
<!-- GroupChatInfoView.kt -->
|
||||
<string name="button_add_members">Пригласить членов группы</string>
|
||||
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
|
||||
<string name="group_info_member_you">вы: <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="group_info_member_you">Вы: <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="button_delete_group">Удалить группу</string>
|
||||
<string name="delete_group_question">Удалить группу?</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">Группа будет удалена для всех членов - это действие нельзя отменить!</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">Группа будет удалена для вас - это действие нельзя отменить!</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">Группа будет удалена для Вас - это действие нельзя отменить!</string>
|
||||
<string name="button_leave_group">Выйти из группы</string>
|
||||
<string name="button_edit_group_profile">Редактировать профиль группы</string>
|
||||
<string name="group_link">Ссылка группы</string>
|
||||
@@ -824,7 +835,7 @@
|
||||
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
|
||||
<string name="group_display_name_field">Имя группы:</string>
|
||||
<string name="group_full_name_field">Полное имя:</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - Ваш основной профиль будет отправлен членам группы</string>
|
||||
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
|
||||
<!-- GroupProfileView.kt -->
|
||||
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
|
||||
@@ -846,10 +857,10 @@
|
||||
<string name="incognito">Инкогнито</string>
|
||||
<string name="incognito_random_profile">Случайный профиль</string>
|
||||
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
|
||||
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Контакту, от которого Вы получили эту ссылку, будет отправлен случайный профиль</string>
|
||||
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения Вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
|
||||
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
|
||||
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
|
||||
<string name="incognito_info_share">Когда Вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
|
||||
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
|
||||
<!-- Default themes -->
|
||||
<string name="theme_system">Системная</string>
|
||||
@@ -878,23 +889,23 @@
|
||||
<string name="full_deletion">Удаление для всех</string>
|
||||
<string name="voice_messages">Голосовые сообщения</string>
|
||||
<string name="feature_enabled">включено</string>
|
||||
<string name="feature_enabled_for_you">включено для вас</string>
|
||||
<string name="feature_enabled_for_you">включено для Вас</string>
|
||||
<string name="feature_enabled_for_contact">включено для контакта</string>
|
||||
<string name="feature_off">выключено</string>
|
||||
<string name="feature_received_prohibited">получено, не разрешено</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Разрешить вашим контактам необратимо удалять отправленные сообщения.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Разрешить вашим контактам отправлять голосовые сообщения.</string>
|
||||
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает ваш контакт.</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Разрешить Вашим контактам необратимо удалять отправленные сообщения.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если Ваш контакт разрешает это Вам.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Разрешить Вашим контактам отправлять голосовые сообщения.</string>
|
||||
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает Ваш контакт.</string>
|
||||
<string name="prohibit_sending_voice_messages">Запретить отправлять голосовые сообщений.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Вы и ваш контакт можете необратимо удалять отправленные сообщения.</string>
|
||||
<string name="only_you_can_delete_messages">Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</string>
|
||||
<string name="only_your_contact_can_delete">Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Вы и Ваш контакт можете необратимо удалять отправленные сообщения.</string>
|
||||
<string name="only_you_can_delete_messages">Только Вы можете необратимо удалять сообщения (Ваш контакт может помечать их на удаление).</string>
|
||||
<string name="only_your_contact_can_delete">Только Ваш контакт может необратимо удалять сообщения (Вы можете помечать их на удаление).</string>
|
||||
<string name="message_deletion_prohibited">Необратимое удаление сообщений запрещено в этой группе.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Вы и ваш контакт можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_you_can_send_voice">Только вы можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_your_contact_can_send_voice">Только ваш контакт может отправлять голосовые сообщения.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Вы и Ваш контакт можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_you_can_send_voice">Только Вы можете отправлять голосовые сообщения.</string>
|
||||
<string name="only_your_contact_can_send_voice">Только Ваш контакт может отправлять голосовые сообщения.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Голосовые сообщения запрещены в этом чате.</string>
|
||||
<string name="allow_direct_messages">Разрешить посылать прямые сообщения членам группы.</string>
|
||||
<string name="prohibit_direct_messages">Запретить посылать прямые сообщения членам группы.</string>
|
||||
@@ -937,18 +948,18 @@
|
||||
<string name="timed_messages">Исчезающие сообщения</string>
|
||||
<string name="view_security_code">Показать код безопасности</string>
|
||||
<string name="verify_security_code">Подтвердить код безопасности</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Вы и ваш контакт можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_you_can_send_disappearing">Только вы можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Только ваш контакт может отправлять исчезающие сообщения.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Вы и Ваш контакт можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_you_can_send_disappearing">Только Вы можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Только Ваш контакт может отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Исчезающие сообщения запрещены в этом чате.</string>
|
||||
<string name="allow_to_send_disappearing">Разрешить посылать исчезающие сообщения.</string>
|
||||
<string name="contact_developers">Пожалуйста, обновите приложение и свяжитесь с разработчиками.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить вашим контактам отправлять исчезающие сообщения.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить Вашим контактам отправлять исчезающие сообщения.</string>
|
||||
<string name="failed_to_parse_chat_title">Не удалось открыть чат</string>
|
||||
<string name="failed_to_parse_chats_title">Не удалось открыть чаты</string>
|
||||
<string name="incorrect_code">Неправильный код безопасности!</string>
|
||||
<string name="scan_code">Сканировать код</string>
|
||||
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как вы его вводите</string>
|
||||
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как Вы его вводите</string>
|
||||
<string name="create_group_link">Создать ссылку группы</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Запретить отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Исчезающие сообщения запрещены в этой группе.</string>
|
||||
@@ -956,13 +967,13 @@
|
||||
<string name="ttl_d">%dд</string>
|
||||
<string name="ttl_weeks">%d нед.</string>
|
||||
<string name="ttl_days">%d дней</string>
|
||||
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
|
||||
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с Вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
|
||||
<string name="is_verified">%s подтверждён</string>
|
||||
<string name="is_not_verified">%s не подтверждён</string>
|
||||
<string name="security_code">Код безопасности</string>
|
||||
<string name="mark_code_verified">Подтвердить</string>
|
||||
<string name="clear_verification">Сбросить подтверждение</string>
|
||||
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если ваш контакт разрешает их вам.</string>
|
||||
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если Ваш контакт разрешает их Вам.</string>
|
||||
<string name="prohibit_sending_disappearing">Запретить посылать исчезающие сообщения.</string>
|
||||
<string name="group_members_can_send_disappearing">Члены группы могут посылать исчезающие сообщения.</string>
|
||||
<string name="whats_new">Новые функции</string>
|
||||
@@ -980,9 +991,9 @@
|
||||
<string name="v4_4_disappearing_messages_desc">Отправленные сообщения будут удалены через заданное время.</string>
|
||||
<string name="v4_3_improved_server_configuration">Улучшенная конфигурация серверов</string>
|
||||
<string name="v4_4_live_messages">\"Живые\" сообщения</string>
|
||||
<string name="v4_4_live_messages_desc">Получатели видят их в то время как вы их набираете.</string>
|
||||
<string name="v4_4_live_messages_desc">Получатели видят их в то время как Вы их набираете.</string>
|
||||
<string name="v4_4_verify_connection_security">Проверить безопасность соединения</string>
|
||||
<string name="v4_4_verify_connection_security_desc">Сравните код безопасности с вашими контактами.</string>
|
||||
<string name="v4_4_verify_connection_security_desc">Сравните код безопасности с Вашими контактами.</string>
|
||||
<string name="invalid_chat">ошибка чата</string>
|
||||
<string name="accept_feature">Принять</string>
|
||||
<string name="accept_feature_set_1_day">Установить 1 день</string>
|
||||
@@ -1006,25 +1017,24 @@
|
||||
<string name="files_and_media_section">Файлы и медиа</string>
|
||||
<string name="users_delete_data_only">Только локальные данные профиля</string>
|
||||
<string name="messages_section_title">Сообщения</string>
|
||||
<string name="smp_servers_per_user">Серверы для новых соединений вашего текущего профиля чата</string>
|
||||
<string name="your_chat_profiles_stored_locally">Ваши профили чата хранятся локально, только на вашем устройстве</string>
|
||||
<string name="smp_servers_per_user">Серверы для новых соединений Вашего текущего профиля чата</string>
|
||||
<string name="your_chat_profiles">Ваши профили чата</string>
|
||||
<string name="users_delete_all_chats_deleted">Все чаты и сообщения будут удалены - это нельзя отменить!</string>
|
||||
<string name="app_version_code">Сборка приложения: %s</string>
|
||||
<string name="app_version_name">Версия приложения: v%s</string>
|
||||
<string name="network_session_mode_entity_description">Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться <b>для каждого контакта и члена группы</b>.
|
||||
\n<b>Обратите внимание</b>: если у вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать.</string>
|
||||
<string name="network_session_mode_user_description">Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться <b>для каждого профиля чата, который вы имеете в приложении</b>.</string>
|
||||
\n<b>Обратите внимание</b>: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать.</string>
|
||||
<string name="network_session_mode_user_description">Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться <b>для каждого профиля чата, который Вы имеете в приложении</b>.</string>
|
||||
<string name="core_build_timestamp">Ядро скомпилировано: %s</string>
|
||||
<string name="core_version">Версия ядра: v%s</string>
|
||||
<string name="users_delete_question">Удалить профиль чата\?</string>
|
||||
<string name="users_delete_profile_for">Удалить профиль чата для</string>
|
||||
<string name="messages_section_description">Эта настройка применяется к сообщениям в вашем текущем профиле чата</string>
|
||||
<string name="messages_section_description">Эта настройка применяется к сообщениям в Вашем текущем профиле чата</string>
|
||||
<string name="network_session_mode_transport_isolation">Отдельные сессии для</string>
|
||||
<string name="update_network_session_mode_question">Обновить режим отдельных сессий\?</string>
|
||||
<string name="failed_to_create_user_duplicate_title">Имя профиля уже используется</string>
|
||||
<string name="failed_to_create_user_title">Ошибка создания профиля!</string>
|
||||
<string name="failed_to_create_user_duplicate_desc">У вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя.</string>
|
||||
<string name="failed_to_create_user_duplicate_desc">У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя.</string>
|
||||
<string name="failed_to_active_user_title">Ошибка выбора профиля!</string>
|
||||
<string name="v4_5_transport_isolation_descr">По профилю чата или по соединению (БЕТА)</string>
|
||||
<string name="v4_4_french_interface_descr">Благодаря пользователям – добавьте переводы через Weblate!</string>
|
||||
@@ -1035,7 +1045,7 @@
|
||||
<string name="v4_5_message_draft_descr">Сохранить последний черновик, вместе с вложениями.</string>
|
||||
<string name="v4_5_private_filenames">Защищенные имена файлов</string>
|
||||
<string name="v4_5_italian_interface_descr">Благодаря пользователям – добавьте переводы через Weblate!</string>
|
||||
<string name="v4_5_private_filenames_descr">Чтобы защитить ваш часовой пояс, файлы картинок и голосовых сообщений используют UTC.</string>
|
||||
<string name="v4_5_private_filenames_descr">Чтобы защитить Ваш часовой пояс, файлы картинок и голосовых сообщений используют UTC.</string>
|
||||
<string name="v4_4_french_interface">Французский интерфейс</string>
|
||||
<string name="v4_5_reduced_battery_usage_descr">Дополнительные улучшения скоро!</string>
|
||||
<string name="v4_5_reduced_battery_usage">Уменьшенное потребление батареи</string>
|
||||
@@ -1052,4 +1062,83 @@
|
||||
<string name="group_member_role_observer">читатель</string>
|
||||
<string name="initial_member_role">Роль при вступлении</string>
|
||||
<string name="error_updating_link_for_group">Ошибка обновления ссылки группы</string>
|
||||
<string name="language_system">Системный</string>
|
||||
<string name="v4_6_audio_video_calls">Аудио и видео звонки</string>
|
||||
<string name="error_saving_user_password">Ошибка при сохранении пароля пользователя</string>
|
||||
<string name="smp_save_servers_question">Сохранить серверы\?</string>
|
||||
<string name="should_be_at_least_one_profile">Должен быть хотя бы один профиль пользователя.</string>
|
||||
<string name="should_be_at_least_one_visible_profile">Должен быть хотя бы один открытый профиль пользователя.</string>
|
||||
<string name="to_reveal_profile_enter_password">Чтобы показать Ваш скрытый профиль, введите пароль в поле поиска на странице Ваши профили чата.</string>
|
||||
<string name="user_unmute">Уведомлять</string>
|
||||
<string name="group_welcome_title">Приветственное сообщение</string>
|
||||
<string name="confirm_password">Подтвердить пароль</string>
|
||||
<string name="button_add_welcome_message">Добавить приветственное сообщение</string>
|
||||
<string name="button_welcome_message">Приветственное сообщение</string>
|
||||
<string name="save_and_update_group_profile">Сохранить сообщение и обновить группу</string>
|
||||
<string name="muted_when_inactive">Без звука, когда не активный!</string>
|
||||
<string name="cant_delete_user_profile">Нельзя удалить профиль пользователя!</string>
|
||||
<string name="enter_password_to_show">Введите пароль в поиске!</string>
|
||||
<string name="save_profile_password">Сохранить пароль профиля</string>
|
||||
<string name="v4_6_chinese_spanish_interface">Китайский и Испанский интерфейс</string>
|
||||
<string name="dont_show_again">Не показывать</string>
|
||||
<string name="user_hide">Скрыть</string>
|
||||
<string name="v4_6_reduced_battery_usage">Уменьшенное потребление батареи</string>
|
||||
<string name="v4_6_group_moderation">Модерация группы</string>
|
||||
<string name="hidden_profile_password">Пароль скрытого профиля</string>
|
||||
<string name="password_to_show">Пароль чтобы раскрыть</string>
|
||||
<string name="v4_6_hidden_chat_profiles">Скрытые профили чата</string>
|
||||
<string name="error_updating_user_privacy">Ошибка при обновлении конфиденциальности</string>
|
||||
<string name="v4_6_group_welcome_message">Приветственное сообщение группы</string>
|
||||
<string name="hide_profile">Скрыть профиль</string>
|
||||
<string name="user_mute">Без звука</string>
|
||||
<string name="make_profile_private">Сделайте профиль конфиденциальным!</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">Дополнительные улучшения скоро!</string>
|
||||
<string name="v4_6_group_moderation_descr">Теперь админы могут:
|
||||
\n- удалять сообщения членов.
|
||||
\n- приостанавливать членов (роль \"наблюдатель\")</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">Защитите Ваши профили чата паролем!</string>
|
||||
<string name="user_unhide">Раскрыть</string>
|
||||
<string name="v4_6_audio_video_calls_descr">Поддержка bluetooth и другие улучшения.</string>
|
||||
<string name="save_welcome_message_question">Сохранить приветственное сообщение\?</string>
|
||||
<string name="v4_6_group_welcome_message_descr">Установить сообщение для новых членов группы!</string>
|
||||
<string name="tap_to_activate_profile">Нажмите, чтобы сделать профиль активным.</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">Благодаря пользователям – добавьте переводы через Weblate!</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">Вы все равно получите звонки и уведомления в профилях без звука, когда они активные.</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">Вы можете скрыть профиль или выключить уведомления - подержите, чтобы увидеть меню.</string>
|
||||
<string name="settings_send_files_via_xftp">Отправлять видео и файлы через XFTP</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">Изображение будет принято когда Ваш контакт его загрузит.</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">Файл будет принят когда Ваш контакт загрузит его.</string>
|
||||
<string name="database_upgrade">Обновление базы данных</string>
|
||||
<string name="confirm_database_upgrades">Подтвердить обновление базы данных</string>
|
||||
<string name="database_downgrade">Откат базы данных</string>
|
||||
<string name="incompatible_database_version">Несовместимая версия базы данных</string>
|
||||
<string name="invalid_migration_confirmation">Ошибка подтверждения миграции</string>
|
||||
<string name="upgrade_and_open_chat">Обновить и открыть чат</string>
|
||||
<string name="show_dev_options">Показать:</string>
|
||||
<string name="database_migrations">Миграции: %s</string>
|
||||
<string name="mtr_error_no_down_migration">версия базы данных новее чем приложения, но нет миграции для отката: %s</string>
|
||||
<string name="mtr_error_different">разная миграция в приложении/базе данных: %s / %s</string>
|
||||
<string name="downgrade_and_open_chat">Откатить версию и открыть чат</string>
|
||||
<string name="database_downgrade_warning">Предупреждение: Вы можете потерять какие то данные!</string>
|
||||
<string name="cancel_file__question">Прекратить передачу файла\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">Передача файла будет прекращена. Если она в процессе, она будет остановлена.</string>
|
||||
<string name="developer_options">ID базы данных и опция Отдельные транспортные сессии.</string>
|
||||
<string name="show_developer_options">Показать опции для девелоперов</string>
|
||||
<string name="delete_chat_profile">Удалить профиль чата</string>
|
||||
<string name="delete_profile">Удалить профиль</string>
|
||||
<string name="profile_password">Пароль профиля</string>
|
||||
<string name="xftp_requires_v461">v4.6.1+ необходима для приема файлов через XFTP.</string>
|
||||
<string name="videos_limit_title">Слишком много видео!</string>
|
||||
<string name="icon_descr_video_asked_to_receive">Запросил прием видео</string>
|
||||
<string name="video_descr">Видео</string>
|
||||
<string name="icon_descr_video_snd_complete">Видео отправлено</string>
|
||||
<string name="icon_descr_waiting_for_video">Ожидание видео</string>
|
||||
<string name="waiting_for_video">Ожидание видео</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">Видео будет получено когда Ваш контакт загрузит его.</string>
|
||||
<string name="hide_dev_options">Скрыть:</string>
|
||||
<string name="settings_section_title_experimenta">ЭКСПЕРИМЕНТАЛЬНЫЕ</string>
|
||||
<string name="videos_limit_desc">Только 10 видео могут быть отправлены одновременно</string>
|
||||
<string name="unhide_profile">Раскрыть профиль</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="unhide_chat_profile">Раскрыть профиль чата</string>
|
||||
</resources>
|
||||
@@ -112,7 +112,7 @@
|
||||
<string name="integrity_msg_bad_hash">错误消息散列</string>
|
||||
<string name="integrity_msg_bad_id">错误消息 ID</string>
|
||||
<string name="settings_audio_video_calls">语音和视频通话</string>
|
||||
<string name="accept_automatically">自动地</string>
|
||||
<string name="accept_automatically">自动</string>
|
||||
<string name="turning_off_service_and_periodic">激活电池优化,关闭了后台服务和新消息的定期请求。您可以通过设置重新启用它们。</string>
|
||||
<string name="notifications_mode_service_desc">后台服务一直在运行——一旦有消息,就会显示通知。</string>
|
||||
<string name="icon_descr_audio_off">关闭音频</string>
|
||||
@@ -132,7 +132,7 @@
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b> 可以通过设置禁用它 </b> - 应用程序运行时仍会显示通知。</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b> 使用更多电池 </b>!后台服务一直在运行——一旦收到消息,就会显示通知。</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>请注意</b>:如果您丢失密码,您将无法恢复或者更改密码。</string>
|
||||
<string name="call_already_ended">通话已经结束!</string>
|
||||
<string name="call_already_ended">通话已结束!</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>扫描二维码</b> :与向您展示二维码的联系人联系。</string>
|
||||
<string name="alert_title_cant_invite_contacts">无法邀请联系人!</string>
|
||||
<string name="invite_prohibited">无法邀请联系人!</string>
|
||||
@@ -234,7 +234,7 @@
|
||||
<string name="clear_chat_question">清除聊天记录?</string>
|
||||
<string name="chat_with_developers">与开发者聊天</string>
|
||||
<string name="clear_contacts_selection_button">清除</string>
|
||||
<string name="colored">有色</string>
|
||||
<string name="colored">彩色</string>
|
||||
<string name="callstate_connected">已连接</string>
|
||||
<string name="connect_button">连接</string>
|
||||
<string name="connect_via_link_verb">连接</string>
|
||||
@@ -266,7 +266,7 @@
|
||||
<string name="error_deleting_pending_contact_connection">删除待定的联系人连接错误</string>
|
||||
<string name="error_receiving_file">接收文件错误</string>
|
||||
<string name="failed_to_active_user_title">切换资料错误!</string>
|
||||
<string name="notification_preview_mode_hidden">已隐藏</string>
|
||||
<string name="notification_preview_mode_hidden">隐藏</string>
|
||||
<string name="edit_verb">编辑</string>
|
||||
<string name="notification_display_mode_hidden_desc">隐藏联系人和消息</string>
|
||||
<string name="icon_descr_edited">已编辑</string>
|
||||
@@ -510,7 +510,7 @@
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">只有客户端设备存储用户配置文件、联系人、群组和使用 <b>双层端到端加密 </b> 发送的消息。</string>
|
||||
<string name="video_call_no_encryption">视频通话(非端到端加密)</string>
|
||||
<string name="onboarding_notifications_mode_periodic">定期</string>
|
||||
<string name="onboarding_notifications_mode_title">私人通知</string>
|
||||
<string name="onboarding_notifications_mode_title">私密通知</string>
|
||||
<string name="onboarding_notifications_mode_off">应用程序运行时</string>
|
||||
<string name="status_no_e2e_encryption">无端到端加密</string>
|
||||
<string name="show_call_on_lock_screen">显示</string>
|
||||
@@ -592,7 +592,6 @@
|
||||
<string name="your_chat_profiles">您的聊天资料</string>
|
||||
<string name="icon_descr_call_missed">未接来电</string>
|
||||
<string name="icon_descr_call_pending_sent">待定来电</string>
|
||||
<string name="your_chat_profiles_stored_locally">您的聊天资料存储在本地,仅存储在您的设备上</string>
|
||||
<string name="connection_error_auth_desc">除非您的联系人已删除此连接或此链接已被使用,否则它可能是一个错误——请报告。
|
||||
\n如果要连接,请让您的联系人创建另一个连接链接,并检查您的网络连接是否稳定。</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">您已经连接到 <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>。</string>
|
||||
@@ -628,7 +627,7 @@
|
||||
\n<xliff:g id="appName">SimpleX</xliff:g> 服务器无法看见您的资料。</string>
|
||||
<string name="your_profile_is_stored_on_your_device">您的资料、联系人和发送的消息存储在您的设备上。</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">该资料仅与您的联系人共享。</string>
|
||||
<string name="chat_preferences_on">在</string>
|
||||
<string name="chat_preferences_on">开启</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。</string>
|
||||
<string name="messages_section_description">此设置适用于您当前聊天资料中的消息</string>
|
||||
<string name="database_restore_error">恢复数据库错误</string>
|
||||
@@ -715,7 +714,7 @@
|
||||
<string name="is_verified">%s 已验证</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">用于新连接</string>
|
||||
<string name="network_disable_socks">使用直接互联网连接?</string>
|
||||
<string name="network_use_onion_hosts_required">必要</string>
|
||||
<string name="network_use_onion_hosts_required">必须</string>
|
||||
<string name="save_and_notify_contact">保存并通知联系人</string>
|
||||
<string name="save_and_notify_contacts">保存并通知联系人</string>
|
||||
<string name="read_more_in_github">在我们的 GitHub 仓库中阅读更多内容。</string>
|
||||
@@ -843,7 +842,7 @@
|
||||
<string name="group_invitation_tap_to_join">点击加入</string>
|
||||
<string name="stop_chat_confirmation">停止</string>
|
||||
<string name="restart_the_app_to_use_imported_chat_database">重新启动应用程序以使用导入的聊天数据库。</string>
|
||||
<string name="group_member_role_owner">所有者</string>
|
||||
<string name="group_member_role_owner">群主</string>
|
||||
<string name="group_member_status_removed">已删除</string>
|
||||
<string name="role_in_group">角色</string>
|
||||
<string name="network_option_seconds_label">秒</string>
|
||||
@@ -956,7 +955,7 @@
|
||||
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">你加入了这个群组。连接到邀请组成员。</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">您更改了 %s 的地址</string>
|
||||
<string name="snd_group_event_user_left">您已离开</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="connection ID" example="1">%1$d</xliff:g> 已选择联系人</string>
|
||||
<string name="num_contacts_selected">%d 已选择联系人</string>
|
||||
<string name="chat_preferences_you_allow">您允许</string>
|
||||
<string name="v4_2_auto_accept_contact_requests_desc">带有可选的欢迎消息。</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
@@ -972,7 +971,7 @@
|
||||
<string name="moderated_item_description">由 %s 审核</string>
|
||||
<string name="moderate_verb">管理员移除</string>
|
||||
<string name="moderate_message_will_be_deleted_warning">将为所有成员删除该消息。</string>
|
||||
<string name="moderate_message_will_be_marked_warning">该消息将对所有成员显示为已审核。</string>
|
||||
<string name="moderate_message_will_be_marked_warning">该消息将对所有成员标记为已被管理员移除。</string>
|
||||
<string name="delete_member_message__question">删除成员消息?</string>
|
||||
<string name="group_member_role_observer">观察者</string>
|
||||
<string name="you_are_observer">您是观察者</string>
|
||||
@@ -981,4 +980,82 @@
|
||||
<string name="initial_member_role">初始角色</string>
|
||||
<string name="observer_cant_send_message_desc">请联系群组管理员。</string>
|
||||
<string name="language_system">系统</string>
|
||||
<string name="password_to_show">用于显示的密码</string>
|
||||
<string name="save_profile_password">保存个人资料密码</string>
|
||||
<string name="button_add_welcome_message">添加欢迎消息</string>
|
||||
<string name="user_hide">隐藏</string>
|
||||
<string name="make_profile_private">将个人资料设置为私密!</string>
|
||||
<string name="user_mute">静音</string>
|
||||
<string name="save_and_update_group_profile">保存并更新组配置文件</string>
|
||||
<string name="dont_show_again">不再显示</string>
|
||||
<string name="muted_when_inactive">不活跃时静音!</string>
|
||||
<string name="v4_6_audio_video_calls">语音和视频通话</string>
|
||||
<string name="v4_6_chinese_spanish_interface">中文和西班牙文界面</string>
|
||||
<string name="v4_6_reduced_battery_usage">进一步减少电池使用</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">更多改进即将推出!</string>
|
||||
<string name="v4_6_group_moderation_descr">现在管理员可以:
|
||||
\n- 删除成员的消息。
|
||||
\n- 禁用成员(“观察员”角色)</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">使用密码保护您的聊天资料!</string>
|
||||
<string name="confirm_password">确认密码</string>
|
||||
<string name="error_updating_user_privacy">更新用户隐私错误</string>
|
||||
<string name="cant_delete_user_profile">无法删除用户资料!</string>
|
||||
<string name="error_saving_user_password">保存用户密码错误</string>
|
||||
<string name="enter_password_to_show">在搜索中输入密码</string>
|
||||
<string name="v4_6_group_welcome_message">群组欢迎消息</string>
|
||||
<string name="v4_6_group_moderation">群组管理员移除</string>
|
||||
<string name="hidden_profile_password">隐藏的个人资料密码</string>
|
||||
<string name="v4_6_hidden_chat_profiles">隐藏的聊天资料</string>
|
||||
<string name="hide_profile">隐藏个人资料</string>
|
||||
<string name="smp_save_servers_question">保存服务器?</string>
|
||||
<string name="to_reveal_profile_enter_password">要显示您的隐藏的个人资料,请在您的聊天个人资料页面的搜索字段中输入完整密码。</string>
|
||||
<string name="save_welcome_message_question">保存欢迎信息?</string>
|
||||
<string name="tap_to_activate_profile">点击以激活个人资料。</string>
|
||||
<string name="should_be_at_least_one_profile">应该至少有一个用户资料。</string>
|
||||
<string name="user_unhide">取消隐藏</string>
|
||||
<string name="v4_6_group_welcome_message_descr">设置向新成员显示的消息!</string>
|
||||
<string name="v4_6_audio_video_calls_descr">支持蓝牙和其他改进。</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">感谢用户——通过 Weblate 做出贡献!</string>
|
||||
<string name="should_be_at_least_one_visible_profile">应该至少有一个可见的用户资料。</string>
|
||||
<string name="user_unmute">解除静音</string>
|
||||
<string name="button_welcome_message">欢迎信息</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">您可以隐藏或静音用户配置文件——长按以显示菜单。</string>
|
||||
<string name="group_welcome_title">欢迎信息</string>
|
||||
<string name="settings_send_files_via_xftp">通过 XFTP 发送视频和文件</string>
|
||||
<string name="confirm_database_upgrades">确认数据库升级</string>
|
||||
<string name="settings_section_title_experimenta">实验性</string>
|
||||
<string name="database_upgrade">数据库升级</string>
|
||||
<string name="mtr_error_different">应用程序/数据库中的不同迁移:%s / %s</string>
|
||||
<string name="developer_options">数据库 ID 和传输隔离选项。</string>
|
||||
<string name="database_downgrade">数据库降级</string>
|
||||
<string name="mtr_error_no_down_migration">数据库版本比应用程序更新,但无法降级迁移:%s</string>
|
||||
<string name="downgrade_and_open_chat">降级并打开聊天</string>
|
||||
<string name="hide_dev_options">隐藏:</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">文件将在您的联系人完成上传后收到。</string>
|
||||
<string name="incompatible_database_version">数据库版本不兼容</string>
|
||||
<string name="database_migrations">迁移:%s</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">图片将在您的联系人完成上传后收到。</string>
|
||||
<string name="show_developer_options">显示开发者选项</string>
|
||||
<string name="xftp_requires_v461">通过 XFTP 接收需要 v4.6.1 以上版本。</string>
|
||||
<string name="upgrade_and_open_chat">升级并打开聊天</string>
|
||||
<string name="database_downgrade_warning">警告:您可能会丢失部分数据!</string>
|
||||
<string name="invalid_migration_confirmation">迁移确认无效</string>
|
||||
<string name="show_dev_options">显示:</string>
|
||||
<string name="delete_profile">删除个人资料</string>
|
||||
<string name="profile_password">个人资料密码</string>
|
||||
<string name="unhide_chat_profile">取消隐藏聊天资料</string>
|
||||
<string name="cancel_file__question">取消文件传输?</string>
|
||||
<string name="delete_chat_profile">删除聊天资料</string>
|
||||
<string name="unhide_profile">取消隐藏个人资料</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">文件传输将被取消。文件传输将被终止如果它正在进行中。</string>
|
||||
<string name="videos_limit_desc">同一时间只能发送10个视频</string>
|
||||
<string name="videos_limit_title">过多视频!</string>
|
||||
<string name="video_descr">视频</string>
|
||||
<string name="icon_descr_waiting_for_video">等待视频中</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">视频将在您的联系人在线时收到,请稍等或稍后查看!</string>
|
||||
<string name="waiting_for_video">等待视频中</string>
|
||||
<string name="icon_descr_video_snd_complete">视频已发送</string>
|
||||
<string name="icon_descr_video_asked_to_receive">要求接收视频</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">视频将在您的联系人完成上传后收到。</string>
|
||||
</resources>
|
||||
@@ -765,7 +765,6 @@
|
||||
<string name="network_options_save">儲存</string>
|
||||
<string name="update_network_settings_question">更新網路設定?</string>
|
||||
<string name="update_network_settings_confirmation">更新</string>
|
||||
<string name="your_chat_profiles_stored_locally">你的個人檔案只會儲存於你的本機裝置內。</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">更新設定會將客戶端重新連接到所有的伺服器。</string>
|
||||
<string name="users_delete_question">刪除個人檔案?</string>
|
||||
<string name="users_delete_profile_for">刪除個人檔案</string>
|
||||
@@ -927,7 +926,7 @@
|
||||
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">你將停止接收來自此群組的訊息。群組內的記錄會保留。</string>
|
||||
<string name="you_rejected_group_invitation">你已拒絕加入群組</string>
|
||||
<string name="group_member_status_announced">連線中(宣布階段)</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> 已選擇多個聊絡人</string>
|
||||
<string name="num_contacts_selected">%d 已選擇多個聊絡人</string>
|
||||
<string name="your_ice_servers">你的 ICE 伺服器</string>
|
||||
<string name="webrtc_ice_servers">WebRTC ICE 伺服器</string>
|
||||
<string name="update_database">更新</string>
|
||||
@@ -983,4 +982,49 @@
|
||||
<string name="observer_cant_send_message_desc">請聯繫群管理員。</string>
|
||||
<string name="initial_member_role">初始角色</string>
|
||||
<string name="language_system">系統</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">您可以隱藏或靜音用戶配置文件 - 按住它以顯示菜單。</string>
|
||||
<string name="smp_save_servers_question">保存服務器?</string>
|
||||
<string name="confirm_password">確認密碼</string>
|
||||
<string name="hidden_profile_password">隱藏的個人資料密碼</string>
|
||||
<string name="hide_profile">隱藏個人資料</string>
|
||||
<string name="password_to_show">顯示密碼</string>
|
||||
<string name="save_profile_password">保存個人資料密碼</string>
|
||||
<string name="to_reveal_profile_enter_password">要顯示您的隱藏個人資料,請在您的聊天個人資料頁面的搜索字段中輸入完整密碼。</string>
|
||||
<string name="button_welcome_message">歡迎信息</string>
|
||||
<string name="save_and_update_group_profile">保存和更新組配置文件</string>
|
||||
<string name="save_welcome_message_question">保存歡迎信息?</string>
|
||||
<string name="cant_delete_user_profile">無法刪除用戶個人資料!</string>
|
||||
<string name="user_hide">隱藏</string>
|
||||
<string name="make_profile_private">將個人資料設為私密!</string>
|
||||
<string name="v4_6_audio_video_calls">音視頻通話</string>
|
||||
<string name="v4_6_chinese_spanish_interface">中文和西班牙文界面</string>
|
||||
<string name="v4_6_reduced_battery_usage">進一步減少電池使用</string>
|
||||
<string name="v4_6_group_moderation">小組審核</string>
|
||||
<string name="v4_6_reduced_battery_usage_descr">更多改進即將推出!</string>
|
||||
<string name="v4_6_group_moderation_descr">現在管理員可以:
|
||||
\n- 刪除成員的消息。
|
||||
\n- 禁用成員(“觀察員”角色)</string>
|
||||
<string name="v4_6_hidden_chat_profiles_descr">使用密碼保護您的聊天資料!</string>
|
||||
<string name="relay_server_protects_ip">中繼服務器保護您的 IP 地址,但它可以觀察通話的持續時間。</string>
|
||||
<string name="button_add_welcome_message">添加歡迎信息</string>
|
||||
<string name="error_saving_user_password">保存用戶密碼時出錯</string>
|
||||
<string name="error_updating_user_privacy">更新用戶隱私時出錯</string>
|
||||
<string name="relay_server_if_necessary">中繼服務器僅在必要時使用。 另一方可以觀察到您的 IP 地址。</string>
|
||||
<string name="enter_password_to_show">在上面輸入密碼以顯示!</string>
|
||||
<string name="v4_6_group_welcome_message">群组欢迎信息</string>
|
||||
<string name="v4_6_hidden_chat_profiles">隱藏的聊天資料</string>
|
||||
<string name="dont_show_again">不再顯示</string>
|
||||
<string name="user_mute">靜音</string>
|
||||
<string name="muted_when_inactive">Muted when inactive!</string>
|
||||
<string name="v4_6_group_welcome_message_descr">設置向新成員顯示的消息!</string>
|
||||
<string name="tap_to_activate_profile">點擊以激活配置文件。</string>
|
||||
<string name="v4_6_audio_video_calls_descr">支持藍牙和其他改進。</string>
|
||||
<string name="should_be_at_least_one_visible_profile">應該至少有一個可見的用戶配置文件。</string>
|
||||
<string name="group_welcome_title">歡迎信息</string>
|
||||
<string name="v4_6_chinese_spanish_interface_descr">感謝用戶——通過 Weblate 做出貢獻!</string>
|
||||
<string name="should_be_at_least_one_profile">應該至少有一個用戶配置文件。</string>
|
||||
<string name="user_unmute">取消靜音</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">當靜音配置文件處於活動狀態時,您仍會收到來自靜音配置文件的電話和通知。</string>
|
||||
<string name="user_unhide">取消隱藏</string>
|
||||
<string name="settings_send_files_via_xftp">通過 XFTP 傳送文件</string>
|
||||
</resources>
|
||||
@@ -2,5 +2,6 @@
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="highOrLowLight">#8b8786</color>
|
||||
<color name="window_background_dark">#121212</color>
|
||||
</resources>
|
||||
@@ -191,6 +191,8 @@
|
||||
<string name="delete_member_message__question">Delete member message?</string>
|
||||
<string name="moderate_message_will_be_deleted_warning">The message will be deleted for all members.</string>
|
||||
<string name="moderate_message_will_be_marked_warning">The message will be marked as moderated for all members.</string>
|
||||
<string name="cancel_file__question">Cancel file transfer?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">File transfer will be cancelled. If it\'s in progress it will be stoppped.</string>
|
||||
<string name="for_me_only">Delete for me</string>
|
||||
<string name="for_everybody">For everyone</string>
|
||||
|
||||
@@ -225,7 +227,9 @@
|
||||
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
|
||||
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
|
||||
<string name="images_limit_title">Too many images!</string>
|
||||
<string name="videos_limit_title">Too many videos!</string>
|
||||
<string name="images_limit_desc">Only 10 images can be sent at the same time</string>
|
||||
<string name="videos_limit_desc">Only 10 videos can be sent at the same time</string>
|
||||
<string name="image_decoding_exception_title">Decoding error</string>
|
||||
<string name="image_decoding_exception_desc">The image cannot be decoded. Please, try a different image or contact developers.</string>
|
||||
<string name="you_are_observer">you are observer</string>
|
||||
@@ -238,15 +242,26 @@
|
||||
<string name="icon_descr_asked_to_receive">Asked to receive the image</string>
|
||||
<string name="icon_descr_image_snd_complete">Image sent</string>
|
||||
<string name="waiting_for_image">Waiting for image</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">Image will be received when your contact completes uploading it.</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Image will be received when your contact is online, please wait or check later!</string>
|
||||
<string name="image_saved">Image saved to Gallery</string>
|
||||
|
||||
<!-- Videos - chat.simplex.app.views.chat.item.CIVideoView.kt -->
|
||||
<string name="video_descr">Video</string>
|
||||
<string name="icon_descr_waiting_for_video">Waiting for video</string>
|
||||
<string name="icon_descr_video_asked_to_receive">Asked to receive the video</string>
|
||||
<string name="icon_descr_video_snd_complete">Video sent</string>
|
||||
<string name="waiting_for_video">Waiting for video</string>
|
||||
<string name="video_will_be_received_when_contact_completes_uploading">Video will be received when your contact completes uploading it.</string>
|
||||
<string name="video_will_be_received_when_contact_is_online">Video will be received when your contact is online, please wait or check later!</string>
|
||||
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">File</string>
|
||||
<string name="large_file">Large file!</string>
|
||||
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="maximum_supported_file_size">Currently maximum supported file size is <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="waiting_for_file">Waiting for file</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">File will be received when your contact completes uploading it.</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">File will be received when your contact is online, please wait or check later!</string>
|
||||
<string name="file_saved">File saved</string>
|
||||
<string name="file_not_found">File not found</string>
|
||||
@@ -509,6 +524,10 @@
|
||||
<string name="core_version">Core version: v%s</string>
|
||||
<string name="core_build_timestamp">Core built at: %s</string>
|
||||
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
|
||||
<string name="show_dev_options">Show:</string>
|
||||
<string name="hide_dev_options">Hide:</string>
|
||||
<string name="show_developer_options">Show developer options</string>
|
||||
<string name="developer_options">Database IDs and Transport isolation option.</string>
|
||||
|
||||
<!-- Address Items - UserAddressView.kt -->
|
||||
<string name="create_address">Create address</string>
|
||||
@@ -716,6 +735,9 @@
|
||||
<string name="settings_section_title_messages">MESSAGES</string>
|
||||
<string name="settings_section_title_calls">CALLS</string>
|
||||
<string name="settings_section_title_incognito">Incognito mode</string>
|
||||
<string name="settings_section_title_experimenta">EXPERIMENTAL</string>
|
||||
<string name="settings_send_files_via_xftp">Send videos and files via XFTP</string>
|
||||
<string name="xftp_requires_v461">v4.6.1+ is required to receive via XFTP.</string>
|
||||
|
||||
<!-- DatabaseView.kt -->
|
||||
<string name="your_chat_database">Your chat database</string>
|
||||
@@ -823,6 +845,17 @@
|
||||
<string name="restore_database_alert_confirm">Restore</string>
|
||||
<string name="database_restore_error">Restore database error</string>
|
||||
<string name="restore_passphrase_not_found_desc">Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
|
||||
<string name="database_upgrade">Database upgrade</string>
|
||||
<string name="database_downgrade">Database downgrade</string>
|
||||
<string name="incompatible_database_version">Incompatible database version</string>
|
||||
<string name="confirm_database_upgrades">Confirm database upgrades</string>
|
||||
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
|
||||
<string name="upgrade_and_open_chat">Upgrade and open chat</string>
|
||||
<string name="downgrade_and_open_chat">Downgrade and open chat</string>
|
||||
<string name="mtr_error_no_down_migration">database version is newer than the app, but no down migration for: %s</string>
|
||||
<string name="mtr_error_different">different migration in the app/database: %s / %s</string>
|
||||
<string name="database_migrations">Migrations: %s</string>
|
||||
<string name="database_downgrade_warning">Warning: you may lose some data!</string>
|
||||
|
||||
<!-- ChatModel.chatRunning interactions -->
|
||||
<string name="chat_is_stopped_indication">Chat is stopped</string>
|
||||
@@ -921,7 +954,7 @@
|
||||
<string name="select_contacts">Select contacts</string>
|
||||
<string name="icon_descr_contact_checked">Contact checked</string>
|
||||
<string name="clear_contacts_selection_button">Clear</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
|
||||
<string name="num_contacts_selected">%d contact(s) selected</string>
|
||||
<string name="no_contacts_selected">No contacts selected</string>
|
||||
<string name="invite_prohibited">Can\'t invite contact!</string>
|
||||
<string name="invite_prohibited_description">You\'re trying to invite contact with whom you\'ve shared an incognito profile to the group in which you\'re using your main profile</string>
|
||||
@@ -1016,7 +1049,6 @@
|
||||
<string name="update_network_settings_confirmation">Update</string>
|
||||
|
||||
<!-- UserProfilesView.kt -->
|
||||
<string name="your_chat_profiles_stored_locally">Your chat profiles are stored locally, only on your device</string>
|
||||
<string name="users_add">Add profile</string>
|
||||
<string name="users_delete_question">Delete chat profile?</string>
|
||||
<string name="users_delete_all_chats_deleted">All chats and messages will be deleted - this cannot be undone!</string>
|
||||
@@ -1027,16 +1059,21 @@
|
||||
<string name="user_unhide">Unhide</string>
|
||||
<string name="user_mute">Mute</string>
|
||||
<string name="user_unmute">Unmute</string>
|
||||
<string name="enter_password_to_show">Enter password above to show!</string>
|
||||
<string name="enter_password_to_show">Enter password in search</string>
|
||||
<string name="tap_to_activate_profile">Tap to activate profile.</string>
|
||||
<string name="cant_delete_user_profile">Can\'t delete user profile!</string>
|
||||
<string name="should_be_at_least_one_visible_profile">There should be at least one visible user profile.</string>
|
||||
<string name="should_be_at_least_one_profile">There should be at least one user profile.</string>
|
||||
<string name="make_profile_private">Make profile private!</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">You can hide or mute a user profile - hold it for the menu.\nSimpleX Lock must be enabled.</string>
|
||||
<string name="you_can_hide_or_mute_user_profile">You can hide or mute a user profile - hold it for the menu.</string>
|
||||
<string name="dont_show_again">Don\'t show again</string>
|
||||
<string name="muted_when_inactive">Muted when inactive!</string>
|
||||
<string name="you_will_still_receive_calls_and_ntfs">You will still receive calls and notifications from muted profiles when they are active.</string>
|
||||
<string name="delete_profile">Delete profile</string>
|
||||
<string name="delete_chat_profile">Delete chat profile</string>
|
||||
<string name="unhide_profile">Unhide profile</string>
|
||||
<string name="unhide_chat_profile">Unhide chat profile</string>
|
||||
<string name="profile_password">Profile password</string>
|
||||
|
||||
<!-- Incognito mode -->
|
||||
<string name="incognito">Incognito</string>
|
||||
|
||||
@@ -8,6 +8,7 @@ buildscript {
|
||||
compose_version = localProperties['compose_version'] ?: '1.2.0-beta02'
|
||||
kotlin_version = localProperties['kotlin_version'] ?: '1.6.21'
|
||||
gradle_plugin_version = localProperties['gradle_plugin_version'] ?: '7.2.0'
|
||||
abi_filter = localProperties['abi_filter'] ?: 'arm64-v8a'
|
||||
|
||||
// Name that will be shown for debug build. By default it is from strings
|
||||
app_name = localProperties['app_name'] ?: "@string/app_name"
|
||||
|
||||
@@ -40,7 +40,8 @@ class AudioRecorder {
|
||||
AVEncoderBitRateKey: 12000,
|
||||
AVNumberOfChannelsKey: 1
|
||||
]
|
||||
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
|
||||
let url = getAppFilePath(fileName)
|
||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||
|
||||
await MainActor.run {
|
||||
@@ -102,7 +103,8 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
}
|
||||
|
||||
func start(fileName: String) {
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
|
||||
let url = getAppFilePath(fileName)
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
audioPlayer?.play()
|
||||
|
||||
@@ -33,8 +33,6 @@ final class ChatModel: ObservableObject {
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: UserContactLink?
|
||||
@Published var userSMPServers: [ServerCfg]?
|
||||
@Published var presetSMPServers: [String]?
|
||||
@Published var chatItemTTL: ChatItemTTL = .none
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var deviceToken: DeviceToken?
|
||||
@@ -55,13 +53,13 @@ final class ChatModel: ObservableObject {
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
var filesToDelete: [String] = []
|
||||
var filesToDelete: Set<URL> = []
|
||||
|
||||
static let shared = ChatModel()
|
||||
|
||||
@@ -381,7 +379,7 @@ final class ChatModel: ObservableObject {
|
||||
markChatItemsRead(cInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
|
||||
_updateChat(cInfo.id) { chat in
|
||||
chat.chatStats.unreadChat = unreadChat
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
@@ -42,6 +43,17 @@ func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if loadedFilePath != nil, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
if FileManager.default.fileExists(atPath: filePath.path) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
@@ -164,6 +176,20 @@ func saveFileFromURL(_ url: URL) -> String? {
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
do {
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
savedFile = fileName
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
||||
}
|
||||
@@ -204,6 +230,18 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
|
||||
extension AVAsset {
|
||||
func generatePreview() -> (UIImage, Int)? {
|
||||
let generator = AVAssetImageGenerator(asset: self)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
var actualTime = CMTimeMake(value: 0, timescale: 0)
|
||||
if let image = try? generator.copyCGImage(at: CMTimeMakeWithSeconds(0.0, preferredTimescale: 1), actualTime: &actualTime) {
|
||||
return (UIImage(cgImage: image), Int(duration.seconds))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
||||
if let cgImage = cgImage {
|
||||
|
||||
@@ -162,21 +162,21 @@ func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func apiUnhideUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
func apiUnhideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiUnhideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func apiMuteUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
try await setUserPrivacy_(.apiMuteUser(userId: userId, viewPwd: viewPwd))
|
||||
func apiMuteUser(_ userId: Int64) async throws -> User {
|
||||
try await setUserPrivacy_(.apiMuteUser(userId: userId))
|
||||
}
|
||||
|
||||
func apiUnmuteUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
try await setUserPrivacy_(.apiUnmuteUser(userId: userId, viewPwd: viewPwd))
|
||||
func apiUnmuteUser(_ userId: Int64) async throws -> User {
|
||||
try await setUserPrivacy_(.apiUnmuteUser(userId: userId))
|
||||
}
|
||||
|
||||
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
|
||||
let r = await chatSendCmd(cmd)
|
||||
if case let .userPrivacy(user) = r { return user }
|
||||
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -215,12 +215,24 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||
}
|
||||
|
||||
func apiSetTempFolder(tempFolder: String) throws {
|
||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetFilesFolder(filesFolder: String) throws {
|
||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
let r = chatSendCmdSync(.apiSetXFTPConfig(config: cfg))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetIncognito(incognito: Bool) throws {
|
||||
let r = chatSendCmdSync(.setIncognito(incognito: incognito))
|
||||
if case .cmdOk = r { return }
|
||||
@@ -379,30 +391,22 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||
}
|
||||
|
||||
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||
let userId = try currentUserId("getUserSMPServers")
|
||||
return try userSMPServersResponse(chatSendCmdSync(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
func getUserSMPServersAsync() async throws -> ([ServerCfg], [String]) {
|
||||
let userId = try currentUserId("getUserSMPServersAsync")
|
||||
return try userSMPServersResponse(await chatSendCmd(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
private func userSMPServersResponse(_ r: ChatResponse) throws -> ([ServerCfg], [String]) {
|
||||
if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
|
||||
let userId = try currentUserId("getUserProtoServers")
|
||||
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
|
||||
if case let .userProtoServers(_, servers) = r { return servers }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserSMPServers")
|
||||
try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers))
|
||||
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserProtoServers")
|
||||
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
|
||||
}
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
let userId = try currentUserId("testSMPServer")
|
||||
let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
|
||||
if case let .smpTestResult(_, testFailure) = r {
|
||||
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
|
||||
let userId = try currentUserId("testProtoServer")
|
||||
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
|
||||
if case let .serverTestResult(_, _, testFailure) = r {
|
||||
if let t = testFailure {
|
||||
return .failure(t)
|
||||
}
|
||||
@@ -738,6 +742,23 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiCancelFile(fileId: fileId) {
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
|
||||
}
|
||||
}
|
||||
|
||||
func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.cancelFile(fileId: fileId))
|
||||
switch r {
|
||||
case let .sndFileCancelled(_, chatItem, _, _) : return chatItem
|
||||
case let .rcvFileCancelled(_, chatItem, _) : return chatItem
|
||||
default:
|
||||
logger.error("apiCancelFile error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
||||
let am = AlertManager.shared
|
||||
switch r {
|
||||
@@ -972,7 +993,7 @@ func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
|
||||
|
||||
func apiGetVersion() throws -> CoreVersionInfo {
|
||||
let r = chatSendCmdSync(.showVersion)
|
||||
if case let .versionInfo(info) = r { return info }
|
||||
if case let .versionInfo(info, _, _) = r { return info }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -983,16 +1004,18 @@ private func currentUserId(_ funcName: String) throws -> Int64 {
|
||||
throw RuntimeError("\(funcName): no current user")
|
||||
}
|
||||
|
||||
func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws {
|
||||
func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true, confirmMigrations: MigrationConfirmation? = nil) throws {
|
||||
logger.debug("initializeChat")
|
||||
let m = ChatModel.shared
|
||||
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey)
|
||||
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations)
|
||||
if m.chatDbStatus != .ok { return }
|
||||
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||
if encryptionStartedDefault.get() {
|
||||
encryptionStartedDefault.set(false)
|
||||
}
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
try apiSetIncognito(incognito: incognitoGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
@@ -1067,7 +1090,6 @@ func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws {
|
||||
func getUserChatData() throws {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
@@ -1075,13 +1097,11 @@ func getUserChatData() throws {
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
let userAddress = try await apiGetUserAddressAsync()
|
||||
let servers = try await getUserSMPServersAsync()
|
||||
let chatItemTTL = try await getChatItemTTLAsync()
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = userAddress
|
||||
(m.userSMPServers, m.presetSMPServers) = servers
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
@@ -1307,6 +1327,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileComplete(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileSndCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
@@ -1318,6 +1342,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
case let .sndFileRcvCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
let cItem = aChatItem.chatItem
|
||||
let mc = cItem.content.msgContent
|
||||
if case .file = mc,
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
case let .callInvitation(invitation):
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
activateCall(invitation)
|
||||
|
||||
@@ -101,4 +101,4 @@ func startChatAndActivate() {
|
||||
if .active != appStateGroupDefault.get() {
|
||||
activateChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,15 @@ struct CIFileView: View {
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter().string(fromByteCount: file.fileSize)
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
@@ -45,17 +45,34 @@ struct CIFileView: View {
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete))
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
|
||||
func fileSizeValid() -> Bool {
|
||||
private var itemInteractive: Bool {
|
||||
if let file = file {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
switch (file.fileStatus) {
|
||||
case .sndStored: return false
|
||||
case .sndTransfer: return false
|
||||
case .sndComplete: return false
|
||||
case .sndCancelled: return false
|
||||
case .rcvInvitation: return true
|
||||
case .rcvAccepted: return true
|
||||
case .rcvTransfer: return false
|
||||
case .rcvComplete: return true
|
||||
case .rcvCancelled: return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileAction() {
|
||||
private func fileSizeValid() -> Bool {
|
||||
if let file = file {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func fileAction() {
|
||||
logger.debug("CIFileView fileAction")
|
||||
if let file = file {
|
||||
switch (file.fileStatus) {
|
||||
@@ -68,17 +85,25 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
|
||||
)
|
||||
}
|
||||
case .rcvAccepted:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for file",
|
||||
message: "File will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for file",
|
||||
message: "File will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for file",
|
||||
message: "File will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
case .rcvComplete:
|
||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||
if let filePath = getLoadedFilePath(file) {
|
||||
@@ -90,11 +115,19 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func fileIndicator() -> some View {
|
||||
@ViewBuilder private func fileIndicator() -> some View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .sndStored: fileIcon("doc.fill")
|
||||
case .sndTransfer: ProgressView().frame(width: 30, height: 30)
|
||||
case .sndStored:
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: fileIcon("doc.fill")
|
||||
}
|
||||
case let .sndTransfer(sndProgress, sndTotal):
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: progressView()
|
||||
}
|
||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvInvitation:
|
||||
@@ -104,7 +137,12 @@ struct CIFileView: View {
|
||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||
}
|
||||
case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
|
||||
case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
|
||||
progressCircle(rcvProgress, rcvTotal)
|
||||
} else {
|
||||
progressView()
|
||||
}
|
||||
case .rcvComplete: fileIcon("doc.fill")
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
}
|
||||
@@ -113,7 +151,7 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
@@ -132,6 +170,21 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
ProgressView().frame(width: 30, height: 30)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(progress) / Double(total))
|
||||
.stroke(
|
||||
Color(uiColor: .tertiaryLabel),
|
||||
style: StrokeStyle(lineWidth: 3)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
@@ -155,7 +208,7 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
|
||||
@@ -24,7 +24,7 @@ struct CIImageView: View {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
imageView(uiImage)
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenImageView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
@@ -41,10 +41,18 @@ struct CIImageView: View {
|
||||
// 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!"
|
||||
)
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for image",
|
||||
message: "Image will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
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
|
||||
@@ -77,34 +85,37 @@ struct CIImageView: View {
|
||||
@ViewBuilder private func loadingIndicator() -> some View {
|
||||
if let file = chatItem.file {
|
||||
switch file.fileStatus {
|
||||
case .sndTransfer:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
case .sndComplete:
|
||||
Image(systemName: "checkmark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(.white)
|
||||
.padding(13)
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .rcvTransfer:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
case .sndStored:
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
}
|
||||
case .sndTransfer: progressView()
|
||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
334
apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
Normal file
334
apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
//
|
||||
// CIVideoView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Avently on 30/03/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVideoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
@State private var duration: Int
|
||||
@State private var progress: Int = 0
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
@Binding private var videoWidth: CGFloat?
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
@State private var player: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
|
||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
||||
self.chatItem = chatItem
|
||||
self.image = image
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self._videoWidth = videoWidth
|
||||
self.scrollProxy = scrollProxy
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
self._preview = State(initialValue: uiImage)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
ZStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let player = player, let url = url {
|
||||
videoView(player, url, file, preview, duration)
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
durationProgress()
|
||||
}
|
||||
if let file = file, case .rcvInvitation = file.fileStatus {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
|
||||
if playingUrl != url {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
fullScreenPlayer(url)
|
||||
}
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
showFullScreenPlayer = true
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
.onAppear {
|
||||
addObserver(player, url)
|
||||
}
|
||||
.onDisappear {
|
||||
removeObserver()
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12, height: 12)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, 4)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func durationProgress() -> some View {
|
||||
HStack {
|
||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding([.top, .leading], 6)
|
||||
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func loadingIndicator() -> some View {
|
||||
if let file = chatItem.file {
|
||||
switch file.fileStatus {
|
||||
case .sndStored:
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
}
|
||||
case let .sndTransfer(sndProgress, sndTotal):
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: progressView()
|
||||
}
|
||||
case .sndComplete:
|
||||
Image(systemName: "checkmark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(.white)
|
||||
.padding(13)
|
||||
case .rcvInvitation:
|
||||
Image(systemName: "arrow.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
|
||||
progressCircle(rcvProgress, rcvTotal)
|
||||
} else {
|
||||
progressView()
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 16, height: 16)
|
||||
.tint(.white)
|
||||
.padding(11)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(progress) / Double(total))
|
||||
.stroke(
|
||||
Color(uiColor: .white),
|
||||
style: StrokeStyle(lineWidth: 2)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId)
|
||||
}
|
||||
// TODO image accepted alert?
|
||||
}
|
||||
}
|
||||
|
||||
private func fullScreenPlayer(_ url: URL) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
|
||||
}
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button(action: { showFullScreenPlayer = false },
|
||||
label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.tint(.white)
|
||||
.frame(width: 15, height: 15)
|
||||
.padding(.leading, 15)
|
||||
.padding(.top, 13)
|
||||
}
|
||||
)
|
||||
})
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 80)
|
||||
.onChanged { gesture in
|
||||
let t = gesture.translation
|
||||
let w = abs(t.width)
|
||||
if t.height > 60 && t.height > w * 2 {
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.onDisappear {
|
||||
if let fullScreenTimeObserver = fullScreenTimeObserver {
|
||||
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
|
||||
}
|
||||
fullScreenTimeObserver = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
|
||||
let player = AVPlayer(url: url)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
return player
|
||||
}
|
||||
|
||||
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
|
||||
if let item = player.currentItem {
|
||||
let dur = CMTimeGetSeconds(item.duration)
|
||||
if !dur.isInfinite && !dur.isNaN {
|
||||
duration = Int(dur)
|
||||
}
|
||||
progress = Int(CMTimeGetSeconds(player.currentTime()))
|
||||
// `if` prevents showing Play button while the playback seeks to start and then plays
|
||||
if player.currentTime() != player.currentItem?.duration && player.currentTime() != .zero {
|
||||
videoPlaying = player.timeControlStatus == .playing || player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeObserver() {
|
||||
if let timeObserver = timeObserver {
|
||||
player?.removeTimeObserver(timeObserver)
|
||||
}
|
||||
timeObserver = nil
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ struct VoiceMessagePlayer: View {
|
||||
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
@@ -243,7 +243,7 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
)
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
|
||||
@@ -62,7 +62,7 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
Group {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(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."), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ struct FramedItemView: View {
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@State var msgWidth: CGFloat = 0
|
||||
@State var imgWidth: CGFloat? = nil
|
||||
@State var videoWidth: CGFloat? = nil
|
||||
@State var metaColor = Color.secondary
|
||||
@State var showFullScreenImage = false
|
||||
|
||||
@@ -64,7 +65,7 @@ struct FramedItemView: View {
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
}
|
||||
.background(chatItemFrameColorMaybeImage(chatItem, colorScheme))
|
||||
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||
|
||||
@@ -103,6 +104,19 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .video(text, image, duration):
|
||||
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.preference(
|
||||
key: MetaColorPreferenceKey.self,
|
||||
value: .white
|
||||
)
|
||||
} else {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .voice(text, duration):
|
||||
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
|
||||
.overlay(DetermineWidth())
|
||||
@@ -152,8 +166,8 @@ struct FramedItemView: View {
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: msgWidth, alignment: .leading)
|
||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
||||
if let imgWidth = imgWidth, imgWidth < maxWidth {
|
||||
v.frame(maxWidth: imgWidth, alignment: .leading)
|
||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -175,6 +189,19 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
case let .video(_, image, _):
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 68, height: 68)
|
||||
.clipped()
|
||||
} else {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
case .file:
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
|
||||
@@ -190,9 +217,9 @@ struct FramedItemView: View {
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: msgWidth, alignment: .leading)
|
||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
||||
|
||||
if let imgWidth = imgWidth, imgWidth < maxWidth {
|
||||
v.frame(maxWidth: imgWidth, alignment: .leading)
|
||||
|
||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -243,9 +270,9 @@ struct FramedItemView: View {
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: 0, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if let imgWidth = imgWidth, imgWidth < maxWidth {
|
||||
v.frame(maxWidth: imgWidth, alignment: .leading)
|
||||
|
||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -258,6 +285,16 @@ struct FramedItemView: View {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
}
|
||||
|
||||
private func maxMediaWidth() -> CGFloat? {
|
||||
if let imgWidth = imgWidth, let videoWidth = videoWidth {
|
||||
return imgWidth > videoWidth ? imgWidth : videoWidth
|
||||
} else if let imgWidth = imgWidth {
|
||||
return imgWidth
|
||||
} else {
|
||||
return videoWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isRightToLeft(_ s: String) -> Bool {
|
||||
@@ -274,15 +311,17 @@ private struct MetaColorPreferenceKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
func onlyImage(_ ci: ChatItem) -> Bool {
|
||||
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
||||
if case let .image(text, _) = ci.content.msgContent {
|
||||
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
|
||||
} else if case let .video(text, _, _) = ci.content.msgContent {
|
||||
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chatItemFrameColorMaybeImage(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
||||
onlyImage(ci)
|
||||
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
||||
onlyImageOrVideo(ci)
|
||||
? Color.clear
|
||||
: chatItemFrameColor(ci, colorScheme)
|
||||
}
|
||||
|
||||
@@ -9,36 +9,61 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
import AVKit
|
||||
|
||||
struct FullScreenImageView: View {
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var chatItem: ChatItem
|
||||
@State var image: UIImage
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
@State var url: URL? = nil
|
||||
@Binding var showView: Bool
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State private var showNext = false
|
||||
@State private var nextImage: UIImage?
|
||||
@State private var nextPlayer: AVPlayer?
|
||||
@State private var nextURL: URL?
|
||||
@State private var scrolling = false
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var nextOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader(content: imageScrollView)
|
||||
GeometryReader(content: mediaScrollView)
|
||||
}
|
||||
|
||||
func imageScrollView(_ g: GeometryProxy) -> some View {
|
||||
func mediaScrollView(_ g: GeometryProxy) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
if showNext, let nextImage = nextImage {
|
||||
imageView(image).offset(x: offset)
|
||||
if let image = image {
|
||||
imageView(image).offset(x: offset)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url).offset(x: offset)
|
||||
}
|
||||
imageView(nextImage).offset(x: offset + nextOffset)
|
||||
} else if showNext, let nextPlayer = nextPlayer, let nextURL = nextURL {
|
||||
if let image = image {
|
||||
imageView(image).offset(x: offset)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url).offset(x: offset)
|
||||
}
|
||||
videoView(nextPlayer, nextURL).offset(x: offset + nextOffset)
|
||||
} else {
|
||||
ZoomableScrollView {
|
||||
imageView(image)
|
||||
if let image = image {
|
||||
imageView(image)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture { showView = false }
|
||||
.onAppear {
|
||||
startPlayerAndNotify()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 80)
|
||||
.onChanged { gesture in
|
||||
@@ -53,9 +78,17 @@ struct FullScreenImageView: View {
|
||||
let previous = t.width > 0
|
||||
scrolling = true
|
||||
if let item = m.nextChatItemData(chatItem.id, previous: previous, map: chatItemImage) {
|
||||
var img: UIImage
|
||||
(chatItem, img) = item
|
||||
var img: UIImage?
|
||||
var url: URL?
|
||||
(chatItem, img, url) = item
|
||||
nextImage = img
|
||||
nextPlayer?.pause()
|
||||
if let url = url {
|
||||
nextPlayer = VideoPlayerView.getOrCreatePlayer(url, true)
|
||||
} else {
|
||||
nextPlayer = nil
|
||||
}
|
||||
nextURL = url
|
||||
let s = g.size.width
|
||||
var toOffset: CGFloat
|
||||
(toOffset, nextOffset) = previous ? (s, -s) : (-s, s)
|
||||
@@ -65,6 +98,14 @@ struct FullScreenImageView: View {
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
image = img
|
||||
player?.pause()
|
||||
self.url = url
|
||||
if let url = url {
|
||||
player = VideoPlayerView.getOrCreatePlayer(url, true)
|
||||
startPlayerAndNotify()
|
||||
} else {
|
||||
player = nil
|
||||
}
|
||||
showNext = false
|
||||
offset = 0
|
||||
}
|
||||
@@ -87,13 +128,30 @@ struct FullScreenImageView: View {
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
.onTapGesture { showView = false }
|
||||
}
|
||||
|
||||
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage)? {
|
||||
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
|
||||
VideoPlayerView(player: player, url: url, showControls: true)
|
||||
}
|
||||
|
||||
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage?, URL?)? {
|
||||
if case .image = ci.content.msgContent,
|
||||
let img = getLoadedImage(ci.file) {
|
||||
return (ci, img)
|
||||
return (ci, img, nil)
|
||||
}
|
||||
// Currently, video support in gallery is not enabled
|
||||
/*else if case .video = ci.content.msgContent,
|
||||
let url = getLoadedVideo(ci.file) {
|
||||
return (ci, nil, url)
|
||||
}*/
|
||||
return nil
|
||||
}
|
||||
|
||||
private func startPlayerAndNotify() {
|
||||
if let player = player {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ struct ChatView: View {
|
||||
if chatModel.chatId == nil { dismiss() }
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
@@ -489,6 +490,11 @@ struct ChatView: View {
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
if ci.meta.itemDeleted == nil,
|
||||
let file = ci.file,
|
||||
file.cancellable {
|
||||
menu.append(cancelFileUIAction(file.fileId))
|
||||
}
|
||||
if !live || !ci.meta.isLive {
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
@@ -579,6 +585,27 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelFileUIAction(_ fileId: Int64) -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Cancel", comment: "chat item action"),
|
||||
image: UIImage(systemName: "xmark"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Cancel file transfer?"),
|
||||
message: Text("File transfer will be cancelled. If it's in progress it will be stoppped."),
|
||||
primaryButton: .destructive(Text("Confirm")) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await cancelFile(user: user, fileId: fileId)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func hideUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Hide", comment: "chat item action"),
|
||||
@@ -667,7 +694,8 @@ struct ChatView: View {
|
||||
if let di = deletingItem {
|
||||
var deletedItem: ChatItem
|
||||
var toItem: ChatItem?
|
||||
if let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
if case .cidmBroadcast = mode,
|
||||
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
|
||||
groupId: groupInfo.apiId,
|
||||
groupMemberId: groupMember.groupMemberId,
|
||||
|
||||
@@ -14,7 +14,7 @@ import PhotosUI
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
case imagePreviews(imagePreviews: [(String, UploadContent?)])
|
||||
case mediaPreviews(mediaPreviews: [(String, UploadContent?)])
|
||||
case voicePreview(recordingFileName: String, duration: Int)
|
||||
case filePreview(fileName: String, file: URL)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ struct ComposeState {
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return true
|
||||
case .mediaPreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || liveMessage != nil
|
||||
@@ -118,7 +118,7 @@ struct ComposeState {
|
||||
|
||||
var linkPreviewAllowed: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return false
|
||||
case .mediaPreviews: return false
|
||||
case .voicePreview: return false
|
||||
case .filePreview: return false
|
||||
default: return useLinkPreviews
|
||||
@@ -175,7 +175,9 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
case let .link(_, preview: preview):
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image):
|
||||
chatItemPreview = .imagePreviews(imagePreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .video(_, image, _):
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .voice(_, duration):
|
||||
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
case .file:
|
||||
@@ -190,11 +192,13 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
enum UploadContent: Equatable {
|
||||
case simpleImage(image: UIImage)
|
||||
case animatedImage(image: UIImage)
|
||||
case video(image: UIImage, url: URL, duration: Int)
|
||||
|
||||
var uiImage: UIImage {
|
||||
switch self {
|
||||
case let .simpleImage(image): return image
|
||||
case let .animatedImage(image): return image
|
||||
case let .video(image, _, _): return image
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +220,14 @@ enum UploadContent: Equatable {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func loadVideoFromURL(url: URL) -> UploadContent? {
|
||||
let asset = AVAsset(url: url)
|
||||
if let (image, duration) = asset.generatePreview() {
|
||||
return .video(image: image, url: url, duration: duration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@@ -229,10 +241,12 @@ struct ComposeView: View {
|
||||
@State var pendingLinkUrl: URL? = nil
|
||||
@State var cancelledLinks: Set<String> = []
|
||||
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
|
||||
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showMediaPicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State var chosenImages: [UploadContent] = []
|
||||
@State var chosenMedia: [UploadContent] = []
|
||||
@State private var showFileImporter = false
|
||||
|
||||
@State private var audioRecorder: AudioRecorder?
|
||||
@@ -284,7 +298,7 @@ struct ComposeView: View {
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
@@ -327,7 +341,7 @@ struct ComposeView: View {
|
||||
showTakePhoto = true
|
||||
}
|
||||
Button("Choose from library") {
|
||||
showImagePicker = true
|
||||
showMediaPicker = true
|
||||
}
|
||||
if UIPasteboard.general.hasImages {
|
||||
Button("Paste image") {
|
||||
@@ -335,7 +349,7 @@ struct ComposeView: View {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
chosenImages.append(image)
|
||||
chosenMedia.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,31 +363,31 @@ struct ComposeView: View {
|
||||
.fullScreenCover(isPresented: $showTakePhoto) {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
CameraImageListPicker(images: $chosenImages)
|
||||
CameraImageListPicker(images: $chosenMedia)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
|
||||
showImagePicker = false
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
DispatchQueue.main.async {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: []))
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImages) { images in
|
||||
.onChange(of: chosenMedia) { selected in
|
||||
Task {
|
||||
var imgs: [(String, UploadContent)] = []
|
||||
for image in images {
|
||||
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
|
||||
imgs.append((img, image))
|
||||
var media: [(String, UploadContent)] = []
|
||||
for content in selected {
|
||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
media.append((img, content))
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgs.count == 0 {
|
||||
if media.count == 0 {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
@@ -394,10 +408,10 @@ struct ComposeView: View {
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
if let fileSize = fileSize,
|
||||
fileSize <= MAX_FILE_SIZE {
|
||||
fileSize <= maxFileSize {
|
||||
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent, file: fileURL))
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: maxFileSize, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
@@ -447,6 +461,11 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var maxFileSize: Int64 {
|
||||
let fileProtocol: FileProtocol = xftpSendEnabled ? .xftp : .smp
|
||||
return getMaxFileSize(fileProtocol)
|
||||
}
|
||||
|
||||
private func sendLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
let lm = composeState.liveMessage
|
||||
@@ -507,12 +526,12 @@ struct ComposeView: View {
|
||||
EmptyView()
|
||||
case let .linkPreview(linkPreview: preview):
|
||||
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
ComposeImageView(
|
||||
images: images.map { (img, _) in img },
|
||||
images: media.map { (img, _) in img },
|
||||
cancelImage: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenImages = []
|
||||
chosenMedia = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing)
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
@@ -587,21 +606,29 @@ struct ComposeView: View {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
case .linkPreview:
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
let last = images.count - 1
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
let last = media.count - 1
|
||||
if last >= 0 {
|
||||
for i in 0..<last {
|
||||
sent = await sendImage(images[i])
|
||||
if case (_, .video(_, _, _)) = media[i] {
|
||||
sent = await sendVideo(media[i])
|
||||
} else {
|
||||
sent = await sendImage(media[i])
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
sent = await sendImage(images[last], text: msgText, quoted: quoted, live: live)
|
||||
if case (_, .video(_, _, _)) = media[last] {
|
||||
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live)
|
||||
} else {
|
||||
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live)
|
||||
}
|
||||
}
|
||||
if sent == nil {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
chatModel.filesToDelete.removeAll { $0 == recordingFileName }
|
||||
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
@@ -650,6 +677,8 @@ struct ComposeView: View {
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: msgText, image: image)
|
||||
case .video(_, let image, let duration):
|
||||
return .video(text: msgText, image: image, duration: duration)
|
||||
case .voice(_, let duration):
|
||||
return .voice(text: msgText, duration: duration)
|
||||
case .file:
|
||||
@@ -667,6 +696,14 @@ struct ComposeView: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? {
|
||||
if let chatItem = await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
@@ -704,14 +741,15 @@ struct ComposeView: View {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startVoiceMessageRecording() async {
|
||||
startingRecording = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
let fileName = generateNewFileName("voice", "m4a")
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(fileName)
|
||||
audioRecorder = AudioRecorder(
|
||||
onTimer: { voiceMessageRecordingTime = $0 },
|
||||
onFinishRecording: {
|
||||
@@ -797,7 +835,7 @@ struct ComposeView: View {
|
||||
composeState = ComposeState()
|
||||
resetLinkPreview()
|
||||
}
|
||||
chosenImages = []
|
||||
chosenMedia = []
|
||||
audioRecorder = nil
|
||||
voiceMessageRecordingTime = nil
|
||||
startingRecording = false
|
||||
@@ -807,7 +845,7 @@ struct ComposeView: View {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.append(fileName)
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
chatModel.draft = composeState
|
||||
|
||||
@@ -164,7 +164,7 @@ struct ComposeVoiceView: View {
|
||||
|
||||
private func startPlayback() {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
|
||||
@@ -23,7 +23,7 @@ struct SendMessageView: View {
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
var onImagesAdded: ([UploadContent]) -> Void
|
||||
var onMediaAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@@ -69,7 +69,7 @@ struct SendMessageView: View {
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImagesAdded: onImagesAdded
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
@@ -365,7 +365,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
@@ -375,7 +375,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ struct GroupLinkView: View {
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
QRCode(uri: groupLink)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct ChatListView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
if chatModel.users.count > 1 {
|
||||
if chatModel.users.filter { u in u.user.activeUser || !u.user.hidden }.count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct ChatListView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { !$0.user.activeUser }
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
|
||||
@@ -143,7 +143,7 @@ struct ChatPreviewView: View {
|
||||
func attachment() -> Text {
|
||||
switch draft.preview {
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
|
||||
case .imagePreviews: return image("photo")
|
||||
case .mediaPreviews: return image("photo")
|
||||
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
|
||||
default: return Text("")
|
||||
}
|
||||
@@ -159,6 +159,7 @@ struct ChatPreviewView: View {
|
||||
switch cItem.content.msgContent {
|
||||
case .file: return "doc.fill"
|
||||
case .image: return "photo"
|
||||
case .video: return "video"
|
||||
case .voice: return "play.fill"
|
||||
default: return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +126,9 @@ struct UserPicker: View {
|
||||
if user.activeUser {
|
||||
Image(systemName: "checkmark")
|
||||
} else if u.unreadCount > 0 {
|
||||
unreadCounter(u.unreadCount)
|
||||
unreadCounter(u.unreadCount, color: user.showNtfs ? .accentColor : .secondary)
|
||||
} else if !user.showNtfs {
|
||||
Image(systemName: "speaker.slash")
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
@@ -152,13 +154,13 @@ struct UserPicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
func unreadCounter(_ unread: Int) -> some View {
|
||||
private func unreadCounter(_ unread: Int, color: Color) -> some View {
|
||||
unreadCountText(unread)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Color.accentColor)
|
||||
.background(color)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,40 +16,68 @@ struct DatabaseErrorView: View {
|
||||
@State private var storedDBKey = getDatabaseKey()
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var showRestoreDbButton = false
|
||||
@State private var starting = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
databaseErrorView().disabled(starting)
|
||||
if starting {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func databaseErrorView() -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
switch status {
|
||||
case let .errorNotADatabase(dbFile):
|
||||
if useKeychain && storedDBKey != nil && storedDBKey != "" {
|
||||
Text("Wrong database passphrase").font(.title)
|
||||
titleText("Wrong database passphrase")
|
||||
Text("Database passphrase is different from saved in the keychain.")
|
||||
databaseKeyField(onSubmit: saveAndRunChat)
|
||||
saveAndOpenButton()
|
||||
Text("File: \(dbFile)")
|
||||
fileNameText(dbFile)
|
||||
} else {
|
||||
Text("Encrypted database").font(.title)
|
||||
titleText("Encrypted database")
|
||||
Text("Database passphrase is required to open chat.")
|
||||
if useKeychain {
|
||||
databaseKeyField(onSubmit: saveAndRunChat)
|
||||
saveAndOpenButton()
|
||||
} else {
|
||||
databaseKeyField(onSubmit: runChat)
|
||||
databaseKeyField(onSubmit: { runChat() })
|
||||
openChatButton()
|
||||
}
|
||||
}
|
||||
case let .error(dbFile, migrationError):
|
||||
Text("Database error")
|
||||
.font(.title)
|
||||
Text("File: \(dbFile)")
|
||||
Text("Error: \(migrationError)")
|
||||
case let .errorMigration(dbFile, migrationError):
|
||||
switch migrationError {
|
||||
case let .upgrade(upMigrations):
|
||||
titleText("Database upgrade")
|
||||
Button("Upgrade and open chat") { runChat(confirmMigrations: .yesUp) }
|
||||
fileNameText(dbFile)
|
||||
migrationsText(upMigrations.map(\.upName))
|
||||
case let .downgrade(downMigrations):
|
||||
titleText("Database downgrade")
|
||||
Text("Warning: you may lose some data!").bold()
|
||||
Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
|
||||
fileNameText(dbFile)
|
||||
migrationsText(downMigrations)
|
||||
case let .migrationError(mtrError):
|
||||
titleText("Incompatible database version")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: \(migrationSQLError)")
|
||||
case .errorKeychain:
|
||||
Text("Keychain error")
|
||||
.font(.title)
|
||||
titleText("Keychain error")
|
||||
Text("Cannot access keychain to save database password")
|
||||
case .invalidConfirmation:
|
||||
// this can only happen if incorrect parameter is passed
|
||||
Text(String("Invalid migration confirmation")).font(.title)
|
||||
case let .unknown(json):
|
||||
Text("Database error")
|
||||
.font(.title)
|
||||
titleText("Database error")
|
||||
Text("Unknown database error: \(json)")
|
||||
case .ok:
|
||||
EmptyView()
|
||||
@@ -61,10 +89,31 @@ struct DatabaseErrorView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
|
||||
}
|
||||
|
||||
private func titleText(_ s: LocalizedStringKey) -> Text {
|
||||
Text(s).font(.title)
|
||||
}
|
||||
|
||||
private func fileNameText(_ f: String) -> Text {
|
||||
Text("File: \((f as NSString).lastPathComponent)")
|
||||
}
|
||||
|
||||
private func migrationsText(_ ms: [String]) -> Text {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
private func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
|
||||
switch err {
|
||||
case let .noDown(dbMigrations):
|
||||
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
|
||||
case let .different(appMigration, dbMigration):
|
||||
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
|
||||
}
|
||||
}
|
||||
|
||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
}
|
||||
@@ -89,14 +138,24 @@ struct DatabaseErrorView: View {
|
||||
runChat()
|
||||
}
|
||||
|
||||
private func runChat() {
|
||||
private func runChat(confirmMigrations: MigrationConfirmation? = nil) {
|
||||
starting = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
runChatSync(confirmMigrations: confirmMigrations)
|
||||
starting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func runChatSync(confirmMigrations: MigrationConfirmation? = nil) {
|
||||
do {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: m.v3DBMigration.startChat, dbKey: dbKey)
|
||||
try initializeChat(start: m.v3DBMigration.startChat, dbKey: useKeychain ? nil : dbKey, confirmMigrations: confirmMigrations)
|
||||
if let s = m.chatDbStatus {
|
||||
status = s
|
||||
let am = AlertManager.shared
|
||||
switch s {
|
||||
case .invalidConfirmation:
|
||||
am.showAlert(Alert(title: Text(String("Invalid migration confirmation"))))
|
||||
case .errorNotADatabase:
|
||||
am.showAlertMsg(
|
||||
title: "Wrong passphrase!",
|
||||
@@ -104,7 +163,7 @@ struct DatabaseErrorView: View {
|
||||
)
|
||||
case .errorKeychain:
|
||||
am.showAlertMsg(title: "Keychain error")
|
||||
case let .error(_, error):
|
||||
case let .errorSQL(_, error):
|
||||
am.showAlert(Alert(
|
||||
title: Text("Database error"),
|
||||
message: Text(error)
|
||||
@@ -114,6 +173,7 @@ struct DatabaseErrorView: View {
|
||||
title: Text("Unknown error"),
|
||||
message: Text(error)
|
||||
))
|
||||
case .errorMigration: ()
|
||||
case .ok: ()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ struct DatabaseView: View {
|
||||
if fileCount == 0 {
|
||||
Text("No received or sent files")
|
||||
} else {
|
||||
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter().string(fromByteCount: Int64(size)))")
|
||||
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct LibraryImagePicker: View {
|
||||
@State var images: [UploadContent] = []
|
||||
|
||||
var body: some View {
|
||||
LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
.onChange(of: images) { _ in
|
||||
if let img = images.first {
|
||||
image = img.uiImage
|
||||
@@ -26,19 +26,20 @@ struct LibraryImagePicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = PHPickerViewController
|
||||
@Binding var images: [UploadContent]
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) var xftpSendEnabled = false
|
||||
@Binding var media: [UploadContent]
|
||||
var selectionLimit: Int
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
|
||||
class Coordinator: PHPickerViewControllerDelegate {
|
||||
let parent: LibraryImageListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker")
|
||||
var images: [UploadContent] = []
|
||||
var imageCount: Int = 0
|
||||
let parent: LibraryMediaListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
|
||||
var media: [UploadContent] = []
|
||||
var mediaCount: Int = 0
|
||||
|
||||
init(_ parent: LibraryImageListPicker) {
|
||||
init(_ parent: LibraryMediaListPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@@ -48,13 +49,23 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
return
|
||||
}
|
||||
|
||||
parent.images = []
|
||||
images = []
|
||||
imageCount = results.count
|
||||
parent.media = []
|
||||
media = []
|
||||
mediaCount = results.count
|
||||
for result in results {
|
||||
logger.log("LibraryImageListPicker result")
|
||||
logger.log("LibraryMediaListPicker result")
|
||||
let p = result.itemProvider
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let url = url {
|
||||
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
|
||||
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
|
||||
ChatModel.shared.filesToDelete.insert(tempUrl)
|
||||
self.loadVideo(url: tempUrl, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
self.loadImage(object: url, error: error)
|
||||
}
|
||||
@@ -65,14 +76,14 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatchQueue.sync { self.imageCount -= 1}
|
||||
dispatchQueue.sync { self.mediaCount -= 1}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
self.dispatchQueue.sync {
|
||||
if self.parent.images.count == 0 {
|
||||
logger.log("LibraryImageListPicker: added \(self.images.count) images out of \(results.count)")
|
||||
self.parent.images = self.images
|
||||
if self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
|
||||
self.parent.media = self.media
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,19 +91,35 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
|
||||
func loadImage(object: Any?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
} else if let image = object as? UIImage {
|
||||
images.append(.simpleImage(image: image))
|
||||
logger.log("LibraryImageListPicker: added image")
|
||||
media.append(.simpleImage(image: image))
|
||||
logger.log("LibraryMediaListPicker: added image")
|
||||
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
|
||||
images.append(image)
|
||||
media.append(image)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.imageCount -= 1
|
||||
if self.imageCount == 0 && self.parent.images.count == 0 {
|
||||
logger.log("LibraryImageListPicker: added all images")
|
||||
self.parent.images = self.images
|
||||
self.images = []
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadVideo(url: URL?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
|
||||
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
|
||||
media.append(video)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +131,15 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
let allowVideoAttachment = xftpSendEnabled
|
||||
if allowVideoAttachment {
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
} else {
|
||||
config.filter = .images
|
||||
}
|
||||
config.selectionLimit = selectionLimit
|
||||
config.selection = .ordered
|
||||
//config.preferredAssetRepresentationMode = .current
|
||||
let controller = PHPickerViewController(configuration: config)
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
|
||||
61
apps/ios/Shared/Views/Helpers/VideoPlayerView.swift
Normal file
61
apps/ios/Shared/Views/Helpers/VideoPlayerView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Created by Avently on 30.03.2023.
|
||||
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
struct VideoPlayerView: UIViewRepresentable {
|
||||
|
||||
static var players: [String: AVPlayer] = [:]
|
||||
static func getOrCreatePlayer(_ url: URL, _ gallery: Bool) -> AVPlayer {
|
||||
if let player = players[url.absoluteString + gallery.description] {
|
||||
return player
|
||||
} else {
|
||||
let player = AVPlayer(url: url)
|
||||
players[url.absoluteString + gallery.description] = player
|
||||
return player
|
||||
}
|
||||
}
|
||||
|
||||
typealias UIViewType = UIView
|
||||
let player: AVPlayer
|
||||
let url: URL
|
||||
let showControls: Bool
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.showsPlaybackControls = showControls
|
||||
if #available(iOS 16.0, *) {
|
||||
controller.speeds = []
|
||||
}
|
||||
controller.player = player
|
||||
context.coordinator.controller = controller
|
||||
context.coordinator.timeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
return controller.view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> VideoPlayerView.Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
var controller: AVPlayerViewController?
|
||||
var timeObserver: Any? = nil
|
||||
|
||||
deinit {
|
||||
print("deinit coordinator of VideoPlayer")
|
||||
if let timeObserver = timeObserver {
|
||||
NotificationCenter.default.removeObserver(timeObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct TerminalView: View {
|
||||
@State var composeState: ComposeState = ComposeState()
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var terminalItem: TerminalItem?
|
||||
|
||||
var body: some View {
|
||||
if authorized {
|
||||
@@ -38,19 +39,8 @@ struct TerminalView: View {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(chatModel.terminalItems) { item in
|
||||
NavigationLink {
|
||||
let s = item.details
|
||||
ScrollView {
|
||||
Text(s.prefix(maxItemSize))
|
||||
.padding()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showShareSheet(items: [s]) } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
terminalItem = item
|
||||
} label: {
|
||||
HStack {
|
||||
Text(item.id.formatted(date: .omitted, time: .standard))
|
||||
@@ -70,6 +60,11 @@ struct TerminalView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(NavigationLink(
|
||||
isActive: Binding(get: { terminalItem != nil }, set: { _ in }),
|
||||
destination: terminalItemView,
|
||||
label: { EmptyView() }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +74,7 @@ struct TerminalView: View {
|
||||
composeState: $composeState,
|
||||
sendMessage: sendMessage,
|
||||
showVoiceMessageButton: false,
|
||||
onImagesAdded: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -96,6 +91,22 @@ struct TerminalView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func terminalItemView() -> some View {
|
||||
let s = terminalItem?.details ?? ""
|
||||
return ScrollView {
|
||||
Text(s.prefix(maxItemSize))
|
||||
.padding()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showShareSheet(items: [s]) } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear { terminalItem = nil }
|
||||
}
|
||||
|
||||
func sendMessage() {
|
||||
let cmd = ChatCommand.string(composeState.message)
|
||||
|
||||
@@ -147,6 +147,7 @@ struct AdvancedNetworkSettings: View {
|
||||
Text("\(value) \(label)")
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
|
||||
@@ -155,6 +156,7 @@ struct AdvancedNetworkSettings: View {
|
||||
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import SimpleXChat
|
||||
|
||||
struct CallSettings: View {
|
||||
@AppStorage(DEFAULT_WEBRTC_POLICY_RELAY) private var webrtcPolicyRelay = true
|
||||
@AppStorage(GROUP_DEFAULT_CALL_KIT_ENABLED, store: UserDefaults(suiteName: APP_GROUP_NAME)!) private var callKitEnabled = true
|
||||
@AppStorage(GROUP_DEFAULT_CALL_KIT_ENABLED, store: groupDefaults) private var callKitEnabled = true
|
||||
@AppStorage(DEFAULT_CALL_KIT_CALLS_IN_RECENTS) private var callKitCallsInRecents = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
private let allowChangingCallsHistory = false
|
||||
|
||||
var body: some View {
|
||||
|
||||
72
apps/ios/Shared/Views/UserSettings/DeveloperView.swift
Normal file
72
apps/ios/Shared/Views/UserSettings/DeveloperView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// DeveloperView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 26/03/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct DeveloperView: View {
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(colorScheme == .dark ? "github_light" : "github")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.opacity(0.5)
|
||||
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
NavigationLink {
|
||||
TerminalView()
|
||||
} label: {
|
||||
settingsRow("terminal") { Text("Chat console") }
|
||||
}
|
||||
settingsRow("internaldrive") {
|
||||
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
|
||||
}
|
||||
settingsRow("chevron.left.forwardslash.chevron.right") {
|
||||
Toggle("Show developer options", isOn: $developerTools)
|
||||
}
|
||||
} footer: {
|
||||
(developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")
|
||||
}
|
||||
|
||||
Section {
|
||||
settingsRow("arrow.up.doc") {
|
||||
Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
|
||||
.onChange(of: xftpSendEnabled) { _ in
|
||||
do {
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
} catch {
|
||||
logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Experimental")
|
||||
} footer: {
|
||||
if xftpSendEnabled {
|
||||
Text("v4.6.1+ is required to receive via XFTP.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DeveloperView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DeveloperView()
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,23 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ExperimentalFeaturesView: View {
|
||||
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("") {
|
||||
settingsRow("video") {
|
||||
Toggle("Audio & video calls", isOn: $enableCalls)
|
||||
settingsRow("arrow.up.doc") {
|
||||
Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled)
|
||||
.onChange(of: xftpSendEnabled) { _ in
|
||||
do {
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
} catch {
|
||||
logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ struct HiddenProfileView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: true, showStrength: true)
|
||||
PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: passwordValid, showStrength: true)
|
||||
PassphraseField(key: $confirmHidePassword, placeholder: "Confirm password", valid: confirmValid)
|
||||
|
||||
settingsRow("lock") {
|
||||
@@ -72,9 +72,11 @@ struct HiddenProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var passwordValid: Bool { hidePassword == hidePassword.trimmingCharacters(in: .whitespaces) }
|
||||
|
||||
var confirmValid: Bool { confirmHidePassword == "" || hidePassword == confirmHidePassword }
|
||||
|
||||
var saveDisabled: Bool { hidePassword == "" || confirmHidePassword == "" || !confirmValid }
|
||||
var saveDisabled: Bool { hidePassword == "" || !passwordValid || confirmHidePassword == "" || !confirmValid }
|
||||
}
|
||||
|
||||
struct ProfilePrivacyView_Previews: PreviewProvider {
|
||||
|
||||
@@ -25,6 +25,7 @@ private enum NetworkAlert: Identifiable {
|
||||
|
||||
struct NetworkAndServers: View {
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
|
||||
@State private var cfgLoaded = false
|
||||
@State private var currentNetCfg = NetCfg.defaults
|
||||
@State private var netCfg = NetCfg.defaults
|
||||
@@ -37,12 +38,21 @@ struct NetworkAndServers: View {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
SMPServersView()
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
} label: {
|
||||
Text("SMP servers")
|
||||
}
|
||||
|
||||
if xftpSendEnabled {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
} label: {
|
||||
Text("XFTP servers")
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
@@ -52,6 +62,7 @@ struct NetworkAndServers: View {
|
||||
Picker("Transport isolation", selection: $sessionMode) {
|
||||
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
@@ -61,7 +72,7 @@ struct NetworkAndServers: View {
|
||||
Text("Advanced network settings")
|
||||
}
|
||||
} header: {
|
||||
Text("Messages")
|
||||
Text("Messages & files")
|
||||
} footer: {
|
||||
Text("Using .onion hosts requires compatible VPN provider.")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import SimpleXChat
|
||||
struct PrivacySettings: View {
|
||||
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
||||
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SMPServerView: View {
|
||||
struct ProtocolServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: SMPTestFailure?
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
|
||||
var proto: String { serverProtocol.rawValue.uppercased() }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -28,7 +31,7 @@ struct SMPServerView: View {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Your SMP servers") {
|
||||
.modifier(BackButton(label: "Your \(proto) servers") {
|
||||
server = serverToEdit
|
||||
dismiss()
|
||||
})
|
||||
@@ -57,7 +60,8 @@ struct SMPServerView: View {
|
||||
|
||||
private func customServer() -> some View {
|
||||
VStack {
|
||||
let valid = parseServerAddress(serverToEdit.server)?.valid == true
|
||||
let serverAddress = parseServerAddress(serverToEdit.server)
|
||||
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $serverToEdit.server)
|
||||
@@ -144,18 +148,17 @@ struct BackButton: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
func testServerConnection(server: Binding<ServerCfg>) async -> SMPTestFailure? {
|
||||
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
|
||||
do {
|
||||
let r = try await testSMPServer(smpServer: server.wrappedValue.server)
|
||||
|
||||
switch r {
|
||||
case .success:
|
||||
await MainActor.run { server.wrappedValue.tested = true }
|
||||
return nil
|
||||
case let .failure(f):
|
||||
await MainActor.run { server.wrappedValue.tested = false }
|
||||
return f
|
||||
}
|
||||
let r = try await testProtoServer(server: server.wrappedValue.server)
|
||||
switch r {
|
||||
case .success:
|
||||
await MainActor.run { server.wrappedValue.tested = true }
|
||||
return nil
|
||||
case let .failure(f):
|
||||
await MainActor.run { server.wrappedValue.tested = false }
|
||||
return f
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("testServerConnection \(responseError(error))")
|
||||
await MainActor.run {
|
||||
@@ -169,8 +172,12 @@ func serverHostname(_ srv: String) -> String {
|
||||
parseServerAddress(srv)?.hostnames.first ?? srv
|
||||
}
|
||||
|
||||
struct SMPServerView_Previews: PreviewProvider {
|
||||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SMPServerView(server: Binding.constant(ServerCfg.sampleData.custom), serverToEdit: ServerCfg.sampleData.custom)
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user